diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fee5b0c2c..a1b007e18 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -362,17 +362,24 @@ jobs: # this workflow has no way to drive. What a build proves is the half that # actually breaks: the CMake, the plugin registration, and every package in # the graph compiling for a platform none of them had been asked about. + # + # The level editor as well, for P11: it ships desktop downloads for both, so + # its runners are compiled here rather than first by whoever downloads one. + # Its graph brings `file_selector`'s own GTK and Win32 plugins, which the + # dungeon demo does not. desktop: strategy: - # Both, whichever fails: a matrix that stops at the first failure hides - # whether the other one was ever going to work. + # Every cell, whichever fails: a matrix that stops at the first failure + # hides whether the others were ever going to work. fail-fast: false matrix: + target: [linux, windows] + app: [flutter3d_demo_dungeon, flutter3d_editor] include: - - os: ubuntu-latest - target: linux - - os: windows-latest - target: windows + - target: linux + os: ubuntu-latest + - target: windows + os: windows-latest runs-on: ${{ matrix.os }} timeout-minutes: 30 steps: @@ -385,8 +392,8 @@ jobs: cache: true # libasound2-dev is not part of the Flutter desktop toolchain: it is what - # flutter_soloud's own ALSA backend compiles against, and the dungeon demo - # is the application that pulls the audio plugin in. Without it the build + # flutter_soloud's own ALSA backend compiles against, and both applications + # pull the audio plugin in, the editor through `flutter3d_game`. Without it the build # gets all the way to soloud_alsa.cpp and stops at a missing header. - name: linux desktop toolchain if: matrix.target == 'linux' @@ -402,8 +409,33 @@ jobs: - run: flutter precache --${{ matrix.target }} - run: cd packages/flutter3d_impeller && dart run bin/build_shader_bundle.dart - - name: build the dungeon demo - run: cd apps/flutter3d_demo_dungeon && flutter build ${{ matrix.target }} --debug + - name: build ${{ matrix.app }} + run: cd apps/${{ matrix.app }} && flutter build ${{ matrix.target }} --debug + + # The native physics core on Windows, built by MSVC as the hook builds it + # there: its C tests, its digests — the bits every other platform steps + # to, written in csrc/tests/test_digest.c — the pool on Win32 threads, and + # the Dart side over the library the hook made. The check job runs the + # same package on Linux; nothing else here compiles the core with MSVC. + physics-native-windows: + runs-on: windows-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v7 + + # `cl` on the path, for test/c_unit_test.dart; the hook finds MSVC + # itself. + - uses: ilammy/msvc-dev-cmd@v1 + + - uses: subosito/flutter-action@v2 + with: + flutter-version: '3.47.0' + channel: stable + cache: true + + - run: flutter pub get + - run: flutter test + working-directory: packages/flutter3d_physics_native godot: runs-on: ubuntu-latest diff --git a/AGENTS.md b/AGENTS.md index aa6f62205..162d085e2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,7 +23,7 @@ row a change belongs to. ```sh dart analyze # in the packages you touched -dart run tool/structure.dart # 35 rules about how the tree is arranged +dart run tool/structure.dart # 36 rules about how the tree is arranged dart run tool/verify_plan.dart # every finished row names something real ``` diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index bc0cbe184..0a761a384 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -239,6 +239,7 @@ point of §3.3. | `flutter3d_editor_core` | The headless half of a level editor: the document being changed and undone, the handles a pointer hits, the palette a level builds out of itself, the project a template becomes; `LevelScene`, which turns a level into brush meshes, materials, lights and probes with no Flutter; the seeded level generators; and `LightOptimizer`, which finds fewer lights that light a level the way it was lit. Plain Dart | | `flutter3d_editor_widgets` | Editor controls the modeller and the level editor share instead of each keeping its own copy — `ui-27`'s own package: `SectionLabel`, `NumberField`, `ColorField`, `RangeSliderField`, `EnumField`, `TextureSlotRow`, `TexturePathField`, `ColorSwatchField`, `HintTextBox`/`NumbersRow`, `FieldRow` and `EditorWidgetsTheme` so far | | `flutter3d_editor_mcp` | The same editor offered to an agent: `EditorCommand` as a table of MCP tools over stdio, one document per process, plus the two verbs a caller with no screen needs — a flat listing, and the validator. Plain Dart | +| `flutter3d_editor_play` | Play from a level editor: the game a level belongs to run with `flutter run --machine`, swapped, restarted and stopped, its devices listed, and a saved level sent to it. What the editor's Play button and `flutter3d_editor_mcp`'s `play` tools both run. Plain Dart with `dart:io`, which is why it is not in `flutter3d_editor_core`; `attach.dart` is the part without it, a game attached to by its VM service address, which is the web editor's Play | | `flutter3d_mcp_kit` | What every MCP server here shares: a tool paired with its handler, a server that is a list of them over one session, the two shapes of answer, and a loopback HTTP transport an open application offers its session over. Plain Dart | | `flutter3d_sim_mcp` | A level an agent plays blind, of whatever `HeadlessGame` a host hands it — step, read back, digest, hand over the run — and many seeded playtests in isolates; and a rendered frame of a level offered to an agent: drawn with no GPU in one of the renderer's debug views, one pixel read back unclamped, the passes the frame graph ran. Names no genre | | `flutter3d_build` | The converter behind `dart run flutter3d_build:convert` and the build hook that runs it on every build, and the bakes that need a renderer or a mesh editor ahead of time: levels of detail, impostors, cluster tables, a file per device class, paged splat captures, six-way smoke sheets, and the light optimizer's command. That is why it depends on `flutter3d_cpu`, `flutter3d_mesh`, `flutter3d_model_core`, `flutter3d_editor_core` and `flutter3d_sim`. Not a dependency of the engine: no game that draws a frame runs it. Plain Dart | @@ -334,7 +335,7 @@ a caller for that cannot supply a Flutter SDK. ### 3.3 Rules that are scanned, not remembered -`tool/structure.dart` walks `packages/` and `apps/` and enforces thirty-five rules in +`tool/structure.dart` walks `packages/` and `apps/` and enforces thirty-six rules in under a second, as the first step of CI. They cover the *arrangement* of the code — who imports what, what a name says, where a thing may live — while anything about what the code *does* stays a test. @@ -405,7 +406,8 @@ written and read in: ``` point shadows (static) → point shadows → directional shadows → shadow moments reflection probe 0 … n → irradiance update -scene → scene colour copy → transparent → object ids +render textures → planar reflections +scene → decals → scene colour copy → transparent → object ids (overlay nodes) → reflections → luminance → depth pyramid ssao → ssao blur → contact shadows → contact shadow resolve camera velocity → object velocity → reactive mask @@ -2689,8 +2691,8 @@ storage. |---|---| | Style | `dart format` | | Analysis | `flutter analyze` clean across the workspace, no warnings | -| Unit tests | **10789 tests** across 42 packages and 10 applications | -| Structure rules | 35, `dart run tool/structure.dart`, the first CI step | +| Unit tests | **11490 tests** across 44 packages and 10 applications | +| Structure rules | 36, `dart run tool/structure.dart`, the first CI step | | CI | GitHub Actions over `tool/ci.sh`, on `ubuntu-latest`, with no graphics card | ### 13.1 The published-enum boundary @@ -2729,7 +2731,7 @@ material parameter — is the case `doc/boundary-0.5.0.md` worked through for set belongs to whoever builds on the package rather than to the package itself. -**Golden render tests.** 78 scenes against **four complete independent +**Golden render tests.** 95 scenes against **four complete independent reference sets** — Impeller, the software rasteriser, WebGL2 and WebGPU — each held to zero differing pixels against its own set, with a per-channel tolerance of 8. @@ -3141,7 +3143,7 @@ metres. The directional light's cascades fit the view up to that distance and nothing beyond it casts — a level whose far end matters visually wants the number raised, and pays for it in texels. -**The web backend draws all seventy-eight golden scenes the way Impeller does**, +**The web backend draws all ninety-five golden scenes the way Impeller does**, between 0.01% and 0.42% of pixels differing by more than 8 per channel — the silhouette's worth of disagreement two rasterisers always have. Two of those numbers fell when the minification filter learned to read a sampler's @@ -3257,7 +3259,7 @@ and charge each of them the bytes, and neither is a change to make on somebody else's behalf. A build that wants it says so in one flag, and the engine's own example takes the same answer from `?backend=webgpu` in the URL — a query parameter rather than a define, because the browser golden stand's whole saving -is one dart2js run serving seventy-eight scenes and both browser backends. +is one dart2js run serving ninety-five scenes and both browser backends. **`flutter3d_shaders` is one text and no two backends take it the same way.** Impeller compiles the GLSL with `impellerc`; the WebGL2 generator translates it @@ -3707,13 +3709,14 @@ what went out at 0.4.2. `flutter3d_audio_core`, `flutter3d_physics`, `flutter3d_mcp_kit`, `flutter3d_lti`, `pad_input`, `pointer_lock`, `flame_multiplayer` 2. `flutter3d_conformance`, `flutter3d_core`, `flutter3d_audio`, - `flame_multiplayer_dashwire` + `flame_multiplayer_dashwire`, `flutter3d_physics_native` 3. `flutter3d_mesh`, `flutter3d_build`, `flutter3d_particles`, `flutter3d_editor_widgets` 4. `flutter3d`, `flutter3d_model_core` 5. `flutter3d_impeller`, `flutter3d_webgl`, `flutter3d_webgpu`, `flutter3d_cpu`, `flutter3d_sim` -6. `flutter3d_app`, `flutter3d_editor_core`, `flutter3d_net`, `flutter3d_lab` +6. `flutter3d_app`, `flutter3d_editor_core`, `flutter3d_editor_play`, + `flutter3d_net`, `flutter3d_lab` 7. `flutter3d_game`, `flutter3d_stereo`, `flutter3d_testing`, `flutter3d_editor_mcp`, `flutter3d_model_mcp`, `flutter3d_net_webrtc` 8. `flutter3d_game_shooter`, `flutter3d_game_platformer`, `flutter3d_game_racing`, diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 93b176183..200c5c8c0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -57,7 +57,7 @@ number should be. ## Architecture is checked by scans, not by review -`dart run tool/structure.dart` holds thirty-five rules about how the repository is +`dart run tool/structure.dart` holds thirty-six rules about how the repository is arranged — that a genre package names no other genre, that the hardware layer names no graphics API, that the engine names no backend, that a simulation step reaches for no clock and no loose dice. It runs first in CI and takes under a diff --git a/README.md b/README.md index 1015287da..41b9d6271 100644 --- a/README.md +++ b/README.md @@ -158,9 +158,9 @@ Or one package at a time: (cd packages/flutter3d_physics && dart test) # plain Dart, no Flutter needed ``` -There are 10789 tests across forty-two packages and nine applications. The +There are 11490 tests across forty-four packages and nine applications. The only ones that need a GPU are the Impeller half of the golden set. The other -half is rendered by the software backend, which is what makes 78 scenes +half is rendered by the software backend, which is what makes 95 scenes checkable in a headless run. Several steps run in a browser. `flutter test --platform chrome` covers the two @@ -209,7 +209,7 @@ binary has not got, and binding a slot a compiled shader does not have takes the frame down. The message names nothing that leads back to the file that was edited. -So `dart run tool/structure.dart` checks it: one of its thirty-five rules compares +So `dart run tool/structure.dart` checks it: one of its thirty-six rules compares the bundle against the sources it was built from and says which of them are newer. The rule skips when there is no bundle at all, which is every fresh checkout and every CI run. `impellerc` is not there to build one, and a rule diff --git a/apps/flutter3d_demo_arcade/macos/Flutter/GeneratedPluginRegistrant.swift b/apps/flutter3d_demo_arcade/macos/Flutter/GeneratedPluginRegistrant.swift index 4c6c08a37..859b13e8e 100644 --- a/apps/flutter3d_demo_arcade/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/apps/flutter3d_demo_arcade/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,12 +5,10 @@ import FlutterMacOS import Foundation -import flutter_soloud import pad_input import pointer_lock func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { - FlutterSoloudPlugin.register(with: registry.registrar(forPlugin: "FlutterSoloudPlugin")) GamepadPlugin.register(with: registry.registrar(forPlugin: "GamepadPlugin")) PointerLockPlugin.register(with: registry.registrar(forPlugin: "PointerLockPlugin")) } diff --git a/apps/flutter3d_demo_dungeon/lib/main.dart b/apps/flutter3d_demo_dungeon/lib/main.dart index 3da46c91f..59d598c70 100644 --- a/apps/flutter3d_demo_dungeon/lib/main.dart +++ b/apps/flutter3d_demo_dungeon/lib/main.dart @@ -406,6 +406,9 @@ class _GameScreenState extends State frameTimes: _frameTimes, bugReport: _remoteBugReport, ); + // `P12`: the frame this game draws, pass by pass and draw by draw, for + // whichever renderer is open when somebody asks. + registerRenderExtensions(() => _renderer); _view = RenderView(camera: _camera); diff --git a/apps/flutter3d_demo_platformer/lib/main.dart b/apps/flutter3d_demo_platformer/lib/main.dart index bd116d785..96bef8e31 100644 --- a/apps/flutter3d_demo_platformer/lib/main.dart +++ b/apps/flutter3d_demo_platformer/lib/main.dart @@ -30,6 +30,7 @@ import 'src/credits.dart'; import 'src/effects.dart'; import 'src/hud.dart'; import 'src/lens.dart'; +import 'src/photo_mode.dart'; import 'src/reactions.dart'; import 'src/run.dart'; import 'src/run_cubit.dart'; @@ -214,6 +215,9 @@ class _GameScreenState extends State final CameraNode _camera = CameraNode(projection: Lens.base); late final RenderView _view; + /// P stops the world and hands the player a camera — see `photo_mode.dart`. + final PhotoMode _photo = PhotoMode(); + /// The device, for whoever needs it before it exists. /// /// **A level used to be dropped on the floor if it got here first.** @@ -316,6 +320,10 @@ class _GameScreenState extends State /// reads [_runOrNull]. RunCubit get _run => _runOrNull!; + /// Writes the run at a checkpoint, on the way into a pause and when the + /// application goes to the background. Built beside [_runOrNull]. + Autosave? _autosave; + /// What a step sounds like. See `soundtrack.dart` for why this is a class /// and not a method: a decision can be tested, an effect inside a widget /// cannot. @@ -340,41 +348,60 @@ class _GameScreenState extends State /// this repeats rather than reinvents. DemoFile? _demos; - /// Where the run being recorded started, and in which level. - Snapshot? _demoStart; - String? _demoLevel; - String? _demoLevelHash; - - /// A checkpoint every so many steps, taken live while the run is recorded. - DigestTrace? _demoCheckpoints; - - /// The demo's own recorder, beside no rewind buffer here — this game has - /// none of the dungeon's kill camera to share one with. - InputTapeRecorder? _demoRecorder; + /// The run being written down: its start, its own recorder — beside no + /// rewind buffer here, this game has none of the dungeon's kill camera to + /// share one with — its checkpoints, and `HR3`'s edits made under it. + DemoRecording? _demo; /// Starts writing the run down, from the state the level is in now. /// /// Now rather than at load: a level resumed from a save begins mid-run, and /// the demo has to begin where the player did. The tape's seed is the dice /// the snapshot carries, which is the one number a replay cannot do without. - void _beginDemo(String asset, LevelReady level) { - final start = level.sim.save(); - _demoStart = start; - _demoLevel = asset; - _demoLevelHash = level.loaded.level.digestHex; - _demoCheckpoints = DigestTrace(); + void _beginDemo(String asset, LevelReady level) => _record( + asset: asset, + levelHash: level.loaded.level.digestHex, + start: level.sim.save(), + ); + + void _record({ + required String asset, + required String levelHash, + required Snapshot start, + }) { _endRecording(); - final recorder = InputTapeRecorder(seed: start.data.integer('random')); - _demoRecorder = recorder; - _loop.recorders.add(recorder); + final demo = DemoRecording( + level: asset, + levelHash: levelHash, + start: start, + seed: start.data.integer('random'), + ); + _demo = demo; + _loop.recorders.add(demo.recorder); } /// Stops the demo's recorder. - InputTapeRecorder? _endRecording() { - final recorder = _demoRecorder; - if (recorder != null) _loop.recorders.remove(recorder); - _demoRecorder = null; - return recorder; + DemoRecording? _endRecording() { + final demo = _demo; + if (demo != null) _loop.recorders.remove(demo.recorder); + _demo = null; + return demo; + } + + /// `HR3`: writes an edit the timeline swapped in before [step] into the + /// demo, so the `.f3drun` replays it rather than parting from the run + /// there. + /// + /// An edit that took effect before this demo began — within a keyframe of + /// the level loading — cannot go into it, and the demo starts again from + /// now, in the level it was loaded as, with the edit at its first step. + void _recordSwap(Level next, int step) { + final demo = _demo; + final sim = _sim; + if (demo == null || sim == null) return; + if (demo.levelSwapped(next, stepsAgo: _rewind.step - step)) return; + _record(asset: demo.level, levelHash: demo.levelHash, start: sim.save()); + _demo?.levelSwapped(next, stepsAgo: 0); } /// Writes the run down when it ends, either way. @@ -383,33 +410,52 @@ class _GameScreenState extends State /// me off the edge" is a sentence, and the demo is the proof. Written once /// at the end rather than as it goes — the same reason the save is. void _endDemo() { - final recorder = _endRecording(); - final start = _demoStart; - final level = _demoLevel; - final levelHash = _demoLevelHash; - final checkpoints = _demoCheckpoints; - if (recorder == null || - start == null || - level == null || - levelHash == null || - checkpoints == null) { - return; - } + final demo = _endRecording(); + if (demo == null) return; _demos?.write( - Demo( - level: level, - levelHash: levelHash, - start: start, - tape: recorder.tape, - buildStamp: _buildStamp, - checkpoints: checkpoints, - platform: defaultTargetPlatform.name, - ), + demo.demo(buildStamp: _buildStamp, platform: defaultTargetPlatform.name), ); } - /// Where the run was standing when it was last written to disk. + /// `HR3`: the level on screen, as the editor sees it. + LiveLevel? _live; + + /// Stops the replay [replayAfterHotSwap] starts after every hot reload. + late final VoidCallback _stopReplays; + + /// The run as it stands, for the replay to compare against. Empty before + /// a level is up, when there is also nothing recorded to replay. + Snapshot _present() => _sim?.save() ?? const Snapshot({}); + + /// Lets a level saved in the editor into this run, or tells the door + /// which level is up now. /// + /// **Registered with the first level rather than in [initState]**, because + /// a `LiveLevel` compares every edit with the level it holds and there is + /// none before one loads; a VM service extension cannot be registered + /// twice, so later levels move the one door along. Everything the edit + /// takes is the run's: it builds the edited level ahead, swaps it in inside + /// [_timeline]'s replay when the simulation has to be lived again, and only + /// then tells this widget, through [PlatformerRun.onLevelEdited]. The step + /// the edit took effect at goes into the demo on the way ([_recordSwap]). + void _takeEdits(LevelReady level) { + final live = _live; + if (live != null) { + live.level = level.loaded.level; + return; + } + final run = _run.run; + registerLevelExtension( + _live = LiveLevel( + level: level.loaded.level, + timeline: _timeline, + prepare: run.prepareEdit, + rebuild: (Level next) => run.installEdit(), + present: (Level next, LevelDiff diff) => run.announceEdit(), + swapped: _recordSwap, + ), + ); + } @override void initState() { @@ -446,6 +492,12 @@ class _GameScreenState extends State // `rp-02`: harmless where the VM service is off — `registerExtension` // just adds an entry nothing ever asks for. registerTimelineExtensions(_timeline, bugReport: _remoteBugReport); + // `P12`: the frame this game draws, pass by pass and draw by draw, for + // whichever renderer is open when somebody asks. + registerRenderExtensions(() => _renderer); + // `HR4`: after every hot reload, the last three seconds lived again under + // the new code, and the console says whether they came out the same. + _stopReplays = replayAfterHotSwap(_timeline, capture: _present); _view = RenderView(camera: _camera); unawaited(_openGraphics()); } @@ -571,9 +623,15 @@ class _GameScreenState extends State onLevelBuilt: (String asset, LevelReady level, GraphicsDevice device) { setState(() => _levelArrived(level, device)); _beginDemo(asset, level); + _takeEdits(level); + }, + onLevelEdited: (String asset, LevelReady level, GraphicsDevice device) { + setState(() => _levelArrived(level, device)); + _takeEdits(level); }, ), ); + _autosave = Autosave(_run.run)..watchLifecycle(); _ticker = createTicker(_onTick)..start(); @@ -739,7 +797,7 @@ class _GameScreenState extends State final sim = _sim; if (sim == null) return; if (!_screen.shouldSave(sim.respawnPoint)) return; - _run.save(); + _autosave?.checkpoint(); } /// What the pad means to a screen rather than to the runner. @@ -844,7 +902,11 @@ class _GameScreenState extends State pointerIsTheGate: Playing.capturesPointer, pointerHeld: _devices.isCaptured, padConnected: _pad.isConnected, + photoMode: _photo.active, ); + // A pause is where most sessions end — the menu opened to quit, the pad + // put down — so the run is written on the way in. + _autosave?.paused(_loop.paused); _loop.advance(dt); // The loop has always counted the simulated time it could not run. Nobody // read it, so a machine that could not keep up ran the game slowly and said @@ -863,6 +925,15 @@ class _GameScreenState extends State _particles.advance(_loop.lastFrame); _runnerVisuals.animate(dt, _runner); _placeCamera(dt); + if (_photo.active) { + // The paused loop drains nothing, so the look is taken here, and the + // photo camera is put on the node after the follow camera was. + final look = Vector2.zero(); + _drainLook(look); + _photo + ..fly(_input, look, dt) + ..applyTo(_camera); + } _fixtures?.sync(_frames.elapsed); _burnLamps(); _keepSaved(); @@ -873,9 +944,84 @@ class _GameScreenState extends State // cached outcome off `RunOutcome.playing`. _run.observe(); unawaited(_run.advance()); - if (mounted) setState(() {}); + // Not while a photo is drawn: a rebuild draws a frame on the renderer the + // tiles are drawn on — see `capturePhoto`. + if (mounted && !_photo.busy) setState(() {}); } + /// Opens photo mode where the follow camera is, or closes it. + void _togglePhoto() { + final camera = _followCamera; + final level = _loaded; + final runner = _runner; + if (_photo.active) { + setState(_photo.leave); + return; + } + if (camera == null || level == null || runner == null) return; + setState( + () => _photo.enter( + world: level.collision, + eye: camera.eye, + target: camera.target, + anchor: runner.body.position, + fieldOfView: Lens.base.fovYRadians + camera.extraFov, + ), + ); + } + + /// Draws the photo at [scale] times the window and saves it. + Future _takePhoto(int scale) async { + final renderer = _renderer; + if (renderer == null || _photo.busy) return; + final size = + MediaQuery.sizeOf(context) * MediaQuery.devicePixelRatioOf(context); + setState(() => _photo.busy = true); + // The frame saying so is drawn first; after it nothing redraws until the + // picture is done. + await SchedulerBinding.instance.endOfFrame; + final taken = await takePhoto( + renderer: renderer, + scene: _scene, + camera: _camera, + width: (size.width * scale).round(), + height: (size.height * scale).round(), + settings: _renderSettings(filtered: false), + filter: _photo.filter, + clearColor: _view.clearColor, + shelf: defaultPhotoShelf('platformer'), + name: 'platformer-${DateTime.now().millisecondsSinceEpoch}.png', + ); + if (!mounted) return; + setState(() { + _photo + ..busy = false + ..said = taken.saved.message; + }); + } + + /// What every frame is drawn with; [filtered] puts photo mode's filter on. + RenderSettings _renderSettings({bool filtered = true}) => RenderSettings( + fog: FogSettings( + color: _loaded?.level.fogColor ?? Vector3(0.05, 0.07, 0.12), + density: _loaded?.level.fogDensity ?? 0.0, + ), + // Three cascades, because this level is a hundred and twenty metres by two + // hundred and sixty and one map over that is fourteen centimetres of world + // per texel — which drew the runner's own shadow as a blurred slab beside + // them, and was reported as the character being drawn twice. + // + // 2048 rather than the default 1024, which is a real cost: the atlas is + // `resolution × cascades` wide, so this is 6144 × 2048. What it buys is the + // character's own shadow reading as soft rather than as a staircase — at + // 1024 the near cascade is 1.9 cm of world per texel and the penguin's + // shadow is a visible flight of steps beside it. + shadows: const ShadowSettings(cascades: 3, resolution: 2048), + look: filtered && _photo.active + ? _photo.look(const LookSettings()) + : const LookSettings(), + ); + /// One simulation step. Nothing here draws. /// What the last simulated step reported. See where it is drained. List _lastStep = const []; @@ -895,10 +1041,7 @@ class _GameScreenState extends State // `rp-01`'s own checkpoint, taken here rather than replayed later from the // finished tape — see the dungeon's identical placement for why the step // number has to be the recorder's own. - final demoRecorder = _demoRecorder; - if (demoRecorder != null) { - _demoCheckpoints?.observe(demoRecorder.tape.steps, sim.save().toJson()); - } + _demo?.observe(sim.save); // Drained once, here, and handed to everything that wants it. Draining // empties the buffer, so two readers each draining would each get half of // what happened — and which half would depend on the order they ran in. @@ -1057,9 +1200,11 @@ class _GameScreenState extends State @override void dispose() { + _stopReplays(); // Null if the device never opened: nothing ran, so there is nothing to // keep — and the cubit to close was never built either. _runOrNull?.save(); + _autosave?.dispose(); // Closed like the three below, and the cubit unhooks itself from the // session first — see `RunCubit.close` for why the order matters. unawaited(_runOrNull?.close()); @@ -1144,6 +1289,26 @@ class _GameScreenState extends State focusNode: _keyboard, autofocus: true, onKeyEvent: (_, KeyEvent event) { + // Photo mode before the settings: Escape there means "back to the + // game", and the panel would take it as "open me". + if (event is KeyDownEvent && + _screen.state.started && + !_settings.state.isOpen && + !_photo.busy && + (event.logicalKey == LogicalKeyboardKey.keyP || + (_photo.active && + event.logicalKey == LogicalKeyboardKey.escape))) { + _togglePhoto(); + return KeyEventResult.handled; + } + final photoSays = _photo.key( + event, + onCapture: (int scale) => unawaited(_takePhoto(scale)), + ); + if (photoSays != null) { + setState(() {}); + return photoSays; + } // The settings get the key first — see `settingsKeys` for the order // and for the bug this call fixed here: R sat above the rebinding, so // a player at the end of a run could not bind R to anything. @@ -1210,29 +1375,7 @@ class _GameScreenState extends State scene: scene, view: _view, onBeforeFrame: () {}, - settings: () => RenderSettings( - fog: FogSettings( - color: - _loaded?.level.fogColor ?? Vector3(0.05, 0.07, 0.12), - density: _loaded?.level.fogDensity ?? 0.0, - ), - // Three cascades, because this level is a hundred and twenty - // metres by two hundred and sixty and one map over that is - // fourteen centimetres of world per texel — which drew the - // runner's own shadow as a blurred slab beside them, and was - // reported as the character being drawn twice. - // - // 2048 rather than the default 1024, which is a real cost: - // the atlas is `resolution × cascades` wide, so this is - // 6144 × 2048. What it buys is the character's own shadow - // reading as soft rather than as a staircase — at 1024 the - // near cascade is 1.9 cm of world per texel and the penguin's - // shadow is a visible flight of steps beside it. - shadows: const ShadowSettings( - cascades: 3, - resolution: 2048, - ), - ), + settings: _renderSettings, presentFrame: presentFrame, ), ), @@ -1260,7 +1403,10 @@ class _GameScreenState extends State // Not behind the title card: the tallies and its own "Click to // play" banner showed through it, saying the same thing twice // and counting a run the player has not started. - if (sim != null && _screen.state.started) + if (_photo.active) PhotoBar(mode: _photo), + // Not in photo mode either: the picture is the level, and the + // tallies over it are not. + if (sim != null && _screen.state.started && !_photo.active) Hud( coins: _runner?.purse['coin'] ?? 0, deaths: sim.deaths, diff --git a/apps/flutter3d_demo_platformer/lib/src/photo_mode.dart b/apps/flutter3d_demo_platformer/lib/src/photo_mode.dart new file mode 100644 index 000000000..1115c8731 --- /dev/null +++ b/apps/flutter3d_demo_platformer/lib/src/photo_mode.dart @@ -0,0 +1,163 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter3d/flutter3d.dart'; +import 'package:flutter3d_game_platformer/flutter3d_game_platformer.dart'; +import 'package:flutter3d_sim/flutter3d_sim.dart'; +import 'package:vector_math/vector_math.dart' hide Colors; + +import 'lens.dart'; + +/// This game's photo mode — `N8`: the world stopped, a camera to fly, a +/// filter to pick and a picture larger than the window. +/// +/// The parts are the engine's: [PhotoCamera] keeps the camera in the level, +/// [PhotoFilter] is a change to the composite's look, and `takePhoto` draws, +/// encodes and saves. What is here is only what is this game's: which keys +/// do what, and how the camera meets this game's lens. +/// +/// **The world's own input, read rather than stepped.** The loop is paused, +/// so nothing drains [InputState]; the keys the player already has for +/// running fly the camera instead, read as held each frame, and the jump and +/// the drop take it up and down. +final class PhotoMode { + PhotoCamera? _camera; + int _filter = 0; + + /// Whether the world is stopped for a picture. + bool get active => _camera != null; + + /// Whether a picture is being drawn; the keys wait for it, and the game + /// stops redrawing its surface — see `capturePhoto`. + bool busy = false; + + /// The last thing a picture had to say: where it went, or why not. + String? said; + + PhotoFilter get filter => PhotoFilter.all[_filter]; + + /// Stops the world and takes the camera from where the game had it. + void enter({ + required CollisionWorld world, + required Vector3 eye, + required Vector3 target, + required Vector3 anchor, + required double fieldOfView, + }) { + _camera = PhotoCamera(world: world) + ..begin( + eye: eye, + target: target, + anchor: anchor, + fieldOfView: fieldOfView, + ); + said = null; + } + + void leave() => _camera = null; + + /// One frame of flying. + void fly(InputState input, Vector2 look, double dt) { + final camera = _camera; + if (camera == null || busy) return; + camera.look(-look.x * _sensitivity, -look.y * _sensitivity); + double axis(GameAction plus, GameAction minus) => + (input.held(plus) ? 1.0 : 0.0) - (input.held(minus) ? 1.0 : 0.0); + final intent = Vector3( + axis(GameAction.moveRight, GameAction.moveLeft), + axis(GameAction.jump, PlatformerActions.dropThrough), + axis(GameAction.moveForward, GameAction.moveBack), + ); + // Sprint is a faster camera, as it is a faster runner. + camera.fly(input.held(GameAction.sprint) ? intent * 3.0 : intent, dt); + } + + /// The follow camera's own mouse sensitivity, so the view turns at the + /// rate the player's hand already knows. + static const double _sensitivity = 0.0035; + + /// Puts the photo camera on the scene's [node]. + void applyTo(CameraNode node) { + final camera = _camera; + if (camera == null) return; + node + ..setPositionFrom(camera.eye) + ..lookAt(camera.target, up: camera.up) + ..projection = Lens.base.copyWith(fovYRadians: camera.fieldOfView); + } + + /// The game's look with the chosen filter on it. + LookSettings look(LookSettings game) => filter.applyTo(game); + + /// The keys photo mode owns while it is open, or null for one it does not. + /// + /// [onCapture] is asked for a picture at a multiple of the window's size. + KeyEventResult? key(KeyEvent event, {required void Function(int) onCapture}) { + final camera = _camera; + if (camera == null || event is KeyUpEvent) return null; + if (busy) return KeyEventResult.handled; + final repeatable = event is KeyDownEvent || event is KeyRepeatEvent; + switch (event.logicalKey) { + // Brackets rather than the arrows, which already fly the camera. + case LogicalKeyboardKey.bracketRight when event is KeyDownEvent: + _filter = (_filter + 1) % PhotoFilter.all.length; + case LogicalKeyboardKey.bracketLeft when event is KeyDownEvent: + _filter = (_filter - 1) % PhotoFilter.all.length; + case LogicalKeyboardKey.comma when repeatable: + camera.tilt(-0.05); + case LogicalKeyboardKey.period when repeatable: + camera.tilt(0.05); + case LogicalKeyboardKey.equal when repeatable: + camera.zoom(1.1); + case LogicalKeyboardKey.minus when repeatable: + camera.zoom(1.0 / 1.1); + case LogicalKeyboardKey.enter when event is KeyDownEvent: + onCapture(HardwareKeyboard.instance.isShiftPressed ? 4 : 2); + default: + return null; + } + return KeyEventResult.handled; + } +} + +/// The strip along the bottom of the screen in photo mode: the filter, the +/// keys, and the last picture's sentence. +class PhotoBar extends StatelessWidget { + const PhotoBar({required this.mode, super.key}); + + final PhotoMode mode; + + @override + Widget build(BuildContext context) => IgnorePointer( + child: Align( + alignment: Alignment.bottomCenter, + child: Container( + margin: const EdgeInsets.all(16), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.6), + borderRadius: BorderRadius.circular(8), + ), + child: DefaultTextStyle( + style: const TextStyle(color: Colors.white, fontSize: 14), + textAlign: TextAlign.center, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + mode.busy + ? 'Taking the picture…' + : 'Photo mode · filter: ${mode.filter.name}', + style: const TextStyle(fontWeight: FontWeight.bold), + ), + const Text( + 'WASD fly · Space/C up and down · Shift faster · [ ] filter · ' + ', . tilt · − = zoom · Enter 2× · Shift+Enter 4× · P back', + ), + if (mode.said case final said?) Text(said), + ], + ), + ), + ), + ), + ); +} diff --git a/apps/flutter3d_demo_platformer/lib/src/run.dart b/apps/flutter3d_demo_platformer/lib/src/run.dart index 11156e92d..36cfaf25b 100644 --- a/apps/flutter3d_demo_platformer/lib/src/run.dart +++ b/apps/flutter3d_demo_platformer/lib/src/run.dart @@ -3,6 +3,7 @@ import 'package:flutter3d_app/flutter3d_app.dart'; import 'package:flutter3d_game/flutter3d_game.dart'; // RunSession import 'package:flutter3d_game_platformer/flutter3d_game_platformer.dart'; +import 'package:flutter3d_physics_native/flutter3d_physics_native.dart'; import 'package:flutter3d_sim/flutter3d_sim.dart'; import 'looks.dart'; @@ -48,15 +49,29 @@ typedef Carried = ({int lives, int deaths, double elapsed, int coins}); /// /// Deliberately *not* in `staging.dart`: everything here needs a /// [GraphicsDevice], and that file exists to be callable without one. +/// +/// [document] builds that level instead of reading [asset]: a level edited +/// in the editor, which exists as a document and not yet as an asset. Future<({EntityRegistry kinds, LoadedLevel loaded, FixtureVisuals fixtures})> -openLevel(String asset, {required GraphicsDevice device}) async { +openLevel( + String asset, { + required GraphicsDevice device, + Level? document, +}) async { final kinds = platformerRegistry(); - final loaded = await LevelLoader().load( - asset, - device: device, - registry: kinds, - rules: platformerRules(), - ); + final loaded = document == null + ? await const LevelLoader().load( + asset, + device: device, + registry: kinds, + rules: platformerRules(), + ) + : await const LevelLoader().build( + document, + device: device, + registry: kinds, + rules: platformerRules(), + ); final fixtures = FixtureVisuals( loaded.scene, loaded, @@ -81,6 +96,7 @@ final class PlatformerRun extends RunSession { required this.input, required this.openDevice, required this.onLevelBuilt, + this.onLevelEdited, this.startingLives = 3, this.pauseBetweenLevels = const Duration(milliseconds: 1400), }); @@ -101,6 +117,16 @@ final class PlatformerRun extends RunSession { final void Function(String asset, LevelReady level, GraphicsDevice device) onLevelBuilt; + /// What the widget does with an edit of the level being played, once the + /// run is back at now. [onLevelBuilt] when null. + /// + /// **Not the same as a load**, because the run goes on: a load begins a + /// new demo, and an edit is written into the one being recorded + /// (`DemoRecording.levelSwapped`). A widget that began a new demo here + /// would lose everything played before the edit. + final void Function(String asset, LevelReady level, GraphicsDevice device)? + onLevelEdited; + final int startingLives; /// A beat on the results screen before the next level: arriving somewhere new @@ -118,7 +144,24 @@ final class PlatformerRun extends RunSession { Future open(String asset) async { final device = await openDevice(); _device = device; - final (:kinds, :loaded, :fixtures) = await openLevel(asset, device: device); + final level = await _build(asset, device); + onLevelBuilt(asset, level, device); + return level; + } + + Future _build( + String asset, + GraphicsDevice device, { + Level? document, + }) async { + // The physics core, which the browser fetches as WebAssembly once; + // natively it is in the app already and this returns at once. + if (physicsBackend != 'dart') await loadPhysicsCore(); + final (:kinds, :loaded, :fixtures) = await openLevel( + asset, + device: device, + document: document, + ); final staged = stage( loaded.level, @@ -132,13 +175,111 @@ final class PlatformerRun extends RunSession { elapsed: _carried?.elapsed ?? 0.0, ); - final level = LevelReady( - loaded: loaded, - staged: staged, - fixtures: fixtures, + return LevelReady(loaded: loaded, staged: staged, fixtures: fixtures); + } + + /// An edit of the level being played, built and waiting for + /// [installEdit], with the build it was made to replace. + ({LevelReady edited, LevelReady replaces})? _edit; + + /// Whether an edit went in and the widget has not been told yet. + bool _editUntold = false; + + /// Builds [next], an edit of the level being played, without touching the + /// run: textures, meshes, colliders and a simulation of its own, so the + /// swap itself can be synchronous. Throws, and changes nothing, when + /// nothing is being played, when [next] is another level, or when it does + /// not build. + Future prepareEdit(Level next) async { + final playing = status; + if (playing is! RunPlaying) { + throw StateError('no level is being played'); + } + final name = playing.level.loaded.level.name; + if (next.name != name) { + throw StateError('the game is playing $name, not ${next.name}'); + } + final device = await openDevice(); + final edited = await _build(playing.asset, device, document: next); + final waiting = _edit; + if (waiting != null) close(waiting.edited); + _edit = (edited: edited, replaces: playing.level); + } + + /// Swaps in what [prepareEdit] built, the run carried over. Called inside + /// the timeline's replay, so nothing here may tell the widget: that is + /// [announceEdit]'s, once the run has been brought back to now. + /// + /// A build made for a level that has since been left is let go. + void installEdit() { + final edit = _edit; + if (edit == null) return; + _edit = null; + if (!identical(level, edit.replaces)) { + close(edit.edited); + return; + } + _editUntold = replaceLevel(edit.edited); + } + + /// Installs the edit if [installEdit] has not, then hands the new build to + /// [onLevelEdited]: the runner's node, the camera, the edit written into + /// the demo. + void announceEdit() { + installEdit(); + final playing = status; + final device = _device; + if (!_editUntold || playing is! RunPlaying || device == null) { + return; + } + _editUntold = false; + (onLevelEdited ?? onLevelBuilt)(playing.asset, playing.level, device); + } + + /// Plays [demo] in the level being played, from its start, with the edits + /// it recorded swapped in where they were (`HR3`), and says whether it + /// kept to its checkpoints. The run is left where the demo ended. + /// + /// **Every edit is built before the first step**, for the reason + /// [prepareEdit] builds ahead: a build waits on the device, and a replay + /// that stopped to wait in the middle would be a replay with a hole in it. + /// The swap itself is [replaceLevel], as it was when the edit arrived live. + /// + /// Throws, and plays nothing, when the level up is not the one the demo + /// starts in, by path or by content. + Future replay(Demo demo) async { + final playing = status; + if (playing is! RunPlaying) { + throw StateError('no level is being played'); + } + if (playing.asset != demo.level) { + throw StateError( + 'the demo starts in ${demo.level}, and ${playing.asset} is up', + ); + } + final hash = playing.level.loaded.level.digestHex; + if (hash != demo.levelHash) { + throw StateError( + '${demo.level} digests to $hash, and the demo was recorded in ' + '${demo.levelHash}; the level has changed since', + ); + } + final device = await openDevice(); + final edits = [ + for (final swap in demo.levelSwaps) + await _build(playing.asset, device, document: swap.level), + ]; + final next = edits.iterator; + return replayDemo( + demo: demo, + input: input, + restore: (Snapshot snapshot) => level!.sim.restore(snapshot), + save: () => level!.sim.save(), + stepSim: (double dt) => level!.sim.step(dt), + swapLevel: (Level _) { + if (next.moveNext()) replaceLevel(next.current); + }, ); - onLevelBuilt(asset, level, device); - return level; } @override @@ -154,6 +295,12 @@ final class PlatformerRun extends RunSession { void restoreInto(LevelReady level, Snapshot snapshot) => level.sim.restore(snapshot); + /// The elapsed time in steps. It is carried from level to level and adds up + /// the loop's fixed sixtieths, so it is the run's step count across levels + /// without a counter of its own. + @override + int stepOf(LevelReady level) => (level.sim.elapsed * 60.0).round(); + /// The tally of the run so far. Only this side knows what the level after /// this one is, so only this side can carry anything into it. @override @@ -190,6 +337,11 @@ final class PlatformerRun extends RunSession { @override void close(LevelReady level) { level.fixtures.dispose(); + // The core's world, which the collector would get to eventually, let go + // with the rest of the level. + if (level.staged.dynamics case final NativeDynamics native) { + native.dispose(); + } final device = _device; if (device != null) level.loaded.dispose(device); } diff --git a/apps/flutter3d_demo_platformer/lib/src/staging.dart b/apps/flutter3d_demo_platformer/lib/src/staging.dart index 8cc25783d..fb21705e4 100644 --- a/apps/flutter3d_demo_platformer/lib/src/staging.dart +++ b/apps/flutter3d_demo_platformer/lib/src/staging.dart @@ -1,7 +1,23 @@ import 'package:flutter3d_game_platformer/flutter3d_game_platformer.dart'; +import 'package:flutter3d_physics_native/flutter3d_physics_native.dart'; import 'package:flutter3d_sim/flutter3d_sim.dart'; import 'package:vector_math/vector_math.dart'; +/// Which dynamics steps the crates: the native core, unless the build is +/// made with `--dart-define=FLUTTER3D_PHYSICS=dart`, which is the reference +/// in plain Dart — the same crates, and other bits. +const String physicsBackend = String.fromEnvironment( + 'FLUTTER3D_PHYSICS', + defaultValue: 'native', +); + +/// The dynamics [physicsBackend] names, for [world]. In the browser the +/// core must be loaded first, with `loadPhysicsCore`. +RigidDynamics platformerDynamics(CollisionWorld world) => + physicsBackend == 'dart' + ? Dynamics(world: world) + : NativeDynamics(world: world); + /// A level, spawned, with somebody standing in it ready to be stepped. final class Staged { const Staged({ @@ -17,7 +33,7 @@ final class Staged { /// The one registry that validated the document and then spawned it. final EntityRegistry registry; - final Dynamics dynamics; + final RigidDynamics dynamics; final ActorSystem actors; final MechanismWorld mechanisms; final Runner runner; @@ -66,6 +82,10 @@ Staged stage( int deaths = 0, double elapsed = 0.0, GameRandom? random, + + /// What steps the crates: [platformerDynamics] unless a caller hands over + /// another, as the test that compares the two does. + RigidDynamics Function(CollisionWorld world)? dynamicsFor, }) { // One registry validates the document and then spawns it. Two could disagree // about what a document may contain, which is the failure this seam was built @@ -73,7 +93,7 @@ Staged stage( // world, exactly as the shooter tells its monster kind where the bestiary is. final kinds = registry ?? platformerRegistry(); - final dynamics = Dynamics(world: world); + final dynamics = (dynamicsFor ?? platformerDynamics)(world); (kinds[PlatformerEntities.crate] as CrateKind?)?.dynamics = dynamics; // **One generator for the whole world, and it is the same object the diff --git a/apps/flutter3d_demo_platformer/pubspec.yaml b/apps/flutter3d_demo_platformer/pubspec.yaml index df89ff2f4..860e6c183 100644 --- a/apps/flutter3d_demo_platformer/pubspec.yaml +++ b/apps/flutter3d_demo_platformer/pubspec.yaml @@ -40,6 +40,12 @@ dependencies: flutter3d_audio: path: ../../packages/flutter3d_audio + # The physics core the crates are stepped by: C built by its hook for each + # target, WebAssembly in the browser. `FLUTTER3D_PHYSICS=dart` at build + # time steps them with flutter3d_physics' reference instead. + flutter3d_physics_native: + path: ../../packages/flutter3d_physics_native + # Dust, sparks and the flame on a lamp. One pool, one draw call. flutter3d_particles: path: ../../packages/flutter3d_particles @@ -73,6 +79,11 @@ dev_dependencies: flutter3d_cpu: path: ../../packages/flutter3d_cpu + # `N5`'s `testReplay`: `test/replay_test.dart` replays a recorded run on the + # software backend and checks it by digest and by golden. + flutter3d_testing: + path: ../../packages/flutter3d_testing + # `ap-12`: the build hook (`hook/build.dart`) that converts # `assets_src/models/*.glb` into `flutter3d_generated/*.f3d` on every # build — `path`, the same convention every other sibling package in this diff --git a/apps/flutter3d_demo_platformer/test/climbing.dart b/apps/flutter3d_demo_platformer/test/climbing.dart index 4b150c64a..df847084b 100644 --- a/apps/flutter3d_demo_platformer/test/climbing.dart +++ b/apps/flutter3d_demo_platformer/test/climbing.dart @@ -48,7 +48,7 @@ final class Climb { final EntityRegistry kinds = platformerRegistry(); late final Staged staged; - Dynamics get dynamics => staged.dynamics; + RigidDynamics get dynamics => staged.dynamics; MechanismWorld get mechanisms => staged.mechanisms; ActorSystem get actors => staged.actors; Runner get runner => staged.runner; diff --git a/apps/flutter3d_demo_platformer/test/demo_level_swap_test.dart b/apps/flutter3d_demo_platformer/test/demo_level_swap_test.dart new file mode 100644 index 000000000..f718f3197 --- /dev/null +++ b/apps/flutter3d_demo_platformer/test/demo_level_swap_test.dart @@ -0,0 +1,225 @@ +/// `HR3`: a run with an editor's edit in it, written down and played back. +/// +/// flutter test test/demo_level_swap_test.dart +/// +/// The live half goes the way `main.dart` goes: a rewind buffer and the demo's +/// own recorder beside it, the edit through `LiveLevel` and the timeline, the +/// step it took effect at written into the demo by `LiveLevel.swapped`. The +/// replay is `PlatformerRun.replay` in a game of its own, from the file's +/// JSON. The claim is that the two arrive at the same bytes — and that +/// without the swap in the file they would not. +library; + +import 'dart:convert'; + +import 'package:flutter3d/flutter3d.dart' hide Material; +import 'package:flutter3d_app/flutter3d_app.dart'; +import 'package:flutter3d_cpu/flutter3d_cpu.dart'; +import 'package:flutter3d_demo_platformer/src/run.dart'; +import 'package:flutter3d_game/flutter3d_game.dart'; +import 'package:flutter3d_sim/flutter3d_sim.dart'; +import 'package:flutter_test/flutter_test.dart'; + +const String _first = 'assets/levels/first_steps.json'; +const double _dt = 1.0 / 60.0; + +final class _Storage implements Storage { + final Map documents = {}; + + @override + String? read(String name) => documents[name]; + + @override + bool write(String name, String contents) { + documents[name] = contents; + return true; + } + + @override + void remove(String name) => documents.remove(name); +} + +PlatformerRun _game(InputState input) { + final device = CpuDevice( + width: 16, + height: 9, + shaders: CpuShaderLibrary(builtinCpuShaders()), + ); + return PlatformerRun( + firstLevel: _first, + saves: SaveFile(appName: 'platformer', storage: _Storage()), + input: input, + openDevice: () async => device, + onLevelBuilt: (String asset, LevelReady level, GraphicsDevice device) {}, + pauseBetweenLevels: Duration.zero, + ); +} + +/// Forward most of the time, a jump a second: a run the floor matters to. +void _play(InputState input, int step) { + if (step % 30 < 22) { + input.press(GameAction.moveForward); + } else { + input.release(GameAction.moveForward); + } + if (step % 60 == 0) input.press(GameAction.jump); + if (step % 60 == 3) input.release(GameAction.jump); +} + +/// Steps [from] to [to] the way `GameLoop` and `main.dart`'s `_step` do: +/// both recorders, the keyframe before the step, the checkpoint after it. +void _live( + PlatformerRun run, + InputState input, + RewindBuffer rewind, + DemoRecording demo, { + required int from, + required int to, +}) { + for (var step = from; step < to; step++) { + _play(input, step); + rewind.recorder.record(input); + demo.recorder.record(input); + input.beginStep(); + if (rewind.keyframeDue) rewind.keyframe(run.level!.sim.save()); + run.level!.sim.step(_dt); + demo.observe(run.level!.sim.save); + input.endStep(); + } +} + +/// [level] with every brush half a metre lower: the runner, mid-run, is +/// suddenly over a floor that is not where it was. +Level _lowered(Level level) { + final document = level.toJson(); + return Level.fromJson({ + ...document, + 'brushes': [ + for (final brush in document['brushes']! as List) + switch (brush) { + {'at': final List at} => { + ...brush as Map, + 'at': [ + (at[0]! as num).toDouble(), + (at[1]! as num).toDouble() - 0.5, + (at[2]! as num).toDouble(), + ], + }, + _ => brush, + }, + ], + }); +} + +String _bytes(Snapshot snapshot) => jsonEncode(snapshot.toJson()); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('a run with an edit at step K, saved and replayed, arrives where the ' + 'live run did', () async { + const edited = 150; + const end = 300; + final input = InputState(); + final run = _game(input); + await run.begin(); + final start = run.level!.sim.save(); + final demo = DemoRecording( + level: _first, + levelHash: run.level!.loaded.level.digestHex, + start: start, + seed: start.data.integer('random'), + ); + final rewind = RewindBuffer(stepsPerSecond: 60, history: 10.0); + final timeline = RunTimeline( + rewind: rewind, + input: input, + stepSim: (double dt) => run.level!.sim.step(dt), + restore: (Snapshot snapshot) => run.level!.sim.restore(snapshot), + ); + + _live(run, input, rewind, demo, from: 0, to: edited); + final applied = await LiveLevel( + level: run.level!.loaded.level, + timeline: timeline, + prepare: run.prepareEdit, + rebuild: (Level next) => run.installEdit(), + present: (Level next, LevelDiff diff) => run.announceEdit(), + swapped: (Level next, int step) => + demo.levelSwapped(next, stepsAgo: rewind.step - step), + ).applyWhenReady(_lowered(run.level!.loaded.level)); + _live(run, input, rewind, demo, from: edited, to: end); + final arrived = _bytes(run.level!.sim.save()); + + final sent = jsonEncode(demo.demo(buildStamp: 'test-build').toJson()); + final file = Demo.fromJson(jsonDecode(sent) as Map); + expect(file.steps, end); + expect( + file.levelSwaps.single.step, + applied.swappedAt, + reason: 'the demo started with the timeline, so the steps agree', + ); + expect(file.levelSwaps.single.step, lessThan(edited)); + expect(file.levelSwaps.single.levelHash, run.level!.loaded.level.digestHex); + + final replayInput = InputState(); + final replayRun = _game(replayInput); + await replayRun.begin(); + final replayed = await replayRun.replay(file); + + expect(replayed.steps, end); + expect( + replayed.divergence, + isNull, + reason: 'the replay keeps to the checkpoints written live', + ); + expect(_bytes(replayRun.level!.sim.save()), arrived); + expect( + replayRun.level!.loaded.level.digestHex, + file.levelSwaps.single.levelHash, + reason: 'the replay ends in the edited level, as the run did', + ); + + // Mutation: drop the swap from the file — the replay plays through the + // shipped floor to the end — and it has to arrive somewhere else, or the + // edit never reached the runner and the test above compared nothing. + final unswapped = Demo( + level: file.level, + levelHash: file.levelHash, + start: file.start, + tape: file.tape, + buildStamp: file.buildStamp, + checkpoints: file.checkpoints, + ); + final controlInput = InputState(); + final control = _game(controlInput); + await control.begin(); + final parted = await control.replay(unswapped); + expect(_bytes(control.level!.sim.save()), isNot(arrived)); + expect(parted.divergence, isNotNull); + expect(parted.divergence!.step, greaterThan(file.levelSwaps.single.step)); + }); + + test('a demo recorded in another level is refused before a step', () async { + final run = _game(InputState()); + await run.begin(); + final start = run.level!.sim.save(); + + // Mutation: drop the path check in `replay` — the tape plays into the + // level that happens to be up and answers with a divergence, which reads + // as a determinism bug rather than as the wrong file. + await expectLater( + run.replay( + Demo( + level: 'assets/levels/ascent.json', + levelHash: run.level!.loaded.level.digestHex, + start: start, + tape: InputTape(seed: 1, frames: const []), + buildStamp: 'test-build', + checkpoints: DigestTrace(), + ), + ), + throwsA(isA()), + ); + }); +} diff --git a/apps/flutter3d_demo_platformer/test/first_steps_test.dart b/apps/flutter3d_demo_platformer/test/first_steps_test.dart index 68e9719bc..0bcf1dcb3 100644 --- a/apps/flutter3d_demo_platformer/test/first_steps_test.dart +++ b/apps/flutter3d_demo_platformer/test/first_steps_test.dart @@ -55,7 +55,7 @@ final class _Game { final EntityRegistry kinds = platformerRegistry(); late final Staged staged; - Dynamics get dynamics => staged.dynamics; + RigidDynamics get dynamics => staged.dynamics; MechanismWorld get mechanisms => staged.mechanisms; ActorSystem get actors => staged.actors; Runner get runner => staged.runner; diff --git a/apps/flutter3d_demo_platformer/test/frame_test.dart b/apps/flutter3d_demo_platformer/test/frame_test.dart index 7959ed2f8..cf7bbb062 100644 --- a/apps/flutter3d_demo_platformer/test/frame_test.dart +++ b/apps/flutter3d_demo_platformer/test/frame_test.dart @@ -140,7 +140,7 @@ final class _Shown { final CameraNode camera; MechanismWorld get mechanisms => staged.mechanisms; - Dynamics get dynamics => staged.dynamics; + RigidDynamics get dynamics => staged.dynamics; ActorSystem get actors => staged.actors; Runner get runner => staged.runner; PlatformerSimulation get sim => staged.sim; diff --git a/apps/flutter3d_demo_platformer/test/goldens/ascent-120.png b/apps/flutter3d_demo_platformer/test/goldens/ascent-120.png new file mode 100644 index 000000000..9158c0a5a Binary files /dev/null and b/apps/flutter3d_demo_platformer/test/goldens/ascent-120.png differ diff --git a/apps/flutter3d_demo_platformer/test/goldens/ascent-600.png b/apps/flutter3d_demo_platformer/test/goldens/ascent-600.png new file mode 100644 index 000000000..b875f6adc Binary files /dev/null and b/apps/flutter3d_demo_platformer/test/goldens/ascent-600.png differ diff --git a/apps/flutter3d_demo_platformer/test/native_physics_test.dart b/apps/flutter3d_demo_platformer/test/native_physics_test.dart new file mode 100644 index 000000000..5d41a967a --- /dev/null +++ b/apps/flutter3d_demo_platformer/test/native_physics_test.dart @@ -0,0 +1,238 @@ +/// The shipped level on the native core, held against the reference. +/// +/// The game steps its crates with `NativeDynamics`; `stage` takes any +/// `RigidDynamics`, and this builds the level on both and plays it the way +/// `playthrough_test` does. What is held: every crate lands where the +/// reference lands it and none falls out of the world, the runner crosses +/// what it crosses with the reference, a crate pushed moves, and two runs on +/// the core land on the same bits. +library; + +import 'dart:convert'; +import 'dart:io'; +import 'dart:math' as math; + +import 'package:flutter3d_demo_platformer/src/staging.dart'; +import 'package:flutter3d_game_platformer/flutter3d_game_platformer.dart'; +import 'package:flutter3d_physics_native/flutter3d_physics_native.dart'; +import 'package:flutter3d_sim/flutter3d_sim.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:vector_math/vector_math.dart'; + +const double _dt = 1.0 / 60.0; + +Level _shipped() => Level.fromJson( + jsonDecode(File('assets/levels/ascent.json').readAsStringSync()) + as Map, +); + +/// The shipped level staged on [native] or the reference, and stepped. +final class _Game { + _Game({required bool native}) { + level.addTo(world); + staged = stage( + level, + world, + input: input, + registry: platformerRegistry(), + dynamicsFor: native + ? (world) { + final dynamics = NativeDynamics(world: world); + addTearDown(dynamics.dispose); + return dynamics; + } + : (world) => Dynamics(world: world), + ); + } + + final Level level = _shipped(); + final CollisionWorld world = CollisionWorld(); + final InputState input = InputState(); + late final Staged staged; + + List get crates => staged.dynamics.bodies; + + bool _forward = false; + bool _jump = false; + + void step({bool forward = false, bool jump = false}) { + input.beginStep(); + if (forward != _forward) { + forward + ? input.press(GameAction.moveForward) + : input.release(GameAction.moveForward); + _forward = forward; + } + if (jump != _jump) { + jump ? input.press(GameAction.jump) : input.release(GameAction.jump); + _jump = jump; + } + staged.sim.step(_dt); + input.endStep(); + } + + void settle(int steps) { + for (var i = 0; i < steps; i++) { + step(); + } + } + + /// Forward, jumping on the rhythm `playthrough_test` uses. + void autopilot(int steps) { + for (var i = 0; i < steps; i++) { + step(forward: true, jump: i % 30 < 22); + } + } +} + +/// Whether [crate] starts more than a centimetre deep in level geometry +/// other than what it stands on, by their boxes. +bool _inWall(RigidBody crate, CollisionWorld world) { + final c = crate.collider.bounds; + for (final other in world.statics) { + final o = other.bounds; + final dx = math.min(c.max.x, o.max.x) - math.max(c.min.x, o.min.x); + final dy = math.min(c.max.y, o.max.y) - math.max(c.min.y, o.min.y); + final dz = math.min(c.max.z, o.max.z) - math.max(c.min.z, o.min.z); + if (dx > 0.01 && dy > 0.01 && dz > 0.01) return true; + } + return false; +} + +void main() { + test('the level stages on the core and its crates land where the ' + 'reference lands them', () { + final native = _Game(native: true); + final reference = _Game(native: false); + expect(native.staged.dynamics, isA()); + expect(native.crates, isNotEmpty); + expect(native.crates.length, reference.crates.length); + // A crate placed inside a wall is pushed out of it by each solver its + // own way, and may land elsewhere by centimetres. The shipped level has + // nine of them: four 1.4 m wide in gaps of a metre between pillars, and + // five half sunk in a step. Those are held only to stay in the world; + // the rest must agree. + final jammed = { + for (var i = 0; i < reference.crates.length; i++) + if (_inWall(reference.crates[i], reference.world)) i, + }; + native.settle(180); + reference.settle(180); + for (var i = 0; i < native.crates.length; i++) { + final a = native.crates[i].position, b = reference.crates[i].position; + expect(a.y, greaterThan(-1.0), reason: 'crate $i fell out'); + if (jammed.contains(i)) continue; + expect( + a.distanceTo(b), + lessThan(0.05), + reason: 'crate $i: core $a, reference $b', + ); + expect(native.crates[i].isAsleep, isTrue, reason: 'crate $i'); + } + }); + + test('the runner crosses on the core what it crosses on the ' + 'reference', () { + final native = _Game(native: true); + final reference = _Game(native: false); + native.autopilot(600); + reference.autopilot(600); + final a = native.staged.runner.position; + final b = reference.staged.runner.position; + // The runner does not stand on a crate on this route, so nothing the + // core does reaches it: it walks exactly as it walks on the reference. + expect(a, b); + for (final crate in native.crates) { + expect(crate.position.y, greaterThan(-20.0)); + expect(crate.position.x.isFinite, isTrue); + } + }); + + test('a crate a walker walks into moves', () { + final game = _Game(native: true)..settle(60); + final crate = game.crates.first; + final before = crate.position.clone(); + final half = (crate.collider.shape as CollisionBox).halfExtents; + // A walker standing against the crate's +z face, walking into it: the + // push `Runner` gives a crate, without steering a runner there. + final walker = game.world.add( + Collider( + shape: CollisionCapsule(radius: 0.3, halfHeight: 0.3), + position: before + Vector3(0.0, 0.0, half.z + 0.3 + 0.005), + kind: ColliderKind.kinematic, + ), + ); + game.staged.dynamics.push(walker, Vector3(0.0, 0.0, -3.0)); + game.world.remove(walker); + game.settle(30); + expect(crate.position.z, lessThan(before.z - 0.1)); + }); + + test('a run restored from a save taken as a shoved crate comes to rest ' + 'steps on to the same bits, as a rewind needs', () { + final game = _Game(native: true)..settle(60); + // A crate shoved across the floor. + final crate = game.crates.first; + final half = (crate.collider.shape as CollisionBox).halfExtents; + final walker = game.world.add( + Collider( + shape: CollisionCapsule(radius: 0.3, halfHeight: 0.3), + position: crate.position + Vector3(0.0, 0.0, half.z + 0.3 + 0.005), + kind: ColliderKind.kinematic, + ), + ); + game.staged.dynamics.push(walker, Vector3(0.0, 0.0, -6.0)); + game.world.remove(walker); + // Saved as it comes to rest: slower than sleep's threshold, the core's + // clock counting towards sleep — which a body's own save cannot carry. + var steps = 0; + while (crate.velocity.length > 0.05 && steps < 120) { + game.settle(1); + steps++; + } + game.settle(3); + expect(crate.isAsleep, isFalse); + final mid = game.staged.sim.save(); + expect(mid.data['dynamics'], isA()); + // Every step's save, as a demo's checkpoints and a rewind's resim check + // them: a crate that fell asleep a step later ends where it would have, + // and only the steps between say so. + List rest() => [ + for (var i = 0; i < 90; i++) + (() { + game.settle(1); + return jsonEncode(game.staged.sim.save().toJson()); + })(), + ]; + + final live = rest(); + game.staged.sim.restore(mid); + final again = rest(); + for (var i = 0; i < live.length; i++) { + if (live[i] == again[i]) continue; + final a = jsonDecode(live[i]), b = jsonDecode(again[i]); + void diff(Object? x, Object? y, String path) { + if (x is Map && y is Map) { + for (final k in {...x.keys, ...y.keys}) diff(x[k], y[k], '$path/$k'); + } else if (x is List && y is List && x.length == y.length) { + for (var j = 0; j < x.length; j++) diff(x[j], y[j], '$path[$j]'); + } else if (jsonEncode(x) != jsonEncode(y)) { + printOnFailure('step $i $path: live ${jsonEncode(x)} again ${jsonEncode(y)}'); + } + } + diff(a, b, ''); + break; + } + expect(again, live); + }); + + test('two runs on the core land on the same bits', () { + String run() { + final game = _Game(native: true)..autopilot(300); + return [for (final c in game.crates) c.save()].toString() + + game.staged.runner.position.toString(); + } + + expect(run(), run()); + }); +} diff --git a/apps/flutter3d_demo_platformer/test/photo_mode_test.dart b/apps/flutter3d_demo_platformer/test/photo_mode_test.dart new file mode 100644 index 000000000..61b8c01eb --- /dev/null +++ b/apps/flutter3d_demo_platformer/test/photo_mode_test.dart @@ -0,0 +1,92 @@ +/// The platformer's photo mode — `N8`: which keys it takes, and which it +/// leaves to the camera it flies. +/// +/// flutter test test/photo_mode_test.dart +library; + +import 'package:flutter/material.dart' show KeyEventResult; +import 'package:flutter/services.dart'; +import 'package:flutter3d/flutter3d.dart'; +import 'package:flutter3d_demo_platformer/src/photo_mode.dart'; +import 'package:flutter3d_sim/flutter3d_sim.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:vector_math/vector_math.dart'; + +KeyDownEvent _down(LogicalKeyboardKey key) => KeyDownEvent( + physicalKey: PhysicalKeyboardKey.keyA, + logicalKey: key, + timeStamp: Duration.zero, +); + +PhotoMode _open() => PhotoMode() + ..enter( + world: CollisionWorld()..update(), + eye: Vector3(0.0, 2.0, 5.0), + target: Vector3.zero(), + anchor: Vector3(0.0, 1.0, 3.0), + fieldOfView: 1.05, + ); + +void main() { + // Enter asks the keyboard whether Shift is held. + TestWidgetsFlutterBinding.ensureInitialized(); + + test( + 'brackets step through the filters, and Enter asks for a 2x picture', + () { + final mode = _open(); + final asked = []; + void capture(int scale) => asked.add(scale); + + mode.key(_down(LogicalKeyboardKey.bracketRight), onCapture: capture); + expect(mode.filter, PhotoFilter.all[1]); + mode.key(_down(LogicalKeyboardKey.bracketLeft), onCapture: capture); + mode.key(_down(LogicalKeyboardKey.bracketLeft), onCapture: capture); + expect(mode.filter, PhotoFilter.all.last, reason: 'and wraps round'); + + mode.key(_down(LogicalKeyboardKey.enter), onCapture: capture); + expect(asked, [2]); + }, + ); + + test('the arrows are left to fly the camera', () { + // Mutation: put the filters back on the arrows. The arrows are bound to + // moving left and right, and in photo mode they stop flying the camera + // and flick through filters instead. + final mode = _open(); + expect( + mode.key(_down(LogicalKeyboardKey.arrowLeft), onCapture: (_) {}), + isNull, + ); + expect(mode.filter, PhotoFilter.none); + }); + + test('a closed photo mode takes no keys, and a busy one takes them all', () { + final closed = PhotoMode(); + expect( + closed.key(_down(LogicalKeyboardKey.enter), onCapture: (_) {}), + isNull, + ); + // Mutation: drop the `busy` check. A second Enter while a 4x picture is + // drawing starts another capture on the same renderer. + final asked = []; + final busy = _open()..busy = true; + expect( + busy.key(_down(LogicalKeyboardKey.enter), onCapture: asked.add), + KeyEventResult.handled, + ); + expect(asked, isEmpty); + }); + + test('the held run keys fly the camera, and the node follows it', () { + final mode = _open(); + final input = InputState()..press(GameAction.moveForward); + final node = CameraNode(projection: const PerspectiveProjection()); + mode + ..fly(input, Vector2.zero(), 0.5) + ..applyTo(node); + // Two metres along the view, which looks from (0, 2, 5) at the origin. + expect(node.readPosition().z, lessThan(5.0 - 1.0)); + expect(node.readPosition().y, lessThan(2.0)); + }); +} diff --git a/apps/flutter3d_demo_platformer/test/playthrough_test.dart b/apps/flutter3d_demo_platformer/test/playthrough_test.dart index aa6192836..4b3240086 100644 --- a/apps/flutter3d_demo_platformer/test/playthrough_test.dart +++ b/apps/flutter3d_demo_platformer/test/playthrough_test.dart @@ -99,7 +99,7 @@ final class _Game { final EntityRegistry kinds = platformerRegistry(); late final Staged staged; - Dynamics get dynamics => staged.dynamics; + RigidDynamics get dynamics => staged.dynamics; MechanismWorld get mechanisms => staged.mechanisms; ActorSystem get actors => staged.actors; Runner get runner => staged.runner; diff --git a/apps/flutter3d_demo_platformer/test/reactions_test.dart b/apps/flutter3d_demo_platformer/test/reactions_test.dart index f9bb56ff3..ba5400386 100644 --- a/apps/flutter3d_demo_platformer/test/reactions_test.dart +++ b/apps/flutter3d_demo_platformer/test/reactions_test.dart @@ -38,7 +38,7 @@ final class _Run { final EntityRegistry kinds = platformerRegistry(); late final Staged staged; - Dynamics get dynamics => staged.dynamics; + RigidDynamics get dynamics => staged.dynamics; MechanismWorld get mechanisms => staged.mechanisms; ActorSystem get actors => staged.actors; Runner get runner => staged.runner; diff --git a/apps/flutter3d_demo_platformer/test/replay_test.dart b/apps/flutter3d_demo_platformer/test/replay_test.dart new file mode 100644 index 000000000..d08e0470c --- /dev/null +++ b/apps/flutter3d_demo_platformer/test/replay_test.dart @@ -0,0 +1,111 @@ +/// `N5`: a recorded run through the Ascent, replayed with no GPU and checked +/// by digest at every checkpoint and by golden at two steps. +/// +/// flutter test test/replay_test.dart +/// +/// **What a game built on flutter3d writes to keep its CI honest**, and the +/// shortest version of it: one [testReplay] call and the four methods of a +/// [ReplaySubject]. The tape is `tool/record_sample.dart`'s, written beside +/// the site's sample; when the simulation changes on purpose, that script +/// records it again and the goldens are deleted and re-recorded. +/// +/// Built from what the game itself calls — `openLevel`, `stage`, the +/// runner's box from [RunnerVisuals], the [FollowCamera] — rather than from a +/// harness's idea of the game, for the reason `frame_test.dart` gives: a +/// picture is only worth checking if it is the picture that ships. +library; + +import 'package:flutter3d/flutter3d.dart'; +import 'package:flutter3d_app/flutter3d_app.dart'; +import 'package:flutter3d_demo_platformer/src/lens.dart'; +import 'package:flutter3d_demo_platformer/src/run.dart'; +import 'package:flutter3d_demo_platformer/src/runner_visuals.dart'; +import 'package:flutter3d_demo_platformer/src/staging.dart'; +import 'package:flutter3d_game/flutter3d_game.dart'; +import 'package:flutter3d_game_platformer/flutter3d_game_platformer.dart'; +import 'package:flutter3d_sim/flutter3d_sim.dart'; +import 'package:flutter3d_testing/flutter3d_testing.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// The Ascent, loaded and staged the way `PlatformerRun` does it. +final class _Ascent implements ReplaySubject { + _Ascent._(this._loaded, this._staged, this._fixtures, this._runnerNode) + : _follow = FollowCamera(world: _loaded.collision); + + static Future<_Ascent> open(ReplayStart start) async { + final (:kinds, :loaded, :fixtures) = await openLevel( + start.demo.level, + device: start.device, + ); + final staged = stage( + loaded.level, + loaded.collision, + input: start.input, + registry: kinds, + onFixture: fixtures.add, + ); + loaded.collision.update(); + final runnerNode = RunnerVisuals( + model: '', + ).box(start.device, loaded.scene, staged.runner); + return _Ascent._(loaded, staged, fixtures, runnerNode); + } + + final LoadedLevel _loaded; + final Staged _staged; + final FixtureVisuals _fixtures; + final SceneNode _runnerNode; + final FollowCamera _follow; + final CameraNode _camera = CameraNode(projection: Lens.base); + double _elapsed = 0.0; + + @override + String? get levelHash => _loaded.level.digestHex; + + @override + void restore(Snapshot start) { + _staged.sim.restore(start); + _follow.cut(); + } + + @override + void step(double dt) { + _staged.sim.step(dt); + _staged.sim.events.drain(); + _elapsed += dt; + // Followed every step, as the game follows it every frame: a camera + // placed only when a golden is drawn would start from where it was built. + final runner = _staged.runner; + _follow.follow(runner.position, dt, travelling: runner.body.velocity); + } + + @override + Snapshot save() => _staged.sim.save(); + + @override + Future frame(int step) async { + // The coins and the gates are models read in the background; drawn + // before they arrive, the golden depends on how busy the machine was. + await _fixtures.settled; + _fixtures.sync(_elapsed); + final p = _staged.runner.position; + _runnerNode.setPosition(p.x, p.y, p.z); + _camera + ..setPositionFrom(_follow.eye) + ..lookAt(_follow.target); + if (_camera.parent == null) _loaded.scene.add(_camera); + return (scene: _loaded.scene, camera: _camera); + } +} + +void main() { + // Mutation: change a number in the runner's tuning — `jumpSpeed`, say — and + // the first checkpoint after the first jump disagrees and names its step. + // Move a brush in `ascent.json` and the test fails on the level's hash + // before a step is taken. + testReplay( + 'test/tapes/ascent.f3drun', + start: _Ascent.open, + goldensAt: [120, 600], + ); +} diff --git a/apps/flutter3d_demo_platformer/test/run_test.dart b/apps/flutter3d_demo_platformer/test/run_test.dart index 340889a54..4c6840b72 100644 --- a/apps/flutter3d_demo_platformer/test/run_test.dart +++ b/apps/flutter3d_demo_platformer/test/run_test.dart @@ -47,6 +47,7 @@ final class _Storage implements Storage { /// exists so the loader has somewhere to upload the level's textures. ({PlatformerRun run, _Storage storage}) _game({ void Function(String asset)? onLevelBuilt, + InputState? input, }) { final device = CpuDevice( width: 16, @@ -59,7 +60,7 @@ final class _Storage implements Storage { run: PlatformerRun( firstLevel: _first, saves: SaveFile(appName: 'platformer', storage: storage), - input: InputState(), + input: input ?? InputState(), openDevice: () async => device, // The widget's half, which this test does not have: a camera, a box for // the runner, the interpolators — `onLevelBuilt` itself, `rp-01`'s own @@ -245,6 +246,132 @@ void main() { expect(await again.run.begin(), isTrue, reason: 'did not resume'); expect((again.run.status as RunPlaying).asset, _first); }); + + group('a level edited in the editor', () { + test('goes in under the run, which carries on where it stood', () async { + final input = InputState(); + final built = []; + final it = _game(input: input, onLevelBuilt: built.add); + await it.run.begin(); + final before = it.run.level!; + final timeline = _played(it.run, input, steps: 90); + final elapsed = before.sim.elapsed; + final standing = before.runner.body.position.clone(); + final lastBrush = before.loaded.level.brushes.last.centre.y; + built.clear(); + + final applied = await _live( + it.run, + timeline, + ).applyWhenReady(_raised(before.loaded.level)); + + final after = it.run.level!; + expect(applied.swappedAt, isNotNull, reason: 'a brush is the sim’s'); + expect(after, isNot(same(before))); + expect(after.loaded.level.brushes.last.centre.y, lastBrush + 40.0); + expect( + after.sim.elapsed, + closeTo(elapsed, 1e-9), + reason: 'the run was lived again up to now', + ); + expect(after.runner.body.position.distanceTo(standing), lessThan(1e-6)); + expect(built, [ + _first, + ], reason: 'the widget hears of it once, after the replay'); + expect((it.run.status as RunPlaying).asset, _first); + }); + + test('a look-only edit swaps the build and replays nothing', () async { + final input = InputState(); + final it = _game(input: input); + await it.run.begin(); + final before = it.run.level!; + final timeline = _played(it.run, input, steps: 30); + final document = before.loaded.level.toJson()..['fogDensity'] = 0.03; + + final applied = await _live( + it.run, + timeline, + ).applyWhenReady(Level.fromJson(document)); + + expect(applied.swappedAt, isNull); + expect(it.run.level, isNot(same(before))); + expect(it.run.level!.loaded.level.fogDensity, 0.03); + expect( + timeline.history, + isEmpty, + reason: 'the timeline was not branched', + ); + }); + + test('another level is refused, and the one up is kept', () async { + final input = InputState(); + final it = _game(input: input); + await it.run.begin(); + final before = it.run.level!; + // Another level differs in more than its name: a name alone is not + // something `diffLevel` compares, and an edit that changes nothing + // else is taken as the same level. + final document = _raised(before.loaded.level).toJson() + ..['name'] = 'elsewhere'; + + await expectLater( + _live( + it.run, + _played(it.run, input, steps: 1), + ).applyWhenReady(Level.fromJson(document)), + throwsA(isA()), + ); + expect(it.run.level, same(before)); + }); + }); +} + +/// The door the game opens for the editor, as `main.dart` builds it. +LiveLevel _live(PlatformerRun run, RunTimeline timeline) => LiveLevel( + level: run.level!.loaded.level, + timeline: timeline, + prepare: run.prepareEdit, + rebuild: (Level next) => run.installEdit(), + present: (Level next, LevelDiff diff) => run.announceEdit(), +); + +/// Plays [steps] steps of the level that is up the way `GameLoop` does, +/// keyframes and all, and hands back the timeline over them. +RunTimeline _played(PlatformerRun run, InputState input, {required int steps}) { + final rewind = RewindBuffer(stepsPerSecond: 60, history: 10.0); + for (var i = 0; i < steps; i++) { + rewind.recorder.record(input); + input.beginStep(); + if (rewind.keyframeDue) rewind.keyframe(run.level!.sim.save()); + run.level!.sim.step(1.0 / 60.0); + input.endStep(); + } + return RunTimeline( + rewind: rewind, + input: input, + stepSim: (double dt) => run.level!.sim.step(dt), + restore: (Snapshot snapshot) => run.level!.sim.restore(snapshot), + ); +} + +/// [level] with its last brush forty metres up, out of the runner's way. +Level _raised(Level level) { + final document = level.toJson(); + final brushes = [...document['brushes']! as List]; + final last = Map.of( + brushes.removeLast()! as Map, + ); + final at = (last['at']! as List).cast(); + last['at'] = [ + at[0].toDouble(), + at[1].toDouble() + 40.0, + at[2].toDouble(), + ]; + return Level.fromJson({ + ...document, + 'brushes': [...brushes, last], + }); } /// Finishes the level that is up, and moves on. diff --git a/apps/flutter3d_demo_platformer/test/soundtrack_test.dart b/apps/flutter3d_demo_platformer/test/soundtrack_test.dart index 87ea418ad..51d9cbfab 100644 --- a/apps/flutter3d_demo_platformer/test/soundtrack_test.dart +++ b/apps/flutter3d_demo_platformer/test/soundtrack_test.dart @@ -38,7 +38,7 @@ final class _Run { final EntityRegistry kinds = platformerRegistry(); late final Staged staged; - Dynamics get dynamics => staged.dynamics; + RigidDynamics get dynamics => staged.dynamics; MechanismWorld get mechanisms => staged.mechanisms; ActorSystem get actors => staged.actors; Runner get runner => staged.runner; diff --git a/apps/flutter3d_demo_platformer/test/tapes/ascent.f3drun b/apps/flutter3d_demo_platformer/test/tapes/ascent.f3drun new file mode 100644 index 000000000..c5009fce6 --- /dev/null +++ b/apps/flutter3d_demo_platformer/test/tapes/ascent.f3drun @@ -0,0 +1 @@ +{"version":1,"level":"assets/levels/ascent.json","levelHash":"1e491529","run":{"version":1,"runner":{"body":{"at":[0.0,0.8999999761581421,-22.0],"velocity":[0.0,0.0,0.0],"grounded":false,"coyote":0.0,"jumpBuffer":0.0},"health":{"current":100.0,"armour":0.0,"mourned":false},"purse":{},"keys":[],"yaw":0.0,"coyote":0.12,"buffer":0.0,"airJumps":1,"dashCooldown":0.0,"wallCoyote":0.0,"wallAway":[0.0,0.0,0.0]},"random":1,"state":"running","respawn":[0.0,0.0,-22.0],"deaths":0,"lives":-1,"elapsed":0.0,"mechanisms":{"coin one":{"taken":false},"coin two":{"taken":false},"coin three":{"taken":false},"yard coin one":{"taken":false},"yard coin two":{"taken":false},"yard coin three":{"taken":false},"vault coin 1":{"taken":false},"vault coin 2":{"taken":false},"vault coin 3":{"taken":false},"vault coin 4":{"taken":false},"the green key":{"taken":false},"plateau coin one":{"taken":false},"plateau coin two":{"taken":false},"plateau coin three":{"taken":false},"the high pad":{},"plateau coin four":{"taken":false},"the first pad":{},"high coin":{"taken":false},"the brink":{"reached":false},"arc coin one":{"taken":false},"arc coin two":{"taken":false},"arc coin three":{"taken":false},"the first drop":{},"past the drop":{"reached":false},"the ruin spikes":{},"the ruin pad":{},"the shaft coin":{"taken":false},"the second shaft coin":{"taken":false},"the third shaft coin":{"taken":false},"the blue key":{"taken":false},"the canyon rim":{"reached":false},"the gulf":{},"the first barge":{"progress":0.0,"goal":1.0,"held":0.0,"wasMoving":false},"the second barge":{"progress":0.5468750000000017,"goal":1.0,"held":0.0,"wasMoving":false},"the third barge":{"progress":1.0,"goal":1.0,"held":0.4166666666666794,"wasMoving":false},"canyon coin one":{"taken":false},"canyon coin two":{"taken":false},"canyon coin three":{"taken":false},"the west ferry":{"progress":0.5624999999999991,"goal":1.0,"held":0.0,"wasMoving":false},"the west skiff":{"progress":1.0,"goal":1.0,"held":0.3166666666666723,"wasMoving":false},"the east ferry":{"progress":0.5624999999999991,"goal":1.0,"held":0.0,"wasMoving":false},"the east skiff":{"progress":1.0,"goal":1.0,"held":0.3166666666666723,"wasMoving":false},"the quarry pad":{},"the scaffold hoist":{"progress":0.0,"goal":1.0,"held":0.0,"wasMoving":false},"the scaffold shuttle":{"progress":0.25,"goal":1.0,"held":0.0,"wasMoving":false},"the forecourt":{"reached":false},"the tower lift":{"progress":0.0,"goal":0.0,"held":0.0,"wasMoving":false},"the lift plate":{"spent":false},"tower coin one":{"taken":false},"tower coin two":{"taken":false},"tower coin three":{"taken":false},"tower coin four":{"taken":false},"the blue gate":{"progress":0.0,"goal":0.0,"held":0.0,"wasMoving":false},"the blue gate's plate":{"spent":false},"the maze lamp 1":{"enabled":true},"the maze lamp 2":{"enabled":true},"the maze lamp 3":{"enabled":true},"the maze lamp 4":{"enabled":true},"the maze lamp 5":{"enabled":true},"the maze lamp 6":{"enabled":true},"the maze lamp 7":{"enabled":true},"the maze lamp 8":{"enabled":true},"the maze lamp 9":{"enabled":true},"maze coin 1":{"taken":false},"maze coin 2":{"taken":false},"maze coin 3":{"taken":false},"maze coin 4":{"taken":false},"maze coin 5":{"taken":false},"maze coin 6":{"taken":false},"maze coin 7":{"taken":false},"maze coin 8":{"taken":false},"maze coin 9":{"taken":false},"maze coin 10":{"taken":false},"maze coin 11":{"taken":false},"maze coin 12":{"taken":false},"maze coin 13":{"taken":false},"the green gate":{"progress":0.0,"goal":0.0,"held":0.0,"wasMoving":false},"the green gate's plate":{"spent":false},"the east lift":{"progress":0.0,"goal":0.0,"held":0.0,"wasMoving":false},"the east plate":{"spent":false},"the saw arm":{"progress":0.0,"goal":1.0,"held":0.0,"wasMoving":false},"the saw":{},"the gallery ladder":{"time":0.0},"the rope":{"time":0.0},"the cold":{"reached":false},"the crevasse":{},"the ferry":{"progress":0.0,"goal":1.0,"held":0.0,"wasMoving":false},"the west floe":{"progress":0.3124999999999995,"goal":1.0,"held":0.0,"wasMoving":false},"the east floe":{"progress":0.9375000000000041,"goal":1.0,"held":0.0,"wasMoving":false},"floe coin one":{"taken":false},"floe coin two":{"taken":false},"floe coin three":{"taken":false},"floe coin four":{"taken":false},"the far west floe":{"progress":0.49999999999999994,"goal":1.0,"held":0.0,"wasMoving":false},"the far east floe":{"progress":0.49999999999999994,"goal":1.0,"held":0.0,"wasMoving":false},"the shelf 1":{"since":null,"fallen":false,"away":0.0},"the shelf 2":{"since":null,"fallen":false,"away":0.0},"the shelf 3":{"since":null,"fallen":false,"away":0.0},"the shelf 4":{"since":null,"fallen":false,"away":0.0},"the shelf 5":{"since":null,"fallen":false,"away":0.0},"the spikes":{},"more spikes":{},"the far spikes":{},"the eastern spikes":{},"the cold pad":{},"the cold coin":{"taken":false},"ice coin one":{"taken":false},"ice coin two":{"taken":false},"the floe pad west":{},"the floe pad east":{},"past the ice":{"reached":false},"the chimney coin one":{"taken":false},"the chimney coin two":{"taken":false},"the dash coin":{"taken":false},"the coin over the gap":{"taken":false},"the foot of the stair":{"reached":false},"stair coin one":{"taken":false},"the last pad":{},"stair coin two":{"taken":false},"stair coin three":{"taken":false},"stair coin four":{"taken":false},"the summit lift":{"progress":0.0,"goal":0.0,"held":0.0,"wasMoving":false},"the summit plate":{"spent":false},"ring coin 1":{"taken":false},"ring coin 2":{"taken":false},"ring coin 3":{"taken":false},"ring coin 4":{"taken":false},"ring coin 5":{"taken":false},"ring coin 6":{"taken":false},"ring coin 7":{"taken":false},"ring coin 8":{"taken":false},"the summit":{"reached":false},"the cap 1":{"broken":false},"the cap 2":{"broken":false},"the cap 3":{"broken":false},"the west hoist":{"progress":0.0,"goal":0.0,"held":0.0,"wasMoving":false},"the west hoist plate":{"spent":false},"the east hoist":{"progress":0.0,"goal":0.0,"held":0.0,"wasMoving":false},"the east hoist plate":{"spent":false},"coin 019":{"taken":false},"coin 020":{"taken":false},"coin 021":{"taken":false},"coin 022":{"taken":false},"coin 023":{"taken":false},"coin 024":{"taken":false},"coin 025":{"taken":false},"coin 026":{"taken":false},"coin 027":{"taken":false},"coin 028":{"taken":false},"coin 029":{"taken":false},"coin 030":{"taken":false},"coin 031":{"taken":false},"coin 032":{"taken":false},"coin 033":{"taken":false},"coin 034":{"taken":false},"coin 035":{"taken":false},"coin 036":{"taken":false},"coin 037":{"taken":false},"coin 038":{"taken":false},"coin 039":{"taken":false},"coin 040":{"taken":false},"coin 041":{"taken":false},"coin 042":{"taken":false},"coin 043":{"taken":false},"coin 044":{"taken":false},"coin 045":{"taken":false},"coin 046":{"taken":false},"coin 047":{"taken":false},"coin 048":{"taken":false},"coin 049":{"taken":false},"coin 050":{"taken":false},"coin 051":{"taken":false},"coin 052":{"taken":false},"coin 053":{"taken":false},"coin 054":{"taken":false},"coin 055":{"taken":false},"coin 056":{"taken":false},"coin 057":{"taken":false},"coin 058":{"taken":false},"coin 059":{"taken":false},"coin 060":{"taken":false},"coin 061":{"taken":false},"coin 062":{"taken":false},"coin 063":{"taken":false},"coin 064":{"taken":false},"coin 065":{"taken":false},"coin 066":{"taken":false},"coin 067":{"taken":false},"coin 068":{"taken":false},"coin 069":{"taken":false},"coin 070":{"taken":false},"coin 077":{"taken":false},"coin 078":{"taken":false},"coin 079":{"taken":false},"coin 080":{"taken":false},"coin 081":{"taken":false},"coin 082":{"taken":false},"coin 083":{"taken":false},"coin 084":{"taken":false},"coin 085":{"taken":false},"coin 086":{"taken":false},"coin 087":{"taken":false},"coin 088":{"taken":false},"coin 089":{"taken":false},"coin 090":{"taken":false},"coin 091":{"taken":false},"coin 092":{"taken":false},"coin 093":{"taken":false},"coin 094":{"taken":false},"coin 095":{"taken":false},"coin 096":{"taken":false},"coin 097":{"taken":false},"coin 098":{"taken":false},"coin 099":{"taken":false},"coin 100":{"taken":false},"coin 101":{"taken":false},"coin 102":{"taken":false},"coin 103":{"taken":false},"coin 104":{"taken":false},"coin 105":{"taken":false},"coin 106":{"taken":false},"coin 107":{"taken":false},"coin 108":{"taken":false},"coin 109":{"taken":false},"coin 127":{"taken":false},"coin 128":{"taken":false},"coin 129":{"taken":false},"coin 130":{"taken":false},"coin 131":{"taken":false},"coin 132":{"taken":false},"coin 133":{"taken":false},"coin 134":{"taken":false},"coin 135":{"taken":false},"coin 136":{"taken":false},"coin 137":{"taken":false},"coin 138":{"taken":false},"coin 139":{"taken":false},"coin 140":{"taken":false},"coin 141":{"taken":false},"coin 142":{"taken":false},"coin 143":{"taken":false},"coin 144":{"taken":false},"coin 145":{"taken":false},"coin 146":{"taken":false},"coin 147":{"taken":false},"coin 148":{"taken":false},"coin 149":{"taken":false},"coin 150":{"taken":false},"coin 151":{"taken":false},"coin 152":{"taken":false},"coin 153":{"taken":false},"coin 154":{"taken":false},"coin 155":{"taken":false},"coin 156":{"taken":false},"coin 157":{"taken":false},"coin 158":{"taken":false},"coin 159":{"taken":false},"coin 160":{"taken":false},"coin 161":{"taken":false},"coin 162":{"taken":false},"coin 167":{"taken":false},"coin 168":{"taken":false},"coin 169":{"taken":false},"coin 170":{"taken":false},"coin 171":{"taken":false},"coin 175":{"taken":false},"coin 176":{"taken":false},"coin 177":{"taken":false},"coin 178":{"taken":false},"coin 179":{"taken":false},"coin 180":{"taken":false},"coin 181":{"taken":false},"coin 182":{"taken":false},"coin 183":{"taken":false},"coin 184":{"taken":false},"coin 185":{"taken":false},"coin 186":{"taken":false},"coin 187":{"taken":false},"coin 188":{"taken":false},"coin 189":{"taken":false},"coin 190":{"taken":false},"coin 207":{"taken":false},"coin 208":{"taken":false},"coin 209":{"taken":false},"coin 210":{"taken":false},"coin 211":{"taken":false},"coin 212":{"taken":false},"coin 213":{"taken":false},"coin 214":{"taken":false},"coin 215":{"taken":false},"coin 216":{"taken":false},"coin 217":{"taken":false},"coin 218":{"taken":false},"coin 219":{"taken":false},"coin 220":{"taken":false},"coin 221":{"taken":false},"coin 222":{"taken":false},"coin 223":{"taken":false},"coin 224":{"taken":false},"coin 225":{"taken":false},"coin 226":{"taken":false},"coin 227":{"taken":false},"coin 228":{"taken":false},"coin 229":{"taken":false},"coin 230":{"taken":false},"coin 231":{"taken":false},"coin 232":{"taken":false},"coin 233":{"taken":false},"coin 234":{"taken":false},"coin 235":{"taken":false},"coin 236":{"taken":false},"coin 237":{"taken":false},"coin 238":{"taken":false},"coin 239":{"taken":false},"coin 240":{"taken":false},"coin 241":{"taken":false},"coin 242":{"taken":false},"coin 243":{"taken":false},"coin 244":{"taken":false},"coin 245":{"taken":false},"coin 246":{"taken":false},"coin 247":{"taken":false},"coin 248":{"taken":false},"coin 249":{"taken":false},"coin 250":{"taken":false},"coin 251":{"taken":false},"coin 252":{"taken":false},"coin 253":{"taken":false},"coin 254":{"taken":false},"coin 255":{"taken":false},"coin 256":{"taken":false},"coin 257":{"taken":false},"coin 258":{"taken":false},"coin 259":{"taken":false}},"entities":{"generations":[0,0,0],"free":[],"components":{"body":{"0":{"at":[-10.0,0.3499999940395355,-4.0],"velocity":[0.0,0.0,0.0],"grounded":false,"coyote":0.0,"jumpBuffer":0.0},"1":{"at":[30.0,0.3499999940395355,-6.0],"velocity":[0.0,0.0,0.0],"grounded":false,"coyote":0.0,"jumpBuffer":0.0},"2":{"at":[-16.0,0.3499999940395355,74.0],"velocity":[0.0,0.0,0.0],"grounded":false,"coyote":0.0,"jumpBuffer":0.0}},"vitality":{"0":{"current":20.0,"armour":0.0,"mourned":false},"1":{"current":20.0,"armour":0.0,"mourned":false},"2":{"current":20.0,"armour":0.0,"mourned":false}},"facing":{"0":{"yaw":0.0,"turnRate":6.0,"eyeFraction":0.32},"1":{"yaw":0.0,"turnRate":6.0,"eyeFraction":0.32},"2":{"yaw":0.0,"turnRate":6.0,"eyeFraction":0.32}},"thinking":{"0":{"target":1,"waiting":0.0},"1":{"target":1,"waiting":0.0},"2":{"target":1,"waiting":0.0}}}},"actors":{"tick":0},"dynamics":"RjNEUwgAAAAEAAAAsAAAAAABAAAYAAAAqAAAAFQAAAAsAAAA0AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADD9RzBAAAAAAAAAAAAAAAAAAAAADOTkkNgQqINCtejPQAAAD8iAAAAIgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACtejPAEAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAABAAIKAADgQDMzMz8AAIDAAAAAAAAAAAAAAAAAAACAPwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADMzMz8zMzM/MzMzPwAA8EGJiAg9zMwcQczMHEHMzBxBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD1KDxBZmaGPwAAAAAAAHpEZmZmPwAAAAAAAAAAAAAAAAAAAAAAAAAAAACAPzOTkkMAAAAAAAAAAAAAAAAAAAAAzcxMPwAAAAABAAAA/////wEAAAAAAAAAAQACCs3MDEEzMzM/AACAwAAAAAAAAAAAAAAAAAAAgD8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAzMzM/MzMzPzMzMz8AAPBBiYgIPczMHEHMzBxBzMwcQQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA9Sg8QWZmhj8AAAAAAAB6RGZmZj8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAgD8zk5JDAAAAAAAAAAAAAAAAAAAAAM3MTD8AAAAAAQAAAP////8BAAAAAAAAAAEAAgoAAOjBMzMzPwAAmMEAAAAAAAAAAAAAAAAAAIA/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMzMzPzMzMz8zMzM/AADwQYmICD3MzBxBzMwcQczMHEEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPUoPEFmZoY/AAAAAAAAekRmZmY/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAIA/M5OSQwAAAAAAAAAAAAAAAAAAAADNzEw/AAAAAAEAAAD/////AQAAAAAAAAABAAIKAAD4wTMzMz8AAJjBAAAAAAAAAAAAAAAAAACAPwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADMzMz8zMzM/MzMzPwAA8EGJiAg9zMwcQczMHEHMzBxBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD1KDxBZmaGPwAAAAAAAHpEZmZmPwAAAAAAAAAAAAAAAAAAAAAAAAAAAACAPzOTkkMAAAAAAAAAAAAAAAAAAAAAzcxMPwAAAAABAAAA/////wEAAAAAAAAAAQACCgAA8MEzMzM/AACowQAAAAAAAAAAAAAAAAAAgD8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAzMzM/MzMzPzMzMz8AAPBBiYgIPczMHEHMzBxBzMwcQQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA9Sg8QWZmhj8AAAAAAAB6RGZmZj8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAgD8zk5JDAAAAAAAAAAAAAAAAAAAAAM3MTD8AAAAAAQAAAP////8BAAAAAAAAAAEAAgoAAMBBMzMzPwAAsMEAAAAAAAAAAAAAAAAAAIA/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMzMzPzMzMz8zMzM/AADwQYmICD3MzBxBzMwcQczMHEEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPUoPEFmZoY/AAAAAAAAekRmZmY/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAIA/M5OSQwAAAAAAAAAAAAAAAAAAAADNzEw/AAAAAAEAAAD/////AQAAAAAAAAABAAIKAAAEQjMzMz8AALDBAAAAAAAAAAAAAAAAAACAPwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADMzMz8zMzM/MzMzPwAA8EGJiAg9zMwcQczMHEHMzBxBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD1KDxBZmaGPwAAAAAAAHpEZmZmPwAAAAAAAAAAAAAAAAAAAAAAAAAAAACAPzOTkkMAAAAAAAAAAAAAAAAAAAAAzcxMPwAAAAABAAAA/////wEAAAAAAAAAAQACCgAAKEIzMzM/AACwwQAAAAAAAAAAAAAAAAAAgD8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAzMzM/MzMzPzMzMz8AAPBBiYgIPczMHEHMzBxBzMwcQQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA9Sg8QWZmhj8AAAAAAAB6RGZmZj8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAgD8zk5JDAAAAAAAAAAAAAAAAAAAAAM3MTD8AAAAAAQAAAP////8BAAAAAAAAAAEAAgoAAExCMzMzPwAAsMEAAAAAAAAAAAAAAAAAAIA/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMzMzPzMzMz8zMzM/AADwQYmICD3MzBxBzMwcQczMHEEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPUoPEFmZoY/AAAAAAAAekRmZmY/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAIA/M5OSQwAAAAAAAAAAAAAAAAAAAADNzEw/AAAAAAEAAAD/////AQAAAAAAAAABAAIKAABQwjMzMz8AAPhBAAAAAAAAAAAAAAAAAACAPwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADMzMz8zMzM/MzMzPwAA8EGJiAg9zMwcQczMHEHMzBxBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD1KDxBZmaGPwAAAAAAAHpEZmZmPwAAAAAAAAAAAAAAAAAAAAAAAAAAAACAPzOTkkMAAAAAAAAAAAAAAAAAAAAAzcxMPwAAAAABAAAA/////wEAAAAAAAAAAQACCgAAMMIzMzM/AAD4QQAAAAAAAAAAAAAAAAAAgD8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAzMzM/MzMzPzMzMz8AAPBBiYgIPczMHEHMzBxBzMwcQQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA9Sg8QWZmhj8AAAAAAAB6RGZmZj8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAgD8zk5JDAAAAAAAAAAAAAAAAAAAAAM3MTD8AAAAAAQAAAP////8BAAAAAAAAAAEAAgoAABDCMzMzPwAA+EEAAAAAAAAAAAAAAAAAAIA/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMzMzPzMzMz8zMzM/AADwQYmICD3MzBxBzMwcQczMHEEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPUoPEFmZoY/AAAAAAAAekRmZmY/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAIA/M5OSQwAAAAAAAAAAAAAAAAAAAADNzEw/AAAAAAEAAAD/////AQAAAAAAAAABAAIKAABIwjMzMz8AAJRCAAAAAAAAAAAAAAAAAACAPwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADMzMz8zMzM/MzMzPwAA8EGJiAg9zMwcQczMHEHMzBxBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD1KDxBZmaGPwAAAAAAAHpEZmZmPwAAAAAAAAAAAAAAAAAAAAAAAAAAAACAPzOTkkMAAAAAAAAAAAAAAAAAAAAAzcxMPwAAAAABAAAA/////wEAAAAAAAAAAQACCgAAkMEzMzM/AACUQgAAAAAAAAAAAAAAAAAAgD8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAzMzM/MzMzPzMzMz8AAPBBiYgIPczMHEHMzBxBzMwcQQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA9Sg8QWZmhj8AAAAAAAB6RGZmZj8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAgD8zk5JDAAAAAAAAAAAAAAAAAAAAAM3MTD8AAAAAAQAAAP////8BAAAAAAAAAAEAAgoAAADBMzMzPwAAlEIAAAAAAAAAAAAAAAAAAIA/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMzMzPzMzMz8zMzM/AADwQYmICD3MzBxBzMwcQczMHEEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPUoPEFmZoY/AAAAAAAAekRmZmY/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAIA/M5OSQwAAAAAAAAAAAAAAAAAAAADNzEw/AAAAAAEAAAD/////AQAAAAAAAAABAAIKAAAAQTMzMz8AAJRCAAAAAAAAAAAAAAAAAACAPwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADMzMz8zMzM/MzMzPwAA8EGJiAg9zMwcQczMHEHMzBxBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD1KDxBZmaGPwAAAAAAAHpEZmZmPwAAAAAAAAAAAAAAAAAAAAAAAAAAAACAPzOTkkMAAAAAAAAAAAAAAAAAAAAAzcxMPwAAAAABAAAA/////wEAAAAAAAAAAQACCgAAkEEzMzM/AACUQgAAAAAAAAAAAAAAAAAAgD8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAzMzM/MzMzPzMzMz8AAPBBiYgIPczMHEHMzBxBzMwcQQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA9Sg8QWZmhj8AAAAAAAB6RGZmZj8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAgD8zk5JDAAAAAAAAAAAAAAAAAAAAAM3MTD8AAAAAAQAAAP////8BAAAAAAAAAAEAAgoAAEhCMzMzPwAAlEIAAAAAAAAAAAAAAAAAAIA/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMzMzPzMzMz8zMzM/AADwQYmICD3MzBxBzMwcQczMHEEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPUoPEFmZoY/AAAAAAAAekRmZmY/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAIA/M5OSQwAAAAAAAAAAAAAAAAAAAADNzEw/AAAAAAEAAAD/////AQAAAAAAAAABAAIKAABYwjMzMz8AALRCAAAAAAAAAAAAAAAAAACAPwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADMzMz8zMzM/MzMzPwAA8EGJiAg9zMwcQczMHEHMzBxBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD1KDxBZmaGPwAAAAAAAHpEZmZmPwAAAAAAAAAAAAAAAAAAAAAAAAAAAACAPzOTkkMAAAAAAAAAAAAAAAAAAAAAzcxMPwAAAAABAAAA/////wEAAAAAAAAAAQACCgAAWMIzMzM/AAC+QgAAAAAAAAAAAAAAAAAAgD8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAzMzM/MzMzPzMzMz8AAPBBiYgIPczMHEHMzBxBzMwcQQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA9Sg8QWZmhj8AAAAAAAB6RGZmZj8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAgD8zk5JDAAAAAAAAAAAAAAAAAAAAAM3MTD8AAAAAAQAAAP////8BAAAAAAAAAAEAAgoAAFjCMzMzPwAAyEIAAAAAAAAAAAAAAAAAAIA/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMzMzPzMzMz8zMzM/AADwQYmICD3MzBxBzMwcQczMHEEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPUoPEFmZoY/AAAAAAAAekRmZmY/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAIA/M5OSQwAAAAAAAAAAAAAAAAAAAADNzEw/AAAAAAEAAAD/////AQAAAAAAAAABAAIKAABIwjMzMz8AALRCAAAAAAAAAAAAAAAAAACAPwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADMzMz8zMzM/MzMzPwAA8EGJiAg9zMwcQczMHEHMzBxBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD1KDxBZmaGPwAAAAAAAHpEZmZmPwAAAAAAAAAAAAAAAAAAAAAAAAAAAACAPzOTkkMAAAAAAAAAAAAAAAAAAAAAzcxMPwAAAAABAAAA/////wEAAAAAAAAAAQACCgAASMIzMzM/AAC+QgAAAAAAAAAAAAAAAAAAgD8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAzMzM/MzMzPzMzMz8AAPBBiYgIPczMHEHMzBxBzMwcQQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA9Sg8QWZmhj8AAAAAAAB6RGZmZj8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAgD8zk5JDAAAAAAAAAAAAAAAAAAAAAM3MTD8AAAAAAQAAAP////8BAAAAAAAAAAEAAgoAAEjCMzMzPwAAyEIAAAAAAAAAAAAAAAAAAIA/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMzMzPzMzMz8zMzM/AADwQYmICD3MzBxBzMwcQczMHEEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPUoPEFmZoY/AAAAAAAAekRmZmY/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAIA/M5OSQwAAAAAAAAAAAAAAAAAAAADNzEw/AAAAAAEAAAD/////AQAAAAAAAAABAAIKAAA4wjMzMz8AALRCAAAAAAAAAAAAAAAAAACAPwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADMzMz8zMzM/MzMzPwAA8EGJiAg9zMwcQczMHEHMzBxBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD1KDxBZmaGPwAAAAAAAHpEZmZmPwAAAAAAAAAAAAAAAAAAAAAAAAAAAACAPzOTkkMAAAAAAAAAAAAAAAAAAAAAzcxMPwAAAAABAAAA/////wEAAAAAAAAAAQACCgAAOMIzMzM/AAC+QgAAAAAAAAAAAAAAAAAAgD8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAzMzM/MzMzPzMzMz8AAPBBiYgIPczMHEHMzBxBzMwcQQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA9Sg8QWZmhj8AAAAAAAB6RGZmZj8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAgD8zk5JDAAAAAAAAAAAAAAAAAAAAAM3MTD8AAAAAAQAAAP////8BAAAAAAAAAAEAAgoAADjCMzMzPwAAyEIAAAAAAAAAAAAAAAAAAIA/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMzMzPzMzMz8zMzM/AADwQYmICD3MzBxBzMwcQczMHEEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPUoPEFmZoY/AAAAAAAAekRmZmY/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAIA/M5OSQwAAAAAAAAAAAAAAAAAAAADNzEw/AAAAAAEAAAD/////AQAAAAAAAAABAAIKAAAowjMzMz8AALRCAAAAAAAAAAAAAAAAAACAPwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADMzMz8zMzM/MzMzPwAA8EGJiAg9zMwcQczMHEHMzBxBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD1KDxBZmaGPwAAAAAAAHpEZmZmPwAAAAAAAAAAAAAAAAAAAAAAAAAAAACAPzOTkkMAAAAAAAAAAAAAAAAAAAAAzcxMPwAAAAABAAAA/////wEAAAAAAAAAAQACCgAAKMIzMzM/AAC+QgAAAAAAAAAAAAAAAAAAgD8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAzMzM/MzMzPzMzMz8AAPBBiYgIPczMHEHMzBxBzMwcQQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA9Sg8QWZmhj8AAAAAAAB6RGZmZj8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAgD8zk5JDAAAAAAAAAAAAAAAAAAAAAM3MTD8AAAAAAQAAAP////8BAAAAAAAAAAEAAgoAACjCMzMzPwAAyEIAAAAAAAAAAAAAAAAAAIA/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMzMzPzMzMz8zMzM/AADwQYmICD3MzBxBzMwcQczMHEEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPUoPEFmZoY/AAAAAAAAekRmZmY/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAIA/M5OSQwAAAAAAAAAAAAAAAAAAAADNzEw/AAAAAAEAAAD/////AQAAAAAAAAABAAIKAACAwTMzMz8AABRDAAAAAAAAAAAAAAAAAACAPwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADMzMz8zMzM/MzMzPwAA8EGJiAg9zMwcQczMHEHMzBxBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD1KDxBZmaGPwAAAAAAAHpEZmZmPwAAAAAAAAAAAAAAAAAAAAAAAAAAAACAPzOTkkMAAAAAAAAAAAAAAAAAAAAAzcxMPwAAAAABAAAA/////wEAAAAAAAAAAQACCgAAgMEzMzM/AAAgQwAAAAAAAAAAAAAAAAAAgD8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAzMzM/MzMzPzMzMz8AAPBBiYgIPczMHEHMzBxBzMwcQQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA9Sg8QWZmhj8AAAAAAAB6RGZmZj8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAgD8zk5JDAAAAAAAAAAAAAAAAAAAAAM3MTD8AAAAAAQAAAP////8BAAAAAAAAAAEAAgoAAIBBMzMzPwAAFEMAAAAAAAAAAAAAAAAAAIA/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMzMzPzMzMz8zMzM/AADwQYmICD3MzBxBzMwcQczMHEEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPUoPEFmZoY/AAAAAAAAekRmZmY/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAIA/M5OSQwAAAAAAAAAAAAAAAAAAAADNzEw/AAAAAAEAAAD/////AQAAAAAAAAABAAIKAACAQTMzMz8AACBDAAAAAAAAAAAAAAAAAACAPwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADMzMz8zMzM/MzMzPwAA8EGJiAg9zMwcQczMHEHMzBxBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD1KDxBZmaGPwAAAAAAAHpEZmZmPwAAAAAAAAAAAAAAAAAAAAAAAAAAAACAPzOTkkMAAAAAAAAAAAAAAAAAAAAAzcxMPwAAAAABAAAA/////w=="},"tape":{"seed":1,"frames":[{"pressed":["moveForward","jump"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"released":["jump"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward","dropThrough"],"sy":1.0},{"pressed":["moveForward"],"released":["dropThrough"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"pressed":["moveForward","jump"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"released":["jump"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"pressed":["moveForward","jump"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"released":["jump"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward","dropThrough"],"sy":1.0},{"pressed":["moveForward"],"released":["dropThrough"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"pressed":["moveForward","jump"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"released":["jump"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward","dropThrough"],"sy":1.0},{"pressed":["moveForward"],"released":["dropThrough"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"pressed":["moveForward","jump"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"released":["jump"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"pressed":["moveForward","jump"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"released":["jump"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward","dropThrough"],"sy":1.0},{"pressed":["moveForward"],"released":["dropThrough"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"pressed":["moveForward","jump"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"released":["jump"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward","dropThrough"],"sy":1.0},{"pressed":["moveForward"],"released":["dropThrough"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"pressed":["moveForward","jump"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"released":["jump"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"pressed":["moveForward","jump"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"released":["jump"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward","dropThrough"],"sy":1.0},{"pressed":["moveForward"],"released":["dropThrough"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"pressed":["moveForward","jump"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"released":["jump"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward","dropThrough"],"sy":1.0},{"pressed":["moveForward"],"released":["dropThrough"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0}]},"buildStamp":"tpl-02-sample","checkpoints":{"every":25,"steps":[25,50,75,100,125,150,175,200,225,250,275,300,325,350,375,400,425,450,475,500,525,550,575,600],"digests":["893f9ae2","a26efa8b","92a1fac5","abd845c6","d747ce77","2f349d17","71c6e765","07ba22b2","4d524c80","d7e34933","0856721b","57694edc","0b3adba1","438cee90","e50dfa6a","6cd81810","1a637ea7","754e26fb","34dbbd1d","3656f8dc","49e0cd0a","f8622ce0","db278984","50a1e25d"]}} \ No newline at end of file diff --git a/apps/flutter3d_demo_platformer/tool/record_sample.dart b/apps/flutter3d_demo_platformer/tool/record_sample.dart index 8dbc58cf2..09c638b7a 100644 --- a/apps/flutter3d_demo_platformer/tool/record_sample.dart +++ b/apps/flutter3d_demo_platformer/tool/record_sample.dart @@ -2,6 +2,11 @@ /// built from the exact staging and route `test/demo_test.dart` already /// proves round-trips — this script only adds the write. /// +/// `N5`: the same bytes go to `test/tapes/ascent.f3drun`, which +/// `test/replay_test.dart` replays. One recording in two places rather than +/// the test reading the site's, so the game's tests do not reach outside the +/// game; and one script, so the two cannot be recorded from different runs. +/// /// A `flutter test`-shaped generator rather than `dart run`, for the reason /// `rp-05`'s own row now documents at length: `flutter3d_game_platformer` /// names `flutter: sdk: flutter`, and plain `dart run` fails compiling the @@ -56,7 +61,8 @@ void _play(InputState input, int step) { } void main() { - test('records site/assets/samples/platformer.f3drun', () { + test('records site/assets/samples/platformer.f3drun and the replay ' + "test's tape", () { final levelHash = _shipped().digestHex; final live = _stage(); final start = live.sim.save(); @@ -79,9 +85,14 @@ void main() { checkpoints: checkpoints, ); - final outFile = File('../../site/assets/samples/platformer.f3drun'); - outFile.writeAsStringSync(jsonEncode(demo.toJson())); - // ignore: avoid_print - print('wrote ${outFile.path} (${outFile.lengthSync()} bytes)'); + final json = jsonEncode(demo.toJson()); + for (final path in [ + '../../site/assets/samples/platformer.f3drun', + 'test/tapes/ascent.f3drun', + ]) { + final outFile = File(path)..writeAsStringSync(json); + // ignore: avoid_print + print('wrote ${outFile.path} (${outFile.lengthSync()} bytes)'); + } }); } diff --git a/apps/flutter3d_demo_racing/lib/main.dart b/apps/flutter3d_demo_racing/lib/main.dart index 162da5f5b..220ebe416 100644 --- a/apps/flutter3d_demo_racing/lib/main.dart +++ b/apps/flutter3d_demo_racing/lib/main.dart @@ -691,6 +691,8 @@ class _RaceScreenState extends State // `rp-02`: harmless where the VM service is off — `registerExtension` // just adds an entry nothing ever asks for. registerTimelineExtensions(_timeline, bugReport: _remoteBugReport); + // `P12`: the frame this game draws, pass by pass and draw by draw. + registerRenderExtensions(() => _renderer); await _loadCircuit(device); } diff --git a/apps/flutter3d_demo_river/lib/src/course.dart b/apps/flutter3d_demo_river/lib/src/course.dart index 7cb00f80a..ed483cce5 100644 --- a/apps/flutter3d_demo_river/lib/src/course.dart +++ b/apps/flutter3d_demo_river/lib/src/course.dart @@ -23,10 +23,16 @@ const double sectionLength = 180.0; /// How far either side of the valley's middle the water may ever reach. const double riverReach = 17.0; -/// How far either side the land is drawn: past what the camera sees at the -/// far end of the view, so the valley has no edge on screen. +/// How far either side of the middle the trees and houses stand. const double valleyReach = 46.0; +/// How far either side the grass is drawn: as far as the camera sees at +/// all. The stretches ahead reach a few hundred units up the river, and a +/// wide window sees about as far across up there, so land that stopped at +/// [valleyReach] left the sky showing in both top corners. Only the outer +/// quads of each row get wider; the valley has no more vertices for it. +const double landReach = 400.0; + /// Half the water's width under a bridge, and where each stretch starts. const double narrowHalf = 4.5; @@ -301,15 +307,24 @@ final class Section { while (distance < section.end - 32.0) { final kind = mix.kindFor(random.nextDouble()); final heading = random.nextBool() ? 1 : -1; - final moves = random.nextDouble() < mix.moving; + final rolledMoving = random.nextDouble() < mix.moving; final gunner = kind == TargetKind.helicopter && random.nextDouble() < mix.gunners; + final row = section.rowAt(distance); final double? x = switch (kind) { // A jet comes in from off the side and crosses the whole valley. TargetKind.jet => -heading * (riverReach + 6.0), - _ => _placeOnWater(section.rowAt(distance), kind.halfLength, random), + _ => _placeOnWater(row, kind.halfLength, random), }; if (x != null) { + // **A mover needs water to move across.** One put on a channel + // barely longer than itself turned at each bank several times a + // second and read as a craft shaking in place, not moving. + final room = switch (row.channelAt(x)) { + (final from, final to) => to - from - 2.0 * kind.halfLength, + null => 0.0, + }; + final moves = rolledMoving && room >= _roomToMove; targets.add( TargetPlan( kind: kind, @@ -333,6 +348,10 @@ final class Section { return targets; } + /// How far a tanker or a helicopter must be able to travel across its + /// channel to be given a speed at all. + static const double _roomToMove = 3.0; + /// Somewhere on one of the row's channels, clear of both banks, or null /// when the channel picked is too narrow for it. static double? _placeOnWater( diff --git a/apps/flutter3d_demo_river/lib/src/craft.dart b/apps/flutter3d_demo_river/lib/src/craft.dart index bc86ff743..5e35315ca 100644 --- a/apps/flutter3d_demo_river/lib/src/craft.dart +++ b/apps/flutter3d_demo_river/lib/src/craft.dart @@ -43,12 +43,18 @@ final class _Kit { _Kit(this.device) : playerJet = _upload( device, - jetMesh(Vector4(0.9, 0.2, 0.15, 1.0), Vector4(0.95, 0.95, 0.9, 1.0)), + jetPlaneMesh( + Vector4(0.9, 0.2, 0.15, 1.0), + Vector4(0.95, 0.95, 0.9, 1.0), + ), math.pi, ), enemyJet = _upload( device, - jetMesh(Vector4(0.25, 0.3, 0.55, 1.0), Vector4(0.6, 0.65, 0.75, 1.0)), + jetPlaneMesh( + Vector4(0.25, 0.3, 0.55, 1.0), + Vector4(0.6, 0.65, 0.75, 1.0), + ), math.pi, ), tanker = _upload(device, tankerMesh(), -math.pi / 2.0), @@ -56,6 +62,7 @@ final class _Kit { rotor = DeviceMesh.upload(device, rotorMesh()), depot = DeviceMesh.upload(device, depotMesh()), shot = DeviceMesh.upload(device, shotMesh()), + bullet = DeviceMesh.upload(device, bulletMesh()), shard = DeviceMesh.upload(device, shardMesh()), puff = DeviceMesh.upload(device, puffMesh()), water = DeviceMesh.upload(device, waterMesh()); @@ -68,6 +75,7 @@ final class _Kit { final DeviceMesh rotor; final DeviceMesh depot; final DeviceMesh shot; + final DeviceMesh bullet; final DeviceMesh shard; final DeviceMesh puff; final DeviceMesh water; diff --git a/apps/flutter3d_demo_river/lib/src/levels.dart b/apps/flutter3d_demo_river/lib/src/levels.dart index dd8fc4066..d0bbdd9a8 100644 --- a/apps/flutter3d_demo_river/lib/src/levels.dart +++ b/apps/flutter3d_demo_river/lib/src/levels.dart @@ -102,7 +102,7 @@ const List campaign = [ tanker: 0.45, helicopter: 0.2, depot: 0.3, - moving: 0.35, + moving: 0.55, speed: 0.8, islands: 0.25, narrowest: 7.0, @@ -114,7 +114,7 @@ const List campaign = [ briefing: 'Sink six tankers before the last bridge.', bridges: 3, task: {TargetKind.tanker: 6}, - mix: Mix(tanker: 0.55, helicopter: 0.15, depot: 0.25, moving: 0.6), + mix: Mix(tanker: 0.55, helicopter: 0.15, depot: 0.25, moving: 0.7), ), Level( name: 'Rotor Alley', @@ -126,7 +126,7 @@ const List campaign = [ helicopter: 0.45, depot: 0.22, jet: 0.08, - moving: 0.65, + moving: 0.75, speed: 1.1, gunners: 0.4, ), @@ -141,7 +141,7 @@ const List campaign = [ helicopter: 0.3, depot: 0.2, jet: 0.18, - moving: 0.7, + moving: 0.8, speed: 1.2, gunners: 0.5, islands: 0.45, @@ -159,7 +159,7 @@ const List campaign = [ helicopter: 0.32, depot: 0.12, jet: 0.14, - moving: 0.75, + moving: 0.85, speed: 1.3, gunners: 0.6, islands: 0.45, @@ -176,7 +176,7 @@ const List campaign = [ helicopter: 0.33, depot: 0.14, jet: 0.16, - moving: 0.8, + moving: 0.9, speed: 1.4, gunners: 0.7, islands: 0.5, diff --git a/apps/flutter3d_demo_river/lib/src/models.dart b/apps/flutter3d_demo_river/lib/src/models.dart index db8b8e2f2..55b73f585 100644 --- a/apps/flutter3d_demo_river/lib/src/models.dart +++ b/apps/flutter3d_demo_river/lib/src/models.dart @@ -80,7 +80,7 @@ MeshData valleyMesh(Section section) { final grown = row.islandGrown; final crest = bedDepth + (landHeight - bedDepth) * grown; return [ - p(-valleyReach, landHeight), + p(-landReach, landHeight), p(row.left - bankTop, landHeight), p(row.left + bankUnder, bedDepth), p(row.islandLeft - bankUnder * grown, bedDepth), @@ -89,7 +89,7 @@ MeshData valleyMesh(Section section) { p(row.islandRight + bankUnder * grown, bedDepth), p(row.right - bankUnder, bedDepth), p(row.right + bankTop, landHeight), - p(valleyReach, landHeight), + p(landReach, landHeight), ]; } @@ -120,21 +120,21 @@ MeshData valleyMesh(Section section) { if (section.hasBridge) { final row = section.rowAt(section.bridgeAt); final z = -section.bridgeAt; - final leftLength = row.left - 0.3 + valleyReach; - final rightLength = valleyReach - row.right - 0.3; + final leftLength = row.left - 0.3 + landReach; + final rightLength = landReach - row.right - 0.3; parts ..add( _part( CuboidShape(size: Vector3(leftLength, 0.06, 1.8)), _road, - _at(-valleyReach + leftLength / 2.0, landHeight + 0.03, z), + _at(-landReach + leftLength / 2.0, landHeight + 0.03, z), ), ) ..add( _part( CuboidShape(size: Vector3(rightLength, 0.06, 1.8)), _road, - _at(valleyReach - rightLength / 2.0, landHeight + 0.03, z), + _at(landReach - rightLength / 2.0, landHeight + 0.03, z), ), ); } @@ -307,6 +307,11 @@ MeshData shotMesh() => CuboidShape( size: Vector3(0.14, 0.14, 0.9), ).build().withColor(linearFromSrgb(1.0, 0.9, 0.4)); +/// A helicopter's bullet: a long rod along Z, white, for its material to +/// colour. Long so it reads as a streak coming at the jet from eleven +/// metres up; a cube the size of a shard was lost against the water. +MeshData bulletMesh() => CuboidShape(size: Vector3(0.3, 0.3, 1.6)).build(); + /// One shard of an explosion, white: its material gives it its colour. MeshData shardMesh() => CuboidShape(size: Vector3.all(0.32)).build(); @@ -319,7 +324,7 @@ MeshData puffMesh() => /// A jet, nose along -Z, about two metres long. The player's until its /// model loads, and an enemy jet's in other colours. -MeshData jetMesh(Vector4 body, Vector4 trim) => MeshData.merge([ +MeshData jetPlaneMesh(Vector4 body, Vector4 trim) => MeshData.merge([ _part( const CylinderShape( radiusTop: 0.17, diff --git a/apps/flutter3d_demo_river/lib/src/pieces.dart b/apps/flutter3d_demo_river/lib/src/pieces.dart index 1082f852e..068fcf739 100644 --- a/apps/flutter3d_demo_river/lib/src/pieces.dart +++ b/apps/flutter3d_demo_river/lib/src/pieces.dart @@ -128,10 +128,19 @@ final class TargetComponent extends Object3dComponent } /// Seconds between a gunner's shots. - static const double fireInterval = 1.8; + static const double fireInterval = 1.1; /// A gunner fires only at a jet this far ahead of it, and no nearer. - static const (double, double) fireRange = (7.0, 36.0); + /// + /// **From almost as far as it wakes.** Starting at 36 with a shot every + /// 1.8 seconds, a jet at cruise went through the whole window in about + /// one interval and drew a single shot, and on the throttle often none. + /// With the first shot soon after waking, it now draws three at cruise + /// and two on the throttle. + static const (double, double) fireRange = (7.0, 46.0); + + /// Seconds from waking to a gunner's first shot. + static const double firstShot = 0.2; final TargetPlan plan; @@ -148,7 +157,7 @@ final class TargetComponent extends Object3dComponent double _spin = 0.0; double _dying = 0.0; double _smokeIn = 0.0; - double _fireIn = fireInterval / 2.0; + double _fireIn = firstShot; /// Made with the component rather than on load, so a target hit before /// Flame has loaded it has a hitbox to take away. diff --git a/apps/flutter3d_demo_river/lib/src/river_game.dart b/apps/flutter3d_demo_river/lib/src/river_game.dart index 9ed26cab0..144b73bc4 100644 --- a/apps/flutter3d_demo_river/lib/src/river_game.dart +++ b/apps/flutter3d_demo_river/lib/src/river_game.dart @@ -474,21 +474,41 @@ final class RiverGame extends FlameGame } } - /// A helicopter at [from] fires at where the jet is now. + /// A helicopter at [from] fires at where the jet is now: a red flash at + /// its nose, and a streak laid along the way it flies. void enemyFire({required Vector2 from}) { final aim = (jet.position - from)..normalize(); + final muzzle = from + aim * 1.4; _sayAt(Sounds.tracer, river.to3d(from, at: flightHeight)); + _muzzleFlash(river.to3d(muzzle, at: flightHeight)); add( EnemyShotComponent( - node: MeshNode(_kit.shard, _kit.tracer, name: 'tracer') - ..setUniformScale(0.7), + // The rod turned inside a node of its own: the component writes + // the outer node's place, and the streak keeps its heading. + node: SceneNode(name: 'tracer') + ..add( + MeshNode(_kit.bullet, _kit.tracer) + ..setRotation(_facing(aim.x, aim.y)), + ), scene: _scene, - position: from + aim * 1.4, + position: muzzle, velocity: aim * EnemyShotComponent.speed, ), ); } + void _muzzleFlash(Vector3 at) => blasts.system.burst( + ParticleEffect( + count: 10, + emitter: const SphereEmitter(speed: Range(1.5, 3.5)), + lifetime: const Range(0.2, 0.3), + size: const Range(0.5, 0.8), + color: _muzzle, + affectors: const [ParticleSizeOverLife()], + ), + at, + ); + /// Fire: glowing shards thrown up and out, falling, shrinking, dimming /// from orange to a dull red, round the flash of the blast itself. void fireball(Vector3 at, {double size = 1.0}) { @@ -540,6 +560,7 @@ final class RiverGame extends FlameGame static Vector4 get _flame => Vector4(4.0, 2.2, 0.6, 1.0); static Vector4 get _ember => Vector4(1.2, 0.2, 0.05, 1.0); static Vector4 get _spark => Vector4(4.0, 3.4, 1.6, 1.0); + static Vector4 get _muzzle => Vector4(4.0, 0.9, 0.4, 1.0); static Vector4 get _spray => Vector4(0.7, 0.8, 0.9, 1.0); /// How much of what is behind it a puff of smoke takes away, fresh and diff --git a/apps/flutter3d_demo_river/test/course_test.dart b/apps/flutter3d_demo_river/test/course_test.dart index 888a93f98..e19b2aa8d 100644 --- a/apps/flutter3d_demo_river/test/course_test.dart +++ b/apps/flutter3d_demo_river/test/course_test.dart @@ -48,6 +48,24 @@ void main() { } }); + test('a tanker or a helicopter that moves has water to move across', () { + final course = Course(); + var movers = 0; + for (var i = 0; i < _sections; i++) { + for (final target in course.section(i).targets) { + if (target.kind == TargetKind.jet || target.speed == 0.0) continue; + movers++; + final (from, to) = course.rowAt(target.distance).channelAt(target.x)!; + expect( + to - from - 2.0 * target.kind.halfLength, + greaterThanOrEqualTo(3.0), + reason: '${target.kind} at ${target.distance}', + ); + } + } + expect(movers, greaterThan(0)); + }); + test('there is always a channel a jet fits through', () { final course = Course(); for (var d = -sectionLength; d < sectionLength * _sections; d += 0.5) { diff --git a/apps/flutter3d_demo_river/test/game_test.dart b/apps/flutter3d_demo_river/test/game_test.dart index d2c51a78f..d5b1e9405 100644 --- a/apps/flutter3d_demo_river/test/game_test.dart +++ b/apps/flutter3d_demo_river/test/game_test.dart @@ -4,6 +4,8 @@ /// Flame's collision detection finding two hitboxes overlapping. library; +import 'dart:math' as math; + import 'package:flame/collisions.dart' show ShapeHitbox; import 'package:flame/components.dart' show TextComponent, Vector2; import 'package:flame_test/flame_test.dart'; @@ -363,9 +365,15 @@ void main() { // Still on the water, only nearer: it is distance that wakes a target, // not the jet being in the air. game.jet.position.y = -(mover.plan.distance - RiverGame.wakeRange + 5.0); - await _run(game, 60); + // The furthest it got rather than where it ended: a mover that meets a + // bank turns back, and a second later can be where it started. + var furthest = 0.0; + for (var i = 0; i < 60; i++) { + await _run(game, 1); + furthest = math.max(furthest, (mover.position.x - mover.plan.x).abs()); + } expect(mover.awake, isTrue); - expect((mover.position.x - mover.plan.x).abs(), greaterThan(0.5)); + expect(furthest, greaterThan(0.5)); }); test('every model loads and takes its primitive\'s place', () async { diff --git a/apps/flutter3d_demo_strategy/macos/Flutter/GeneratedPluginRegistrant.swift b/apps/flutter3d_demo_strategy/macos/Flutter/GeneratedPluginRegistrant.swift index 4c6c08a37..859b13e8e 100644 --- a/apps/flutter3d_demo_strategy/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/apps/flutter3d_demo_strategy/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,12 +5,10 @@ import FlutterMacOS import Foundation -import flutter_soloud import pad_input import pointer_lock func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { - FlutterSoloudPlugin.register(with: registry.registrar(forPlugin: "FlutterSoloudPlugin")) GamepadPlugin.register(with: registry.registrar(forPlugin: "GamepadPlugin")) PointerLockPlugin.register(with: registry.registrar(forPlugin: "PointerLockPlugin")) } diff --git a/apps/flutter3d_editor/.metadata b/apps/flutter3d_editor/.metadata index b15f453e2..75cae063a 100644 --- a/apps/flutter3d_editor/.metadata +++ b/apps/flutter3d_editor/.metadata @@ -18,6 +18,12 @@ migration: - platform: macos create_revision: 4cf24164269a5ebf0c16a028a00727d0e77bbb05 base_revision: 4cf24164269a5ebf0c16a028a00727d0e77bbb05 + - platform: linux + create_revision: 4cf24164269a5ebf0c16a028a00727d0e77bbb05 + base_revision: 4cf24164269a5ebf0c16a028a00727d0e77bbb05 + - platform: windows + create_revision: 4cf24164269a5ebf0c16a028a00727d0e77bbb05 + base_revision: 4cf24164269a5ebf0c16a028a00727d0e77bbb05 # User provided section diff --git a/apps/flutter3d_editor/README.md b/apps/flutter3d_editor/README.md index 251184790..e23ee35e6 100644 --- a/apps/flutter3d_editor/README.md +++ b/apps/flutter3d_editor/README.md @@ -348,11 +348,13 @@ so. `⌘S` refuses and says who owns the file. `⇧⌘S` writes `crypt.edited.js beside it and takes ownership of the copy, because a copy still naming the generator invites the same accident. -The editor is desktop only, with no web half. The other three applications -choose between Impeller and WebGL at compile time because they ship to a -browser as well. This one exists to write a file back over itself, which a -browser will not do, so there is no backend to choose and no -`backend_web.dart`. +The editor runs on the desktop: there are runners for macOS, Windows and +Linux, and CI builds the last two. The other applications choose between +Impeller and WebGL at compile time because they ship to a browser as well. +This one was written to save a file back over itself, which a browser will not +do, so for now there is no backend to choose and no `backend_web.dart`. A web +build that opens and edits but cannot Play another project is a later step of +the same roadmap item. The editor has no vocabulary of its own. A level says `monster` or `coin` or `checkpoint`, and what those are worth belongs to the game. The engine's own diff --git a/apps/flutter3d_editor/lib/main.dart b/apps/flutter3d_editor/lib/main.dart index 87e46015a..e995e1398 100644 --- a/apps/flutter3d_editor/lib/main.dart +++ b/apps/flutter3d_editor/lib/main.dart @@ -3,9 +3,9 @@ /// cd apps/flutter3d_editor /// flutter run -d macos --dart-define=level=../flutter3d_demo_dungeon/assets/levels/crypt.json /// -/// **Desktop only, and that is not an omission.** This application exists to -/// write a file back over itself, which a browser will not do — so unlike the -/// three games there is no web build and no backend to choose between. +/// **Desktop first: macOS, Windows and Linux.** This application was written +/// to save a file back over itself, which a browser will not do — so unlike +/// the games there is no web build yet and no backend to choose between. /// /// What is here is the shell: a window, a camera, a mouse and a keyboard. The /// parts that can lose somebody's work are `package:flutter3d_editor_core`, @@ -32,6 +32,7 @@ import 'package:flutter3d/flutter3d.dart' hide Material; import 'package:flutter3d_app/flutter3d_app.dart'; import 'package:flutter3d_app/native.dart'; import 'package:flutter3d_editor_core/flutter3d_editor_core.dart'; +import 'package:flutter3d_editor_play/flutter3d_editor_play.dart'; import 'package:flutter3d_game/flutter3d_game.dart'; import 'package:flutter3d_sim/flutter3d_sim.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -48,7 +49,11 @@ import 'src/editor_palette.dart'; import 'src/editor_theme.dart'; import 'src/fly_camera.dart'; import 'src/light_plan_dialog.dart'; +import 'src/material_panel.dart'; import 'src/open_run_channel.dart'; +import 'src/play/device_picker.dart'; +import 'src/play/live_material.dart'; +import 'src/play/play_screen.dart'; import 'src/playtest_report_screen.dart'; import 'src/recent_projects.dart'; import 'src/run_info.dart'; @@ -144,6 +149,13 @@ class _EditorScreenState extends State /// The documents this editor has had open, kept between launches. final RecentProjects _projects = RecentProjects(); + /// `HR5`: the game being played — the project this level belongs to, run + /// with `flutter run`, or a game attached to by its VM service. Kept here + /// rather than by [PlayScreen] so closing the panel leaves the game + /// running; replaced when a level from another project is played, or + /// another game attached to. + PlayedGame? _run; + /// What [_projects] said when the chooser last needed it. /// /// Read once rather than in `build`, because `_onTick` rebuilds this screen @@ -190,6 +202,23 @@ class _EditorScreenState extends State /// `edu-01`: whether the step panel is open. bool _showSteps = false; + /// `HR4`: whether the material panel is open. + bool _showMaterials = false; + + /// `HR4`: what the material panel drags, on its way to the game [_run] + /// has running, thirty times a second at most. + late final ThrottledMaterials _liveMaterials = ThrottledMaterials( + send: _sendMaterial, + ); + + /// The connection [_liveMaterials] sends through, to the game running now. + GameMaterials? _gameMaterials; + + /// `HR3`: the open document as it was last read or written, which is the + /// best guess at what a running game is playing. A save sends the game a + /// patch from this rather than the whole level; the game checks the guess. + String? _written; + /// `rp-04`: the macOS side of file association calls back through this — /// a double-click on a `.f3drun` in Finder, or a drop on the dock icon. OpenRunChannel? _openRunChannel; @@ -363,10 +392,8 @@ class _EditorScreenState extends State /// that turns out not to be a level is not offered back tomorrow. Future _openAt(String found) async { try { - final editing = Editing.parse( - await File(found).readAsString(), - path: found, - ); + final text = await File(found).readAsString(); + final editing = Editing.parse(text, path: found); _recent = _projects.remember(found, exists: _onDisk); // Where this document's own `assets/…` live. A game never has to work // this out; an editor always does, because the level it has open belongs @@ -378,6 +405,7 @@ class _EditorScreenState extends State final looks = await _readLooks(assetRoot); _standWhereThePlayerWould(editing.level); if (!mounted) return; + _written = text; _cubit.opened(editing, assetRoot: assetRoot, looks: looks); await _build(); } catch (error) { @@ -1105,20 +1133,66 @@ class _EditorScreenState extends State // since they were written. A crash or a full disk halfway through left a // truncated level where the good one had been, so one lost session // became every future one. - await writeFileAtomically( - path, - // A copy of a generated document takes ownership of itself. One that - // still named the generator would invite somebody to run it again, and - // running it again is exactly what throws the work away. - editing.write(claiming: copy ? kAuthor : null), - ); + // A copy of a generated document takes ownership of itself. One that + // still named the generator would invite somebody to run it again, and + // running it again is exactly what throws the work away. + final document = editing.write(claiming: copy ? kAuthor : null); + await writeFileAtomically(path, document); editing.history.saved(); _cubit.say('written to $path'); + // `HR3`: the game this level is playing in takes it too. Not a copy: + // the running game plays the original, not the file beside it. + if (!copy) { + final base = _written; + _written = document; + unawaited(_pushToRunningGame(document, base: base)); + } } catch (error) { _cubit.say('could not write $path: $error'); } } + /// Sends a saved level to the game [_run] plays, when it is running, + /// whether this editor started it or attached to it, and says in the + /// status strip what the game did with it. + /// + /// As a patch from [base], the document this save wrote over, when the + /// game is playing that; whole when it is not. + Future _pushToRunningGame(String document, {String? base}) async { + if (_run?.state.value case PlayRunning(:final vmService)) { + try { + final answer = await pushLevel(vmService, document, base: base); + _cubit.say(describeLevelApplied(answer)); + } catch (error) { + _cubit.say('saved, but the running game did not take it: $error'); + } + } + } + + /// Sets [fields] on [material] in the game [_run] has running, when one + /// is. Nothing is sent, and nothing said, when no game is running: the + /// panel edits the level either way. + Future _sendMaterial( + String material, + Map fields, + ) async { + if (_run?.state.value case PlayRunning(:final vmService)) { + final link = _gameMaterials; + final GameMaterials game; + if (link != null && link.vmService == vmService) { + game = link; + } else { + unawaited(link?.dispose()); + game = _gameMaterials = GameMaterials( + vmService, + onError: (Object error) => + _cubit.say('the running game did not take the material: $error'), + ); + } + await game.set(material, fields); + } + } + /// A name beside [path] that nothing is using yet. /// /// **It used to be one name.** Shift-save always wrote `foo.edited.json`, so @@ -1142,6 +1216,9 @@ class _EditorScreenState extends State @override void dispose() { + _liveMaterials.dispose(); + unawaited(_gameMaterials?.dispose()); + unawaited(_run?.dispose()); _openRunChannel?.dispose(); _shaders?.dispose(); _ticker?.dispose(); @@ -1418,6 +1495,15 @@ class _EditorScreenState extends State _changed('$what — ${state.editing.says}'), ), ), + // `HR4`: a value dragged here reaches a running game + // at once; a value written is the level's, as any. + if (_showMaterials) + MaterialPanel( + editing: state.editing, + onChanged: (String what) => + _changed('$what — ${state.editing.says}'), + onLive: _liveMaterials.push, + ), EditorInspector( state: state, onChanged: (String what) => @@ -1433,9 +1519,24 @@ class _EditorScreenState extends State bottom: 0, child: EditorLegend(state: state), ), - // `rp-02`: attaches to a game already running elsewhere, - // over the same VM service channel DevTools uses — this - // editor still edits no simulation of its own. + // `HR5`: runs the project this level belongs to. Beside the + // material panel's button: at 92 it lay under the step + // panel's, which was drawn over it and took every press. + Positioned( + top: 4, + right: 212, + child: IconButton( + icon: const Icon( + Icons.play_circle_outline, + color: Color(0xFFE6EAF0), + ), + tooltip: 'Play the project', + onPressed: () => _play(state), + ), + ), + // `rp-02`, and `HR5` without a process: attaches to a game + // already running elsewhere, over the same VM service + // channel DevTools uses, and plays it in the same panel. Positioned( top: 4, right: 12, @@ -1472,6 +1573,19 @@ class _EditorScreenState extends State onPressed: () => setState(() => _showSteps = !_showSteps), ), ), + Positioned( + top: 4, + right: 172, + child: IconButton( + icon: Icon( + _showMaterials ? Icons.palette : Icons.palette_outlined, + color: const Color(0xFFE6EAF0), + ), + tooltip: 'Open the material panel', + onPressed: () => + setState(() => _showMaterials = !_showMaterials), + ), + ), // `rp-04`: the same screen file association and the open // panel both land on. Positioned( @@ -1496,10 +1610,14 @@ class _EditorScreenState extends State /// Opens `ai-02`'s own screen — the report itself is opened from inside /// it, since that is where the file picker and the "no report open" state - /// already live. + /// already live. The open level's digest goes with it, for N7's heatmap of + /// players' runs of exactly this document. Future _openPlaytestReport() async { + final levelHash = _editing?.level.digestHex; await Navigator.of(context).push( - MaterialPageRoute(builder: (_) => const PlaytestReportScreen()), + MaterialPageRoute( + builder: (_) => PlaytestReportScreen(levelHash: levelHash), + ), ); } @@ -1540,8 +1658,95 @@ class _EditorScreenState extends State await _openRunAt(file.path); } - /// Asks for a running game's VM service address, connects, and opens the - /// timeline panel on it — `rp-02`'s door, from the editor's side. + /// Opens the play panel on the project [state]'s level belongs to, + /// starting a run of it when none is going. + Future _play(EditorReady state) async { + final root = projectRootOnDisk(state.editing.path); + if (root == null) { + _changed( + 'this level is not inside a Flutter project, so there is ' + 'nothing to run', + ); + return; + } + final run = switch (_run) { + final FlutterRun same? when same.projectRoot == root => same, + final other => () { + unawaited(other?.dispose()); + return _run = FlutterRun(projectRoot: root); + }(), + }; + if (run.state.value case PlayIdle() || PlayStopped()) { + unawaited(run.start()); + } + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => PlayScreen( + session: run, + onTimeline: _openTimeline, + picker: ({required bool enabled}) => + DevicePicker(run: run, enabled: enabled), + ), + ), + ); + } + + /// Attaches to the game at [uri] and opens the play panel on it: its + /// console, hot reload and restart, timeline, and every save sent to it. + /// + /// **Refused while a game this editor started is running.** The editor + /// plays one game at a time, and replacing that run would stop a game + /// somebody is in the middle of; they stop it first, in its own panel. + Future _attach(String uri) async { + final session = switch (_run) { + final AttachedRun same? when same.vmService == uri => same, + final PlayedGame owned? + when owned.ownsTheGame && owned.state.value is! PlayStopped => + null, + final other => () { + unawaited(other?.dispose()); + return _run = AttachedRun(uri); + }(), + }; + if (session == null) { + _changed( + 'a game started here is still running: stop it in the Play panel ' + 'before attaching to another', + ); + return; + } + if (session.state.value case PlayIdle() || PlayStopped()) { + unawaited(session.start()); + } + if (!mounted) return; + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => PlayScreen(session: session, onTimeline: _openTimeline), + ), + ); + } + + Future _openTimeline(String uri) async { + final TimelineClient client; + try { + client = await VmServiceTimelineClient.connect(uri); + } catch (error) { + if (!mounted) return; + _changed('could not attach: $error'); + return; + } + if (!mounted) { + await client.dispose(); + return; + } + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => TimelineAttachScreen(client: client), + ), + ); + await client.dispose(); + } + Future _attachToRunningGame() async { final controller = TextEditingController(text: 'http://127.0.0.1:8181/'); final uri = await showDialog( @@ -1570,26 +1775,8 @@ class _EditorScreenState extends State ), ); controller.dispose(); - if (uri == null || uri.isEmpty || !mounted) return; - - final TimelineClient client; - try { - client = await VmServiceTimelineClient.connect(uri); - } catch (error) { - if (!mounted) return; - _changed('could not attach: $error'); - return; - } - if (!mounted) { - await client.dispose(); - return; - } - await Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => TimelineAttachScreen(client: client), - ), - ); - await client.dispose(); + if (uri == null || uri.trim().isEmpty || !mounted) return; + await _attach(uri.trim()); } } diff --git a/apps/flutter3d_editor/lib/src/material_panel.dart b/apps/flutter3d_editor/lib/src/material_panel.dart index 833017359..042e57762 100644 --- a/apps/flutter3d_editor/lib/src/material_panel.dart +++ b/apps/flutter3d_editor/lib/src/material_panel.dart @@ -153,6 +153,47 @@ bool setLevelMaterialField( return true; } +/// What a running game's `ext.flutter3d.material.set` takes for [key] of a +/// level material set to [value], or null for a key a frame cannot show +/// without a reload: a texture path, the tiling, a material file. +/// +/// **The level's words turned into the engine's**, the way the loader turns +/// them (`LevelScene.materialFrom`): a level's `emissive` is a strength over +/// the base colour, so it is `emissiveStrength`, and a new base colour is +/// the glow's colour too. +Map? liveMaterialFields(String key, Object? value) => + switch ((key, value)) { + ('baseColor', final List colour) + when colour.length >= 3 && colour.every((it) => it is num) => + { + 'baseColor': [ + ...colour.take(3).cast(), + colour.length > 3 ? colour[3]! as num : 1.0, + ], + 'emissive': colour.take(3).cast().toList(), + }, + ('roughness', final num at) => {'roughness': at}, + ('metallic', final num at) => {'metallic': at}, + ('emissive', final num at) => {'emissiveStrength': at}, + _ => null, + }; + +/// What a running game's `ext.flutter3d.material.set` takes for the `.fmat` +/// parameter [key] set to [value], or null for a value that is not numbers. +/// +/// **A list whatever the slider handed over**, for the reason the file is +/// written that way: a uniform is a list, and the game refuses one whose +/// length is not the one it loaded. +Map? liveParameterFields(String key, Object? value) => + switch (value) { + final num one => { + 'parameters/$key': [one], + }, + final List many when many.every((it) => it is num) => + {'parameters/$key': many}, + _ => null, + }; + /// The materials of a level, and the fields of the one that is open. final class MaterialPanel extends StatefulWidget { const MaterialPanel({ @@ -161,11 +202,22 @@ final class MaterialPanel extends StatefulWidget { required this.onChanged, this.documents = const {}, this.onMaterialWritten, + this.onLive, this.offers = nothingToOffer, }); final Editing editing; + /// Called with a material's name and [liveMaterialFields] for every value + /// a slider passes through and every value written, for a running game to + /// show before anything is saved. Keys a frame cannot show are not sent. + /// + /// For a material that defers to a `.fmat`, its parameters go out as + /// [liveParameterFields] and the level's own fields not at all: the game + /// draws that material from the file, so a level colour sent live would + /// show something the next load does not. + final void Function(String material, Map fields)? onLive; + /// Called after a field was actually written, naming it, so the screen can /// rebuild and the scene can be rebuilt from the document. The inspector's /// callback, with the same contract: a refused write tells nobody. @@ -264,6 +316,12 @@ class _MaterialPanelState extends State { value: row[key], hint: levelMaterialHints[key], offers: widget.offers, + onPreview: (Object? value) => _live( + name, + document == null + ? liveMaterialFields(key, value) + : null, + ), onWrite: (Object? value) { if (setLevelMaterialField( widget.editing, @@ -271,6 +329,12 @@ class _MaterialPanelState extends State { key, value, )) { + _live( + name, + document == null + ? liveMaterialFields(key, value) + : null, + ); widget.onChanged('$name.$key'); } }, @@ -319,22 +383,34 @@ class _MaterialPanelState extends State { }, hint: document.hints[key], offers: widget.offers, + onPreview: (Object? value) => + _live(name, liveParameterFields(key, value)), // Back into a list on the way out, because that is what a uniform // is in the file whatever a slider hands over — a bare number under // `parameters` reads back as a list of one and would look to the // gate like a value the reader had misunderstood. - onWrite: (Object? value) => _writeFile( - name, - document, - 'parameters/$key', - value is num ? [value] : value, - ), + onWrite: (Object? value) { + if (_writeFile( + name, + document, + 'parameters/$key', + value is num ? [value] : value, + )) { + _live(name, liveParameterFields(key, value)); + } + }, ), ], ]; } - void _writeFile( + void _live(String material, Map? fields) { + final send = widget.onLive; + if (send != null && fields != null) send(material, fields); + } + + /// Whether the file's reader took the change. + bool _writeFile( String name, MaterialDocument document, String key, @@ -346,9 +422,10 @@ class _MaterialPanelState extends State { value, hint: materialDocumentHint(document, key), ); - if (next == null) return; + if (next == null) return false; widget.onMaterialWritten?.call(name, next); widget.onChanged('$name:$key'); + return true; } Widget _heading(String says) => SectionLabel( diff --git a/apps/flutter3d_editor/lib/src/play/device_picker.dart b/apps/flutter3d_editor/lib/src/play/device_picker.dart new file mode 100644 index 000000000..a89857455 --- /dev/null +++ b/apps/flutter3d_editor/lib/src/play/device_picker.dart @@ -0,0 +1,69 @@ +/// Which device a [FlutterRun] starts on, for `PlayScreen`'s bar. +library; + +import 'package:flutter/material.dart'; +import 'package:flutter3d_editor_play/flutter3d_editor_play.dart'; + +/// Picks [run]'s device from [devices]. +/// +/// **Held still while a run is going** ([enabled] false): the device is +/// `flutter run`'s argument, and changing it means a new run. Its own file +/// because `flutterDevices` runs a process, and `PlayScreen` has to build +/// where there is none. +final class DevicePicker extends StatefulWidget { + const DevicePicker({ + super.key, + required this.run, + required this.enabled, + this.devices = flutterDevices, + }); + + final FlutterRun run; + final bool enabled; + + /// What the picker offers; `flutter devices --machine` by default. + final Future> Function() devices; + + @override + State createState() => _DevicePickerState(); +} + +class _DevicePickerState extends State { + static const Color _background = Color(0xFF14161A); + static const Color _text = Color(0xFFE6EAF0); + + /// Asked once per opening of the panel: the tool takes a second or two to + /// answer, and a phone plugged in afterwards is found by opening it again. + late final Future> _devices = widget.devices(); + + @override + Widget build(BuildContext context) => FutureBuilder>( + future: _devices, + builder: (BuildContext context, AsyncSnapshot> it) { + final devices = it.data ?? const []; + final chosen = widget.run.device; + return DropdownButton( + key: const ValueKey('play.device'), + value: devices.any((d) => d.id == chosen) ? chosen : null, + dropdownColor: _background, + style: const TextStyle(color: _text, fontSize: 13), + underline: const SizedBox.shrink(), + hint: Text( + it.hasError ? 'devices unavailable' : 'default device', + style: const TextStyle(color: _text, fontSize: 13), + ), + items: >[ + const DropdownMenuItem(child: Text('default device')), + for (final device in devices) + DropdownMenuItem( + value: device.id, + child: Text(device.name), + ), + ], + onChanged: widget.enabled + ? (String? id) => setState(() => widget.run.device = id) + : null, + ); + }, + ); +} diff --git a/apps/flutter3d_editor/lib/src/play/live_material.dart b/apps/flutter3d_editor/lib/src/play/live_material.dart new file mode 100644 index 000000000..f27f06075 --- /dev/null +++ b/apps/flutter3d_editor/lib/src/play/live_material.dart @@ -0,0 +1,122 @@ +/// `HR4`'s editor half: a material dragged in the panel, shown in the game +/// that is running before anything is saved. +library; + +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter3d_editor_play/attach.dart'; +import 'package:vm_service/vm_service.dart'; + +/// Passes on what the panel sends at most [interval] apart, merging what +/// arrives in between, and always sends the last of it. +/// +/// **A drag is sixty values a second and the game wants the last one.** Each +/// is a round trip over the VM service, and a phone on the far end of a +/// cable falls behind at that rate: the colour then trails the thumb and +/// keeps changing after the hand has stopped. So the first value goes at +/// once, what comes during the next [interval] is merged by material and +/// field, and whatever is left when it ends goes then. The last value is +/// never dropped, which is the one somebody let go of the slider on. +final class ThrottledMaterials { + ThrottledMaterials({ + required this.send, + this.interval = const Duration(milliseconds: 33), + }); + + /// Sends one material's fields to the game. + final Future Function(String material, Map fields) + send; + + /// Thirty a second by default. + final Duration interval; + + final Map> _pending = + >{}; + Timer? _quiet; + + void push(String material, Map fields) { + (_pending[material] ??= {}).addAll(fields); + if (_quiet == null) _flush(); + } + + void _flush() { + if (_pending.isEmpty) { + _quiet = null; + return; + } + final sending = Map>.of(_pending); + _pending.clear(); + for (final MapEntry(key: material, value: fields) in sending.entries) { + unawaited(send(material, fields)); + } + _quiet = Timer(interval, _flush); + } + + /// Drops what has not been sent yet. + void dispose() { + _quiet?.cancel(); + _quiet = null; + _pending.clear(); + } +} + +/// One VM service connection to a running game, kept for as long as the +/// panel sends to it, to call `ext.flutter3d.material.set`. +/// +/// **Connected once, not per value.** `pushLevel` connects for each save, +/// which is fine for one call a save; thirty a second would spend most of +/// each on a handshake. +final class GameMaterials { + GameMaterials(this.vmService, {this.onError}); + + final String vmService; + + /// Told when a send fails, once per connection: the game has gone, or + /// does not draw through a `SceneSurface`. Every later send in the same + /// drag would say the same thing. + final void Function(Object error)? onError; + + static const String method = 'ext.flutter3d.material.set'; + + Future<({VmService service, String isolate})>? _link; + bool _told = false; + + Future set(String material, Map fields) async { + try { + final (:service, :isolate) = await (_link ??= _connect()); + await service.callServiceExtension( + method, + isolateId: isolate, + args: {'name': material, 'fields': jsonEncode(fields)}, + ); + } on Object catch (error) { + final link = _link; + _link = null; + unawaited(link?.then((it) => it.service.dispose()).catchError((_) {})); + if (!_told) onError?.call(error); + _told = true; + } + } + + Future<({VmService service, String isolate})> _connect() async { + final service = await connectVmService(vmService); + final vm = await service.getVM(); + for (final ref in vm.isolates ?? const []) { + final isolate = await service.getIsolate(ref.id!); + if ((isolate.extensionRPCs ?? const []).contains(method)) { + return (service: service, isolate: ref.id!); + } + } + await service.dispose(); + throw StateError( + 'the running game takes no live materials: nothing registered $method', + ); + } + + Future dispose() async { + final link = _link; + _link = null; + if (link != null) await (await link).service.dispose(); + } +} diff --git a/apps/flutter3d_editor/lib/src/play/play_screen.dart b/apps/flutter3d_editor/lib/src/play/play_screen.dart new file mode 100644 index 000000000..f5b419453 --- /dev/null +++ b/apps/flutter3d_editor/lib/src/play/play_screen.dart @@ -0,0 +1,152 @@ +/// `HR5`'s panel: the game being played, its console, and the four things +/// done to it — start, hot reload, hot restart, stop — plus its timeline and, +/// for a game the editor started, the device it runs on. +library; + +import 'package:flutter/material.dart'; +import 'package:flutter3d_editor_play/attach.dart'; + +/// Shows [session] and drives it. +/// +/// **A view of the session, not its owner.** The editor keeps [session] for +/// as long as the project is open, so closing this screen leaves the game +/// running and opening it again finds the same console — the way a terminal +/// tab with `flutter run` in it would behave. +/// +/// **No `dart:io` here.** The same panel shows a game the editor attached to +/// by its VM service address, which is the only Play a browser has; what +/// needs a process — the device picker — comes in as [picker] from a file +/// that is allowed one. +final class PlayScreen extends StatefulWidget { + const PlayScreen({ + super.key, + required this.session, + required this.onTimeline, + this.picker, + }); + + final PlayedGame session; + + /// Opens the timeline panel on the running game's VM service. + final void Function(String vmService) onTimeline; + + /// What chooses the device the next run starts on, enabled between runs; + /// none for a game somebody else started. + final Widget Function({required bool enabled})? picker; + + @override + State createState() => _PlayScreenState(); +} + +class _PlayScreenState extends State { + static const Color _background = Color(0xFF14161A); + static const Color _text = Color(0xFFE6EAF0); + + @override + Widget build(BuildContext context) { + final session = widget.session; + final owns = session.ownsTheGame; + return StreamBuilder( + stream: session.state.changes, + initialData: session.state.value, + builder: (BuildContext context, _) { + final state = session.state.value; + final running = state is PlayRunning; + final going = state is PlayStarting || running; + return Scaffold( + backgroundColor: _background, + appBar: AppBar( + title: Text(session.title, overflow: TextOverflow.ellipsis), + actions: [ + ?widget.picker?.call(enabled: !going), + IconButton( + key: const ValueKey('play.start'), + icon: Icon(switch ((going, owns)) { + (true, true) => Icons.stop, + (true, false) => Icons.link_off, + (false, true) => Icons.play_arrow, + (false, false) => Icons.link, + }), + tooltip: switch ((going, owns)) { + (true, true) => 'Stop', + (true, false) => 'Detach (the game keeps running)', + (false, true) => 'Run the project', + (false, false) => 'Attach again', + }, + onPressed: going ? session.stop : session.start, + ), + IconButton( + key: const ValueKey('play.reload'), + icon: const Icon(Icons.bolt), + tooltip: 'Hot reload', + onPressed: running ? session.hotSwap : null, + ), + IconButton( + key: const ValueKey('play.restart'), + icon: const Icon(Icons.restart_alt), + tooltip: 'Hot restart', + onPressed: running ? session.hotRestart : null, + ), + IconButton( + key: const ValueKey('play.timeline'), + icon: const Icon(Icons.podcasts), + tooltip: 'Timeline', + onPressed: switch (state) { + PlayRunning(:final vmService) => () => widget.onTimeline( + vmService, + ), + _ => null, + }, + ), + ], + ), + body: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(12, 8, 12, 4), + child: Text( + switch (state) { + PlayIdle() => owns ? 'Not running' : 'Not attached', + PlayStarting(:final message) => message, + PlayRunning(:final vmService) => + owns ? 'Running, $vmService' : 'Attached, $vmService', + PlayStopped(:final String reason) => reason, + PlayStopped(:final exitCode) => 'Stopped ($exitCode)', + }, + key: const ValueKey('play.status'), + style: const TextStyle(color: _text), + ), + ), + Expanded( + child: StreamBuilder>( + stream: session.console.changes, + initialData: session.console.value, + // Reversed, so the newest line sits at the bottom and the + // list stays there as lines arrive, as a terminal does. + builder: (BuildContext context, _) { + final lines = session.console.value; + return ListView.builder( + reverse: true, + padding: const EdgeInsets.symmetric(horizontal: 12), + itemCount: lines.length, + itemBuilder: (BuildContext context, int index) => + SelectableText( + lines[lines.length - 1 - index], + style: const TextStyle( + color: _text, + fontFamily: 'monospace', + fontSize: 12, + ), + ), + ); + }, + ), + ), + ], + ), + ); + }, + ); + } +} diff --git a/apps/flutter3d_editor/lib/src/playtest_report_screen.dart b/apps/flutter3d_editor/lib/src/playtest_report_screen.dart index 8eb27cff1..63a842ee4 100644 --- a/apps/flutter3d_editor/lib/src/playtest_report_screen.dart +++ b/apps/flutter3d_editor/lib/src/playtest_report_screen.dart @@ -5,18 +5,33 @@ import 'package:flutter/material.dart'; import 'playtest_heatmap_view.dart'; import 'playtest_report.dart'; +import 'telemetry_fetch.dart'; /// `ai-02`'s screen: open a heatmap `ai-01` wrote, look at where a level /// swallows a policy that never learns it, and tap a death for the one /// number worth reading off it — which step, in which run. +/// +/// N7 draws real players here too: the same report, fetched from a telemetry +/// server that played their runs again. final class PlaytestReportScreen extends StatefulWidget { - const PlaytestReportScreen({super.key, this.initialReport}); + const PlaytestReportScreen({ + super.key, + this.initialReport, + this.levelHash, + this.get = getText, + }); /// A report already loaded, for a caller that opened the file itself — /// what every test here hands over, since a real open panel is not /// something a widget test can drive. final PlaytestReport? initialReport; + /// The open level's digest, offered when asking a telemetry server for its + /// heatmap. + final String? levelHash; + + final TextGet get; + @override State createState() => _PlaytestReportScreenState(); } @@ -25,12 +40,30 @@ final class _PlaytestReportScreenState extends State { PlaytestReport? _report; String? _said; + /// Whether [_report] is players' runs rather than a playtest's: a marker + /// is then a run id on the server, not a seed to play again. + bool _fromTelemetry = false; + + final TextEditingController _server = TextEditingController( + text: 'http://localhost:8794', + ); + late final TextEditingController _level = TextEditingController( + text: widget.levelHash ?? '', + ); + @override void initState() { super.initState(); _report = widget.initialReport; } + @override + void dispose() { + _server.dispose(); + _level.dispose(); + super.dispose(); + } + Future _open() async { const jsonFiles = XTypeGroup( label: 'playtest reports', @@ -45,6 +78,7 @@ final class _PlaytestReportScreenState extends State { jsonDecode(await file.readAsString()) as Map; setState(() { _report = PlaytestReport.fromJson(json); + _fromTelemetry = false; _said = null; }); } catch (error) { @@ -52,17 +86,77 @@ final class _PlaytestReportScreenState extends State { } } + Future _fetch() async { + final asked = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Telemetry heatmap'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + key: const ValueKey('telemetry-server'), + controller: _server, + decoration: const InputDecoration(labelText: 'Server'), + ), + TextField( + key: const ValueKey('telemetry-level'), + controller: _level, + decoration: const InputDecoration(labelText: 'Level digest'), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(true), + child: const Text('Fetch'), + ), + ], + ), + ); + if (asked != true || !mounted) return; + final server = Uri.tryParse(_server.text.trim()); + if (server == null || !server.hasScheme) { + setState(() => _said = '"${_server.text}" is not a server address'); + return; + } + final fetched = await fetchTelemetryHeatmap( + server: server, + levelHash: _level.text.trim(), + get: widget.get, + ); + if (!mounted) return; + setState(() { + _said = fetched.says; + if (fetched.report case final report?) { + _report = report; + _fromTelemetry = true; + } + }); + } + void _onDeathTap(DeathPoint death) { + final where = + '(${death.x.toStringAsFixed(1)}, ${death.z.toStringAsFixed(1)})'; showDialog( context: context, builder: (context) => AlertDialog( - title: const Text('A death, from ai-01'), + title: Text( + _fromTelemetry ? 'A run lost, from telemetry' : 'A death, from ai-01', + ), content: Text( - 'run seed ${death.seed}, step ${death.step}, at ' - '(${death.x.toStringAsFixed(1)}, ${death.z.toStringAsFixed(1)}).\n\n' - 'Scrubbing to this step needs a running instance of the level\'s ' - 'own genre — the editor draws where it happened, not what led to ' - 'it.', + _fromTelemetry + ? 'run ${death.seed} on the server, step ${death.step}, at ' + '$where.\n\nThe server keeps where the run went, not ' + 'what was pressed, so it cannot be played here.' + : 'run seed ${death.seed}, step ${death.step}, at $where.\n\n' + 'Scrubbing to this step needs a running instance of the ' + 'level\'s own genre — the editor draws where it happened, ' + 'not what led to it.', ), actions: [ TextButton( @@ -77,10 +171,17 @@ final class _PlaytestReportScreenState extends State { @override Widget build(BuildContext context) { final report = _report; + final said = _said; + final error = TextStyle(color: Theme.of(context).colorScheme.error); return Scaffold( appBar: AppBar( title: const Text('Playtest report'), actions: [ + IconButton( + onPressed: _fetch, + icon: const Icon(Icons.cloud_download), + tooltip: 'Fetch players\' heatmap from a telemetry server', + ), IconButton( onPressed: _open, icon: const Icon(Icons.folder_open), @@ -94,14 +195,9 @@ final class _PlaytestReportScreenState extends State { mainAxisSize: MainAxisSize.min, children: [ const Text('No report open.'), - if (_said != null) ...[ + if (said != null) ...[ const SizedBox(height: 8.0), - Text( - _said!, - style: TextStyle( - color: Theme.of(context).colorScheme.error, - ), - ), + Text(said, style: error), ], ], ), @@ -111,9 +207,15 @@ final class _PlaytestReportScreenState extends State { Padding( padding: const EdgeInsets.all(8.0), child: Text( + '${_fromTelemetry ? 'Players: ' : ''}' '${report.totalRuns} runs — ${report.outcomes.entries.map((e) => '${e.key}: ${e.value}').join(', ')}', ), ), + if (said != null) + Padding( + padding: const EdgeInsets.only(bottom: 8.0), + child: Text(said), + ), Expanded( child: PlaytestHeatmapView( report: report, diff --git a/apps/flutter3d_editor/lib/src/telemetry_fetch.dart b/apps/flutter3d_editor/lib/src/telemetry_fetch.dart new file mode 100644 index 000000000..04b553af4 --- /dev/null +++ b/apps/flutter3d_editor/lib/src/telemetry_fetch.dart @@ -0,0 +1,71 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'playtest_report.dart'; + +/// One GET: a status and a body. Handed in so a test needs no server. +typedef TextGet = Future<({int status, String body})> Function(Uri url); + +/// N7: the heatmap a telemetry server binned from players' runs of one level, +/// as the same [PlaytestReport] `ai-01`'s playtests are read into. +/// +/// **The level goes by its digest, not its path.** Runs are kept against the +/// document they were played in, so a level edited since has a heatmap of its +/// own — empty until somebody plays it — rather than one drawn from geometry +/// that is no longer there. +Future<({PlaytestReport? report, String says})> fetchTelemetryHeatmap({ + required Uri server, + required String levelHash, + double cellSize = 1.0, + TextGet get = getText, +}) async { + final url = server.resolve( + '/api/telemetry/heatmap?level=${Uri.encodeQueryComponent(levelHash)}' + '&cell=$cellSize', + ); + final ({int status, String body}) answer; + try { + answer = await get(url); + } catch (error) { + return (report: null, says: 'could not reach $server: $error'); + } + final Object? json; + try { + json = jsonDecode(answer.body); + } on FormatException { + return (report: null, says: '$url answered ${answer.status}, not JSON'); + } + if (json is! Map) { + return (report: null, says: '$url answered ${answer.status}, not a map'); + } + final says = json['says'] is String + ? json['says']! as String + : 'answered ${answer.status}'; + if (answer.status != 200) return (report: null, says: says); + try { + final report = PlaytestReport.fromJson(json); + return ( + report: report, + says: report.totalRuns == 0 + ? 'nobody has sent a run of this level yet' + : says, + ); + } catch (error) { + return (report: null, says: '$url sent a heatmap this cannot read: $error'); + } +} + +/// [TextGet] over `dart:io`. +Future<({int status, String body})> getText(Uri url) async { + final client = HttpClient()..connectionTimeout = const Duration(seconds: 10); + try { + final request = await client.getUrl(url); + final response = await request.close(); + return ( + status: response.statusCode, + body: await response.transform(utf8.decoder).join(), + ); + } finally { + client.close(); + } +} diff --git a/apps/flutter3d_editor/lib/src/timeline_attach_screen.dart b/apps/flutter3d_editor/lib/src/timeline_attach_screen.dart index c77e8d39c..c0a8f7f6e 100644 --- a/apps/flutter3d_editor/lib/src/timeline_attach_screen.dart +++ b/apps/flutter3d_editor/lib/src/timeline_attach_screen.dart @@ -43,6 +43,17 @@ final class _TimelineAttachScreenState extends State { /// disable itself and nobody double-taps a file picker. bool _savingBugReport = false; + /// `N4`'s scrubber range; null until the first status arrives. + TimelineWindow? _window; + + /// Where the slider is while it is being dragged, before the drag ends and + /// the game is asked to move — one scrub per release, not per pixel. + double? _dragging; + + /// Lanes loaded on request; they cost the game a replay of its buffer, so + /// they are not polled. + List? _lanes; + @override void initState() { super.initState(); @@ -58,6 +69,7 @@ final class _TimelineAttachScreenState extends State { Future _refresh() async { final paused = await widget.client.status(); final history = await widget.client.history(); + final window = await widget.client.window(); // `frameTimes` is optional on the other end — a game that registered no // `StepTimeTrace` throws here, and that is silence, not a failure to // report through `_said`. @@ -71,10 +83,26 @@ final class _TimelineAttachScreenState extends State { setState(() { _paused = paused; _history = history; + _window = window; _frameTimes = frameTimes; }); } + /// A scrub or branch the game refused: its reason goes where any other + /// failure here goes, and one it carried out clears the last. Set before + /// [_run]'s refresh, whose `setState` draws it. + Future _answer(Future Function() action) => + _run(() async => _said = await action()); + + Future _loadTracks() async { + try { + final lanes = await widget.client.tracks(); + if (mounted) setState(() => _lanes = lanes); + } catch (error) { + if (mounted) setState(() => _said = '$error'); + } + } + Future _run(Future Function() action) async { try { await action(); @@ -106,6 +134,71 @@ final class _TimelineAttachScreenState extends State { } } + /// `N4`: a slider over the steps the game holds, the two ways out of a + /// scrub, and the lanes. Shown only while paused, since the game refuses a + /// scrub on a live run, and only once there is a range to scrub. + List _scrubber() { + final window = _window; + final oldest = window?.oldest; + if (!_paused || window == null || oldest == null) return const []; + if (window.present <= oldest) return const []; + final at = window.scrubbedAt ?? window.present; + final shown = (_dragging ?? at.toDouble()).round(); + final scrubbed = window.scrubbedAt != null; + return [ + const SizedBox(height: 16.0), + Text( + scrubbed || _dragging != null + ? 'Scrub: step $shown of ${window.present}' + : 'Scrub: at the present, step ${window.present}', + ), + Slider( + min: oldest.toDouble(), + max: window.present.toDouble(), + divisions: window.present - oldest, + value: (_dragging ?? at.toDouble()).clamp( + oldest.toDouble(), + window.present.toDouble(), + ), + onChanged: (value) => setState(() => _dragging = value), + onChangeEnd: (value) { + setState(() => _dragging = null); + _answer(() => widget.client.scrubTo(value.round())); + }, + ), + Row( + children: [ + OutlinedButton( + onPressed: scrubbed + ? () => _run(widget.client.returnToPresent) + : null, + child: const Text('Back to present'), + ), + const SizedBox(width: 8.0), + FilledButton.tonal( + onPressed: scrubbed + ? () => _answer(widget.client.branchHere) + : null, + child: const Text('Branch here'), + ), + const SizedBox(width: 8.0), + OutlinedButton( + onPressed: _loadTracks, + child: const Text('Load tracks'), + ), + ], + ), + if (_lanes case final lanes?) + _TrackLanes( + lanes: lanes, + oldest: oldest, + present: window.present, + cursor: at, + onTapStep: (step) => _answer(() => widget.client.scrubTo(step)), + ), + ]; + } + @override Widget build(BuildContext context) { return Scaffold( @@ -174,6 +267,7 @@ final class _TimelineAttachScreenState extends State { }), child: const Text('Release here'), ), + ..._scrubber(), if (_said != null) ...[ const SizedBox(height: 8.0), Text( @@ -271,3 +365,104 @@ final class _FrameTimeStrip extends StatelessWidget { ); } } + +/// `N4`'s lanes: one row per component of each entity, a mark at every step +/// it changed, placed along the same range as the scrubber above, and a +/// line where the live state is. Tapping a mark scrubs there — the change +/// that looks wrong is one tap from the state it happened in. +final class _TrackLanes extends StatelessWidget { + const _TrackLanes({ + required this.lanes, + required this.oldest, + required this.present, + required this.cursor, + required this.onTapStep, + }); + + final List lanes; + final int oldest; + final int present; + final int cursor; + final ValueChanged onTapStep; + + static const double _laneHeight = 18.0; + static const double _labelWidth = 160.0; + + @override + Widget build(BuildContext context) { + if (lanes.isEmpty) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: 8.0), + child: Text('no entities in what the game holds'), + ); + } + final colours = Theme.of(context).colorScheme; + final span = (present - oldest).clamp(1, 1 << 30); + return SizedBox( + height: (lanes.length * _laneHeight).clamp(_laneHeight, 120.0), + child: ListView( + children: [ + for (final lane in lanes) + SizedBox( + height: _laneHeight, + child: Row( + children: [ + SizedBox( + width: _labelWidth, + child: Text( + '${lane.entity} · ${lane.component}', + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 12), + ), + ), + Expanded( + child: LayoutBuilder( + builder: (context, constraints) { + double x(int step) => + constraints.maxWidth * (step - oldest) / span; + return Stack( + children: [ + Positioned( + left: x(cursor), + top: 0, + bottom: 0, + child: Container( + width: 1.0, + color: colours.error, + ), + ), + for (final (i, step) in lane.steps.indexed) + Positioned( + left: (x(step) - 2.0).clamp( + 0.0, + constraints.maxWidth - 4.0, + ), + top: 3.0, + child: Tooltip( + message: 'step $step: ${lane.values[i]}', + child: GestureDetector( + key: ValueKey( + '${lane.entity}.${lane.component}@$step', + ), + onTap: () => onTapStep(step), + child: Container( + width: 4.0, + height: _laneHeight - 6.0, + color: colours.primary, + ), + ), + ), + ), + ], + ); + }, + ), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/apps/flutter3d_editor/lib/src/timeline_client.dart b/apps/flutter3d_editor/lib/src/timeline_client.dart index 513aef1f0..d040528cc 100644 --- a/apps/flutter3d_editor/lib/src/timeline_client.dart +++ b/apps/flutter3d_editor/lib/src/timeline_client.dart @@ -1,5 +1,5 @@ +import 'package:flutter3d_editor_play/attach.dart'; import 'package:vm_service/vm_service.dart'; -import 'package:vm_service/vm_service_io.dart'; /// What `ext.flutter3d.timeline.preview` answers. typedef TimelinePreview = ({bool found, int? step}); @@ -9,6 +9,21 @@ typedef TimelinePreview = ({bool found, int? step}); /// for the one shape it reads out of it. typedef StepCosts = ({List steps, List millis}); +/// What `ext.flutter3d.timeline.status` answers: the two ends of the +/// scrubber — the oldest step a rewind reaches and the present — and where +/// the live state is scrubbed to, null at the present. +typedef TimelineWindow = ({int present, int? oldest, int? scrubbedAt}); + +/// One lane of `ext.flutter3d.timeline.tracks`: the steps one component of +/// one entity changed at, and what it changed to, as text — the panel draws +/// marks and tooltips and never reads the value back. +typedef TrackLane = ({ + String entity, + String component, + List steps, + List values, +}); + /// What a running game's `RunTimeline` looks like from outside it. /// /// An interface rather than [VmServiceTimelineClient] directly, so @@ -49,6 +64,24 @@ abstract interface class TimelineClient { /// if that callback itself threw (nothing recorded yet, most likely). Future> bugReport(); + /// `N4`: the scrubber's range and position. + Future window(); + + /// Moves the live state to before [step] without cutting anything. Null + /// when it moved; otherwise the running game's reason it did not. + Future scrubTo(int step); + + /// Puts the present back after a scrub; false when there was none. + Future returnToPresent(); + + /// Makes the scrubbed step the present. Null when it did; otherwise the + /// reason it did not. + Future branchHere(); + + /// Every lane over the steps the game holds, entity by entity. Throws if + /// the running game registered no entity layout to read them through. + Future> tracks(); + /// Closes the connection. Safe to call more than once. Future dispose(); } @@ -58,10 +91,10 @@ abstract interface class TimelineClient { /// /// **The same channel DevTools and `flutter attach` use, and nothing else.** /// This does not start a game, does not know what a genre is, and does not -/// hold a level document — it only speaks the seven +/// hold a level document — it only speaks the /// `ext.flutter3d.timeline.*` extensions `registerTimelineExtensions` -/// registers on the other end, over a plain WebSocket that every desktop -/// platform this application ships to already has through `dart:io`. +/// registers on the other end, over `connectVmService`'s WebSocket, which is +/// `dart:io`'s on a desktop and the browser's in a web build. final class VmServiceTimelineClient implements TimelineClient { VmServiceTimelineClient._(this._service, this._isolateId); @@ -72,7 +105,7 @@ final class VmServiceTimelineClient implements TimelineClient { /// the same one a person pastes into DevTools, `http://` or `ws://`, /// with or without a trailing slash. static Future connect(String uri) async { - final service = await vmServiceConnectUri(_asWebSocket(uri)); + final service = await connectVmService(uri); final vm = await service.getVM(); final isolates = vm.isolates; if (isolates == null || isolates.isEmpty) { @@ -82,15 +115,6 @@ final class VmServiceTimelineClient implements TimelineClient { return VmServiceTimelineClient._(service, isolates.first.id!); } - static String _asWebSocket(String uri) { - final withScheme = uri - .replaceFirst('http://', 'ws://') - .replaceFirst('https://', 'wss://'); - return withScheme.endsWith('/ws') - ? withScheme - : '${withScheme.replaceFirst(RegExp(r'/$'), '')}/ws'; - } - Future> _call( String verb, [ Map? args, @@ -153,6 +177,57 @@ final class VmServiceTimelineClient implements TimelineClient { @override Future> bugReport() => _call('bugReport'); + @override + Future window() async { + final json = await _call('status'); + // A game built before `N4` answers `paused` alone; it reads as a window + // with nothing in it, which hides the scrubber rather than failing. + return ( + present: json['step'] as int? ?? 0, + oldest: json['oldest'] as int?, + scrubbedAt: json['scrubbedAt'] as int?, + ); + } + + @override + Future scrubTo(int step) async => + _refusal(await _call('scrubTo', {'step': '$step'})); + + @override + Future returnToPresent() async => + (await _call('returnToPresent'))['returned']! as bool; + + @override + Future branchHere() async => _refusal(await _call('branchHere')); + + static String? _refusal(Map json) => + json['moved'] == true ? null : '${json['refusal']}'; + + @override + Future> tracks() async { + final json = await _call('tracks'); + final entities = json['entities']; + if (entities is! Map) return const []; + return [ + for (final MapEntry(key: entity, value: lanes) in entities.entries) + if (lanes is Map) + for (final MapEntry(key: component, value: samples) in lanes.entries) + if (samples is List) + ( + entity: entity, + component: component, + steps: [ + for (final sample in samples.cast>()) + sample['step']! as int, + ], + values: [ + for (final sample in samples.cast>()) + sample['absent'] == true ? 'absent' : '${sample['value']}', + ], + ), + ]; + } + @override Future dispose() => _service.dispose(); } diff --git a/apps/flutter3d_editor/linux/.gitignore b/apps/flutter3d_editor/linux/.gitignore new file mode 100644 index 000000000..d3896c984 --- /dev/null +++ b/apps/flutter3d_editor/linux/.gitignore @@ -0,0 +1 @@ +flutter/ephemeral diff --git a/apps/flutter3d_editor/linux/CMakeLists.txt b/apps/flutter3d_editor/linux/CMakeLists.txt new file mode 100644 index 000000000..966f2a3b1 --- /dev/null +++ b/apps/flutter3d_editor/linux/CMakeLists.txt @@ -0,0 +1,128 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.13) +project(runner LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "flutter3d_editor") +# The unique GTK application identifier for this application. See: +# https://wiki.gnome.org/HowDoI/ChooseApplicationID +set(APPLICATION_ID "dev.flutter3d.editor") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(SET CMP0063 NEW) + +# Load bundled libraries from the lib/ directory relative to the binary. +set(CMAKE_INSTALL_RPATH "$ORIGIN/lib") + +# Root filesystem for cross-building. +if(FLUTTER_TARGET_PLATFORM_SYSROOT) + set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) + set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) +endif() + +# Define build configuration options. +if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") +endif() + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_14) + target_compile_options(${TARGET} PRIVATE -Wall -Werror) + target_compile_options(${TARGET} PRIVATE "$<$>:-O3>") + target_compile_definitions(${TARGET} PRIVATE "$<$>:NDEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) + +# Only the install-generated bundle's copy of the executable will launch +# correctly, since the resources must in the right relative locations. To avoid +# people trying to run the unbundled copy, put it in a subdirectory instead of +# the default top-level location. +set_target_properties(${BINARY_NAME} + PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run" +) + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# By default, "installing" just makes a relocatable bundle in the build +# directory. +set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle") +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +# Start with a clean build bundle directory every time. +install(CODE " + file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\") + " COMPONENT Runtime) + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES}) + install(FILES "${bundled_library}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endforeach(bundled_library) + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/linux/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +if(NOT CMAKE_BUILD_TYPE MATCHES "Debug") + install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() diff --git a/apps/flutter3d_editor/linux/flutter/CMakeLists.txt b/apps/flutter3d_editor/linux/flutter/CMakeLists.txt new file mode 100644 index 000000000..d5bd01648 --- /dev/null +++ b/apps/flutter3d_editor/linux/flutter/CMakeLists.txt @@ -0,0 +1,88 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.10) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. + +# Serves the same purpose as list(TRANSFORM ... PREPEND ...), +# which isn't available in 3.10. +function(list_prepend LIST_NAME PREFIX) + set(NEW_LIST "") + foreach(element ${${LIST_NAME}}) + list(APPEND NEW_LIST "${PREFIX}${element}") + endforeach(element) + set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE) +endfunction() + +# === Flutter Library === +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) +pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0) +pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0) + +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "fl_basic_message_channel.h" + "fl_binary_codec.h" + "fl_binary_messenger.h" + "fl_dart_project.h" + "fl_engine.h" + "fl_json_message_codec.h" + "fl_json_method_codec.h" + "fl_message_codec.h" + "fl_method_call.h" + "fl_method_channel.h" + "fl_method_codec.h" + "fl_method_response.h" + "fl_plugin_registrar.h" + "fl_plugin_registry.h" + "fl_standard_message_codec.h" + "fl_standard_method_codec.h" + "fl_string_codec.h" + "fl_value.h" + "fl_view.h" + "flutter_linux.h" +) +list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}") +target_link_libraries(flutter INTERFACE + PkgConfig::GTK + PkgConfig::GLIB + PkgConfig::GIO +) +add_dependencies(flutter flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CMAKE_CURRENT_BINARY_DIR}/_phony_ + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh" + ${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE} + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} +) diff --git a/apps/flutter3d_editor/linux/flutter/generated_plugin_registrant.cc b/apps/flutter3d_editor/linux/flutter/generated_plugin_registrant.cc new file mode 100644 index 000000000..64a0ecea4 --- /dev/null +++ b/apps/flutter3d_editor/linux/flutter/generated_plugin_registrant.cc @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + +#include + +void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); + file_selector_plugin_register_with_registrar(file_selector_linux_registrar); +} diff --git a/apps/flutter3d_editor/linux/flutter/generated_plugin_registrant.h b/apps/flutter3d_editor/linux/flutter/generated_plugin_registrant.h new file mode 100644 index 000000000..e0f0a47bc --- /dev/null +++ b/apps/flutter3d_editor/linux/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void fl_register_plugins(FlPluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/apps/flutter3d_editor/linux/flutter/generated_plugins.cmake b/apps/flutter3d_editor/linux/flutter/generated_plugins.cmake new file mode 100644 index 000000000..2db3c22ae --- /dev/null +++ b/apps/flutter3d_editor/linux/flutter/generated_plugins.cmake @@ -0,0 +1,24 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + file_selector_linux +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/apps/flutter3d_editor/linux/runner/CMakeLists.txt b/apps/flutter3d_editor/linux/runner/CMakeLists.txt new file mode 100644 index 000000000..e97dabc70 --- /dev/null +++ b/apps/flutter3d_editor/linux/runner/CMakeLists.txt @@ -0,0 +1,26 @@ +cmake_minimum_required(VERSION 3.13) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} + "main.cc" + "my_application.cc" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the application ID. +add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}") + +# Add dependency libraries. Add any application-specific dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter) +target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK) + +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") diff --git a/apps/flutter3d_editor/linux/runner/main.cc b/apps/flutter3d_editor/linux/runner/main.cc new file mode 100644 index 000000000..e7c5c5437 --- /dev/null +++ b/apps/flutter3d_editor/linux/runner/main.cc @@ -0,0 +1,6 @@ +#include "my_application.h" + +int main(int argc, char** argv) { + g_autoptr(MyApplication) app = my_application_new(); + return g_application_run(G_APPLICATION(app), argc, argv); +} diff --git a/apps/flutter3d_editor/linux/runner/my_application.cc b/apps/flutter3d_editor/linux/runner/my_application.cc new file mode 100644 index 000000000..dfc1179ea --- /dev/null +++ b/apps/flutter3d_editor/linux/runner/my_application.cc @@ -0,0 +1,148 @@ +#include "my_application.h" + +#include +#ifdef GDK_WINDOWING_X11 +#include +#endif + +#include "flutter/generated_plugin_registrant.h" + +struct _MyApplication { + GtkApplication parent_instance; + char** dart_entrypoint_arguments; +}; + +G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) + +// Called when first Flutter frame received. +static void first_frame_cb(MyApplication* self, FlView* view) { + gtk_widget_show(gtk_widget_get_toplevel(GTK_WIDGET(view))); +} + +// Implements GApplication::activate. +static void my_application_activate(GApplication* application) { + MyApplication* self = MY_APPLICATION(application); + GtkWindow* window = + GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); + + // Use a header bar when running in GNOME as this is the common style used + // by applications and is the setup most users will be using (e.g. Ubuntu + // desktop). + // If running on X and not using GNOME then just use a traditional title bar + // in case the window manager does more exotic layout, e.g. tiling. + // If running on Wayland assume the header bar will work (may need changing + // if future cases occur). + gboolean use_header_bar = TRUE; +#ifdef GDK_WINDOWING_X11 + GdkScreen* screen = gtk_window_get_screen(window); + if (GDK_IS_X11_SCREEN(screen)) { + const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen); + if (g_strcmp0(wm_name, "GNOME Shell") != 0) { + use_header_bar = FALSE; + } + } +#endif + if (use_header_bar) { + GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); + gtk_widget_show(GTK_WIDGET(header_bar)); + gtk_header_bar_set_title(header_bar, "flutter3d_editor"); + gtk_header_bar_set_show_close_button(header_bar, TRUE); + gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); + } else { + gtk_window_set_title(window, "flutter3d_editor"); + } + + gtk_window_set_default_size(window, 1280, 720); + + g_autoptr(FlDartProject) project = fl_dart_project_new(); + fl_dart_project_set_dart_entrypoint_arguments( + project, self->dart_entrypoint_arguments); + + FlView* view = fl_view_new(project); + GdkRGBA background_color; + // Background defaults to black, override it here if necessary, e.g. #00000000 + // for transparent. + gdk_rgba_parse(&background_color, "#000000"); + fl_view_set_background_color(view, &background_color); + gtk_widget_show(GTK_WIDGET(view)); + gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); + + // Show the window when Flutter renders. + // Requires the view to be realized so we can start rendering. + g_signal_connect_swapped(view, "first-frame", G_CALLBACK(first_frame_cb), + self); + gtk_widget_realize(GTK_WIDGET(view)); + + fl_register_plugins(FL_PLUGIN_REGISTRY(view)); + + gtk_widget_grab_focus(GTK_WIDGET(view)); +} + +// Implements GApplication::local_command_line. +static gboolean my_application_local_command_line(GApplication* application, + gchar*** arguments, + int* exit_status) { + MyApplication* self = MY_APPLICATION(application); + // Strip out the first argument as it is the binary name. + self->dart_entrypoint_arguments = g_strdupv(*arguments + 1); + + g_autoptr(GError) error = nullptr; + if (!g_application_register(application, nullptr, &error)) { + g_warning("Failed to register: %s", error->message); + *exit_status = 1; + return TRUE; + } + + g_application_activate(application); + *exit_status = 0; + + return TRUE; +} + +// Implements GApplication::startup. +static void my_application_startup(GApplication* application) { + // MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application startup. + + G_APPLICATION_CLASS(my_application_parent_class)->startup(application); +} + +// Implements GApplication::shutdown. +static void my_application_shutdown(GApplication* application) { + // MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application shutdown. + + G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application); +} + +// Implements GObject::dispose. +static void my_application_dispose(GObject* object) { + MyApplication* self = MY_APPLICATION(object); + g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); + G_OBJECT_CLASS(my_application_parent_class)->dispose(object); +} + +static void my_application_class_init(MyApplicationClass* klass) { + G_APPLICATION_CLASS(klass)->activate = my_application_activate; + G_APPLICATION_CLASS(klass)->local_command_line = + my_application_local_command_line; + G_APPLICATION_CLASS(klass)->startup = my_application_startup; + G_APPLICATION_CLASS(klass)->shutdown = my_application_shutdown; + G_OBJECT_CLASS(klass)->dispose = my_application_dispose; +} + +static void my_application_init(MyApplication* self) {} + +MyApplication* my_application_new() { + // Set the program name to the application ID, which helps various systems + // like GTK and desktop environments map this running application to its + // corresponding .desktop file. This ensures better integration by allowing + // the application to be recognized beyond its binary name. + g_set_prgname(APPLICATION_ID); + + return MY_APPLICATION(g_object_new(my_application_get_type(), + "application-id", APPLICATION_ID, "flags", + G_APPLICATION_NON_UNIQUE, nullptr)); +} diff --git a/apps/flutter3d_editor/linux/runner/my_application.h b/apps/flutter3d_editor/linux/runner/my_application.h new file mode 100644 index 000000000..db16367a7 --- /dev/null +++ b/apps/flutter3d_editor/linux/runner/my_application.h @@ -0,0 +1,21 @@ +#ifndef FLUTTER_MY_APPLICATION_H_ +#define FLUTTER_MY_APPLICATION_H_ + +#include + +G_DECLARE_FINAL_TYPE(MyApplication, + my_application, + MY, + APPLICATION, + GtkApplication) + +/** + * my_application_new: + * + * Creates a new Flutter-based application. + * + * Returns: a new #MyApplication. + */ +MyApplication* my_application_new(); + +#endif // FLUTTER_MY_APPLICATION_H_ diff --git a/apps/flutter3d_editor/macos/Flutter/GeneratedPluginRegistrant.swift b/apps/flutter3d_editor/macos/Flutter/GeneratedPluginRegistrant.swift index 48a37e4cf..98ae825f6 100644 --- a/apps/flutter3d_editor/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/apps/flutter3d_editor/macos/Flutter/GeneratedPluginRegistrant.swift @@ -6,13 +6,11 @@ import FlutterMacOS import Foundation import file_selector_macos -import flutter_soloud import pad_input import pointer_lock func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) - FlutterSoloudPlugin.register(with: registry.registrar(forPlugin: "FlutterSoloudPlugin")) GamepadPlugin.register(with: registry.registrar(forPlugin: "GamepadPlugin")) PointerLockPlugin.register(with: registry.registrar(forPlugin: "PointerLockPlugin")) } diff --git a/apps/flutter3d_editor/pubspec.yaml b/apps/flutter3d_editor/pubspec.yaml index 666af8022..6240ee52d 100644 --- a/apps/flutter3d_editor/pubspec.yaml +++ b/apps/flutter3d_editor/pubspec.yaml @@ -11,10 +11,13 @@ dependencies: flutter: sdk: flutter - # **Desktop only, and there is no web half.** The other three applications - # pick a backend at compile time because they ship to a browser; this one - # exists to write a file back to the disk it came from, which a browser will - # not do. One backend, named here, and no `backend_web.dart`. + # **Desktop runners for macOS, Windows and Linux; the web build is a step of + # its own.** The other applications pick a backend at compile time because + # they ship to a browser. This one was written to save a file back to the + # disk it came from, which a browser will not do, so for now there is one + # backend, named here, and no `backend_web.dart`. P11 in + # `tasks/0.9-engine-roadmap.md` asks for a web build that cannot Play another + # project; it brings its backend with it when it lands. flutter3d_impeller: path: ../../packages/flutter3d_impeller @@ -46,6 +49,13 @@ dependencies: flutter3d_editor_widgets: path: ../../packages/flutter3d_editor_widgets + # Play: the project run with `flutter run --machine`, its devices, and a + # saved level sent to it. Shared with `flutter3d_editor_mcp`, so an agent's + # Play is this toolbar's. Its `attach.dart` is the Play a web build has: a + # running game attached to by address, imported by `src/play/play_screen.dart`. + flutter3d_editor_play: + path: ../../packages/flutter3d_editor_play + # Level geometry to mesh nodes. `LevelLoader.build` is what an editor needs # and what a game never did: a level drawn from a document in memory rather # than from an asset. And `writeFileAtomically` through `native.dart`: this @@ -84,8 +94,8 @@ dependencies: # its `RunTimeline` — `rp-02`'s answer to "the editor runs the project", # over the same channel DevTools and `flutter attach` use, per # `doc/tooling-plan.md`. Not a plugin: this package speaks the VM service - # protocol over a plain WebSocket, which this application already has - # through `dart:io` on every platform it ships to. + # protocol over a plain WebSocket, opened by `flutter3d_editor_play`'s + # `connectVmService` so the same code reaches a game from a browser. vm_service: ^15.3.0 # State management — see the note in `packages/flutter3d_game/pubspec.yaml`. diff --git a/apps/flutter3d_editor/test/live_material_test.dart b/apps/flutter3d_editor/test/live_material_test.dart new file mode 100644 index 000000000..816add65f --- /dev/null +++ b/apps/flutter3d_editor/test/live_material_test.dart @@ -0,0 +1,88 @@ +/// `HR4`: what the material panel drags reaches a running game at most +/// thirty times a second, merged, and never without its last value. +/// +/// flutter test test/live_material_test.dart +library; + +import 'package:flutter3d_editor/src/play/live_material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + const interval = Duration(milliseconds: 33); + + /// What reached the game, in order. + ({ThrottledMaterials sender, List<(String, Map)> sent}) + sender() { + final sent = <(String, Map)>[]; + return ( + sender: ThrottledMaterials( + send: (String material, Map fields) async => + sent.add((material, Map.of(fields))), + ), + sent: sent, + ); + } + + testWidgets('the first value goes at once', (WidgetTester tester) async { + final it = sender(); + it.sender.push('stone', {'roughness': 0.1}); + expect(it.sent, hasLength(1)); + it.sender.dispose(); + }); + + testWidgets('a drag inside one interval sends its last value once', ( + WidgetTester tester, + ) async { + final it = sender(); + it.sender.push('stone', {'roughness': 0.1}); + for (final at in [0.2, 0.3, 0.4]) { + it.sender.push('stone', {'roughness': at}); + } + expect(it.sent, hasLength(1), reason: 'the rest wait for the interval'); + + await tester.pump(interval); + + expect(it.sent, hasLength(2)); + expect(it.sent.last.$1, 'stone'); + expect(it.sent.last.$2, {'roughness': 0.4}); + + await tester.pump(interval * 3); + expect(it.sent, hasLength(2), reason: 'nothing new, nothing sent'); + it.sender.dispose(); + }); + + testWidgets('fields and materials are merged, not dropped', ( + WidgetTester tester, + ) async { + final it = sender(); + it.sender.push('stone', {'roughness': 0.1}); + it.sender + ..push('stone', {'metallic': 0.5}) + ..push('moss', {'roughness': 0.9}); + + await tester.pump(interval); + + final merged = it.sent.skip(1).toList(); + expect(merged.map((it) => it.$1), ['stone', 'moss']); + expect(merged.first.$2, {'metallic': 0.5}); + expect(merged.last.$2, {'roughness': 0.9}); + it.sender.dispose(); + }); + + testWidgets('at most one send per material per interval', ( + WidgetTester tester, + ) async { + final it = sender(); + // Two seconds of a sixty-hertz drag. + for (var frame = 0; frame < 120; frame++) { + it.sender.push('stone', {'roughness': frame / 120}); + await tester.pump(const Duration(microseconds: 16667)); + } + await tester.pump(interval); + + expect(it.sent.length, lessThanOrEqualTo(2 * 1000 ~/ 33 + 2)); + expect(it.sent.length, greaterThan(40)); + expect(it.sent.last.$2['roughness'], 119 / 120); + it.sender.dispose(); + }); +} diff --git a/apps/flutter3d_editor/test/material_panel_test.dart b/apps/flutter3d_editor/test/material_panel_test.dart index 4427c818a..fe1af187b 100644 --- a/apps/flutter3d_editor/test/material_panel_test.dart +++ b/apps/flutter3d_editor/test/material_panel_test.dart @@ -54,6 +54,7 @@ Widget _panel( Map documents = const {}, void Function(String)? onChanged, void Function(String, MaterialDocument)? onMaterialWritten, + void Function(String, Map)? onLive, }) => MaterialApp( home: Scaffold( body: MaterialPanel( @@ -61,6 +62,7 @@ Widget _panel( documents: documents, onChanged: onChanged ?? (String _) {}, onMaterialWritten: onMaterialWritten, + onLive: onLive, ), ), ); @@ -91,6 +93,65 @@ void main() { ); }); + testWidgets('a drag reaches a running game before the level is written', ( + WidgetTester tester, + ) async { + // `HR4`: every value the thumb passes goes out live in the engine's + // words, and the one the drag ends on is written to the level as well. + final editing = openTestDocument(); + setLevelMaterialField(editing, 'stone', 'roughness', 0.4); + final live = <(String, Map)>[]; + + await tester.pumpWidget( + _panel( + editing, + onLive: (String m, Map f) { + live.add((m, f)); + }, + ), + ); + final gesture = await tester.startGesture( + tester.getCenter(_in('field:roughness', Slider)), + ); + await gesture.moveBy(const Offset(20, 0)); + await tester.pump(); + expect(live, isNotEmpty, reason: 'sent while the thumb was still down'); + expect(editing.level.materials['stone']!.roughness, 0.4); + + await gesture.moveBy(const Offset(20, 0)); + await gesture.up(); + await tester.pumpAndSettle(); + + expect(live.every((it) => it.$1 == 'stone'), isTrue); + expect(live.last.$2.keys, ['roughness']); + expect( + live.last.$2['roughness'], + editing.level.materials['stone']!.roughness, + reason: 'the last value sent is the one written', + ); + }); + + test('a level material field in the engine’s words', () { + expect(liveMaterialFields('roughness', 0.3), { + 'roughness': 0.3, + }); + expect(liveMaterialFields('emissive', 2.0), { + 'emissiveStrength': 2.0, + }); + expect( + liveMaterialFields('baseColor', [0.1, 0.2, 0.3]), + { + 'baseColor': [0.1, 0.2, 0.3, 1.0], + 'emissive': [0.1, 0.2, 0.3], + }, + ); + expect( + liveMaterialFields('albedo', 'stone.png'), + isNull, + reason: 'a texture needs a reload, not a frame', + ); + }); + testWidgets('and a hinted range is a slider', (WidgetTester tester) async { // **The hint reaching a UI, which is the whole task.** `roughness` is a // number in the document and nothing but a number, so the panel that typed @@ -205,6 +266,65 @@ void main() { expect(_in('parameter:windStrength', Slider), findsOneWidget); }); + testWidgets('and dragging it reaches a running game in the engine’s words', ( + WidgetTester tester, + ) async { + // `HR4`'s last half: the parameter row had no preview at all, so a game + // running beside the editor saw the wind only after a save and a reload. + // + // Mutation: drop the row's `onPreview`. Nothing is sent while the thumb + // is down and the first expectation fails. + final editing = openTestDocument(); + setLevelMaterialField(editing, 'stone', 'roughness', 0.4); + final live = <(String, Map)>[]; + await tester.pumpWidget( + _panel( + editing, + documents: {'stone': _document()}, + onLive: (String m, Map f) => live.add((m, f)), + ), + ); + + final gesture = await tester.startGesture( + tester.getCenter(_in('parameter:windStrength', Slider)), + ); + await gesture.moveBy(const Offset(20, 0)); + await tester.pump(); + expect(live, isNotEmpty, reason: 'sent while the thumb was still down'); + await gesture.up(); + await tester.pumpAndSettle(); + + expect(live.every((it) => it.$1 == 'stone'), isTrue); + expect( + live.every( + (it) => + it.$2.keys.single == 'parameters/windStrength' && + it.$2.values.single is List, + ), + isTrue, + reason: 'one key, and a list as the uniform is', + ); + + // And the level's own roughness is not sent for a material the game + // draws from its file: the next load would undo what it showed. + // + // Mutation: send `liveMaterialFields` whatever the material defers to. + live.clear(); + tester.widget(_in('field:roughness', Slider)).onChangeEnd!(0.75); + await tester.pump(); + expect(live, isEmpty); + }); + + test('a parameter in the engine’s words', () { + expect(liveParameterFields('wind', 0.5), { + 'parameters/wind': [0.5], + }); + expect(liveParameterFields('tint', [0.1, 0.2, 0.3]), { + 'parameters/tint': [0.1, 0.2, 0.3], + }); + expect(liveParameterFields('wind', 'strong'), isNull); + }); + testWidgets('and writing one hands back a document, never a file', ( WidgetTester tester, ) async { diff --git a/apps/flutter3d_editor/test/play_screen_test.dart b/apps/flutter3d_editor/test/play_screen_test.dart new file mode 100644 index 000000000..b6e365f6b --- /dev/null +++ b/apps/flutter3d_editor/test/play_screen_test.dart @@ -0,0 +1,138 @@ +/// `HR5`'s panel over a run of the test's own making, and over a game the +/// editor attached to rather than started. +/// +/// flutter test test/play_screen_test.dart +library; + +import 'package:flutter/material.dart'; +import 'package:flutter3d_editor/src/play/device_picker.dart'; +import 'package:flutter3d_editor/src/play/play_screen.dart'; +import 'package:flutter3d_editor_play/flutter3d_editor_play.dart'; +import 'package:flutter3d_editor_play/testing.dart'; +import 'package:flutter_test/flutter_test.dart'; + +const List _devices = [ + FlutterDevice(id: 'macos', name: 'macOS', platform: 'darwin'), + FlutterDevice(id: 'R5CX', name: 'Galaxy A55', platform: 'android-arm64'), +]; + +Future _pump( + WidgetTester tester, + FlutterRun run, { + List? timelines, +}) => tester.pumpWidget( + MaterialApp( + home: PlayScreen( + session: run, + onTimeline: timelines?.add ?? (String _) {}, + picker: ({required bool enabled}) => DevicePicker( + run: run, + enabled: enabled, + devices: () async => _devices, + ), + ), + ), +); + +IconButton _button(WidgetTester tester, String key) => + tester.widget(find.byKey(ValueKey(key))); + +void main() { + testWidgets('the panel offers a reload only once the game is running', ( + WidgetTester tester, + ) async { + final it = fakeFlutterRun(); + final timelines = []; + await it.run.start(); + await _pump(tester, it.run, timelines: timelines); + + expect(_button(tester, 'play.reload').onPressed, isNull); + expect(_button(tester, 'play.timeline').onPressed, isNull); + + it.tool.running(); + await tester.pump(); + await tester.pump(); + + expect(find.text('Running, ws://game'), findsOneWidget); + await tester.tap(find.byKey(const ValueKey('play.reload'))); + await tester.pump(); + expect(it.tool.sent.single['method'], 'app.restart'); + + await tester.tap(find.byKey(const ValueKey('play.timeline'))); + expect(timelines, ['ws://game']); + }); + + testWidgets('a device picked between runs is the next run\'s', ( + WidgetTester tester, + ) async { + final it = fakeFlutterRun(device: null); + await _pump(tester, it.run); + await tester.pump(); + + await tester.tap(find.byKey(const ValueKey('play.device'))); + await tester.pumpAndSettle(); + await tester.tap(find.text('Galaxy A55').last); + await tester.pumpAndSettle(); + expect(it.run.device, 'R5CX'); + + await tester.tap(find.byKey(const ValueKey('play.start'))); + await tester.pump(); + expect(it.started.single, [ + '/game', + 'run', + '--machine', + '-d', + 'R5CX', + ]); + }); + + testWidgets('and is held still while one is going', ( + WidgetTester tester, + ) async { + final it = fakeFlutterRun(); + await it.run.start(); + await _pump(tester, it.run); + await tester.pump(); + + final picker = tester.widget>( + find.byKey(const ValueKey('play.device')), + ); + expect(picker.onChanged, isNull); + }); + + testWidgets('an attached game is reloaded, timed and let go of', ( + WidgetTester tester, + ) async { + final game = FakeGame(registered: FakeGame.flutterTool()); + final run = fakeAttachedRun(game); + final timelines = []; + await tester.runAsync(run.start); + await tester.pumpWidget( + MaterialApp( + home: PlayScreen(session: run, onTimeline: timelines.add), + ), + ); + + // No process, so no device to pick. + expect(find.byKey(const ValueKey('play.device')), findsNothing); + expect(find.text('Attached, ws://game/ws'), findsOneWidget); + + await tester.tap(find.byKey(const ValueKey('play.reload'))); + await tester.runAsync(() => Future.delayed(Duration.zero)); + expect(game.calls.last['method'], 's0.reloadSources'); + + await tester.tap(find.byKey(const ValueKey('play.timeline'))); + expect(timelines, ['ws://game/ws']); + + // Mutation: the owner's tooltip here would promise a Stop that ends a + // game somebody else started, where the button only lets go of it. + expect( + _button(tester, 'play.start').tooltip, + 'Detach (the game keeps running)', + ); + await tester.tap(find.byKey(const ValueKey('play.start'))); + await tester.runAsync(() => Future.delayed(Duration.zero)); + await tester.pump(); + expect(find.text('Detached; the game keeps running'), findsOneWidget); + }); +} diff --git a/apps/flutter3d_editor/test/playtest_report_screen_test.dart b/apps/flutter3d_editor/test/playtest_report_screen_test.dart index 5587532e4..5b5e5f780 100644 --- a/apps/flutter3d_editor/test/playtest_report_screen_test.dart +++ b/apps/flutter3d_editor/test/playtest_report_screen_test.dart @@ -48,4 +48,92 @@ void main() { expect(find.textContaining('seed 5'), findsOneWidget); expect(find.textContaining('step 120'), findsOneWidget); }); + + group('N7: players\' runs from a telemetry server', () { + // What `cloud/server`'s `/api/telemetry/heatmap` answers: the playtest + // report's keys, with a run id under `seed`. + const String heatmap = + '{"cellSize":1.0,"cells":[{"x":0,"z":0,"samples":6,"runs":3}],' + '"deaths":[{"seed":41,"step":300,"x":0.5,"z":0.5}],' + '"outcomes":{"won":2,"lost":1,"unfinished":0},' + '"level":"cafe0001","says":"3 runs of cafe0001"}'; + + Future fetch(WidgetTester tester) async { + await tester.tap(find.byIcon(Icons.cloud_download)); + await tester.pumpAndSettle(); + await tester.tap(find.text('Fetch')); + await tester.pumpAndSettle(); + } + + testWidgets('asks for the open level by its digest, and draws what the ' + 'server binned', (tester) async { + // Mutation: asking for the level by path, or not at all, draws + // somebody else's level. + Uri? asked; + await tester.pumpWidget( + MaterialApp( + home: PlaytestReportScreen( + levelHash: 'cafe0001', + get: (url) async { + asked = url; + return (status: 200, body: heatmap); + }, + ), + ), + ); + await fetch(tester); + + expect(asked!.path, '/api/telemetry/heatmap'); + expect(asked!.queryParameters['level'], 'cafe0001'); + expect(find.textContaining('Players: 3 runs'), findsOneWidget); + expect(find.byType(PlaytestHeatmapView), findsOneWidget); + }); + + testWidgets('a lost run is named as a run on the server, not a seed to ' + 'play again', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: PlaytestReportScreen( + levelHash: 'cafe0001', + get: (url) async => (status: 200, body: heatmap), + ), + ), + ); + await fetch(tester); + + final view = tester.widget( + find.byType(PlaytestHeatmapView), + ); + final origin = tester.getTopLeft(find.byType(PlaytestHeatmapView)); + final size = tester.getSize(find.byType(PlaytestHeatmapView)); + final layout = HeatmapLayout(report: view.report, size: size); + await tester.tapAt(origin + layout.toScreen(0.5, 0.5)); + await tester.pumpAndSettle(); + + expect(find.text('A run lost, from telemetry'), findsOneWidget); + expect(find.textContaining('run 41 on the server'), findsOneWidget); + }); + + testWidgets('a server that refuses is quoted, and nothing is drawn', ( + tester, + ) async { + // Mutation: drawing an empty report on a refusal looks like a level + // nobody struggled with. + await tester.pumpWidget( + MaterialApp( + home: PlaytestReportScreen( + levelHash: 'cafe0001', + get: (url) async => ( + status: 400, + body: '{"says":"name the level: ?level="}', + ), + ), + ), + ); + await fetch(tester); + + expect(find.text('No report open.'), findsOneWidget); + expect(find.text('name the level: ?level='), findsOneWidget); + }); + }); } diff --git a/apps/flutter3d_editor/test/timeline_attach_screen_test.dart b/apps/flutter3d_editor/test/timeline_attach_screen_test.dart index 36884dc89..6da4efdd1 100644 --- a/apps/flutter3d_editor/test/timeline_attach_screen_test.dart +++ b/apps/flutter3d_editor/test/timeline_attach_screen_test.dart @@ -83,6 +83,46 @@ final class _FakeTimelineClient implements TimelineClient { @override Future> bugReport() async => nextBugReport; + /// `N4`: a buffer from [oldest] to [present], scrubbed to [scrubbedAt]. + int present = 600; + int? oldest = 0; + int? scrubbedAt; + String? scrubRefusal; + List nextLanes = const []; + + @override + Future window() async => + (present: present, oldest: oldest, scrubbedAt: scrubbedAt); + + @override + Future scrubTo(int step) async { + if (scrubRefusal != null) return scrubRefusal; + scrubbedAt = step == present ? null : step; + commands.add('scrubbed:$step'); + return null; + } + + @override + Future returnToPresent() async { + final was = scrubbedAt != null; + scrubbedAt = null; + if (was) commands.add('returned'); + return was; + } + + @override + Future branchHere() async { + final at = scrubbedAt; + if (at == null) return 'scrub to a step first'; + present = at; + scrubbedAt = null; + commands.add('branched:$at'); + return null; + } + + @override + Future> tracks() async => nextLanes; + @override Future dispose() async {} } @@ -271,4 +311,90 @@ void main() { expect(button.onPressed, isNotNull, reason: 'usable again once saved'); }, ); + + group('N4 scrubber', () { + testWidgets('is hidden on a live run, which would refuse a scrub', ( + tester, + ) async { + // Mutation: drop the `_paused` check in `_scrubber`. The slider is + // then offered on a run that refuses every scrub it sends. + await _pump(tester, _FakeTimelineClient()); + expect(find.byType(Slider), findsNothing); + }); + + testWidgets('a released drag scrubs once, and the way back and the branch ' + 'come alive', (tester) async { + // Mutation: scrub from `onChanged`. Every pixel of a drag is then a + // replay in the game, and `commands` holds dozens of scrubs. + final client = _FakeTimelineClient()..paused = true; + await _pump(tester, client); + expect(find.text('Scrub: at the present, step 600'), findsOneWidget); + final branch = find.widgetWithText(FilledButton, 'Branch here'); + expect(tester.widget(branch).onPressed, isNull); + + final slider = tester.widget(find.byType(Slider)); + slider.onChanged!(120.0); + slider.onChanged!(150.0); + slider.onChangeEnd!(150.0); + await tester.pumpAndSettle(); + + expect(client.commands, ['scrubbed:150']); + expect(find.text('Scrub: step 150 of 600'), findsOneWidget); + expect(tester.widget(branch).onPressed, isNotNull); + + await tester.tap(find.text('Back to present')); + await tester.pumpAndSettle(); + expect(client.commands.last, 'returned'); + expect(find.text('Scrub: at the present, step 600'), findsOneWidget); + + tester.widget(find.byType(Slider)).onChangeEnd!(90.0); + await tester.pumpAndSettle(); + await tester.tap(branch); + await tester.pumpAndSettle(); + expect(client.commands.last, 'branched:90'); + expect(find.text('Scrub: at the present, step 90'), findsOneWidget); + }); + + testWidgets('a refused scrub says why', (tester) async { + // Mutation: ignore the answer of `scrubTo`. The panel then shows the + // scrubbed step while the game is still at the present. + final client = _FakeTimelineClient() + ..paused = true + ..scrubRefusal = 'step 5 is not held; the buffer reaches from 0'; + await _pump(tester, client); + + tester.widget(find.byType(Slider)).onChangeEnd!(5.0); + await tester.pumpAndSettle(); + + expect(find.textContaining('is not held'), findsOneWidget); + expect(client.commands, isEmpty); + }); + + testWidgets('lanes load on request, and a mark scrubs to its step', ( + tester, + ) async { + // Mutation: hand the mark's tap to `releaseAtStep` like the frame + // strip. Tapping a change would then cut the future it sits in. + final client = _FakeTimelineClient() + ..paused = true + ..nextLanes = [ + ( + entity: '7', + component: 'Health', + steps: [0, 412], + values: ['100', '93'], + ), + ]; + await _pump(tester, client); + expect(find.text('7 · Health'), findsNothing); + + await tester.tap(find.text('Load tracks')); + await tester.pumpAndSettle(); + expect(find.text('7 · Health'), findsOneWidget); + + await tester.tap(find.byKey(const ValueKey('7.Health@412'))); + await tester.pumpAndSettle(); + expect(client.commands, ['scrubbed:412']); + }); + }); } diff --git a/apps/flutter3d_editor/windows/.gitignore b/apps/flutter3d_editor/windows/.gitignore new file mode 100644 index 000000000..d492d0d98 --- /dev/null +++ b/apps/flutter3d_editor/windows/.gitignore @@ -0,0 +1,17 @@ +flutter/ephemeral/ + +# Visual Studio user-specific files. +*.suo +*.user +*.userosscache +*.sln.docstates + +# Visual Studio build-related files. +x64/ +x86/ + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ diff --git a/apps/flutter3d_editor/windows/CMakeLists.txt b/apps/flutter3d_editor/windows/CMakeLists.txt new file mode 100644 index 000000000..7dbd62538 --- /dev/null +++ b/apps/flutter3d_editor/windows/CMakeLists.txt @@ -0,0 +1,108 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.14) +project(flutter3d_editor LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "flutter3d_editor") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(VERSION 3.14...3.25) + +# Define build configuration option. +get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) +if(IS_MULTICONFIG) + set(CMAKE_CONFIGURATION_TYPES "Debug;Profile;Release" + CACHE STRING "" FORCE) +else() + if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") + endif() +endif() +# Define settings for the Profile build mode. +set(CMAKE_EXE_LINKER_FLAGS_PROFILE "${CMAKE_EXE_LINKER_FLAGS_RELEASE}") +set(CMAKE_SHARED_LINKER_FLAGS_PROFILE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE}") +set(CMAKE_C_FLAGS_PROFILE "${CMAKE_C_FLAGS_RELEASE}") +set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}") + +# Use Unicode for all projects. +add_definitions(-DUNICODE -D_UNICODE) + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_17) + target_compile_options(${TARGET} PRIVATE /W4 /WX /wd"4100") + target_compile_options(${TARGET} PRIVATE /EHsc) + target_compile_definitions(${TARGET} PRIVATE "_HAS_EXCEPTIONS=0") + target_compile_definitions(${TARGET} PRIVATE "$<$:_DEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# Support files are copied into place next to the executable, so that it can +# run in place. This is done instead of making a separate bundle (as on Linux) +# so that building and running from within Visual Studio will work. +set(BUILD_BUNDLE_DIR "$") +# Make the "install" step default, as it's required to run. +set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD 1) +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +if(PLUGIN_BUNDLED_LIBRARIES) + install(FILES "${PLUGIN_BUNDLED_LIBRARIES}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/windows/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + CONFIGURATIONS Profile;Release + COMPONENT Runtime) diff --git a/apps/flutter3d_editor/windows/flutter/CMakeLists.txt b/apps/flutter3d_editor/windows/flutter/CMakeLists.txt new file mode 100644 index 000000000..903f4899d --- /dev/null +++ b/apps/flutter3d_editor/windows/flutter/CMakeLists.txt @@ -0,0 +1,109 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.14) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. +set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper") + +# Set fallback configurations for older versions of the flutter tool. +if (NOT DEFINED FLUTTER_TARGET_PLATFORM) + set(FLUTTER_TARGET_PLATFORM "windows-x64") +endif() + +# === Flutter Library === +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/windows/app.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "flutter_export.h" + "flutter_windows.h" + "flutter_messenger.h" + "flutter_plugin_registrar.h" + "flutter_texture_registrar.h" +) +list(TRANSFORM FLUTTER_LIBRARY_HEADERS PREPEND "${EPHEMERAL_DIR}/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}.lib") +add_dependencies(flutter flutter_assemble) + +# === Wrapper === +list(APPEND CPP_WRAPPER_SOURCES_CORE + "core_implementations.cc" + "standard_codec.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_CORE PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_PLUGIN + "plugin_registrar.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_PLUGIN PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_APP + "flutter_engine.cc" + "flutter_view_controller.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_APP PREPEND "${WRAPPER_ROOT}/") + +# Wrapper sources needed for a plugin. +add_library(flutter_wrapper_plugin STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} +) +apply_standard_settings(flutter_wrapper_plugin) +set_target_properties(flutter_wrapper_plugin PROPERTIES + POSITION_INDEPENDENT_CODE ON) +set_target_properties(flutter_wrapper_plugin PROPERTIES + CXX_VISIBILITY_PRESET hidden) +target_link_libraries(flutter_wrapper_plugin PUBLIC flutter) +target_include_directories(flutter_wrapper_plugin PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_plugin flutter_assemble) + +# Wrapper sources needed for the runner. +add_library(flutter_wrapper_app STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_APP} +) +apply_standard_settings(flutter_wrapper_app) +target_link_libraries(flutter_wrapper_app PUBLIC flutter) +target_include_directories(flutter_wrapper_app PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_app flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +set(PHONY_OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/_phony_") +set_source_files_properties("${PHONY_OUTPUT}" PROPERTIES SYMBOLIC TRUE) +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} + ${PHONY_OUTPUT} + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" + ${FLUTTER_TARGET_PLATFORM} $ + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} +) diff --git a/apps/flutter3d_editor/windows/flutter/generated_plugin_registrant.cc b/apps/flutter3d_editor/windows/flutter/generated_plugin_registrant.cc new file mode 100644 index 000000000..77ab7a09a --- /dev/null +++ b/apps/flutter3d_editor/windows/flutter/generated_plugin_registrant.cc @@ -0,0 +1,14 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + +#include + +void RegisterPlugins(flutter::PluginRegistry* registry) { + FileSelectorWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FileSelectorWindows")); +} diff --git a/apps/flutter3d_editor/windows/flutter/generated_plugin_registrant.h b/apps/flutter3d_editor/windows/flutter/generated_plugin_registrant.h new file mode 100644 index 000000000..dc139d85a --- /dev/null +++ b/apps/flutter3d_editor/windows/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void RegisterPlugins(flutter::PluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/apps/flutter3d_editor/windows/flutter/generated_plugins.cmake b/apps/flutter3d_editor/windows/flutter/generated_plugins.cmake new file mode 100644 index 000000000..a423a0247 --- /dev/null +++ b/apps/flutter3d_editor/windows/flutter/generated_plugins.cmake @@ -0,0 +1,24 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + file_selector_windows +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/windows plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/windows plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/apps/flutter3d_editor/windows/runner/CMakeLists.txt b/apps/flutter3d_editor/windows/runner/CMakeLists.txt new file mode 100644 index 000000000..394917c05 --- /dev/null +++ b/apps/flutter3d_editor/windows/runner/CMakeLists.txt @@ -0,0 +1,40 @@ +cmake_minimum_required(VERSION 3.14) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} WIN32 + "flutter_window.cpp" + "main.cpp" + "utils.cpp" + "win32_window.cpp" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" + "Runner.rc" + "runner.exe.manifest" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the build version. +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION=\"${FLUTTER_VERSION}\"") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MAJOR=${FLUTTER_VERSION_MAJOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MINOR=${FLUTTER_VERSION_MINOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_PATCH=${FLUTTER_VERSION_PATCH}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_BUILD=${FLUTTER_VERSION_BUILD}") + +# Disable Windows macros that collide with C++ standard library functions. +target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX") + +# Add dependency libraries and include directories. Add any application-specific +# dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app) +target_link_libraries(${BINARY_NAME} PRIVATE "dwmapi.lib") +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) diff --git a/apps/flutter3d_editor/windows/runner/Runner.rc b/apps/flutter3d_editor/windows/runner/Runner.rc new file mode 100644 index 000000000..a3fb216cb --- /dev/null +++ b/apps/flutter3d_editor/windows/runner/Runner.rc @@ -0,0 +1,121 @@ +// Microsoft Visual C++ generated resource script. +// +#pragma code_page(65001) +#include "resource.h" + +#define APSTUDIO_READONLY_SYMBOLS +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 2 resource. +// +#include "winres.h" + +///////////////////////////////////////////////////////////////////////////// +#undef APSTUDIO_READONLY_SYMBOLS + +///////////////////////////////////////////////////////////////////////////// +// English (United States) resources + +#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU) +LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US + +#ifdef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// TEXTINCLUDE +// + +1 TEXTINCLUDE +BEGIN + "resource.h\0" +END + +2 TEXTINCLUDE +BEGIN + "#include ""winres.h""\r\n" + "\0" +END + +3 TEXTINCLUDE +BEGIN + "\r\n" + "\0" +END + +#endif // APSTUDIO_INVOKED + + +///////////////////////////////////////////////////////////////////////////// +// +// Icon +// + +// Icon with lowest ID value placed first to ensure application icon +// remains consistent on all systems. +IDI_APP_ICON ICON "resources\\app_icon.ico" + + +///////////////////////////////////////////////////////////////////////////// +// +// Version +// + +#if defined(FLUTTER_VERSION_MAJOR) && defined(FLUTTER_VERSION_MINOR) && defined(FLUTTER_VERSION_PATCH) && defined(FLUTTER_VERSION_BUILD) +#define VERSION_AS_NUMBER FLUTTER_VERSION_MAJOR,FLUTTER_VERSION_MINOR,FLUTTER_VERSION_PATCH,FLUTTER_VERSION_BUILD +#else +#define VERSION_AS_NUMBER 1,0,0,0 +#endif + +#if defined(FLUTTER_VERSION) +#define VERSION_AS_STRING FLUTTER_VERSION +#else +#define VERSION_AS_STRING "1.0.0" +#endif + +VS_VERSION_INFO VERSIONINFO + FILEVERSION VERSION_AS_NUMBER + PRODUCTVERSION VERSION_AS_NUMBER + FILEFLAGSMASK VS_FFI_FILEFLAGSMASK +#ifdef _DEBUG + FILEFLAGS VS_FF_DEBUG +#else + FILEFLAGS 0x0L +#endif + FILEOS VOS__WINDOWS32 + FILETYPE VFT_APP + FILESUBTYPE 0x0L +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904e4" + BEGIN + VALUE "CompanyName", "dev.flutter3d" "\0" + VALUE "FileDescription", "flutter3d_editor" "\0" + VALUE "FileVersion", VERSION_AS_STRING "\0" + VALUE "InternalName", "flutter3d_editor" "\0" + VALUE "LegalCopyright", "Copyright (C) 2026 dev.flutter3d. All rights reserved." "\0" + VALUE "OriginalFilename", "flutter3d_editor.exe" "\0" + VALUE "ProductName", "flutter3d_editor" "\0" + VALUE "ProductVersion", VERSION_AS_STRING "\0" + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1252 + END +END + +#endif // English (United States) resources +///////////////////////////////////////////////////////////////////////////// + + + +#ifndef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 3 resource. +// + + +///////////////////////////////////////////////////////////////////////////// +#endif // not APSTUDIO_INVOKED diff --git a/apps/flutter3d_editor/windows/runner/flutter_window.cpp b/apps/flutter3d_editor/windows/runner/flutter_window.cpp new file mode 100644 index 000000000..955ee3038 --- /dev/null +++ b/apps/flutter3d_editor/windows/runner/flutter_window.cpp @@ -0,0 +1,71 @@ +#include "flutter_window.h" + +#include + +#include "flutter/generated_plugin_registrant.h" + +FlutterWindow::FlutterWindow(const flutter::DartProject& project) + : project_(project) {} + +FlutterWindow::~FlutterWindow() {} + +bool FlutterWindow::OnCreate() { + if (!Win32Window::OnCreate()) { + return false; + } + + RECT frame = GetClientArea(); + + // The size here must match the window dimensions to avoid unnecessary surface + // creation / destruction in the startup path. + flutter_controller_ = std::make_unique( + frame.right - frame.left, frame.bottom - frame.top, project_); + // Ensure that basic setup of the controller was successful. + if (!flutter_controller_->engine() || !flutter_controller_->view()) { + return false; + } + RegisterPlugins(flutter_controller_->engine()); + SetChildContent(flutter_controller_->view()->GetNativeWindow()); + + flutter_controller_->engine()->SetNextFrameCallback([&]() { + this->Show(); + }); + + // Flutter can complete the first frame before the "show window" callback is + // registered. The following call ensures a frame is pending to ensure the + // window is shown. It is a no-op if the first frame hasn't completed yet. + flutter_controller_->ForceRedraw(); + + return true; +} + +void FlutterWindow::OnDestroy() { + if (flutter_controller_) { + flutter_controller_ = nullptr; + } + + Win32Window::OnDestroy(); +} + +LRESULT +FlutterWindow::MessageHandler(HWND hwnd, UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + // Give Flutter, including plugins, an opportunity to handle window messages. + if (flutter_controller_) { + std::optional result = + flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam, + lparam); + if (result) { + return *result; + } + } + + switch (message) { + case WM_FONTCHANGE: + flutter_controller_->engine()->ReloadSystemFonts(); + break; + } + + return Win32Window::MessageHandler(hwnd, message, wparam, lparam); +} diff --git a/apps/flutter3d_editor/windows/runner/flutter_window.h b/apps/flutter3d_editor/windows/runner/flutter_window.h new file mode 100644 index 000000000..6da0652f0 --- /dev/null +++ b/apps/flutter3d_editor/windows/runner/flutter_window.h @@ -0,0 +1,33 @@ +#ifndef RUNNER_FLUTTER_WINDOW_H_ +#define RUNNER_FLUTTER_WINDOW_H_ + +#include +#include + +#include + +#include "win32_window.h" + +// A window that does nothing but host a Flutter view. +class FlutterWindow : public Win32Window { + public: + // Creates a new FlutterWindow hosting a Flutter view running |project|. + explicit FlutterWindow(const flutter::DartProject& project); + virtual ~FlutterWindow(); + + protected: + // Win32Window: + bool OnCreate() override; + void OnDestroy() override; + LRESULT MessageHandler(HWND window, UINT const message, WPARAM const wparam, + LPARAM const lparam) noexcept override; + + private: + // The project to run. + flutter::DartProject project_; + + // The Flutter instance hosted by this window. + std::unique_ptr flutter_controller_; +}; + +#endif // RUNNER_FLUTTER_WINDOW_H_ diff --git a/apps/flutter3d_editor/windows/runner/main.cpp b/apps/flutter3d_editor/windows/runner/main.cpp new file mode 100644 index 000000000..e4d356312 --- /dev/null +++ b/apps/flutter3d_editor/windows/runner/main.cpp @@ -0,0 +1,43 @@ +#include +#include +#include + +#include "flutter_window.h" +#include "utils.h" + +int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, + _In_ wchar_t *command_line, _In_ int show_command) { + // Attach to console when present (e.g., 'flutter run') or create a + // new console when running with a debugger. + if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) { + CreateAndAttachConsole(); + } + + // Initialize COM, so that it is available for use in the library and/or + // plugins. + ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + + flutter::DartProject project(L"data"); + + std::vector command_line_arguments = + GetCommandLineArguments(); + + project.set_dart_entrypoint_arguments(std::move(command_line_arguments)); + + FlutterWindow window(project); + Win32Window::Point origin(10, 10); + Win32Window::Size size(1280, 720); + if (!window.Create(L"flutter3d_editor", origin, size)) { + return EXIT_FAILURE; + } + window.SetQuitOnClose(true); + + ::MSG msg; + while (::GetMessage(&msg, nullptr, 0, 0)) { + ::TranslateMessage(&msg); + ::DispatchMessage(&msg); + } + + ::CoUninitialize(); + return EXIT_SUCCESS; +} diff --git a/apps/flutter3d_editor/windows/runner/resource.h b/apps/flutter3d_editor/windows/runner/resource.h new file mode 100644 index 000000000..66a65d1e4 --- /dev/null +++ b/apps/flutter3d_editor/windows/runner/resource.h @@ -0,0 +1,16 @@ +//{{NO_DEPENDENCIES}} +// Microsoft Visual C++ generated include file. +// Used by Runner.rc +// +#define IDI_APP_ICON 101 + +// Next default values for new objects +// +#ifdef APSTUDIO_INVOKED +#ifndef APSTUDIO_READONLY_SYMBOLS +#define _APS_NEXT_RESOURCE_VALUE 102 +#define _APS_NEXT_COMMAND_VALUE 40001 +#define _APS_NEXT_CONTROL_VALUE 1001 +#define _APS_NEXT_SYMED_VALUE 101 +#endif +#endif diff --git a/apps/flutter3d_editor/windows/runner/resources/app_icon.ico b/apps/flutter3d_editor/windows/runner/resources/app_icon.ico new file mode 100644 index 000000000..c04e20caf Binary files /dev/null and b/apps/flutter3d_editor/windows/runner/resources/app_icon.ico differ diff --git a/apps/flutter3d_editor/windows/runner/runner.exe.manifest b/apps/flutter3d_editor/windows/runner/runner.exe.manifest new file mode 100644 index 000000000..153653e8d --- /dev/null +++ b/apps/flutter3d_editor/windows/runner/runner.exe.manifest @@ -0,0 +1,14 @@ + + + + + PerMonitorV2 + + + + + + + + + diff --git a/apps/flutter3d_editor/windows/runner/utils.cpp b/apps/flutter3d_editor/windows/runner/utils.cpp new file mode 100644 index 000000000..3cb714665 --- /dev/null +++ b/apps/flutter3d_editor/windows/runner/utils.cpp @@ -0,0 +1,69 @@ +#include "utils.h" + +#include +#include +#include +#include + +#include + +void CreateAndAttachConsole() { + if (::AllocConsole()) { + FILE *unused; + if (freopen_s(&unused, "CONOUT$", "w", stdout)) { + _dup2(_fileno(stdout), 1); + } + if (freopen_s(&unused, "CONOUT$", "w", stderr)) { + _dup2(_fileno(stdout), 2); + } + std::ios::sync_with_stdio(); + FlutterDesktopResyncOutputStreams(); + } +} + +std::vector GetCommandLineArguments() { + // Convert the UTF-16 command line arguments to UTF-8 for the Engine to use. + int argc; + wchar_t** argv = ::CommandLineToArgvW(::GetCommandLineW(), &argc); + if (argv == nullptr) { + return std::vector(); + } + + std::vector command_line_arguments; + + // Skip the first argument as it's the binary name. + for (int i = 1; i < argc; i++) { + command_line_arguments.push_back(Utf8FromUtf16(argv[i])); + } + + ::LocalFree(argv); + + return command_line_arguments; +} + +std::string Utf8FromUtf16(const wchar_t* utf16_string) { + if (utf16_string == nullptr) { + return std::string(); + } + // First, find the length of the string with a safe upper bound (CWE-126). + // UNICODE_STRING_MAX_CHARS (32767) is the maximum length of a UNICODE_STRING. + int input_length = static_cast(wcsnlen(utf16_string, UNICODE_STRING_MAX_CHARS)); + // Now use that bounded length to determine the required buffer size. + // When an explicit length is passed, WideCharToMultiByte does not include + // the null terminator in its returned size. + int target_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + input_length, nullptr, 0, nullptr, nullptr); + std::string utf8_string; + if (target_length == 0 || static_cast(target_length) > utf8_string.max_size()) { + return utf8_string; + } + utf8_string.resize(target_length); + int converted_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + input_length, utf8_string.data(), target_length, nullptr, nullptr); + if (converted_length == 0) { + return std::string(); + } + return utf8_string; +} diff --git a/apps/flutter3d_editor/windows/runner/utils.h b/apps/flutter3d_editor/windows/runner/utils.h new file mode 100644 index 000000000..3879d5475 --- /dev/null +++ b/apps/flutter3d_editor/windows/runner/utils.h @@ -0,0 +1,19 @@ +#ifndef RUNNER_UTILS_H_ +#define RUNNER_UTILS_H_ + +#include +#include + +// Creates a console for the process, and redirects stdout and stderr to +// it for both the runner and the Flutter library. +void CreateAndAttachConsole(); + +// Takes a null-terminated wchar_t* encoded in UTF-16 and returns a std::string +// encoded in UTF-8. Returns an empty std::string on failure. +std::string Utf8FromUtf16(const wchar_t* utf16_string); + +// Gets the command line arguments passed in as a std::vector, +// encoded in UTF-8. Returns an empty std::vector on failure. +std::vector GetCommandLineArguments(); + +#endif // RUNNER_UTILS_H_ diff --git a/apps/flutter3d_editor/windows/runner/win32_window.cpp b/apps/flutter3d_editor/windows/runner/win32_window.cpp new file mode 100644 index 000000000..60608d0fe --- /dev/null +++ b/apps/flutter3d_editor/windows/runner/win32_window.cpp @@ -0,0 +1,288 @@ +#include "win32_window.h" + +#include +#include + +#include "resource.h" + +namespace { + +/// Window attribute that enables dark mode window decorations. +/// +/// Redefined in case the developer's machine has a Windows SDK older than +/// version 10.0.22000.0. +/// See: https://docs.microsoft.com/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute +#ifndef DWMWA_USE_IMMERSIVE_DARK_MODE +#define DWMWA_USE_IMMERSIVE_DARK_MODE 20 +#endif + +constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW"; + +/// Registry key for app theme preference. +/// +/// A value of 0 indicates apps should use dark mode. A non-zero or missing +/// value indicates apps should use light mode. +constexpr const wchar_t kGetPreferredBrightnessRegKey[] = + L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize"; +constexpr const wchar_t kGetPreferredBrightnessRegValue[] = L"AppsUseLightTheme"; + +// The number of Win32Window objects that currently exist. +static int g_active_window_count = 0; + +using EnableNonClientDpiScaling = BOOL __stdcall(HWND hwnd); + +// Scale helper to convert logical scaler values to physical using passed in +// scale factor +int Scale(int source, double scale_factor) { + return static_cast(source * scale_factor); +} + +// Dynamically loads the |EnableNonClientDpiScaling| from the User32 module. +// This API is only needed for PerMonitor V1 awareness mode. +void EnableFullDpiSupportIfAvailable(HWND hwnd) { + HMODULE user32_module = LoadLibraryA("User32.dll"); + if (!user32_module) { + return; + } + auto enable_non_client_dpi_scaling = + reinterpret_cast( + GetProcAddress(user32_module, "EnableNonClientDpiScaling")); + if (enable_non_client_dpi_scaling != nullptr) { + enable_non_client_dpi_scaling(hwnd); + } + FreeLibrary(user32_module); +} + +} // namespace + +// Manages the Win32Window's window class registration. +class WindowClassRegistrar { + public: + ~WindowClassRegistrar() = default; + + // Returns the singleton registrar instance. + static WindowClassRegistrar* GetInstance() { + if (!instance_) { + instance_ = new WindowClassRegistrar(); + } + return instance_; + } + + // Returns the name of the window class, registering the class if it hasn't + // previously been registered. + const wchar_t* GetWindowClass(); + + // Unregisters the window class. Should only be called if there are no + // instances of the window. + void UnregisterWindowClass(); + + private: + WindowClassRegistrar() = default; + + static WindowClassRegistrar* instance_; + + bool class_registered_ = false; +}; + +WindowClassRegistrar* WindowClassRegistrar::instance_ = nullptr; + +const wchar_t* WindowClassRegistrar::GetWindowClass() { + if (!class_registered_) { + WNDCLASS window_class{}; + window_class.hCursor = LoadCursor(nullptr, IDC_ARROW); + window_class.lpszClassName = kWindowClassName; + window_class.style = CS_HREDRAW | CS_VREDRAW; + window_class.cbClsExtra = 0; + window_class.cbWndExtra = 0; + window_class.hInstance = GetModuleHandle(nullptr); + window_class.hIcon = + LoadIcon(window_class.hInstance, MAKEINTRESOURCE(IDI_APP_ICON)); + window_class.hbrBackground = 0; + window_class.lpszMenuName = nullptr; + window_class.lpfnWndProc = Win32Window::WndProc; + RegisterClass(&window_class); + class_registered_ = true; + } + return kWindowClassName; +} + +void WindowClassRegistrar::UnregisterWindowClass() { + UnregisterClass(kWindowClassName, nullptr); + class_registered_ = false; +} + +Win32Window::Win32Window() { + ++g_active_window_count; +} + +Win32Window::~Win32Window() { + --g_active_window_count; + Destroy(); +} + +bool Win32Window::Create(const std::wstring& title, + const Point& origin, + const Size& size) { + Destroy(); + + const wchar_t* window_class = + WindowClassRegistrar::GetInstance()->GetWindowClass(); + + const POINT target_point = {static_cast(origin.x), + static_cast(origin.y)}; + HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST); + UINT dpi = FlutterDesktopGetDpiForMonitor(monitor); + double scale_factor = dpi / 96.0; + + HWND window = CreateWindow( + window_class, title.c_str(), WS_OVERLAPPEDWINDOW, + Scale(origin.x, scale_factor), Scale(origin.y, scale_factor), + Scale(size.width, scale_factor), Scale(size.height, scale_factor), + nullptr, nullptr, GetModuleHandle(nullptr), this); + + if (!window) { + return false; + } + + UpdateTheme(window); + + return OnCreate(); +} + +bool Win32Window::Show() { + return ShowWindow(window_handle_, SW_SHOWNORMAL); +} + +// static +LRESULT CALLBACK Win32Window::WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + if (message == WM_NCCREATE) { + auto window_struct = reinterpret_cast(lparam); + SetWindowLongPtr(window, GWLP_USERDATA, + reinterpret_cast(window_struct->lpCreateParams)); + + auto that = static_cast(window_struct->lpCreateParams); + EnableFullDpiSupportIfAvailable(window); + that->window_handle_ = window; + } else if (Win32Window* that = GetThisFromHandle(window)) { + return that->MessageHandler(window, message, wparam, lparam); + } + + return DefWindowProc(window, message, wparam, lparam); +} + +LRESULT +Win32Window::MessageHandler(HWND hwnd, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + switch (message) { + case WM_DESTROY: + window_handle_ = nullptr; + Destroy(); + if (quit_on_close_) { + PostQuitMessage(0); + } + return 0; + + case WM_DPICHANGED: { + auto newRectSize = reinterpret_cast(lparam); + LONG newWidth = newRectSize->right - newRectSize->left; + LONG newHeight = newRectSize->bottom - newRectSize->top; + + SetWindowPos(hwnd, nullptr, newRectSize->left, newRectSize->top, newWidth, + newHeight, SWP_NOZORDER | SWP_NOACTIVATE); + + return 0; + } + case WM_SIZE: { + RECT rect = GetClientArea(); + if (child_content_ != nullptr) { + // Size and position the child window. + MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left, + rect.bottom - rect.top, TRUE); + } + return 0; + } + + case WM_ACTIVATE: + if (child_content_ != nullptr) { + SetFocus(child_content_); + } + return 0; + + case WM_DWMCOLORIZATIONCOLORCHANGED: + UpdateTheme(hwnd); + return 0; + } + + return DefWindowProc(window_handle_, message, wparam, lparam); +} + +void Win32Window::Destroy() { + OnDestroy(); + + if (window_handle_) { + DestroyWindow(window_handle_); + window_handle_ = nullptr; + } + if (g_active_window_count == 0) { + WindowClassRegistrar::GetInstance()->UnregisterWindowClass(); + } +} + +Win32Window* Win32Window::GetThisFromHandle(HWND const window) noexcept { + return reinterpret_cast( + GetWindowLongPtr(window, GWLP_USERDATA)); +} + +void Win32Window::SetChildContent(HWND content) { + child_content_ = content; + SetParent(content, window_handle_); + RECT frame = GetClientArea(); + + MoveWindow(content, frame.left, frame.top, frame.right - frame.left, + frame.bottom - frame.top, true); + + SetFocus(child_content_); +} + +RECT Win32Window::GetClientArea() { + RECT frame; + GetClientRect(window_handle_, &frame); + return frame; +} + +HWND Win32Window::GetHandle() { + return window_handle_; +} + +void Win32Window::SetQuitOnClose(bool quit_on_close) { + quit_on_close_ = quit_on_close; +} + +bool Win32Window::OnCreate() { + // No-op; provided for subclasses. + return true; +} + +void Win32Window::OnDestroy() { + // No-op; provided for subclasses. +} + +void Win32Window::UpdateTheme(HWND const window) { + DWORD light_mode; + DWORD light_mode_size = sizeof(light_mode); + LSTATUS result = RegGetValue(HKEY_CURRENT_USER, kGetPreferredBrightnessRegKey, + kGetPreferredBrightnessRegValue, + RRF_RT_REG_DWORD, nullptr, &light_mode, + &light_mode_size); + + if (result == ERROR_SUCCESS) { + BOOL enable_dark_mode = light_mode == 0; + DwmSetWindowAttribute(window, DWMWA_USE_IMMERSIVE_DARK_MODE, + &enable_dark_mode, sizeof(enable_dark_mode)); + } +} diff --git a/apps/flutter3d_editor/windows/runner/win32_window.h b/apps/flutter3d_editor/windows/runner/win32_window.h new file mode 100644 index 000000000..e901dde68 --- /dev/null +++ b/apps/flutter3d_editor/windows/runner/win32_window.h @@ -0,0 +1,102 @@ +#ifndef RUNNER_WIN32_WINDOW_H_ +#define RUNNER_WIN32_WINDOW_H_ + +#include + +#include +#include +#include + +// A class abstraction for a high DPI-aware Win32 Window. Intended to be +// inherited from by classes that wish to specialize with custom +// rendering and input handling +class Win32Window { + public: + struct Point { + unsigned int x; + unsigned int y; + Point(unsigned int x, unsigned int y) : x(x), y(y) {} + }; + + struct Size { + unsigned int width; + unsigned int height; + Size(unsigned int width, unsigned int height) + : width(width), height(height) {} + }; + + Win32Window(); + virtual ~Win32Window(); + + // Creates a win32 window with |title| that is positioned and sized using + // |origin| and |size|. New windows are created on the default monitor. Window + // sizes are specified to the OS in physical pixels, hence to ensure a + // consistent size this function will scale the inputted width and height as + // as appropriate for the default monitor. The window is invisible until + // |Show| is called. Returns true if the window was created successfully. + bool Create(const std::wstring& title, const Point& origin, const Size& size); + + // Show the current window. Returns true if the window was successfully shown. + bool Show(); + + // Release OS resources associated with window. + void Destroy(); + + // Inserts |content| into the window tree. + void SetChildContent(HWND content); + + // Returns the backing Window handle to enable clients to set icon and other + // window properties. Returns nullptr if the window has been destroyed. + HWND GetHandle(); + + // If true, closing this window will quit the application. + void SetQuitOnClose(bool quit_on_close); + + // Return a RECT representing the bounds of the current client area. + RECT GetClientArea(); + + protected: + // Processes and route salient window messages for mouse handling, + // size change and DPI. Delegates handling of these to member overloads that + // inheriting classes can handle. + virtual LRESULT MessageHandler(HWND window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Called when CreateAndShow is called, allowing subclass window-related + // setup. Subclasses should return false if setup fails. + virtual bool OnCreate(); + + // Called when Destroy is called. + virtual void OnDestroy(); + + private: + friend class WindowClassRegistrar; + + // OS callback called by message pump. Handles the WM_NCCREATE message which + // is passed when the non-client area is being created and enables automatic + // non-client DPI scaling so that the non-client area automatically + // responds to changes in DPI. All other messages are handled by + // MessageHandler. + static LRESULT CALLBACK WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Retrieves a class instance pointer for |window| + static Win32Window* GetThisFromHandle(HWND const window) noexcept; + + // Update the window frame's theme to match the system theme. + static void UpdateTheme(HWND const window); + + bool quit_on_close_ = false; + + // window handle for top level window. + HWND window_handle_ = nullptr; + + // window handle for hosted content. + HWND child_content_ = nullptr; +}; + +#endif // RUNNER_WIN32_WINDOW_H_ diff --git a/apps/flutter3d_showcase/lib/catalog/flame.dart b/apps/flutter3d_showcase/lib/catalog/flame.dart index 493f93578..d19e9c5b0 100644 --- a/apps/flutter3d_showcase/lib/catalog/flame.dart +++ b/apps/flutter3d_showcase/lib/catalog/flame.dart @@ -107,4 +107,40 @@ const List flameFeatures = [ evidenceFile: 'packages/flame_flutter3d/CHANGELOG.md', packages: ['flame_flutter3d', 'flutter3d_particles'], ), + Feature( + id: 'flame-seats', + title: 'Two layouts, and whoever presses first is player one', + category: Category.flame, + summary: + 'PlayerSeats over two keyboard layouts read from the keyboard ' + 'itself: a layout joins on its own key, in the order people press.', + since: '0.8.4', + evidence: 'lets players claim one by pressing, in the order they join', + evidenceFile: 'packages/flame_flutter3d/CHANGELOG.md', + packages: ['flame_flutter3d', 'flutter3d_game', 'flutter3d_sim'], + ), + Feature( + id: 'flame-horde', + title: 'Ninety-six monsters in one draw, chasing two players', + category: Category.flame, + summary: + 'Simulated actors drawn as slots of one instanced batch, stepped ' + 'towards two players at once, each going for the one it can reach.', + since: '0.8.4', + evidence: 'draws a simulated actor as a slot of a shared', + evidenceFile: 'packages/flame_flutter3d/CHANGELOG.md', + packages: ['flame_flutter3d', 'flutter3d_sim'], + ), + Feature( + id: 'flame-level-scene', + title: 'A level is a scene, and the camera frames the whole party', + category: Category.flame, + summary: + 'A Flame game moving from one level\'s scene to the next, and a ' + 'camera eased towards a view worked out from four walkers at once.', + since: '0.8.4', + evidence: 'moves the game to the next level\'s scene with the camera', + evidenceFile: 'packages/flame_flutter3d/CHANGELOG.md', + packages: ['flame_flutter3d'], + ), ]; diff --git a/apps/flutter3d_showcase/lib/pages/flame/flame_horde.dart b/apps/flutter3d_showcase/lib/pages/flame/flame_horde.dart new file mode 100644 index 000000000..7b450a036 --- /dev/null +++ b/apps/flutter3d_showcase/lib/pages/flame/flame_horde.dart @@ -0,0 +1,269 @@ +/// A horde of `flutter3d_sim` actors drawn as slots of one instanced batch, +/// stepped by one `ActorSystemComponent` towards two players at once. +/// +/// Quoted by `flame_horde.md` and shown whole in the Source tab. +library; + +import 'dart:math' as math; + +import 'package:flame/components.dart' show Component; +import 'package:flame/game.dart'; +import 'package:flame_flutter3d/flame_flutter3d.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter3d/flutter3d.dart'; +import 'package:flutter3d_showcase/src/demo/demo.dart'; +import 'package:flutter3d_showcase/src/demo/flame_layer.dart'; +import 'package:flutter3d_sim/flutter3d_sim.dart'; + +/// How many monsters there are: one draw for all of them. +const int _monsters = 96; + +// #region brain +/// Walks at whichever player the system says it can reach first, and +/// remembers which one that was. +final class _Chase extends Brain { + int attended = -1; + + @override + void act(Mind it) { + attended = it.focusIndex; + it.steerTowardsFocus(); + } +} +// #endregion brain + +final class FlameHordeDemo extends ShowcaseDemo { + late final DemoContext _context; + late final Scene _scene; + late final _Horde _game; + late final Widget _body = flameOrbit( + _context, + Flutter3dFlameWidget( + game: _game, + existing: (device: _context.device, renderer: _context.renderer), + ), + ); + + @override + void configureView(DemoContext context) { + context.orbit + ..distance = 22.0 + ..pitch = 0.95 + ..yaw = 0.2; + context.orbit.target.setValues(0.0, 0.0, 0.0); + } + + @override + Scene build(DemoContext context) { + _context = context; + _scene = Scene() + ..ambientColor = Vector3(0.45, 0.5, 0.6) + ..ambientIntensity = 0.3 + ..add( + MeshNode( + DeviceMesh.upload( + context.device, + CuboidShape(size: Vector3(28.0, 0.1, 28.0)).build(), + ), + Material(name: 'floor', baseColor: Vector4(0.34, 0.36, 0.33, 1.0)), + name: 'floor', + )..setPosition(0.0, -0.05, 0.0), + ) + ..add( + LightNode(name: 'sun', intensity: 2.2) + ..setLocalForward(Vector3(-0.3, -0.7, -0.4)), + ); + _game = _Horde(context.camera)..open3d(context.device, scene: _scene); + // In the scene from the start, empty until the monsters take their slots. + _scene.add(_game.batch); + return _scene; + } + + @override + void update(DemoContext context, double dt) => + context.orbit.syncProjectionDepth(context.camera); + + /// The Flame game the page runs, for a test that steps it without a window. + @visibleForTesting + FlameGame get game => _game; + + /// The one batch every monster is drawn through. + @visibleForTesting + InstancedMeshNode get batch => _game.batch; + + /// Every monster's body, and which player it went for on its last step. + @visibleForTesting + List<({Vector3 at, int attended})> get monsters => + <({Vector3 at, int attended})>[ + for (final (Actor actor, _Chase brain) in _game.monsters) + (at: actor.body!.position, attended: brain.attended), + ]; + + /// Where the two players are now. + @visibleForTesting + List get players => [ + for (final MeshNode hero in _game.heroes) hero.readPosition(), + ]; + + @override + Widget? customBody(BuildContext buildContext, DemoContext context) => _body; + + @override + void verify(Scene scene, FrameResult frame) { + if (frame.drawCalls < 1) throw StateError('the floor was not drawn'); + final batches = scene.meshes.whereType(); + if (batches.length != 1) { + throw StateError( + 'the horde should be one batch, found ${batches.length}', + ); + } + } +} + +/// The colour a monster wears for the player it is after. +final List _playerColours = [ + Vector4(0.95, 0.55, 0.2, 1.0), + Vector4(0.3, 0.7, 0.95, 1.0), +]; + +final class _Horde extends FlameGame with HasFlutter3d { + _Horde(this._eye); + + final CameraNode _eye; + final List heroes = []; + final List<(Actor, _Chase)> monsters = <(Actor, _Chase)>[]; + + // #region batch + /// Every monster's mesh and material, drawn in one call however many there + /// are; the colour is each slot's own. + late final InstancedMeshNode batch = InstancedMeshNode( + DeviceMesh.upload( + device, + CuboidShape(size: Vector3(0.6, 1.0, 0.6)).build(), + ), + Material(name: 'monster', baseColor: Vector4.all(1.0)), + capacity: _monsters, + name: 'horde', + ); + // #endregion batch + + double _clock = 0.0; + + @override + CameraNode createCamera3d() => _eye; + + @override + void onOpen3d() { + final CollisionWorld world = CollisionWorld() + ..addBox(Vector3(0.0, -0.5, 0.0), Vector3(28.0, 1.0, 28.0)) + ..update(); + final ActorSystem system = ActorSystem(world: world, random: GameRandom(7)); + final DeviceMesh ball = DeviceMesh.upload( + device, + SphereShape(segments: 24, radius: 0.5).build(), + ); + for (var i = 0; i < 2; i++) { + final MeshNode hero = MeshNode( + ball, + Material( + name: 'player $i', + baseColor: _playerColours[i], + emissive: _playerColours[i].xyz, + emissiveStrength: 1.5, + ), + name: 'player $i', + ); + heroes.add(hero); + scene.add(hero); + } + _walkHeroes(); + + // #region foci + // One system, stepped towards both players: each monster goes for the + // one it can reach first. The system is a step clock too, and every + // monster is drawn between its last two steps by it. + final ActorSystemComponent stepper = ActorSystemComponent( + system: system, + foci: () => [ + for (final MeshNode hero in heroes) + (at: hero.readPosition()..y = 0.9, body: null), + ], + ); + // #endregion foci + + // #region spawn + // Two rings of monsters, each an actor in the system and a slot in the + // batch; no scene node of their own. + final List slots = []; + for (var i = 0; i < _monsters; i++) { + final double radius = i.isEven ? 9.0 : 11.0; + final double angle = i * 2.0 * math.pi / _monsters; + final _Chase brain = _Chase(); + final Actor actor = system.spawn( + body: CharacterController( + world: world, + position: Vector3( + radius * math.cos(angle), + 0.9, + radius * math.sin(angle), + ), + tuning: const MovementTuning(walkSpeed: 2.2), + ), + brain: brain, + ); + monsters.add((actor, brain)); + slots.add( + _Monster(actor: actor, batch: batch, stepper: stepper, brain: brain), + ); + } + addAll([stepper, ...slots]); + // #endregion spawn + add(flameCaption('one draw, two players: each monster wears its target')); + } + + @override + void update(double dt) { + super.update(dt); + if (!has3d || heroes.isEmpty) return; + _clock += dt; + _walkHeroes(); + } + + /// Two players walking circles of their own on either side of the yard. + void _walkHeroes() { + for (var i = 0; i < heroes.length; i++) { + final double side = i == 0 ? -1.0 : 1.0; + final double turn = _clock * 0.5 + i * math.pi; + heroes[i].setPosition( + side * 3.5 + 2.5 * math.cos(turn), + 0.5, + 2.5 * math.sin(turn), + ); + } + } +} + +// #region monster +/// A monster drawn through its slot, between its steps, in the colour of +/// the player its brain went for. +final class _Monster extends InstancedActorComponent { + _Monster({ + required super.actor, + required super.batch, + required ActorSystemComponent super.stepper, + required this.brain, + }) : super(lift: -0.4); + + final _Chase brain; + int _shown = -1; + + @override + void update(double dt) { + super.update(dt); + final InstanceHandle? at = slot; + if (at == null || brain.attended == _shown || brain.attended < 0) return; + _shown = brain.attended; + at.setColor(_playerColours[_shown]); + } +} +// #endregion monster diff --git a/apps/flutter3d_showcase/lib/pages/flame/flame_horde.md b/apps/flutter3d_showcase/lib/pages/flame/flame_horde.md new file mode 100644 index 000000000..c79a0a26d --- /dev/null +++ b/apps/flutter3d_showcase/lib/pages/flame/flame_horde.md @@ -0,0 +1,48 @@ +# Ninety-six monsters in one draw, chasing two players + +A horde whose every step is decided by `flutter3d_sim` does not need a scene +node per monster. `InstancedActorComponent` draws a simulated actor as a slot +of a shared `InstancedMeshNode`, and `ActorSystemComponent` steps the whole +system towards several players at once, so each monster goes for the one it +can reach first. + +## Step 1: One batch for the horde + +The mesh and the material are the batch's; the colour is each slot's own, +which is how a monster shows who it is after. + +{{code batch}} + +## Step 2: A brain that chases a focus + +The system tells each monster which focus is its own on every step, and +`steerTowardsFocus` walks it there. The brain only remembers the answer so +the page can colour it. + +{{code brain}} + +## Step 3: Several foci + +`foci` is read fresh on every step: here, where the two players are now. With +several, each actor attends to the one nearest by walking where the level has +a flow field, and by straight line where it has none, as in this empty yard. +The component is also what steps the actors, so it is the clock their slots +draw between. + +{{code foci}} + +## Step 4: An actor per monster, a slot per actor + +Each monster is an actor in the system and a component in the game. The +component takes a slot when it is added, gives it back when it goes, and +goes itself when the simulation removes its actor. + +{{code spawn}} + +Drawn with `stepper`, a monster is placed between where it was before the +last step and where it is now, so the horde moves smoothly at any frame rate. +The slot's colour follows the player the brain last went for: orange for the +player on the left, blue for the one on the right, and a monster changes +colour as the two circle and the other one comes nearer. + +{{code monster}} diff --git a/apps/flutter3d_showcase/lib/pages/flame/flame_level_scene.dart b/apps/flutter3d_showcase/lib/pages/flame/flame_level_scene.dart new file mode 100644 index 000000000..60cafc301 --- /dev/null +++ b/apps/flutter3d_showcase/lib/pages/flame/flame_level_scene.dart @@ -0,0 +1,276 @@ +/// A Flame game that moves from one level to the next with +/// `replaceScene3d`, and a `ViewCamera` that frames the whole party as it +/// walks. +/// +/// Quoted by `flame_level_scene.md` and shown whole in the Source tab. +library; + +import 'dart:math' as math; + +import 'package:flame/components.dart' show Component, TextComponent; +import 'package:flame/game.dart'; +import 'package:flame_flutter3d/flame_flutter3d.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter3d/flutter3d.dart'; +import 'package:flutter3d_showcase/src/demo/demo.dart'; +import 'package:flutter3d_showcase/src/demo/flame_layer.dart'; + +/// Seconds on each level before the party moves on. +const double _stay = 6.0; + +final class FlameLevelSceneDemo extends ShowcaseDemo { + late final _Tour _game; + late final Widget _body = Flutter3dFlameWidget( + game: _game, + existing: (device: _context.device, renderer: _context.renderer), + ); + late final DemoContext _context; + + @override + void configureView(DemoContext context) { + context.orbit + ..distance = 14.0 + ..pitch = 0.7 + ..yaw = 0.0; + context.orbit.target.setValues(0.0, 0.0, 0.0); + } + + @override + Scene build(DemoContext context) { + _context = context; + // #region levels + // Two levels, each a scene of its own: its floor, its light, its air. + // The first is the one the game opens on; the second waits unseen. + final List levels = [ + _level( + context.device, + floor: Vector4(0.36, 0.5, 0.3, 1.0), + air: Vector3(0.55, 0.6, 0.7), + rocks: Vector4(0.6, 0.58, 0.52, 1.0), + seed: 1, + ), + _level( + context.device, + floor: Vector4(0.3, 0.28, 0.34, 1.0), + air: Vector3(0.35, 0.3, 0.5), + rocks: Vector4(0.45, 0.4, 0.6, 1.0), + seed: 2, + ), + ]; + _game = _Tour(context.camera, levels) + ..open3d(context.device, scene: levels.first); + // #endregion levels + return levels.first; + } + + /// A floor, a sun and a scatter of rocks. + static Scene _level( + GraphicsDevice device, { + required Vector4 floor, + required Vector3 air, + required Vector4 rocks, + required int seed, + }) { + final math.Random random = math.Random(seed); + final DeviceMesh rock = DeviceMesh.upload( + device, + CuboidShape(size: Vector3(0.8, 1.2, 0.8)).build(), + ); + final Material stone = Material(name: 'rock', baseColor: rocks); + final Scene level = Scene() + ..ambientColor = air + ..ambientIntensity = 0.35 + ..add( + MeshNode( + DeviceMesh.upload( + device, + CuboidShape(size: Vector3(20.0, 0.1, 20.0)).build(), + ), + Material(name: 'floor', baseColor: floor), + name: 'floor', + )..setPosition(0.0, -0.05, 0.0), + ) + ..add( + LightNode(name: 'sun', intensity: 2.4) + ..setLocalForward(Vector3(-0.3, -0.6, -0.4)), + ); + for (var i = 0; i < 14; i++) { + level.add( + MeshNode(rock, stone, name: 'rock $i')..setPosition( + random.nextDouble() * 18.0 - 9.0, + 0.6, + random.nextDouble() * 18.0 - 9.0, + ), + ); + } + return level; + } + + @override + void update(DemoContext context, double dt) => + context.orbit.syncProjectionDepth(context.camera); + + /// The Flame game the page runs, for a test that steps it without a window. + @visibleForTesting + FlameGame get game => _game; + + /// The two levels, in the order the party visits them. + @visibleForTesting + List get levels => _game.levels; + + /// The level the game draws now. + @visibleForTesting + Scene get level => _game.scene; + + /// The camera the view camera moves. + @visibleForTesting + CameraNode get camera => _game.camera3d; + + /// The party's markers. + @visibleForTesting + List get party => _game.party; + + /// Where the view camera wants to be this frame. + @visibleForTesting + Vector3 get wantedEye { + final Vector3 eye = Vector3.zero(); + _game.frameParty(eye, Vector3.zero()); + return eye; + } + + @override + Widget? customBody(BuildContext buildContext, DemoContext context) => _body; + + @override + void verify(Scene scene, FrameResult frame) { + if (frame.drawCalls < 1) throw StateError('the first level was not drawn'); + if (!identical(scene, _game.levels.first)) { + throw StateError('the page should open on the first level'); + } + } +} + +/// The party's colours, one a member. +final List _colours = [ + Vector4(0.95, 0.55, 0.2, 1.0), + Vector4(0.3, 0.7, 0.95, 1.0), + Vector4(0.6, 0.9, 0.35, 1.0), + Vector4(0.9, 0.4, 0.75, 1.0), +]; + +final class _Tour extends FlameGame with HasFlutter3d { + _Tour(this._eye, this.levels); + + final CameraNode _eye; + final List levels; + final List party = []; + late final TextComponent _caption = flameCaption('level one'); + + int _level = 0; + double _clock = 0.0; + + @override + CameraNode createCamera3d() => _eye; + + // #region camera + @override + void onOpen3d() { + final DeviceMesh ball = DeviceMesh.upload( + device, + SphereShape(segments: 20, radius: 0.4).build(), + ); + for (var i = 0; i < _colours.length; i++) { + final MeshNode member = MeshNode( + ball, + Material(name: 'member $i', baseColor: _colours[i]), + name: 'member $i', + ); + party.add(member); + scene.add(member); + } + _walk(); + // Eased each frame towards wherever `frameParty` says, after the party + // has walked: no one member decides where the camera goes. + addAll([ + ViewCameraComponent( + ViewCamera(camera: camera3d, view: frameParty, stiffness: 2.5), + ), + _caption, + ]); + } + // #endregion camera + + // #region frame + /// Above and behind the party's middle, further back the more it spreads. + bool frameParty(Vector3 eye, Vector3 target) { + if (party.isEmpty) return false; + final Vector3 middle = party.fold( + Vector3.zero(), + (Vector3 sum, MeshNode m) => sum..add(m.readPosition()), + )..scale(1.0 / party.length); + final double spread = party.fold( + 0.0, + (double far, MeshNode m) => + math.max(far, m.readPosition().distanceTo(middle)), + ); + target.setFrom(middle); + eye.setValues( + middle.x, + middle.y + 3.5 + spread * 1.5, + middle.z + 5.0 + spread * 1.5, + ); + return true; + } + // #endregion frame + + @override + void update(double dt) { + // The party walks before the camera, which is one of the children + // `super.update` runs. + if (has3d && party.isNotEmpty) { + _clock += dt; + if (_clock >= _stay) _nextLevel(); + _walk(); + } + super.update(dt); + } + + // #region next + /// The next level: the party is carried across, then the scene is + /// replaced, and the camera goes with it. What the old level holds stays + /// there for the next visit. + void _nextLevel() { + _clock = 0.0; + _level = (_level + 1) % levels.length; + final Scene next = levels[_level]; + for (final MeshNode member in party) { + member.removeFromParent(); + next.add(member); + } + replaceScene3d(next); + _caption.text = _level == 0 ? 'level one' : 'level two'; + } + // #endregion next + + /// Each level has its own walk: across the first, round the second, the + /// party spreading and closing as it goes. + void _walk() { + final double t = _clock / _stay; + final Vector3 middle = _level == 0 + ? Vector3(-3.0, 0.4, 6.0 - 12.0 * t) + : Vector3( + 5.0 * math.cos(t * 2.0 * math.pi), + 0.4, + 5.0 * math.sin(t * 2.0 * math.pi), + ); + final double spread = 1.0 + 0.8 * math.sin(_clock * 1.3); + for (var i = 0; i < party.length; i++) { + final double angle = _clock * 0.8 + i * 2.0 * math.pi / party.length; + party[i].setPosition( + middle.x + spread * math.cos(angle), + middle.y, + middle.z + spread * math.sin(angle), + ); + } + } +} diff --git a/apps/flutter3d_showcase/lib/pages/flame/flame_level_scene.md b/apps/flutter3d_showcase/lib/pages/flame/flame_level_scene.md new file mode 100644 index 000000000..0e03dc632 --- /dev/null +++ b/apps/flutter3d_showcase/lib/pages/flame/flame_level_scene.md @@ -0,0 +1,39 @@ +# A level is a scene, and the camera frames the whole party + +A game with levels moves from one to the next, and in flutter3d a level is a +scene: its floor, its lights, its air. `HasFlutter3d.replaceScene3d` moves a +Flame game's 3D layer to the next scene, with the camera, on the same device +and renderer. `ViewCamera` eases a camera towards wherever a function says, +for a view no single component decides, such as a party of four. + +## Step 1: Two levels, two scenes + +Both are built up front. The game opens on the first, and the second waits +without being drawn. + +{{code levels}} + +## Step 2: Move on with replaceScene3d + +Every six seconds the party goes to the next level. The game carries its own +nodes across, since what was in the old scene stays there, and then replaces +the scene. The camera goes with it, and `Flutter3dFlameWidget` draws the +game's scene as it is now, not the one it opened with. + +{{code next}} + +## Step 3: Where the party should be seen from + +The view is worked out from every member at once: above and behind their +middle, further back the more they spread. It is asked once a frame and +writes the eye and the point looked at into the two vectors it is handed. + +{{code frame}} + +## Step 4: A camera eased towards it + +`ViewCameraComponent` runs the view camera after whatever moves what it +frames. On a new level the party starts somewhere else, and the camera eases +across to it rather than jumping. + +{{code camera}} diff --git a/apps/flutter3d_showcase/lib/pages/flame/flame_seats.dart b/apps/flutter3d_showcase/lib/pages/flame/flame_seats.dart new file mode 100644 index 000000000..d1d63560b --- /dev/null +++ b/apps/flutter3d_showcase/lib/pages/flame/flame_seats.dart @@ -0,0 +1,259 @@ +/// `PlayerSeats` over two keyboard layouts: nobody plays until they press +/// their join key, and the first to press is player one. +/// +/// Quoted by `flame_seats.md` and shown whole in the Source tab. +library; + +import 'package:flame/components.dart'; +import 'package:flame/game.dart'; +import 'package:flame_flutter3d/flame_flutter3d.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter3d/flutter3d.dart'; +import 'package:flutter3d_game/flutter3d_game.dart'; +import 'package:flutter3d_showcase/src/demo/demo.dart'; +import 'package:flutter3d_showcase/src/demo/flame_layer.dart'; +import 'package:flutter3d_sim/flutter3d_sim.dart'; + +// #region layouts +/// What a layout presses to join, and to do nothing else yet. +const GameAction join = GameAction('join'); + +/// One way of holding the game: four keys to walk and one to join, in a +/// table and a state of its own. +FlameInputBridge _layout( + LogicalKeyboardKey up, + LogicalKeyboardKey left, + LogicalKeyboardKey down, + LogicalKeyboardKey right, + LogicalKeyboardKey joins, +) => FlameInputBridge( + bindings: Bindings() + ..bind(InputSource.key(up.keyId), GameAction.moveForward) + ..bind(InputSource.key(left.keyId), GameAction.moveLeft) + ..bind(InputSource.key(down.keyId), GameAction.moveBack) + ..bind(InputSource.key(right.keyId), GameAction.moveRight) + ..bind(InputSource.key(joins.keyId), join), + inputState: InputState(), +); + +/// WASD and Space on the left of the keyboard, the arrows and Slash on the +/// right: two players at one keyboard, neither of them a player yet. +PlayerSeats _twoLayouts() => PlayerSeats([ + _layout( + LogicalKeyboardKey.keyW, + LogicalKeyboardKey.keyA, + LogicalKeyboardKey.keyS, + LogicalKeyboardKey.keyD, + LogicalKeyboardKey.space, + ), + _layout( + LogicalKeyboardKey.arrowUp, + LogicalKeyboardKey.arrowLeft, + LogicalKeyboardKey.arrowDown, + LogicalKeyboardKey.arrowRight, + LogicalKeyboardKey.slash, + ), +]); +// #endregion layouts + +// #region claim +/// Seats every free layout whose join key went down this step, in the order +/// the seats have them, and answers the ones it seated. +List seatNewcomers(PlayerSeats seats) => [ + for (final FlameInputBridge layout in seats.free.toList()) + if (layout.inputState.pressed(join) && seats.claim(layout)) layout, +]; +// #endregion claim + +final class FlameSeatsDemo extends ShowcaseDemo { + late final DemoContext _context; + late final Scene _scene; + late final _Lobby _game; + late final Widget _body = flameOrbit( + _context, + Flutter3dFlameWidget( + game: _game, + existing: (device: _context.device, renderer: _context.renderer), + ), + ); + + late final bool _secondFirst; + + @override + void configureView(DemoContext context) { + context.orbit + ..distance = 9.0 + ..pitch = 0.8 + ..yaw = 0.0; + context.orbit.target.setValues(0.0, 0.0, 0.0); + } + + @override + Scene build(DemoContext context) { + _context = context; + _secondFirst = _run(); + _scene = Scene() + ..ambientColor = Vector3(0.5, 0.55, 0.65) + ..ambientIntensity = 0.3 + ..add( + MeshNode( + DeviceMesh.upload( + context.device, + CuboidShape(size: Vector3(9.0, 0.1, 9.0)).build(), + ), + Material(name: 'floor', baseColor: Vector4(0.36, 0.4, 0.38, 1.0)), + name: 'floor', + )..setPosition(0.0, -0.05, 0.0), + ) + ..add( + LightNode(name: 'sun', intensity: 2.5) + ..setLocalForward(Vector3(-0.3, -0.6, -0.4)), + ); + _game = _Lobby(context.camera, _twoLayouts()) + ..open3d(context.device, scene: _scene); + return _scene; + } + + @override + void update(DemoContext context, double dt) => + context.orbit.syncProjectionDepth(context.camera); + + /// Checks, with no window, that the second layout pressing first is + /// player one: seats go in the order people join, not the order listed. + static bool _run() { + final PlayerSeats seats = _twoLayouts(); + final FlameInputBridge arrows = seats.candidates[1]; + arrows.onKeyEvent( + const KeyDownEvent( + physicalKey: PhysicalKeyboardKey.slash, + logicalKey: LogicalKeyboardKey.slash, + timeStamp: Duration.zero, + ), + const {}, + ); + seatNewcomers(seats); + return seats.seated.length == 1 && identical(seats.seated.first, arrows); + } + + /// The Flame game the page runs, for a test that steps it without a window. + @visibleForTesting + FlameGame get game => _game; + + /// Who holds what, for a test that presses keys. + @visibleForTesting + PlayerSeats get seats => _game.seats; + + /// The marker each layout walks, in the order the seats list the layouts. + @visibleForTesting + List get markers => _game.markers; + + @override + Widget? customBody(BuildContext buildContext, DemoContext context) => _body; + + @override + void verify(Scene scene, FrameResult frame) { + if (frame.drawCalls < 1) throw StateError('the floor was not drawn'); + if (!_secondFirst) { + throw StateError( + 'the arrows pressed Slash first and should have been player one', + ); + } + } +} + +/// The colour of each seat, player one first. +final List _seatColours = [ + Vector4(0.95, 0.65, 0.2, 1.0), + Vector4(0.3, 0.75, 0.95, 1.0), +]; + +// #region lobby +/// The game: every layout fed from the keyboard itself and closed after each +/// frame, a marker for each, hidden until its layout joins. +final class _Lobby extends FlameGame with HasFlutter3d { + _Lobby(this._eye, this.seats); + + final CameraNode _eye; + final PlayerSeats seats; + final List markers = []; + late final TextComponent _caption = flameCaption( + 'Space joins WASD, / joins the arrows', + ); + + @override + CameraNode createCamera3d() => _eye; + + @override + void onOpen3d() { + final DeviceMesh cube = DeviceMesh.upload( + device, + CuboidShape(size: Vector3.all(0.8)).build(), + ); + for (var i = 0; i < seats.candidates.length; i++) { + final MeshNode marker = + MeshNode( + cube, + Material(name: 'player', baseColor: Vector4.all(1.0)), + name: 'layout $i', + ) + ..setPosition(-1.5 + 3.0 * i, 0.4, 0.0) + ..visible = false; + markers.add(marker); + scene.add(marker); + } + addAll([ + // From `HardwareKeyboard`, wherever the focus is. + seats.listenToKeyboard(), + _Seating(this), + // Every layout's step closed, the free ones too, so a press made to + // join is seen once. + ...seats.stepEnds(), + _caption, + ]); + } + // #endregion lobby + + // #region seat + /// A layout that has just joined shows its marker in its seat's colour; + /// every seated layout walks its own. + void seatAndWalk(double dt) { + for (final FlameInputBridge joined in seatNewcomers(seats)) { + final int seat = seats.seated.indexOf(joined); + final MeshNode marker = markers[seats.candidates.indexOf(joined)]; + marker + ..material.baseColor.setFrom(_seatColours[seat]) + ..visible = true; + } + for (final FlameInputBridge player in seats.seated) { + final Vector2 axis = player.inputState.moveAxis; + final MeshNode marker = markers[seats.candidates.indexOf(player)]; + final Vector3 at = marker.readPosition(); + marker.setPosition( + (at.x + axis.x * 3.0 * dt).clamp(-4.0, 4.0), + at.y, + (at.z - axis.y * 3.0 * dt).clamp(-4.0, 4.0), + ); + } + _caption.text = switch (seats.seated.length) { + 0 => 'Space joins WASD, / joins the arrows', + 1 => 'player one is in; the other layout can still join', + _ => 'two players, in the order they pressed', + }; + } + // #endregion seat +} + +/// Seats and walks between the keyboard's feed and the steps' ends, so a +/// join press is read in the frame it came in. +final class _Seating extends Component { + _Seating(this._lobby); + + final _Lobby _lobby; + + @override + void update(double dt) { + super.update(dt); + _lobby.seatAndWalk(dt); + } +} diff --git a/apps/flutter3d_showcase/lib/pages/flame/flame_seats.md b/apps/flutter3d_showcase/lib/pages/flame/flame_seats.md new file mode 100644 index 000000000..d7924eb32 --- /dev/null +++ b/apps/flutter3d_showcase/lib/pages/flame/flame_seats.md @@ -0,0 +1,41 @@ +# Two layouts, and whoever presses first is player one + +`PlayerSeats` keeps every way of holding a game at one machine, each a +`FlameInputBridge` with its own bindings and state, and lets the game claim +one as the next player when it sees it pressed. Here the two ways are two +halves of one keyboard. Nobody plays until they press their join key, and +seats go in the order people join, whichever layout is listed first. + +## Step 1: A table and a state for each layout + +Each layout is a bridge of its own: the same `Bindings` and `InputState` +pair a native `flutter3d_game` reads, one per player, so a key reaches the +player it is bound for and no other. + +{{code layouts}} + +## Step 2: Claim a seat by pressing + +What counts as asking to join is the game's to say. Here it is the join key +going down this step on a layout nobody has claimed. `claim` makes it the +next player, and `seated` keeps them in the order they came. + +{{code claim}} + +## Step 3: Keys from the keyboard, not the focus + +`listenToKeyboard` feeds every layout from `HardwareKeyboard`, so a key +reaches the game wherever the focus is, and a key let go while a button or a +menu had the focus is still let go. `stepEnds` closes every layout's step +after the frame, the free ones as well, so a press made to join is seen once +and not on every frame after. + +{{code lobby}} + +## Step 4: A marker for each player + +A layout that has just joined shows its cube in its seat's colour: amber for +player one, blue for player two. From then on it walks with its own four +keys. Press Slash before Space and the arrows are player one. + +{{code seat}} diff --git a/apps/flutter3d_showcase/lib/pages/flame/registry.dart b/apps/flutter3d_showcase/lib/pages/flame/registry.dart index 0cdb19c97..374860ca8 100644 --- a/apps/flutter3d_showcase/lib/pages/flame/registry.dart +++ b/apps/flutter3d_showcase/lib/pages/flame/registry.dart @@ -9,10 +9,13 @@ import 'package:flutter3d_showcase/src/demo/demo.dart'; import 'flame_camera_bridge.dart'; import 'flame_crowd.dart'; import 'flame_ecs_bridge.dart'; +import 'flame_horde.dart'; import 'flame_input_bridge.dart'; +import 'flame_level_scene.dart'; import 'flame_overview.dart'; import 'flame_owned_world.dart'; import 'flame_physics_bridge.dart'; +import 'flame_seats.dart'; import 'flame_transform_bridge.dart'; final Map flameDemos = { @@ -24,4 +27,7 @@ final Map flameDemos = { 'flame-camera-bridge': FlameCameraBridgeDemo.new, 'flame-owned-world': FlameOwnedWorldDemo.new, 'flame-crowd': FlameCrowdDemo.new, + 'flame-seats': FlameSeatsDemo.new, + 'flame-horde': FlameHordeDemo.new, + 'flame-level-scene': FlameLevelSceneDemo.new, }; diff --git a/apps/flutter3d_showcase/pubspec.yaml b/apps/flutter3d_showcase/pubspec.yaml index 4fdc252ea..3b8dd61b1 100644 --- a/apps/flutter3d_showcase/pubspec.yaml +++ b/apps/flutter3d_showcase/pubspec.yaml @@ -1,7 +1,7 @@ name: flutter3d_showcase description: "Every capability of the flutter3d engine on a page of its own, with the version it appeared in and a step-by-step guide." publish_to: 'none' -version: 0.8.3+1 +version: 0.8.4+1 resolution: workspace environment: diff --git a/apps/flutter3d_showcase/test/pages/flame_test.dart b/apps/flutter3d_showcase/test/pages/flame_test.dart index bbb69f274..8669ec3c1 100644 --- a/apps/flutter3d_showcase/test/pages/flame_test.dart +++ b/apps/flutter3d_showcase/test/pages/flame_test.dart @@ -2,21 +2,33 @@ library; import 'package:flame/components.dart' show Vector2; +import 'package:flame/game.dart' show FlameGame; import 'package:flame_flutter3d/flame_flutter3d.dart'; +import 'package:flutter/services.dart' + show + HardwareKeyboard, + KeyDownEvent, + KeyUpEvent, + LogicalKeyboardKey, + PhysicalKeyboardKey; import 'package:flutter3d/flutter3d.dart' hide Material; import 'package:flutter3d_showcase/pages/flame/flame_camera_bridge.dart'; import 'package:flutter3d_showcase/pages/flame/flame_ecs_bridge.dart'; +import 'package:flutter3d_showcase/pages/flame/flame_horde.dart'; +import 'package:flutter3d_showcase/pages/flame/flame_level_scene.dart'; import 'package:flutter3d_showcase/pages/flame/flame_overview.dart'; import 'package:flutter3d_showcase/pages/flame/flame_physics_bridge.dart'; +import 'package:flutter3d_showcase/pages/flame/flame_seats.dart'; import 'package:flutter3d_showcase/pages/flame/flame_transform_bridge.dart'; import 'package:flutter3d_showcase/src/demo/demo_run.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:vector_math/vector_math.dart' show Matrix4, Vector3; import '../support/page_harness.dart'; /// Mounts [game] at 800 by 600 and steps it [frames] times, with no window: /// what a `GameWidget` does, minus the widget. -Future _run(TransparentFlameGame game, int frames) async { +Future _run(FlameGame game, int frames) async { game.onGameResize(Vector2(800.0, 600.0)); // ignore: invalid_use_of_internal_member await game.load(); @@ -32,8 +44,181 @@ Future _run(TransparentFlameGame game, int frames) async { } } +/// A key going down or up on the keyboard itself, where `listenToKeyboard` +/// hears it, with nothing holding the focus. +void _key( + LogicalKeyboardKey logical, + PhysicalKeyboardKey physical, + bool down, +) => HardwareKeyboard.instance.handleKeyEvent( + down + ? KeyDownEvent( + physicalKey: physical, + logicalKey: logical, + timeStamp: Duration.zero, + ) + : KeyUpEvent( + physicalKey: physical, + logicalKey: logical, + timeStamp: Duration.zero, + ), +); + +/// Steps [game] one frame at a time: a key pressed between two of them is +/// read in the next. +void _frames(FlameGame game, int frames) { + for (var i = 0; i < frames; i++) { + game.update(1 / 60); + } +} + void main() { + // `HardwareKeyboard` and the input step's lifecycle listener both need a + // binding, and a plain `test()` never makes one. + TestWidgetsFlutterBinding.ensureInitialized(); + group('flame pages', () { + test('a layout joins on its own key, in the order it pressed', () async { + // Mutation: seat the free layouts in the order they are listed rather + // than on their own key, and WASD is player one with no press at all. + final FlameSeatsDemo demo = FlameSeatsDemo(); + final DemoRun run = await DemoRun.start(cpuDevice(), demo); + try { + await _run(demo.game, 5); + final FlameInputBridge wasd = demo.seats.candidates[0]; + final FlameInputBridge arrows = demo.seats.candidates[1]; + expect(demo.seats.seated, isEmpty); + expect(demo.markers.every((MeshNode m) => !m.visible), isTrue); + + // The second layout presses first, through the keyboard alone. + _key(LogicalKeyboardKey.slash, PhysicalKeyboardKey.slash, true); + _frames(demo.game, 1); + _key(LogicalKeyboardKey.slash, PhysicalKeyboardKey.slash, false); + expect(demo.seats.seated, [arrows]); + expect(demo.markers[1].visible, isTrue); + expect(demo.markers[0].visible, isFalse); + + // Held, the join key seats nobody else on later frames. + _frames(demo.game, 5); + expect(demo.seats.seated, hasLength(1)); + + _key(LogicalKeyboardKey.space, PhysicalKeyboardKey.space, true); + _frames(demo.game, 1); + _key(LogicalKeyboardKey.space, PhysicalKeyboardKey.space, false); + expect(demo.seats.seated, [arrows, wasd]); + // Player one wears the first seat's colour, whichever layout it is. + expect(demo.markers[1].material.baseColor.x, greaterThan(0.9)); + expect(demo.markers[0].material.baseColor.z, greaterThan(0.9)); + + // A seated player walks with its own keys, and only its marker goes. + final double arrowsX = demo.markers[1].readPosition().x; + final double wasdX = demo.markers[0].readPosition().x; + _key( + LogicalKeyboardKey.arrowRight, + PhysicalKeyboardKey.arrowRight, + true, + ); + _frames(demo.game, 30); + _key( + LogicalKeyboardKey.arrowRight, + PhysicalKeyboardKey.arrowRight, + false, + ); + expect(demo.markers[1].readPosition().x, greaterThan(arrowsX + 1.0)); + expect(demo.markers[0].readPosition().x, wasdX); + } finally { + run.dispose(); + } + }); + + test('the horde is one batch, split between the two players', () async { + // Mutation: step the system towards the first player alone, and no + // monster ever goes for the second. + final FlameHordeDemo demo = FlameHordeDemo(); + final DemoRun run = await DemoRun.start(cpuDevice(), demo); + try { + double nearest() { + final List players = demo.players; + return demo.monsters + .map( + (m) => players + .map((Vector3 p) => (p..y = m.at.y).distanceTo(m.at)) + .reduce((double a, double b) => a < b ? a : b), + ) + .reduce((double a, double b) => a + b) / + demo.monsters.length; + } + + await _run(demo.game, 1); + final double before = nearest(); + _frames(demo.game, 90); + + // Every monster is a slot of the one batch. + expect(demo.batch.count, demo.monsters.length); + expect(demo.monsters, hasLength(96)); + // Both players are chased, each by some of the horde. + final Iterable attended = demo.monsters.map((m) => m.attended); + expect(attended.where((int i) => i == 0), isNotEmpty); + expect(attended.where((int i) => i == 1), isNotEmpty); + // And the horde closed in on whichever was nearer. + expect(nearest(), lessThan(before - 2.0)); + // The slot draws the body, not somewhere of its own. + final Matrix4 placed = Matrix4.zero(); + demo.batch.readTransform(0, placed); + final Vector3 drawn = placed.getTranslation(); + final Iterable gaps = demo.monsters.map( + (m) => Vector3(m.at.x - drawn.x, 0.0, m.at.z - drawn.z).length, + ); + expect( + gaps.reduce((double a, double b) => a < b ? a : b), + lessThan(0.2), + ); + } finally { + run.dispose(); + } + }); + + test( + 'the level moves to its next scene and the camera finds the party', + () async { + // Mutation: leave out `replaceScene3d`, and the second level is never + // the one drawn. + final FlameLevelSceneDemo demo = FlameLevelSceneDemo(); + final DemoRun run = await DemoRun.start(cpuDevice(), demo); + try { + await _run(demo.game, 90); + expect(demo.level, same(demo.levels[0])); + // The camera keeps up with the party as it walks the first level. + expect( + demo.camera.readPosition().distanceTo(demo.wantedEye), + lessThan(2.0), + ); + + // Past six seconds: the second level, with the camera and the party. + _frames(demo.game, 6 * 60 - 90 + 2); + expect(demo.level, same(demo.levels[1])); + expect(demo.levels[1].cameras, contains(demo.camera)); + expect(demo.levels[0].cameras, isNot(contains(demo.camera))); + expect( + demo.party.every((MeshNode m) => demo.levels[1].meshes.contains(m)), + isTrue, + ); + // The party starts the new level elsewhere; the camera eases after. + final double moved = demo.camera.readPosition().distanceTo( + demo.wantedEye, + ); + expect(moved, greaterThan(5.0)); + _frames(demo.game, 90); + expect( + demo.camera.readPosition().distanceTo(demo.wantedEye), + lessThan(moved / 2.0), + ); + } finally { + run.dispose(); + } + }, + ); + test('the transform bridge carries each side across, live', () async { // Mutation: give both components the same direction and one of the // two cubes stays where it started. diff --git a/cloud/README.md b/cloud/README.md index 06299be48..098fbbbff 100644 --- a/cloud/README.md +++ b/cloud/README.md @@ -23,6 +23,11 @@ keep are counts of packages. | `docker-compose.yml` | Postgres for development and the integration test | | `monitoring/` | Prometheus and Grafana for the service's own numbers (accounts, models, disk). [README](monitoring/README.md) | +Two more services live beside it, each with its own README: `lessons/` +(a lesson page and its embed) and `lti/` (LTI 1.3 launch and grade passback). +Shared levels and opt-in telemetry are part of this server; see +[Sharing](#sharing) and [Telemetry](#telemetry). + ## Running it ```bash @@ -60,6 +65,10 @@ names all of them. | `MODELS_ASSETS_DIR` | Default `web/assets` | | `MODELS_VIEWER_DIR` | Optional; serves the viewer at `/app/` when set | | `MODELS_LEARN_DIR` | Default `content/learn/modeler`, which is right in a checkout and nowhere else. A directory with no case in it gives an empty tutorial and one line in the log; the start does not fail | +| `MODELS_TELEMETRY_LEVELS_DIR` | Optional; the levels telemetry runs are played again in. See [Telemetry](#telemetry) | +| `MODELS_SHARES_MODERATION` | `review` (default) or `open`. See [Sharing](#sharing) | +| `MODELS_SHARES_MODERATOR_TOKEN` | Optional, 24 characters or more; without it moderation is off | +| `MODELS_SHARES_REPORTS_TO_HIDE` | How many reports send a published level back to review. Default 3 | ## Tests @@ -75,6 +84,59 @@ checks that the first one was signed out, and deletes the model and its file. After editing a migration, run `dart run tool/embed_migrations.dart`. With `--check` it fails when the generated file is stale. +## Telemetry + +The reference server for runs that players agreed to send (N7 in +`tasks/0.9-engine-roadmap.md`). Under `/api/telemetry/`: + +| | | +|---|---| +| `POST runs` | A `TelemetryUpload` from `flutter3d_sim`. Refused without the consent it was sent under. The run is played again through `resimulate`; one that diverges, or names a game or a level this server lacks, is refused and nothing is kept. An accepted run keeps its trail, outcome, length and the consent record, not its input, and answers `201 {run, eraseKey}` | +| `DELETE runs/?key=` | Deletes the run. Only the key's hash is stored | +| `GET heatmap?level=&cell=` | The newest 2000 runs of that level, binned. The JSON is the playtest report's, which the editor's report screen draws | + +`MODELS_TELEMETRY_LEVELS_DIR` names a directory of level documents, keyed by +their digest; a file that does not parse is named in the log and skipped. +The games are `HeadlessGame`s handed to `Services(telemetryGames:)` in code. +`main.server.dart` hands none, because every genre in the repository needs +Flutter and this process runs under `dart run`, so as shipped the endpoint +answers 503 and says why. + +## Sharing + +The reference server for shared levels (N10 in `tasks/0.9-engine-roadmap.md`). +A player shares a level, or a level with a run through it, and gets a short +code back. The client and the format are `RunService` and `ShareBundle` in +`flutter3d_sim`; a game points `RunService` at `https:///api/`. Every +answer is JSON, and every refusal is `{"message": …}`, which `RunService` hands +to the game as the reason. No account is involved. Under `/api/v1/`: + +| | | +|---|---| +| `POST shares` | A `ShareBundle`, at most 4 MB. A run recorded in another version of the level is refused. `201 {code, address, status}`, or `200` with the same code when these bytes were shared before | +| `GET shares/` | `200 {bundle}` when published, `202` while pending, `410` with the moderator's reason when removed, `404` | +| `POST shares//reports` | `{reason}`, answered `202 {message}` | +| `GET moderation/queue` | Token. Pending levels and anything reported | +| `GET moderation/shares/` | Token. One level with its record, whatever its status | +| `POST moderation/shares/` | Token, `{decision: publish \| remove, reason}`. A removal needs a reason; publishing again drops the reports | + +A level is filed under the SHA-256 of the bytes the server writes for it, not +the bytes it was sent, so the same level shared twice gets the same code. Those +bytes are kept as `text` in `shares`, since `jsonb` would not keep them. The +code is the first seven characters of the hash in Crockford's base 32, and gets +longer only if a different level already holds it. People can type it in lower +case, with dashes, and with O for 0 or L for 1. + +In `review` mode nothing opens until a moderator publishes it, so without a +token `POST shares` answers 503 instead of keeping levels nobody could ever +publish. In `open` mode a level opens at once, and +`MODELS_SHARES_REPORTS_TO_HIDE` reports send it back to review. The moderation +routes take `Authorization: Bearer ` and answer +503 when no token is set. + +Not deployed: the production environment sets none of these, so models.pleion.dev +shares nothing yet. + ## Deploying ```bash diff --git a/cloud/server/lib/main.server.dart b/cloud/server/lib/main.server.dart index 6bc8645fd..f283f75ab 100644 --- a/cloud/server/lib/main.server.dart +++ b/cloud/server/lib/main.server.dart @@ -9,6 +9,7 @@ library; import 'dart:async'; import 'dart:io'; +import 'package:flutter3d_sim/flutter3d_sim.dart' show Level; import 'package:jaspr/server.dart'; import 'package:shelf/shelf_io.dart' as shelf_io; @@ -20,6 +21,7 @@ import 'src/http/app.dart'; import 'src/mail/mailer.dart'; import 'src/services.dart'; import 'src/storage/blob_store.dart'; +import 'src/telemetry/telemetry_service.dart'; Future main(List arguments) async { Jaspr.initializeApp(options: defaultServerOptions); @@ -38,6 +40,16 @@ Future main(List arguments) async { stderr.writeln(problem); } + // N7. Not a reason to stop either: a broken level costs its own runs. + final ( + telemetryLevels, + levelProblems, + ) = switch (config.telemetryLevelsDirectory) { + final directory? => readTelemetryLevels(directory), + null => ({}, []), + }; + levelProblems.forEach(stderr.writeln); + final db = await Database.open(config.databaseUrl); final services = Services( config: config, @@ -47,6 +59,10 @@ Future main(List arguments) async { null => ConsoleMailer(), }, blobs: FileBlobStore(config.blobDirectory), + // No games: every genre in the repository needs Flutter, which a server + // started with `dart run` does not have. A deploy that plays one builds + // its own entry point and names it here. + telemetryLevels: telemetryLevels, ); Services.instance = services; @@ -70,9 +86,13 @@ Future main(List arguments) async { } }); + final sharing = services.shares.takesShares + ? 'sharing in ${config.shareModeration.name} mode' + : 'sharing off: review mode with no moderator token'; stdout.writeln( 'models on http://${server.address.host}:${server.port} ' - '(${config.sendsMail ? 'sending mail' : 'printing mail to the log'})', + '(${config.sendsMail ? 'sending mail' : 'printing mail to the log'}; ' + '$sharing)', ); // systemd stops a unit with SIGTERM; answering it lets requests in flight diff --git a/cloud/server/lib/src/config.dart b/cloud/server/lib/src/config.dart index 34c0e9714..0f869ec16 100644 --- a/cloud/server/lib/src/config.dart +++ b/cloud/server/lib/src/config.dart @@ -9,6 +9,17 @@ library; import 'dart:io'; +/// N10: what happens to a level the moment somebody shares it. +enum ShareModeration { + /// Kept as pending until a moderator publishes it. The default: a server + /// somebody stands up and forgets about should not be an open file host on + /// the day it starts. + review, + + /// Published at once; reports send it back to review. + open, +} + /// Where the service listens, what it calls itself, and the secrets it holds. class Config { const Config({ @@ -23,6 +34,10 @@ class Config { this.assetsDirectory = 'web/assets', this.viewerDirectory, this.learnDirectory = 'content/learn/modeler', + this.telemetryLevelsDirectory, + this.shareModeration = ShareModeration.review, + this.shareModeratorToken, + this.shareReportsToHide = 3, }); /// Reads the configuration from the process environment. @@ -46,6 +61,26 @@ class Config { // development wants and what a test can read. final resendApiKey = env['MODELS_RESEND_API_KEY']; + // N10. Absent is a setting too — sharing with nobody to moderate it — but + // present and wrong is a deploy that believes it set something it did not, + // so those are named with the missing ones and stop the start. + final moderationName = + env['MODELS_SHARES_MODERATION'] ?? ShareModeration.review.name; + final moderation = ShareModeration.values.asNameMap()[moderationName]; + if (moderation == null) { + missing.add('MODELS_SHARES_MODERATION (review or open)'); + } + final rawToken = env['MODELS_SHARES_MODERATOR_TOKEN'] ?? ''; + final moderatorToken = rawToken.isEmpty ? null : rawToken; + if (moderatorToken != null && moderatorToken.length < 24) { + missing.add('MODELS_SHARES_MODERATOR_TOKEN (24 characters or more)'); + } + final rawReports = env['MODELS_SHARES_REPORTS_TO_HIDE'] ?? ''; + final reportsToHide = rawReports.isEmpty ? 3 : int.tryParse(rawReports); + if (reportsToHide == null || reportsToHide <= 0) { + missing.add('MODELS_SHARES_REPORTS_TO_HIDE (a positive number)'); + } + final config = Config( // 8794: on bob, 8790–8792 are the documentation site, tooth and its API, // and 8793 is the nginx that sits in front of this. @@ -63,6 +98,10 @@ class Config { assetsDirectory: env['MODELS_ASSETS_DIR'] ?? 'web/assets', viewerDirectory: env['MODELS_VIEWER_DIR'], learnDirectory: env['MODELS_LEARN_DIR'] ?? 'content/learn/modeler', + telemetryLevelsDirectory: env['MODELS_TELEMETRY_LEVELS_DIR'], + shareModeration: moderation ?? ShareModeration.review, + shareModeratorToken: moderatorToken, + shareReportsToHide: reportsToHide ?? 3, ); if (missing.isNotEmpty) throw ConfigError(missing); @@ -120,6 +159,23 @@ class Config { /// nothing. final String learnDirectory; + /// N7: the level documents telemetry runs are played again in, or null for + /// a server that takes no telemetry. Optional, because most deploys of the + /// models service have no game to play them with. + final String? telemetryLevelsDirectory; + + /// N10: whether a shared level waits for a moderator or opens at once. + final ShareModeration shareModeration; + + /// What `Authorization: Bearer …` has to say on `/api/v1/moderation/`, or + /// null for a server nobody moderates — whose moderation routes are off, + /// and which in [ShareModeration.review] takes no shares at all, since none + /// could ever open. + final String? shareModeratorToken; + + /// How many reports send a published level back to review. + final int shareReportsToHide; + /// Whether letters are actually sent. bool get sendsMail => resendApiKey != null; diff --git a/cloud/server/lib/src/db/migrations.g.dart b/cloud/server/lib/src/db/migrations.g.dart index 017b16d76..054249988 100644 --- a/cloud/server/lib/src/db/migrations.g.dart +++ b/cloud/server/lib/src/db/migrations.g.dart @@ -214,5 +214,70 @@ alter table models add column search tsvector ) stored; create index models_search on models using gin (search); +'''), + Migration(4, '004_telemetry_runs.sql', r''' +-- N7: runs players agreed to send, as what they did rather than what they +-- pressed. The input is replayed and dropped; see `telemetry_store.dart`. + +create table telemetry_runs ( + id bigserial primary key, + game text not null, + level_hash text not null, + level text not null, + outcome text not null + check (outcome in ('won', 'lost', 'unfinished')), + steps integer not null check (steps > 0), + -- `[[x, z], ...]`, sampled every few steps of the replay. + trail jsonb not null, + -- The hash, never the key: a leaked table erases nothing. + erase_key_sha256 text not null, + -- The record of consent the run was taken under. + policy text not null, + consented_at timestamptz not null, + received_at timestamptz not null default now() +); + +-- A heatmap reads one level's newest runs. +create index telemetry_runs_by_level on telemetry_runs (level_hash, id desc); +'''), + Migration(5, '005_shares.sql', r''' +-- N10: levels shared behind short codes, and the reports against them. See +-- `shares/share_store.dart`. + +create table shares ( + -- Crockford base 32 of the address, seven characters unless a different + -- bundle already holds those; see `shares/short_code.dart`. + code text primary key check (code ~ '^[0-9A-HJKMNP-TV-Z]{7,51}$'), + -- The SHA-256 of `bundle`, hex. Unique, so a bundle shared twice is one row + -- however the two requests interleave. + address text not null unique check (address ~ '^[0-9a-f]{64}$'), + game text not null, + level_hash text not null, + title text, + has_run boolean not null, + -- **Text, not jsonb.** jsonb keeps neither key order nor the exact spelling + -- of a number, and the address is the hash of these bytes: a code has to + -- open exactly what its address names. + bundle text not null, + status text not null + check (status in ('published', 'pending', 'removed')), + -- Why a moderator decided as they did; what a removed code answers with. + note text, + created_at timestamptz not null default now() +); + +-- The moderation queue reads what still waits, oldest first. +create index shares_pending on shares (created_at) where status = 'pending'; + +-- Reports a moderator has not answered yet. Publishing a level again answers +-- them, and they are deleted then. +create table share_reports ( + id bigserial primary key, + code text not null references shares (code) on delete cascade, + reason text not null, + reported_at timestamptz not null default now() +); + +create index share_reports_by_code on share_reports (code, id); '''), ]; diff --git a/cloud/server/lib/src/db/migrations/004_telemetry_runs.sql b/cloud/server/lib/src/db/migrations/004_telemetry_runs.sql new file mode 100644 index 000000000..cff6b4858 --- /dev/null +++ b/cloud/server/lib/src/db/migrations/004_telemetry_runs.sql @@ -0,0 +1,23 @@ +-- N7: runs players agreed to send, as what they did rather than what they +-- pressed. The input is replayed and dropped; see `telemetry_store.dart`. + +create table telemetry_runs ( + id bigserial primary key, + game text not null, + level_hash text not null, + level text not null, + outcome text not null + check (outcome in ('won', 'lost', 'unfinished')), + steps integer not null check (steps > 0), + -- `[[x, z], ...]`, sampled every few steps of the replay. + trail jsonb not null, + -- The hash, never the key: a leaked table erases nothing. + erase_key_sha256 text not null, + -- The record of consent the run was taken under. + policy text not null, + consented_at timestamptz not null, + received_at timestamptz not null default now() +); + +-- A heatmap reads one level's newest runs. +create index telemetry_runs_by_level on telemetry_runs (level_hash, id desc); diff --git a/cloud/server/lib/src/db/migrations/005_shares.sql b/cloud/server/lib/src/db/migrations/005_shares.sql new file mode 100644 index 000000000..20b381cbb --- /dev/null +++ b/cloud/server/lib/src/db/migrations/005_shares.sql @@ -0,0 +1,38 @@ +-- N10: levels shared behind short codes, and the reports against them. See +-- `shares/share_store.dart`. + +create table shares ( + -- Crockford base 32 of the address, seven characters unless a different + -- bundle already holds those; see `shares/short_code.dart`. + code text primary key check (code ~ '^[0-9A-HJKMNP-TV-Z]{7,51}$'), + -- The SHA-256 of `bundle`, hex. Unique, so a bundle shared twice is one row + -- however the two requests interleave. + address text not null unique check (address ~ '^[0-9a-f]{64}$'), + game text not null, + level_hash text not null, + title text, + has_run boolean not null, + -- **Text, not jsonb.** jsonb keeps neither key order nor the exact spelling + -- of a number, and the address is the hash of these bytes: a code has to + -- open exactly what its address names. + bundle text not null, + status text not null + check (status in ('published', 'pending', 'removed')), + -- Why a moderator decided as they did; what a removed code answers with. + note text, + created_at timestamptz not null default now() +); + +-- The moderation queue reads what still waits, oldest first. +create index shares_pending on shares (created_at) where status = 'pending'; + +-- Reports a moderator has not answered yet. Publishing a level again answers +-- them, and they are deleted then. +create table share_reports ( + id bigserial primary key, + code text not null references shares (code) on delete cascade, + reason text not null, + reported_at timestamptz not null default now() +); + +create index share_reports_by_code on share_reports (code, id); diff --git a/cloud/server/lib/src/db/shares_repository.dart b/cloud/server/lib/src/db/shares_repository.dart new file mode 100644 index 000000000..f934e82eb --- /dev/null +++ b/cloud/server/lib/src/db/shares_repository.dart @@ -0,0 +1,189 @@ +/// Shared levels, as rows — `ShareStore` over Postgres. +library; + +import 'package:flutter3d_sim/flutter3d_sim.dart' show ShareStatus; +import 'package:postgres/postgres.dart'; + +import '../shares/share_store.dart'; +import '../shares/short_code.dart'; +import 'database.dart'; + +/// A record with its open reports, read in one query: the reports come back +/// as one `json` array per row rather than a second round trip a share. +const String _record = ''' + select s.code, s.address, s.game, s.level_hash, s.title, s.has_run, + s.created_at, s.status, s.note, + coalesce( + (select json_agg(json_build_object( + 'reason', r.reason, 'at', r.reported_at) order by r.id) + from share_reports r where r.code = s.code), + '[]'::json) + from shares s +'''; + +class SharesRepository implements ShareStore { + const SharesRepository(this._db); + + final Database _db; + + /// **One insert a candidate code, and the unique address decides the + /// race.** `on conflict do nothing` covers both constraints: when nothing + /// was inserted, either the same bundle is already filed — whoever filed it + /// — or a different one holds this code and the next, longer one is tried. + @override + Future<({ShareRecord record, bool created})> file(NewShare share) => _db.run(( + session, + ) async { + for (final code in codesOf(share.address)) { + final inserted = await session.execute( + Sql.named(''' + insert into shares + (code, address, game, level_hash, title, has_run, bundle, + status, created_at) + values + (@code, @address, @game, @levelHash, @title:text, @hasRun, + @bundle, @status, @createdAt) + on conflict do nothing + returning code + '''), + parameters: { + 'code': code, + 'address': share.address, + 'game': share.game, + 'levelHash': share.levelHash, + 'title': share.title, + 'hasRun': share.hasRun, + 'bundle': share.bundle, + 'status': share.status.name, + 'createdAt': share.createdAt.toUtc(), + }, + ); + if (inserted.isNotEmpty) { + return (record: (await _one(session, 's.code', code))!, created: true); + } + final known = await _one(session, 's.address', share.address); + if (known != null) return (record: known, created: false); + } + throw StateError('every code of ${share.address} is taken'); + }); + + @override + Future byCode(String code) => + _db.run((session) => _one(session, 's.code', code)); + + @override + Future bundleText(String code) => _db.run((session) async { + final rows = await session.execute( + Sql.named('select bundle from shares where code = @code'), + parameters: {'code': code}, + ); + return rows.isEmpty ? null : rows.first[0]! as String; + }); + + /// The row is locked first, so two reports at once count each other. + @override + Future report( + String code, + ShareReport report, { + required int reportsToHide, + }) => _db.transaction((session) async { + final locked = await session.execute( + Sql.named('select 1 from shares where code = @code for update'), + parameters: {'code': code}, + ); + if (locked.isEmpty) return null; + await session.execute( + Sql.named(''' + insert into share_reports (code, reason, reported_at) + values (@code, @reason, @at) + '''), + parameters: { + 'code': code, + 'reason': report.reason, + 'at': report.at.toUtc(), + }, + ); + await session.execute( + Sql.named(''' + update shares set status = 'pending' + where code = @code and status = 'published' + and (select count(*) from share_reports where code = @code) + >= @reportsToHide + '''), + parameters: {'code': code, 'reportsToHide': reportsToHide}, + ); + return _one(session, 's.code', code); + }); + + @override + Future decide(String code, ShareStatus status, String? note) => + _db.transaction((session) async { + final updated = await session.execute( + Sql.named(''' + update shares set status = @status, note = @note:text + where code = @code + returning code + '''), + parameters: {'code': code, 'status': status.name, 'note': note}, + ); + if (updated.isEmpty) return null; + if (status == ShareStatus.published) { + await session.execute( + Sql.named('delete from share_reports where code = @code'), + parameters: {'code': code}, + ); + } + return _one(session, 's.code', code); + }); + + @override + Future> queue({required int limit}) => + _db.run((session) async { + final rows = await session.execute( + Sql.named(''' + $_record + where s.status = 'pending' + or exists (select 1 from share_reports r where r.code = s.code) + order by s.created_at, s.code + limit @limit + '''), + parameters: {'limit': limit}, + ); + return [for (final row in rows) _recordOf(row)]; + }); + + /// The record whose [column] is [value]; the column is one of two names + /// written in this file, never anything a request supplied. + static Future _one( + Session session, + String column, + String value, + ) async { + final rows = await session.execute( + Sql.named('$_record where $column = @value'), + parameters: {'value': value}, + ); + return rows.isEmpty ? null : _recordOf(rows.first); + } + + static ShareRecord _recordOf(ResultRow row) => ShareRecord( + code: row[0]! as String, + address: row[1]! as String, + game: row[2]! as String, + levelHash: row[3]! as String, + title: row[4] as String?, + hasRun: row[5]! as bool, + createdAt: row[6]! as DateTime, + // The check constraint holds the column to the three names. + status: ShareStatus.named(row[7])!, + note: row[8] as String?, + reports: [ + for (final Map report + in (row[9]! as List).cast()) + ShareReport( + reason: report['reason']! as String, + at: DateTime.parse(report['at']! as String), + ), + ], + ); +} diff --git a/cloud/server/lib/src/db/telemetry_repository.dart b/cloud/server/lib/src/db/telemetry_repository.dart new file mode 100644 index 000000000..147ded664 --- /dev/null +++ b/cloud/server/lib/src/db/telemetry_repository.dart @@ -0,0 +1,86 @@ +/// Telemetry runs, as rows — `TelemetryStore` over Postgres. +library; + +import 'dart:convert'; + +import 'package:flutter3d_sim/flutter3d_sim.dart' show HeatmapTrail; +import 'package:postgres/postgres.dart'; + +import '../telemetry/telemetry_store.dart'; +import 'database.dart'; + +class TelemetryRepository implements TelemetryStore { + const TelemetryRepository(this._db); + + final Database _db; + + @override + Future add(TelemetryRunRow row) => _db.run((session) async { + final inserted = await session.execute( + Sql.named(''' + insert into telemetry_runs + (game, level_hash, level, outcome, steps, trail, erase_key_sha256, + policy, consented_at) + values + (@game, @levelHash, @level, @outcome, @steps, @trail::jsonb, + @eraseKey, @policy, @consentedAt) + returning id + '''), + parameters: { + 'game': row.game, + 'levelHash': row.levelHash, + 'level': row.level, + 'outcome': row.outcome, + 'steps': row.steps, + 'trail': jsonEncode(>[ + for (final (x, z) in row.trail) [x, z], + ]), + 'eraseKey': row.eraseKeySha256, + 'policy': row.policy, + 'consentedAt': row.consentedAt.toUtc(), + }, + ); + return inserted.first[0]! as int; + }); + + @override + Future erase(int id, String eraseKeySha256) => _db.run((session) async { + final deleted = await session.execute( + Sql.named( + 'delete from telemetry_runs ' + 'where id = @id and erase_key_sha256 = @key', + ), + parameters: {'id': id, 'key': eraseKeySha256}, + ); + return deleted.affectedRows == 1; + }); + + @override + Future> trails( + String levelHash, { + required int limit, + }) => _db.run((session) async { + final rows = await session.execute( + Sql.named(''' + select id, steps, outcome, trail from telemetry_runs + where level_hash = @level + order by id desc + limit @limit + '''), + parameters: {'level': levelHash, 'limit': limit}, + ); + return [ + for (final row in rows) + HeatmapTrail( + run: row[0]! as int, + steps: row[1]! as int, + outcome: row[2]! as String, + positions: <(double, double)>[ + for (final List point in (row[3]! as List).cast()) + ((point[0]! as num).toDouble(), (point[1]! as num).toDouble()), + ], + endedBadly: row[2] == 'lost', + ), + ]; + }); +} diff --git a/cloud/server/lib/src/http/app.dart b/cloud/server/lib/src/http/app.dart index 57b30bfc2..ec8069ac1 100644 --- a/cloud/server/lib/src/http/app.dart +++ b/cloud/server/lib/src/http/app.dart @@ -37,7 +37,9 @@ import 'learn_routes.dart'; import 'metrics.dart'; import 'render.dart'; import 'request.dart'; +import 'share_routes.dart'; import 'static_files.dart'; +import 'telemetry_routes.dart'; /// The largest preview picture accepted, in bytes. /// @@ -60,6 +62,13 @@ Handler buildHandler(Services services) { // `gal-07`: the modeller's own gallery, behind one endpoint of ours, // so the keys the outside catalogues want stay on a machine we own. ..mount('/gallery/', galleryRoutes(services.gallery).call) + // N7: runs a player agreed to send, played again here; no session and no + // cookie, so nothing for a forged form to ride on. + ..mount('/api/telemetry/', telemetryRoutes(services.telemetry).call) + // N10: levels shared behind short codes, at the paths `RunService` asks + // for from a base of `/api/`. What it does not answer falls through to + // the routes below, `/api/v1/models` among them. + ..mount('/api/', shareRoutes(services.shares).call) // Nothing here checks who is asking. The endpoint is not linked from any // page and nginx keeps it off the public vhost (see // `cloud/monitoring/deploy/nginx-grafana.pleion.dev.conf` and diff --git a/cloud/server/lib/src/http/share_routes.dart b/cloud/server/lib/src/http/share_routes.dart new file mode 100644 index 000000000..20426bd68 --- /dev/null +++ b/cloud/server/lib/src/http/share_routes.dart @@ -0,0 +1,73 @@ +/// N10's routes, mounted under `/api/`: what `RunService` in `flutter3d_sim` +/// calls from a base of `https:///api/`, and the moderation routes for +/// whoever runs the server. +/// +/// **No session and no cookie, the same as telemetry.** A share is anonymous, +/// so there is no account for a forged form to ride on; the moderation routes +/// are a bearer token a tool sends, never a cookie a browser would send on its +/// own. +library; + +import 'dart:convert'; + +import 'package:shelf/shelf.dart'; +import 'package:shelf_router/shelf_router.dart'; + +import '../shares/share_service.dart'; +import 'request.dart'; + +/// The longest report or decision read: both are a sentence. +const int _smallBodyBytes = 4096; + +Response _json(ShareAnswer answer) => Response( + answer.status, + body: jsonEncode(answer.body), + headers: { + 'content-type': 'application/json; charset=utf-8', + 'cache-control': 'no-store', + }, +); + +/// The body as text, or null when it is over [limit] — counted as it arrives, +/// so a body that never says how long it is is still refused before it is +/// held whole. +Future _text(Request request, int limit) async { + final bytes = await readBody(request, limit: limit); + return bytes == null ? null : utf8.decode(bytes, allowMalformed: true); +} + +/// Unmatched paths fall through to the routes mounted after this, which is +/// why the router keeps shelf_router's own not-found: `/api/v1/models` is +/// under the same prefix. +Router shareRoutes(ShareService service) => Router() + ..post('/v1/shares', (Request request) async { + final body = await _text(request, service.maxBodyBytes); + if (body == null) return _json(tooLarge(service.maxBodyBytes)); + return _json(await service.share(body)); + }) + ..get( + '/v1/shares/', + (Request request, String code) async => _json(await service.open(code)), + ) + ..post('/v1/shares//reports', (Request request, String code) async { + final body = await _text(request, _smallBodyBytes); + if (body == null) return _json(tooLarge(_smallBodyBytes)); + return _json(await service.report(code, body)); + }) + ..get( + '/v1/moderation/queue', + (Request request) async => + _json(await service.queue(request.headers['authorization'])), + ) + ..get( + '/v1/moderation/shares/', + (Request request, String code) async => + _json(await service.inspect(request.headers['authorization'], code)), + ) + ..post('/v1/moderation/shares/', (Request request, String code) async { + final body = await _text(request, _smallBodyBytes); + if (body == null) return _json(tooLarge(_smallBodyBytes)); + return _json( + await service.decide(request.headers['authorization'], code, body), + ); + }); diff --git a/cloud/server/lib/src/http/telemetry_routes.dart b/cloud/server/lib/src/http/telemetry_routes.dart new file mode 100644 index 000000000..05c8d35de --- /dev/null +++ b/cloud/server/lib/src/http/telemetry_routes.dart @@ -0,0 +1,55 @@ +/// N7's three endpoints, mounted under `/api/telemetry/`. +/// +/// **No account, on purpose.** A run arrives with the consent it was sent +/// under and leaves with the key that deletes it; tying it to a person would +/// make anonymous telemetry a thing this server promises and could break. +library; + +import 'dart:convert'; + +import 'package:shelf/shelf.dart'; +import 'package:shelf_router/shelf_router.dart'; + +import '../telemetry/telemetry_service.dart'; + +Response _json(TelemetryAnswer answer) => Response( + answer.status, + body: jsonEncode(answer.body), + headers: { + 'content-type': 'application/json; charset=utf-8', + 'cache-control': 'no-store', + }, +); + +Router telemetryRoutes(TelemetryService service) => Router() + ..post('/runs', (Request request) async { + // Read no further than the limit allows: a body that announces itself as + // too large is refused before it is held in memory. + final length = request.contentLength; + if (length != null && length > service.maxBodyBytes) { + return _json(( + status: 413, + body: { + 'says': 'a run is at most ${service.maxBodyBytes} bytes here', + }, + )); + } + return _json(await service.accept(await request.readAsString())); + }) + ..delete('/runs/', (Request request, String id) async { + final run = int.tryParse(id); + final key = request.url.queryParameters['key']; + if (run == null || key == null || key.isEmpty) { + return _json(( + status: 400, + body: { + 'says': 'erase with /api/telemetry/runs/?key=', + }, + )); + } + return _json(await service.erase(run, key)); + }) + ..get('/heatmap', (Request request) async { + final query = request.url.queryParameters; + return _json(await service.heatmap(query['level'], query['cell'])); + }); diff --git a/cloud/server/lib/src/services.dart b/cloud/server/lib/src/services.dart index e7cd78cc0..84e6eed93 100644 --- a/cloud/server/lib/src/services.dart +++ b/cloud/server/lib/src/services.dart @@ -1,6 +1,8 @@ /// Everything a request handler or a page reaches for, built once at start. library; +import 'package:flutter3d_sim/flutter3d_sim.dart' show HeadlessGame, Level; + import 'auth/accounts.dart'; import 'config.dart'; import 'db/database.dart'; @@ -10,11 +12,15 @@ import 'db/models_repository.dart'; import 'db/projects_repository.dart'; import 'db/rate_limit.dart'; import 'db/sessions_repository.dart'; +import 'db/shares_repository.dart'; +import 'db/telemetry_repository.dart'; import 'db/users_repository.dart'; import 'http/cookies.dart'; import 'http/gallery_catalogue.dart'; import 'mail/mailer.dart'; +import 'shares/share_service.dart'; import 'storage/blob_store.dart'; +import 'telemetry/telemetry_service.dart'; class Services { Services({ @@ -23,7 +29,20 @@ class Services { required this.mailer, required this.blobs, GalleryCatalogue? gallery, + Map telemetryGames = const {}, + Map telemetryLevels = const {}, }) : gallery = gallery ?? const FixedCatalogue(), + telemetry = TelemetryService( + games: telemetryGames, + levels: telemetryLevels, + store: TelemetryRepository(db), + ), + shares = ShareService( + store: SharesRepository(db), + moderation: config.shareModeration, + moderatorToken: config.shareModeratorToken, + reportsToHide: config.shareReportsToHide, + ), users = UsersRepository(db), sessions = SessionsRepository(db), tokens = EmailTokensRepository(db), @@ -65,6 +84,21 @@ class Services { final Mailer mailer; final BlobStore blobs; + /// N7: runs sent with a player's consent, played again and binned. + /// + /// **Empty unless a deploy hands it games and levels**, and an empty one + /// answers every run with why it took none. The games are [HeadlessGame]s + /// this process can step, so they are chosen in code where the server is + /// built; the levels come from `MODELS_TELEMETRY_LEVELS_DIR`. + final TelemetryService telemetry; + + /// N10: levels shared behind short codes. + /// + /// **Refuses every share until somebody can moderate them**, unless the + /// deploy chose `MODELS_SHARES_MODERATION=open`: in review, a level nobody + /// could ever publish is a level kept for nothing. + final ShareService shares; + final UsersRepository users; final SessionsRepository sessions; final EmailTokensRepository tokens; diff --git a/cloud/server/lib/src/shares/share_service.dart b/cloud/server/lib/src/shares/share_service.dart new file mode 100644 index 000000000..296c2a3a5 --- /dev/null +++ b/cloud/server/lib/src/shares/share_service.dart @@ -0,0 +1,313 @@ +/// N10: levels shared behind short codes, and what a moderator and a report do +/// to them. +library; + +import 'dart:convert'; + +import 'package:crypto/crypto.dart'; +import 'package:flutter3d_sim/flutter3d_sim.dart' + show ShareBundle, ShareFormatException, ShareStatus; + +import '../config.dart'; +import 'share_store.dart'; +import 'short_code.dart'; + +/// An answer: the status, and a JSON body. A refusal's body is +/// `{"message": …}` — the sentence `RunService` hands the player, saying what +/// was asked for, why it cannot be, and what to do instead. +typedef ShareAnswer = ({int status, Map body}); + +ShareAnswer _say(int status, String message) => + (status: status, body: {'message': message}); + +/// The longest title and reason kept. A title is a line under a code; a +/// reason is a sentence to a moderator. +const int _titleLimit = 80; +const int _reasonLimit = 500; + +final RegExp _gameName = RegExp(r'^[a-z0-9][a-z0-9_-]{0,39}$'); + +/// Files levels by their bytes, hands out codes, and answers them. +/// +/// **No account, on purpose, the same as telemetry.** The person pressing +/// Share is the consent, the code is the only handle, and what is shared is a +/// level and a tape — nothing that names who made it unless they typed it +/// into the title. Tying a share to a session would make the code's reader +/// able to find its author, which a short code read aloud should not. +final class ShareService { + ShareService({ + required this.store, + this.moderation = ShareModeration.review, + this.moderatorToken, + this.reportsToHide = 3, + this.maxBodyBytes = 4 * 1024 * 1024, + DateTime Function()? now, + }) : _now = now ?? DateTime.now; + + final ShareStore store; + final ShareModeration moderation; + + /// What `Authorization: Bearer …` has to carry on the moderation routes; + /// null switches them off. + final String? moderatorToken; + + final int reportsToHide; + + /// The largest bundle accepted. A tape is a few bytes a step, so four + /// megabytes is hours of play; a level with a heightfield is the larger + /// half. + final int maxBodyBytes; + + final DateTime Function() _now; + + /// Whether anything shared here could ever open: in review, only with a + /// moderator to publish it. + bool get takesShares => + moderation == ShareModeration.open || moderatorToken != null; + + /// `POST /api/v1/shares`. + Future share(String body) async { + if (!takesShares) { + return _say( + 503, + 'this server takes no shares: every one waits for a moderator, and ' + 'it has none (MODELS_SHARES_MODERATOR_TOKEN is not set)', + ); + } + if (body.length > maxBodyBytes) return tooLarge(maxBodyBytes); + final json = _object(body); + if (json == null) return _say(400, 'the body is not a JSON object'); + final ShareBundle bundle; + try { + bundle = ShareBundle.fromJson(json); + } on ShareFormatException catch (error) { + return _say(422, 'this is not a bundle: ${error.message}'); + } + if (!_gameName.hasMatch(bundle.game)) { + return _say( + 422, + 'the game is called "${bundle.game}"; a game name here is up to 40 ' + 'lower-case letters, digits, "-" and "_"', + ); + } + final title = bundle.title; + if (title != null && (title.length > _titleLimit || _hasControl(title))) { + return _say( + 422, + 'the title is ${title.length} characters or has a line break in it; ' + 'a title is one line of up to $_titleLimit', + ); + } + // Filed by the bytes this server writes, not the ones it was sent: the + // same bundle with its keys in another order is the same bundle. + final text = jsonEncode(bundle.toJson()); + final (:record, :created) = await store.file( + NewShare( + address: sha256.convert(utf8.encode(text)).toString(), + bundle: text, + game: bundle.game, + levelHash: bundle.levelHash, + title: title, + hasRun: bundle.run != null, + status: switch (moderation) { + ShareModeration.review => ShareStatus.pending, + ShareModeration.open => ShareStatus.published, + }, + createdAt: _now(), + ), + ); + return ( + status: created ? 201 : 200, + body: { + 'code': record.code, + 'address': record.address, + 'status': record.status.name, + 'message': _statusSentence(record), + }, + ); + } + + /// `GET /api/v1/shares/`: the bundle when it is published, and where + /// it stands when it is not. + Future open(String typed) async { + final record = await _find(typed); + if (record == null) return _unknownCode(typed); + if (record.status == ShareStatus.published) { + final text = await store.bundleText(record.code); + return ( + status: 200, + body: { + 'code': record.code, + 'status': record.status.name, + 'bundle': jsonDecode(text!), + }, + ); + } + return ( + status: record.status == ShareStatus.pending ? 202 : 410, + body: { + 'code': record.code, + 'status': record.status.name, + 'message': _statusSentence(record), + }, + ); + } + + /// `POST /api/v1/shares//reports` with `{reason}`. + Future report(String typed, String body) async { + final code = normaliseCode(Uri.decodeComponent(typed)); + final reason = _object(body)?['reason']; + if (reason is! String || reason.trim().isEmpty) { + return _say(422, 'a report says what is wrong with it: {"reason": …}'); + } + final record = code == null + ? null + : await store.report( + code, + ShareReport( + reason: reason.length > _reasonLimit + ? reason.substring(0, _reasonLimit) + : reason, + at: _now(), + ), + reportsToHide: reportsToHide, + ); + if (record == null) return _unknownCode(typed); + return _say(202, 'reported; a moderator will look at "${record.code}"'); + } + + /// `GET /api/v1/moderation/queue`. + Future queue(String? authorization) async { + if (_refusedModerator(authorization) case final refused?) return refused; + final records = await store.queue(limit: 500); + return ( + status: 200, + body: { + 'items': >[ + for (final record in records) record.toJson(), + ], + }, + ); + } + + /// `GET /api/v1/moderation/shares/`: one bundle with its record, + /// whatever its status. + Future inspect(String? authorization, String typed) async { + if (_refusedModerator(authorization) case final refused?) return refused; + final record = await _find(typed); + if (record == null) return _unknownCode(typed); + final text = await store.bundleText(record.code); + return ( + status: 200, + body: {...record.toJson(), 'bundle': jsonDecode(text!)}, + ); + } + + /// `POST /api/v1/moderation/shares/` with + /// `{decision: publish | remove, reason}`. + Future decide( + String? authorization, + String typed, + String body, + ) async { + if (_refusedModerator(authorization) case final refused?) return refused; + final json = _object(body); + if (json == null) return _say(400, 'the body is not a JSON object'); + final decision = switch (json['decision']) { + 'publish' => ShareStatus.published, + 'remove' => ShareStatus.removed, + _ => null, + }; + if (decision == null) { + return _say(422, 'a decision is "publish" or "remove"'); + } + final note = switch (json['reason']) { + final String reason when reason.trim().isNotEmpty => reason, + _ => null, + }; + if (decision == ShareStatus.removed && note == null) { + return _say( + 422, + 'a removal says why: the reason is what the code answers with', + ); + } + final code = normaliseCode(Uri.decodeComponent(typed)); + final record = code == null + ? null + : await store.decide(code, decision, note); + if (record == null) return _unknownCode(typed); + return ( + status: 200, + body: { + 'code': record.code, + 'status': record.status.name, + 'message': _statusSentence(record), + }, + ); + } + + Future _find(String typed) async { + final code = normaliseCode(Uri.decodeComponent(typed)); + return code == null ? null : store.byCode(code); + } + + /// Null when [authorization] carries the moderator token; otherwise the + /// refusal. + /// + /// Compared in full whatever the first difference, so how long a wrong + /// token took to refuse says nothing about how much of it was right. + ShareAnswer? _refusedModerator(String? authorization) { + final token = moderatorToken; + if (token == null) { + return _say( + 503, + 'moderation is off on this server: MODELS_SHARES_MODERATOR_TOKEN is ' + 'not set', + ); + } + final header = authorization ?? ''; + final given = utf8.encode( + header.startsWith('Bearer ') ? header.substring(7) : '', + ); + final wanted = utf8.encode(token); + final difference = Iterable.generate(wanted.length).fold( + given.length ^ wanted.length, + (sum, i) => sum | (wanted[i] ^ (i < given.length ? given[i] : 0)), + ); + return difference == 0 + ? null + : _say( + 401, + 'this is for moderators: send Authorization: Bearer ' + '', + ); + } +} + +/// What a code answers, as a sentence. +String _statusSentence(ShareRecord record) => switch (record.status) { + ShareStatus.published => '"${record.code}" is open to anyone with the code', + ShareStatus.pending => + '"${record.code}" is kept and waits for a moderator before anyone can ' + 'open it', + _ => '"${record.code}" was taken down: ${record.note ?? 'no reason given'}', +}; + +Map? _object(String body) { + try { + final json = jsonDecode(body); + return json is Map ? json : null; + } on FormatException { + return null; + } +} + +bool _hasControl(String text) => text.runes.any((int r) => r < 0x20); + +/// The answer for a body over [limit] bytes — here, and in the routes that +/// refuse one before it is read whole. +ShareAnswer tooLarge(int limit) => + _say(413, 'the body is over the $limit bytes this server takes'); + +ShareAnswer _unknownCode(String typed) => + _say(404, 'no level is shared under "$typed"; check the code'); diff --git a/cloud/server/lib/src/shares/share_store.dart b/cloud/server/lib/src/shares/share_store.dart new file mode 100644 index 000000000..65642d20b --- /dev/null +++ b/cloud/server/lib/src/shares/share_store.dart @@ -0,0 +1,238 @@ +/// Where shared levels are kept, as the service needs them. +/// +/// **The bytes as this server wrote them, not as they were sent.** A bundle is +/// read, checked and written again by `ShareBundle.toJson`, and those bytes +/// are what is kept and what the address is the SHA-256 of — so the same +/// level shared twice with its keys in another order is one row, and what a +/// code opens is byte for byte what its address names. +library; + +import 'package:flutter3d_sim/flutter3d_sim.dart' show ShareStatus; + +import 'short_code.dart'; + +/// One report against a shared level. +final class ShareReport { + const ShareReport({required this.reason, required this.at}); + + final String reason; + final DateTime at; + + Map toJson() => { + 'reason': reason, + 'at': at.toUtc().toIso8601String(), + }; +} + +/// What is known about a shared level without opening it. +final class ShareRecord { + const ShareRecord({ + required this.code, + required this.address, + required this.game, + required this.levelHash, + required this.title, + required this.hasRun, + required this.createdAt, + required this.status, + this.note, + this.reports = const [], + }); + + /// The short code handed out, as [normaliseCode] writes it. + final String code; + + /// The SHA-256 of the kept bytes, hex. + final String address; + + final String game; + final String levelHash; + final String? title; + final bool hasRun; + final DateTime createdAt; + final ShareStatus status; + + /// Why a moderator decided as they did; what a removed code answers with. + final String? note; + + /// Reports not yet answered by a moderator publishing it again. + final List reports; + + ShareRecord copyWith({ + ShareStatus? status, + String? note, + List? reports, + }) => ShareRecord( + code: code, + address: address, + game: game, + levelHash: levelHash, + title: title, + hasRun: hasRun, + createdAt: createdAt, + status: status ?? this.status, + note: note ?? this.note, + reports: reports ?? this.reports, + ); + + Map toJson() => { + 'code': code, + 'address': address, + 'game': game, + 'levelHash': levelHash, + if (title != null) 'title': title, + 'hasRun': hasRun, + 'createdAt': createdAt.toUtc().toIso8601String(), + 'status': status.name, + if (note != null) 'note': note, + 'reports': [for (final report in reports) report.toJson()], + }; +} + +/// A level about to be filed: everything but its code, which the store picks. +final class NewShare { + const NewShare({ + required this.address, + required this.bundle, + required this.game, + required this.levelHash, + required this.title, + required this.hasRun, + required this.status, + required this.createdAt, + }); + + final String address; + + /// The bundle as this server wrote it — the text [address] is the hash of. + final String bundle; + + final String game; + final String levelHash; + final String? title; + final bool hasRun; + final ShareStatus status; + final DateTime createdAt; +} + +abstract interface class ShareStore { + /// Files [share] under the first of [codesOf] its address that no other + /// bundle holds, and answers the record with whether it is new. A share + /// whose address is filed already answers that record, untouched — its + /// status included, so sharing a removed level again does not reopen it. + Future<({ShareRecord record, bool created})> file(NewShare share); + + /// The record filed under [code], as [normaliseCode] wrote it. + Future byCode(String code); + + /// The kept bytes behind [code], as text. + Future bundleText(String code); + + /// Adds [report] to [code] and, when that makes [reportsToHide] reports + /// against a published level, sends it back to pending — in one step, so + /// two reports at once cannot both miss the threshold. + Future report( + String code, + ShareReport report, { + required int reportsToHide, + }); + + /// A moderator's decision. Publishing answers the reports that sent it to + /// review, and they are dropped; leaving them would send it straight back + /// at the next one. + Future decide(String code, ShareStatus status, String? note); + + /// What a moderator should look at — pending, or reported — oldest first. + Future> queue({required int limit}); +} + +/// A store in memory: the tests', and a server's that keeps nothing past a +/// restart. +/// +/// Every method finishes without yielding between its read and its write, so +/// in one isolate nothing interleaves inside one. +final class MemoryShareStore implements ShareStore { + final Map records = {}; + final Map bundles = {}; + + @override + Future<({ShareRecord record, bool created})> file(NewShare share) async { + final known = records.values + .where((record) => record.address == share.address) + .firstOrNull; + if (known != null) return (record: known, created: false); + final code = codesOf( + share.address, + ).firstWhere((code) => !records.containsKey(code)); + final record = ShareRecord( + code: code, + address: share.address, + game: share.game, + levelHash: share.levelHash, + title: share.title, + hasRun: share.hasRun, + createdAt: share.createdAt, + status: share.status, + ); + records[code] = record; + bundles[code] = share.bundle; + return (record: record, created: true); + } + + @override + Future byCode(String code) async => records[code]; + + @override + Future bundleText(String code) async => bundles[code]; + + @override + Future report( + String code, + ShareReport report, { + required int reportsToHide, + }) async { + final record = records[code]; + if (record == null) return null; + final reports = [...record.reports, report]; + final hidden = + record.status == ShareStatus.published && + reports.length >= reportsToHide; + return records[code] = record.copyWith( + reports: reports, + status: hidden ? ShareStatus.pending : null, + ); + } + + @override + Future decide( + String code, + ShareStatus status, + String? note, + ) async { + final record = records[code]; + if (record == null) return null; + return records[code] = ShareRecord( + code: record.code, + address: record.address, + game: record.game, + levelHash: record.levelHash, + title: record.title, + hasRun: record.hasRun, + createdAt: record.createdAt, + status: status, + note: note, + reports: status == ShareStatus.published + ? const [] + : record.reports, + ); + } + + @override + Future> queue({required int limit}) async => records.values + .where( + (record) => + record.status == ShareStatus.pending || record.reports.isNotEmpty, + ) + .take(limit) + .toList(); +} diff --git a/cloud/server/lib/src/shares/short_code.dart b/cloud/server/lib/src/shares/short_code.dart new file mode 100644 index 000000000..751080c8f --- /dev/null +++ b/cloud/server/lib/src/shares/short_code.dart @@ -0,0 +1,51 @@ +/// N10: codes a person types or reads aloud, taken from a bundle's address. +library; + +/// Crockford's base 32: no I, L, O or U, so nothing reads as a digit it is +/// not, and nothing spells much by accident. +const String _alphabet = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'; + +/// The shortest code handed out. Seven characters are 35 bits: with a +/// million bundles filed, a new one lands on a taken code about once in +/// thirty-four thousand, and when it does it takes an eighth character rather +/// than the other bundle's code. +const int shortestCode = 7; + +/// As long as a SHA-256 goes: 256 bits are 51 whole characters of five. +const int longestCode = 256 ~/ 5; + +/// The first [length] characters of [address] (hex SHA-256) in base 32. +/// +/// **Derived, not drawn.** The same bundle shared twice gets the same code +/// without a lookup, and a code says nothing a stranger could not already +/// compute from the bytes. +String codeOf(String address, int length) { + final bits = [ + for (final digit in address.split('')) + int.parse(digit, radix: 16).toRadixString(2).padLeft(4, '0'), + ].join(); + return [ + for (var i = 0; i < length; i++) + _alphabet[int.parse(bits.substring(i * 5, i * 5 + 5), radix: 2)], + ].join(); +} + +/// Every code [address] may be filed under, shortest first: the next one is +/// tried only when a different bundle already holds the one before. +Iterable codesOf(String address) => Iterable.generate( + longestCode - shortestCode + 1, + (int extra) => codeOf(address, shortestCode + extra), +); + +/// [typed] as a code is stored: upper case, with the letters Crockford +/// reads as digits read as those digits, and dashes and spaces dropped, since +/// people group codes when they copy them. Null when it cannot be a code. +String? normaliseCode(String typed) { + final upper = typed + .toUpperCase() + .replaceAll(RegExp(r'[\s-]'), '') + .replaceAll('O', '0') + .replaceAll(RegExp('[IL]'), '1'); + if (upper.length < shortestCode || upper.length > longestCode) return null; + return upper.split('').every(_alphabet.contains) ? upper : null; +} diff --git a/cloud/server/lib/src/telemetry/telemetry_service.dart b/cloud/server/lib/src/telemetry/telemetry_service.dart new file mode 100644 index 000000000..097f4dbfd --- /dev/null +++ b/cloud/server/lib/src/telemetry/telemetry_service.dart @@ -0,0 +1,266 @@ +/// N7: runs players agreed to send, played again here, and the heatmap of +/// where they went. +library; + +import 'dart:convert'; +import 'dart:io'; +import 'dart:isolate'; +import 'dart:math'; + +import 'package:crypto/crypto.dart'; +import 'package:flutter3d_sim/flutter3d_sim.dart'; + +import 'telemetry_store.dart'; + +/// Every outcome a stored run can have, counted even at zero. +const List kTelemetryOutcomes = ['won', 'lost', 'unfinished']; + +/// An answer: the status, and a JSON body whose `says` is a sentence. +typedef TelemetryAnswer = ({int status, Map body}); + +TelemetryAnswer _say(int status, String says, [Map? more]) => + (status: status, body: {'says': says, ...?more}); + +/// What a replay in another isolate hands back: plain data, since a +/// [HeadlessRun] does not cross. +typedef _Replayed = ({ + String? refused, + String outcome, + int steps, + List<(double, double)> trail, +}); + +/// The levels this server can play runs in, by [Level.digestHex], read from +/// every `*.json` in [directory]. +/// +/// A file that is not a level is skipped and named in the second half of the +/// answer, so a deploy with one broken file still takes runs for the rest and +/// the log says which one. +(Map, List) readTelemetryLevels(String directory) { + final folder = Directory(directory); + if (!folder.existsSync()) { + return ({}, ['no directory at $directory']); + } + final files = + folder + .listSync() + .whereType() + .where((file) => file.path.endsWith('.json')) + .toList() + ..sort((a, b) => a.path.compareTo(b.path)); + final read = <(Level?, String)>[for (final file in files) _readLevel(file)]; + return ( + { + for (final level in read.map((entry) => entry.$1).nonNulls) + level.digestHex: level, + }, + [ + for (final (level, problem) in read) + if (level == null) problem, + ], + ); +} + +(Level?, String) _readLevel(File file) { + try { + final json = jsonDecode(file.readAsStringSync()); + return (Level.fromJson((json as Map).cast()), file.path); + } catch (error) { + return (null, '${file.path} is not a level: $error'); + } +} + +/// Takes runs, plays them again, keeps what they did, and bins it. +/// +/// **The server proves nothing about a game it does not have.** [games] are +/// the [HeadlessGame]s this process can step; a run of any other game is +/// refused by name rather than stored unverified. A genre whose package needs +/// Flutter cannot be one of them under `dart run`, so which games a deploy +/// plays is decided where it is built. +final class TelemetryService { + TelemetryService({ + required this.games, + required this.levels, + required this.store, + this.maxSteps = 60 * 60 * 60, + this.maxBodyBytes = 16 * 1024 * 1024, + this.sampleEvery = 10, + this.heatmapRuns = 2000, + this.isolated = true, + Random? random, + }) : _random = random ?? Random.secure(); + + final Map games; + final Map levels; + final TelemetryStore store; + + /// The longest run replayed: an hour at sixty steps a second. A replay is + /// this server's CPU, and a tape is cheap to make long. + final int maxSteps; + final int maxBodyBytes; + final int sampleEvery; + + /// How many of a level's newest runs a heatmap bins. + final int heatmapRuns; + + /// Whether a replay runs in an isolate of its own, so one long run does not + /// hold every other request. Off in tests that hand in a game no isolate + /// can be sent. + final bool isolated; + + final Random _random; + + /// Whether this server can take any run at all. + bool get takesRuns => games.isNotEmpty && levels.isNotEmpty; + + /// `POST /api/telemetry/runs`. + Future accept(String body) async { + if (!takesRuns) { + return _say( + 503, + 'this server takes no telemetry: it has ' + '${games.isEmpty ? 'no game' : 'no level'} to play runs in', + ); + } + if (body.length > maxBodyBytes) { + return _say(413, 'a run is at most $maxBodyBytes bytes here'); + } + final TelemetryUpload upload; + try { + final json = jsonDecode(body); + if (json is! Map) { + return _say(400, 'an upload is a JSON object'); + } + upload = TelemetryUpload.fromJson(json); + } on FormatException catch (error) { + return _say(400, 'not JSON: ${error.message}'); + } on TelemetryUploadFormatException catch (error) { + return _say(400, error.message); + } + + final demo = upload.demo; + final game = games[upload.game]; + if (game == null) { + return _say( + 422, + 'this server plays no ${upload.game}; it plays ' + '${games.keys.join(', ')}', + ); + } + final level = levels[demo.levelHash]; + if (level == null) { + return _say( + 422, + 'this server has no level with hash ${demo.levelHash} ' + '(${demo.level}), so the run cannot be played again here', + ); + } + if (demo.steps > maxSteps) { + return _say( + 413, + 'the run is ${demo.steps} steps and this server replays at most ' + '$maxSteps', + ); + } + + final args = (game: game, level: level, demo: demo, every: sampleEvery); + final replayed = isolated + ? await Isolate.run(() => _replay(args)) + : _replay(args); + if (replayed.refused case final refused?) { + return _say(422, '$refused — nothing was kept'); + } + + final key = _eraseKey(); + final id = await store.add( + TelemetryRunRow( + game: upload.game, + levelHash: demo.levelHash, + level: demo.level, + outcome: replayed.outcome, + steps: replayed.steps, + trail: replayed.trail, + eraseKeySha256: _sha256(key), + policy: upload.policy, + consentedAt: upload.consentedAt, + ), + ); + return _say( + 201, + 'played ${replayed.steps} steps again and kept run $id ' + '(${replayed.outcome}); the input itself was not kept', + {'run': id, 'eraseKey': key}, + ); + } + + /// `DELETE /api/telemetry/runs/` with the receipt's key. + Future erase(int id, String key) async => + await store.erase(id, _sha256(key)) + ? _say(200, 'run $id is deleted') + : _say(404, 'no run $id with that key'); + + /// `GET /api/telemetry/heatmap?level=&cell=`. + Future heatmap(String? levelHash, String? cell) async { + if (levelHash == null || levelHash.isEmpty) { + return _say(400, 'name the level: ?level='); + } + final cellSize = double.tryParse(cell ?? '1') ?? 0.0; + if (cellSize <= 0.0 || cellSize > 1000.0) { + return _say(400, 'a cell is between 0 and 1000 metres, not "$cell"'); + } + final trails = await store.trails(levelHash, limit: heatmapRuns); + final map = Heatmap.bin( + trails, + cellSize: cellSize, + outcomeNames: kTelemetryOutcomes, + ); + return ( + status: 200, + body: { + ...map.toJson(), + 'level': levelHash, + 'says': '${map.runs} runs of $levelHash', + }, + ); + } + + String _eraseKey() => [ + for (var i = 0; i < 24; i++) + _random.nextInt(256).toRadixString(16).padLeft(2, '0'), + ].join(); +} + +String _sha256(String key) => sha256.convert(utf8.encode(key)).toString(); + +_Replayed _replay( + ({HeadlessGame game, Level level, Demo demo, int every}) args, +) => switch (resimulate( + game: args.game, + level: args.level, + demo: args.demo, + sampleEvery: args.every, +)) { + ResimulationLevelChanged(:final found, :final recorded) => _refused( + 'the level is $found here and the run says $recorded', + ), + ResimulationStartDiffers() => _refused( + 'a fresh run of the level does not start where this one started', + ), + ResimulationDiverged(:final divergence, :final agreedUntil) => _refused( + 'the replay parts from the run at step ${divergence.step} (agreed until ' + '$agreedUntil), so it is not the run that was played', + ), + ResimulationRetraced(:final outcome, :final steps, :final trail) => ( + refused: null, + outcome: switch (outcome) { + RunOutcome.won => 'won', + RunOutcome.lost => 'lost', + RunOutcome.playing => 'unfinished', + }, + steps: steps, + trail: trail, + ), +}; + +_Replayed _refused(String why) => + (refused: why, outcome: '', steps: 0, trail: const <(double, double)>[]); diff --git a/cloud/server/lib/src/telemetry/telemetry_store.dart b/cloud/server/lib/src/telemetry/telemetry_store.dart new file mode 100644 index 000000000..850e3370e --- /dev/null +++ b/cloud/server/lib/src/telemetry/telemetry_store.dart @@ -0,0 +1,96 @@ +/// Where accepted telemetry runs are kept, as the service needs them. +/// +/// **Metrics, not input.** What a run did — its trail, how it ended, how long +/// it took — is kept; the tape it was worked out from is not. The tape is the +/// player's every keypress, and nothing the heatmap answers needs it once the +/// replay has run. A run the server re-simulates under new code later is a +/// run the player sends again, under consent that is still current. +library; + +import 'package:flutter3d_sim/flutter3d_sim.dart' show HeatmapTrail; + +/// One accepted run, as it is written. +final class TelemetryRunRow { + const TelemetryRunRow({ + required this.game, + required this.levelHash, + required this.level, + required this.outcome, + required this.steps, + required this.trail, + required this.eraseKeySha256, + required this.policy, + required this.consentedAt, + }); + + final String game; + final String levelHash; + + /// The level's path, as the run named it — for an operator reading rows, + /// not for finding the level, which goes by [levelHash]. + final String level; + + /// `won`, `lost` or `unfinished`. + final String outcome; + final int steps; + final List<(double, double)> trail; + + /// The erase key's SHA-256, never the key: a leaked table erases nothing. + final String eraseKeySha256; + + /// The wording the player agreed to, and when — the record of consent the + /// run was taken under. + final String policy; + final DateTime consentedAt; +} + +abstract interface class TelemetryStore { + /// Keeps [row] and answers its id. + Future add(TelemetryRunRow row); + + /// Deletes run [id] if [eraseKeySha256] is its key's hash. Whether it was + /// there and the key wrong, or not there at all, is one answer: false. + Future erase(int id, String eraseKeySha256); + + /// The newest [limit] runs of the level [levelHash], as trails. + Future> trails(String levelHash, {required int limit}); +} + +/// A store in memory: the tests', and a server's that keeps nothing past a +/// restart. +final class MemoryTelemetryStore implements TelemetryStore { + final Map rows = {}; + int _next = 1; + + @override + Future add(TelemetryRunRow row) async { + final id = _next++; + rows[id] = row; + return id; + } + + @override + Future erase(int id, String eraseKeySha256) async { + if (rows[id]?.eraseKeySha256 != eraseKeySha256) return false; + rows.remove(id); + return true; + } + + @override + Future> trails( + String levelHash, { + required int limit, + }) async => [ + for (final MapEntry(key: id, value: row) in rows.entries.toList().reversed) + if (row.levelHash == levelHash) trailOf(id, row), + ].take(limit).toList(); +} + +/// [row] as the heatmap bins it: a lost run marks where it was lost. +HeatmapTrail trailOf(int id, TelemetryRunRow row) => HeatmapTrail( + run: id, + steps: row.steps, + outcome: row.outcome, + positions: row.trail, + endedBadly: row.outcome == 'lost', +); diff --git a/cloud/server/pubspec.lock b/cloud/server/pubspec.lock index 6eddbab51..0497949d7 100644 --- a/cloud/server/pubspec.lock +++ b/cloud/server/pubspec.lock @@ -223,14 +223,14 @@ packages: path: "../../packages/flutter3d_core" relative: true source: path - version: "0.8.2" + version: "0.8.3+1" flutter3d_hardware: dependency: "direct overridden" description: path: "../../packages/flutter3d_hardware" relative: true source: path - version: "0.8.0" + version: "0.8.0+1" flutter3d_mesh: dependency: "direct overridden" description: @@ -251,21 +251,28 @@ packages: path: "../../packages/flutter3d_particles" relative: true source: path - version: "0.8.0" + version: "0.8.1+1" flutter3d_physics: dependency: "direct main" description: path: "../../packages/flutter3d_physics" relative: true source: path - version: "0.8.2" + version: "0.8.2+1" flutter3d_shaders: dependency: "direct overridden" description: path: "../../packages/flutter3d_shaders" relative: true source: path - version: "0.8.2" + version: "0.8.2+1" + flutter3d_sim: + dependency: "direct main" + description: + path: "../../packages/flutter3d_sim" + relative: true + source: path + version: "0.8.1+1" frontend_server_client: dependency: transitive description: @@ -683,7 +690,7 @@ packages: source: hosted version: "4.6.0" vector_math: - dependency: transitive + dependency: "direct dev" description: name: vector_math sha256: "92b9910f66ed1057fd4da7b040ae7c74cafacf885bdc81be496928d5049b032d" diff --git a/cloud/server/pubspec.yaml b/cloud/server/pubspec.yaml index 471063609..df49e2e26 100644 --- a/cloud/server/pubspec.yaml +++ b/cloud/server/pubspec.yaml @@ -52,6 +52,12 @@ dependencies: flutter3d_physics: path: ../../packages/flutter3d_physics + # N7: a run sent as telemetry is played again here, through the simulation + # the player ran — `resimulate`, `TelemetryUpload`, `Heatmap`. Plain Dart, + # which is why a server can hold it at all. + flutter3d_sim: + path: ../../packages/flutter3d_sim + # Argon2id for passwords, in Dart. The FFI binding to libargon2 stopped at # Dart 2, and a password hash is the one place where a dependency that has # stopped is not a trade worth making. @@ -72,6 +78,10 @@ dev_dependencies: lints: ^6.0.0 test: ^1.25.0 + # The positions of the walking body `telemetry_routes_test.dart` re-simulates + # — the simulation's own vector type, already in the lock through it. + vector_math: ^2.4.3 + # `tool/embed_migrations.dart` formats its own output before writing it, so # `--check`'s byte comparison and the repository's own `dart format` step # never disagree about the same file. @@ -111,6 +121,8 @@ dependency_overrides: path: ../../packages/flutter3d_physics flutter3d_shaders: path: ../../packages/flutter3d_shaders + flutter3d_sim: + path: ../../packages/flutter3d_sim jaspr: mode: server diff --git a/cloud/server/test/share_db_test.dart b/cloud/server/test/share_db_test.dart new file mode 100644 index 000000000..77a2d4bbc --- /dev/null +++ b/cloud/server/test/share_db_test.dart @@ -0,0 +1,131 @@ +/// [SharesRepository] against a real database: the bytes through `text` and +/// back unchanged, the unique address deciding a second filing, a code that +/// lengthens when a different bundle holds it, and reports that hide and are +/// answered. +@Tags(['db']) +library; + +import 'dart:io'; + +import 'package:flutter3d_models/src/db/database.dart'; +import 'package:flutter3d_models/src/db/shares_repository.dart'; +import 'package:flutter3d_models/src/shares/share_store.dart'; +import 'package:flutter3d_models/src/shares/short_code.dart'; +import 'package:flutter3d_sim/flutter3d_sim.dart' show ShareStatus; +import 'package:test/test.dart'; + +/// Not a real SHA-256 of [bundle] — the repository takes the address it is +/// handed — but the shape the column's check asks for. +NewShare _share({ + String address = + 'a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90', + String bundle = '{"version":1,"game":"walk","levelHash":"abcd1234"}', + ShareStatus status = ShareStatus.pending, +}) => NewShare( + address: address, + bundle: bundle, + game: 'walk', + levelHash: 'abcd1234', + title: null, + hasRun: false, + status: status, + createdAt: DateTime.utc(2026, 10, 1, 12), +); + +void main() { + late Database db; + late SharesRepository shares; + + setUpAll(() async { + final url = + Platform.environment['MODELS_TEST_DATABASE_URL'] ?? + 'postgres://models:models@localhost:55432/models'; + db = await Database.open(url); + shares = SharesRepository(db); + }); + + tearDownAll(() => db.close()); + + setUp( + () => db.run( + (s) => s.execute('truncate shares, share_reports restart identity'), + ), + ); + + test('a bundle is kept byte for byte and filed once', () async { + // Mutation: a `jsonb` column re-orders keys and re-spells numbers, and + // the code would open bytes its address is not the hash of. + const bundle = '{"version":1,"z":1.50,"a":{"b":[2,1]}}'; + final first = await shares.file(_share(bundle: bundle)); + final second = await shares.file(_share(bundle: bundle)); + + expect(first.created, isTrue); + expect(second.created, isFalse); + expect(second.record.code, first.record.code); + expect(first.record.code, codeOf(first.record.address, shortestCode)); + expect(await shares.bundleText(first.record.code), bundle); + expect(first.record.createdAt, DateTime.utc(2026, 10, 1, 12)); + }); + + test('a code a different bundle holds is lengthened, not shared', () async { + const one = + 'a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90'; + // Agrees with `one` for its first 39 bits, so its seven-character code + // (35 bits) is the same and its eight-character one (40) is not. + const other = + 'a1b2c3d4e4f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90'; + expect(codeOf(other, shortestCode), codeOf(one, shortestCode)); + + final first = await shares.file(_share(address: one)); + final second = await shares.file(_share(address: other)); + + expect(second.created, isTrue); + expect(second.record.code, codeOf(other, shortestCode + 1)); + expect(first.record.code, hasLength(shortestCode)); + }); + + test('reports hide a published share at the threshold, and publishing it ' + 'answers them', () async { + final code = (await shares.file( + _share(status: ShareStatus.published), + )).record.code; + + final once = await shares.report( + code, + ShareReport(reason: 'spam', at: DateTime.utc(2026, 10, 2)), + reportsToHide: 2, + ); + expect(once!.status, ShareStatus.published); + final twice = await shares.report( + code, + ShareReport(reason: 'still spam', at: DateTime.utc(2026, 10, 3)), + reportsToHide: 2, + ); + expect(twice!.status, ShareStatus.pending); + expect(twice.reports.map((r) => r.reason), ['spam', 'still spam']); + expect(twice.reports.first.at, DateTime.utc(2026, 10, 2)); + expect((await shares.queue(limit: 10)).single.code, code); + + final published = await shares.decide(code, ShareStatus.published, null); + expect(published!.reports, isEmpty); + expect(await shares.queue(limit: 10), isEmpty); + + final removed = await shares.decide(code, ShareStatus.removed, 'spam'); + expect(removed!.note, 'spam'); + expect((await shares.byCode(code))!.status, ShareStatus.removed); + }); + + test('a code nobody holds is reported and decided as nothing', () async { + expect( + await shares.report( + 'ZZZZZZZ', + ShareReport(reason: 'x', at: DateTime.utc(2026)), + reportsToHide: 1, + ), + isNull, + ); + expect(await shares.decide('ZZZZZZZ', ShareStatus.removed, 'x'), isNull); + expect(await shares.byCode('ZZZZZZZ'), isNull); + expect(await shares.bundleText('ZZZZZZZ'), isNull); + }); +} diff --git a/cloud/server/test/share_routes_test.dart b/cloud/server/test/share_routes_test.dart new file mode 100644 index 000000000..7e0e56481 --- /dev/null +++ b/cloud/server/test/share_routes_test.dart @@ -0,0 +1,522 @@ +/// N10: a level filed by its bytes behind a short code, what a moderator and a +/// report do to it, and `RunService` from `flutter3d_sim` against the real +/// routes — the two halves of one protocol, checked together rather than each +/// against a description of the other. +/// +/// dart test test/share_routes_test.dart +/// +/// Mutation: file by the bytes as sent rather than as re-written and the same +/// bundle with its keys reordered gets a second code; publish on arrival in +/// review mode and an unmoderated upload is open to anyone; rename `code`, +/// `bundle` or `message` on either side and the client answers a refusal for +/// a call that worked. +library; + +import 'dart:convert'; + +import 'package:crypto/crypto.dart'; +import 'package:flutter3d_models/src/config.dart'; +import 'package:flutter3d_models/src/http/share_routes.dart'; +import 'package:flutter3d_models/src/shares/share_service.dart'; +import 'package:flutter3d_models/src/shares/share_store.dart'; +import 'package:flutter3d_models/src/shares/short_code.dart'; +import 'package:flutter3d_sim/flutter3d_sim.dart'; +import 'package:shelf/shelf.dart'; +import 'package:shelf_router/shelf_router.dart'; +import 'package:test/test.dart'; +import 'package:vector_math/vector_math.dart'; + +const String _token = 'a-moderator-token-long-enough'; + +Level _level({String name = 'strip'}) => Level( + name: name, + materials: {'stone': LevelMaterial()}, + brushes: [ + Brush( + centre: Vector3(0.0, -0.5, 0.0), + size: Vector3(20.0, 1.0, 4.0), + material: 'stone', + ), + ], + entities: const [], +); + +/// A run of [steps] steps through [level]. Nothing here plays it: a share is +/// checked against its level's hash, not re-simulated, so a tape and a trace +/// of made-up states are all it needs. +Demo _run(Level level, {int steps = 120}) { + final trace = DigestTrace(every: 10); + for (var step = 1; step <= steps; step++) { + trace.observe(step, {'x': step / 20.0}); + } + return Demo( + level: 'levels/${level.name}.json', + levelHash: level.digestHex, + start: Snapshot({'x': 0.0}), + tape: InputTape( + seed: 1, + frames: [ + for (var step = 0; step < steps; step++) const InputFrame(stickX: 1.0), + ], + ), + buildStamp: 'test', + checkpoints: trace, + ); +} + +ShareBundle _bundle({Demo? run, String? title, String game = 'walk'}) => + ShareBundle(game: game, level: _level().toJson(), run: run, title: title); + +/// The routes over a store in memory, mounted the way `app.dart` mounts them, +/// with a route after them under the same prefix so falling through is +/// tested too. +final class _Server { + _Server({ + ShareModeration moderation = ShareModeration.review, + String? token = _token, + int reportsToHide = 3, + int maxBodyBytes = 1024 * 1024, + }) : service = ShareService( + store: MemoryShareStore(), + moderation: moderation, + moderatorToken: token, + reportsToHide: reportsToHide, + maxBodyBytes: maxBodyBytes, + now: () => DateTime.utc(2026, 10, 1, 12), + ); + + final ShareService service; + + MemoryShareStore get store => service.store as MemoryShareStore; + + late final Handler handler = + (Router(notFoundHandler: (Request request) => Response.notFound('none')) + ..mount('/api/', shareRoutes(service).call) + ..get( + '/api/v1/models', + (Request request) => Response.ok('the models route'), + )) + .call; + + Future<(int, Map)> call( + String method, + String path, { + Object? body, + bool moderator = false, + String? authorization, + }) async { + final response = await handler( + Request( + method, + Uri.parse('https://models.example/api$path'), + body: body == null ? null : (body is String ? body : jsonEncode(body)), + headers: { + if (moderator) 'authorization': 'Bearer $_token', + 'authorization': ?authorization, + }, + ), + ); + final text = await response.readAsString(); + return (response.statusCode, jsonDecode(text) as Map); + } + + Future share(ShareBundle bundle) async { + final (_, filed) = await call('POST', '/v1/shares', body: bundle.toJson()); + return filed['code']! as String; + } + + Future decide(String code, Map decision) async => + (await call( + 'POST', + '/v1/moderation/shares/$code', + body: decision, + moderator: true, + )).$1; + + /// [RunService] talking to these routes without a socket, from the base a + /// game would be handed. + RunService client() => RunService( + base: Uri.parse('https://models.example/api/'), + transport: (RunRequest request) async { + final response = await handler( + Request(request.method, request.uri, body: request.body), + ); + return RunResponse(response.statusCode, await response.readAsString()); + }, + ); +} + +void main() { + group('in review mode', () { + test('a share waits for a moderator before anyone can open it', () async { + final server = _Server(); + final (status, filed) = await server.call( + 'POST', + '/v1/shares', + body: _bundle(run: _run(_level()), title: 'east').toJson(), + ); + expect(status, 201, reason: '${filed['message']}'); + expect(filed['status'], 'pending'); + final code = filed['code']! as String; + expect(code, hasLength(shortestCode)); + + final (early, waiting) = await server.call('GET', '/v1/shares/$code'); + expect(early, 202); + expect(waiting['bundle'], isNull); + + expect(await server.decide(code, {'decision': 'publish'}), 200); + final (opened, json) = await server.call('GET', '/v1/shares/$code'); + expect(opened, 200); + final bundle = ShareBundle.fromJson( + json['bundle']! as Map, + ); + expect(bundle.title, 'east'); + expect(bundle.run!.steps, 120); + }); + + test( + 'the same bundle shared twice is filed once, under one code', + () async { + final server = _Server(); + final json = _bundle().toJson(); + final reordered = { + for (final key in json.keys.toList().reversed) key: json[key], + }; + + final first = await server.call('POST', '/v1/shares', body: json); + final second = await server.call('POST', '/v1/shares', body: reordered); + + expect(first.$1, 201); + expect(second.$1, 200); + expect(second.$2['code'], first.$2['code']); + expect(server.store.records, hasLength(1)); + // What is kept is what the address names, byte for byte. + final code = first.$2['code']! as String; + final kept = server.store.bundles[code]!; + expect(kept, jsonEncode(json)); + expect( + first.$2['address'], + sha256.convert(utf8.encode(kept)).toString(), + ); + expect(code, codeOf(first.$2['address']! as String, shortestCode)); + }, + ); + + test('a code is read the way people type it', () async { + final server = _Server(); + final code = await server.share(_bundle()); + final typed = '${code.substring(0, 3).toLowerCase()}-${code.substring(3)}' + .replaceAll('0', 'o') + .replaceAll('1', 'l'); + + expect((await server.call('GET', '/v1/shares/$typed')).$1, 202); + expect((await server.call('GET', '/v1/shares/not-a-code')).$1, 404); + }); + + test('a bundle whose run is from another level is refused', () async { + final server = _Server(); + final json = _bundle().toJson() + ..['run'] = _run(_level(name: 'elsewhere')).toJson(); + + final (status, answer) = await server.call( + 'POST', + '/v1/shares', + body: json, + ); + + expect(status, 422); + expect(answer['message'], contains('another version of the level')); + expect(server.store.records, isEmpty); + }); + + test( + 'a game name or a title this server will not show is refused', + () async { + final server = _Server(); + + final (game, gameAnswer) = await server.call( + 'POST', + '/v1/shares', + body: _bundle(game: 'Walk Two').toJson(), + ); + final (title, titleAnswer) = await server.call( + 'POST', + '/v1/shares', + body: _bundle(title: 'east\nwest').toJson(), + ); + + expect(game, 422); + expect(gameAnswer['message'], contains('"Walk Two"')); + expect(title, 422); + expect(titleAnswer['message'], contains('one line')); + expect(server.store.records, isEmpty); + }, + ); + + test('a removal says why, and the code answers with it', () async { + final server = _Server(); + final code = await server.share(_bundle()); + + expect(await server.decide(code, {'decision': 'remove'}), 422); + expect( + await server.decide(code, {'decision': 'remove', 'reason': 'spam'}), + 200, + ); + + final (status, answer) = await server.call('GET', '/v1/shares/$code'); + expect(status, 410); + expect(answer['message'], contains('spam')); + // Shared again, the level is the same removed row, not a fresh one. + final (again, refiled) = await server.call( + 'POST', + '/v1/shares', + body: _bundle().toJson(), + ); + expect(again, 200); + expect(refiled['status'], 'removed'); + }); + + test('moderation refuses anyone without the token', () async { + final server = _Server(); + final code = await server.share(_bundle()); + + expect((await server.call('GET', '/v1/moderation/queue')).$1, 401); + final (wrong, answer) = await server.call( + 'GET', + '/v1/moderation/queue', + authorization: 'Bearer $_token-', + ); + expect(wrong, 401); + expect(answer['message'], contains('MODELS_SHARES_MODERATOR_TOKEN')); + expect( + (await server.call( + 'POST', + '/v1/moderation/shares/$code', + body: {'decision': 'publish'}, + authorization: 'Bearer ${_token.substring(1)}', + )).$1, + 401, + ); + expect(server.store.records[code]!.status, ShareStatus.pending); + + final (queued, queue) = await server.call( + 'GET', + '/v1/moderation/queue', + moderator: true, + ); + expect(queued, 200); + expect( + (queue['items']! as List).single, + containsPair('code', code), + ); + final (looked, record) = await server.call( + 'GET', + '/v1/moderation/shares/$code', + moderator: true, + ); + expect(looked, 200); + expect(record['bundle'], isA>()); + }); + + test( + 'with no moderator token, nothing is taken and moderation is off', + () async { + // Mutation: a review server with nobody to review would keep every + // upload pending forever, and an empty token would let an empty + // `Bearer ` header through. + final server = _Server(token: null); + + final (status, answer) = await server.call( + 'POST', + '/v1/shares', + body: _bundle().toJson(), + ); + expect(status, 503); + expect(answer['message'], contains('MODELS_SHARES_MODERATOR_TOKEN')); + expect(server.store.records, isEmpty); + expect( + (await server.call( + 'GET', + '/v1/moderation/queue', + authorization: 'Bearer ', + )).$1, + 503, + ); + }, + ); + + test('what these routes do not answer falls through to the rest of ' + '/api/', () async { + final server = _Server(); + final response = await server.handler( + Request('GET', Uri.parse('https://models.example/api/v1/models')), + ); + expect(await response.readAsString(), 'the models route'); + }); + }); + + group('in open mode', () { + test('reports send a published level back to review, and publishing it ' + 'again answers them', () async { + final server = _Server( + moderation: ShareModeration.open, + reportsToHide: 2, + ); + final (_, filed) = await server.call( + 'POST', + '/v1/shares', + body: _bundle().toJson(), + ); + final code = filed['code']! as String; + expect(filed['status'], 'published'); + + final (empty, _) = await server.call( + 'POST', + '/v1/shares/$code/reports', + body: {'reason': ' '}, + ); + expect(empty, 422); + for (final reason in ['offensive title', 'offensive title']) { + final (reported, answer) = await server.call( + 'POST', + '/v1/shares/$code/reports', + body: {'reason': reason}, + ); + expect(reported, 202); + expect(answer['message'], contains(code)); + } + + expect((await server.call('GET', '/v1/shares/$code')).$1, 202); + final (_, queue) = await server.call( + 'GET', + '/v1/moderation/queue', + moderator: true, + ); + final item = (queue['items']! as List).single! as Map; + expect(item['reports'], hasLength(2)); + + expect(await server.decide(code, {'decision': 'publish'}), 200); + expect((await server.call('GET', '/v1/shares/$code')).$1, 200); + expect(server.store.records[code]!.reports, isEmpty); + }); + + test('a body over the limit is refused before it is read whole', () async { + final server = _Server( + moderation: ShareModeration.open, + maxBodyBytes: 2048, + ); + + final (status, answer) = await server.call( + 'POST', + '/v1/shares', + body: ' ' * 4096, + ); + + expect(status, 413); + expect(answer['message'], contains('2048')); + final (report, _) = await server.call( + 'POST', + '/v1/shares/0000000/reports', + body: ' ' * 5000, + ); + expect(report, 413); + }); + }); + + group('RunService against these routes', () { + test('a bundle shared, published and opened is the bundle sent', () async { + final server = _Server(); + final client = server.client(); + final sent = _bundle(run: _run(_level()), title: 'east'); + + final shared = await client.share(sent); + final code = (shared as ServiceDone).value.code; + expect(shared.value.status, ShareStatus.pending); + expect(shared.value.address, hasLength(64)); + + final early = await client.open(code); + expect( + early, + isA>().having( + (r) => r.reason, + 'reason', + contains('moderator'), + ), + ); + + await server.decide(code, {'decision': 'publish'}); + final opened = await client.open(code.toLowerCase()); + + final bundle = (opened as ServiceDone).value; + expect(bundle.levelHash, sent.levelHash); + expect( + bundle.run!.checkpoints.hexDigests, + sent.run!.checkpoints.hexDigests, + ); + }); + + test('a report reaches the moderation queue, and a refusal is the ' + "server's sentence", () async { + final server = _Server(); + final client = server.client(); + final code = + ((await client.share(_bundle())) as ServiceDone) + .value + .code; + + final answer = await client.report(code, 'not a level'); + expect(answer, isA>()); + expect(server.store.records[code]!.reports.single.reason, 'not a level'); + + final unknown = await client.open('ZZZZZZZ'); + expect( + unknown, + isA>().having( + (r) => r.reason, + 'reason', + contains('no level is shared under "ZZZZZZZ"'), + ), + ); + }); + }); + + group('configuration', () { + const base = { + 'MODELS_BASE_URL': 'https://models.example', + 'MODELS_DATABASE_URL': 'postgres://nobody@127.0.0.1/none', + 'MODELS_BLOB_DIR': '/tmp/none', + 'MODELS_SECRET': 'a-secret', + }; + + test('sharing is reviewed and unmoderated unless a deploy says so', () { + final config = Config.fromEnvironment(base); + expect(config.shareModeration, ShareModeration.review); + expect(config.shareModeratorToken, isNull); + expect(config.shareReportsToHide, 3); + + final open = Config.fromEnvironment({ + ...base, + 'MODELS_SHARES_MODERATION': 'open', + 'MODELS_SHARES_MODERATOR_TOKEN': _token, + 'MODELS_SHARES_REPORTS_TO_HIDE': '5', + }); + expect(open.shareModeration, ShareModeration.open); + expect(open.shareModeratorToken, _token); + expect(open.shareReportsToHide, 5); + }); + + test('a setting present and wrong stops the start, named', () { + expect( + () => Config.fromEnvironment({ + ...base, + 'MODELS_SHARES_MODERATION': 'lax', + 'MODELS_SHARES_MODERATOR_TOKEN': 'short', + 'MODELS_SHARES_REPORTS_TO_HIDE': '0', + }), + throwsA( + isA().having((e) => e.missing, 'missing', hasLength(3)), + ), + ); + }); + }); +} diff --git a/cloud/server/test/telemetry_db_test.dart b/cloud/server/test/telemetry_db_test.dart new file mode 100644 index 000000000..dc75c0f31 --- /dev/null +++ b/cloud/server/test/telemetry_db_test.dart @@ -0,0 +1,74 @@ +/// [TelemetryRepository] against a real database: the trail through `jsonb` +/// and back, and an erase that needs the key. +@Tags(['db']) +library; + +import 'dart:io'; + +import 'package:flutter3d_models/src/db/database.dart'; +import 'package:flutter3d_models/src/db/telemetry_repository.dart'; +import 'package:flutter3d_models/src/telemetry/telemetry_store.dart'; +import 'package:test/test.dart'; + +TelemetryRunRow _row({String level = 'abcd1234', String outcome = 'lost'}) => + TelemetryRunRow( + game: 'walk', + levelHash: level, + level: 'levels/strip.json', + outcome: outcome, + steps: 120, + trail: const <(double, double)>[(0.5, 0.0), (-2.5, 1.25)], + eraseKeySha256: 'hash-of-key', + policy: '2026-10', + consentedAt: DateTime.utc(2026, 10, 1), + ); + +void main() { + late Database db; + late TelemetryRepository runs; + + setUpAll(() async { + final url = + Platform.environment['MODELS_TEST_DATABASE_URL'] ?? + 'postgres://models:models@localhost:55432/models'; + db = await Database.open(url); + runs = TelemetryRepository(db); + }); + + tearDownAll(() => db.close()); + + setUp( + () => db.run( + (s) => s.execute('truncate telemetry_runs restart identity cascade'), + ), + ); + + test('a kept run comes back as the trail it was, newest first, and only ' + 'for its own level', () async { + // Mutation: writing the trail as `[x, z]` objects keyed differently, or + // reading it back in another order, moves every cell of the heatmap. + final first = await runs.add(_row()); + final second = await runs.add(_row(outcome: 'won')); + await runs.add(_row(level: 'other')); + + final trails = await runs.trails('abcd1234', limit: 10); + + expect(trails.map((t) => t.run), [second, first]); + expect(trails.last.positions, const <(double, double)>[ + (0.5, 0.0), + (-2.5, 1.25), + ]); + expect(trails.last.endedBadly, isTrue); + expect(trails.first.endedBadly, isFalse); + expect(await runs.trails('abcd1234', limit: 1), hasLength(1)); + }); + + test('a run is erased with its key and with nothing else', () async { + final id = await runs.add(_row()); + + expect(await runs.erase(id, 'wrong'), isFalse); + expect(await runs.trails('abcd1234', limit: 10), hasLength(1)); + expect(await runs.erase(id, 'hash-of-key'), isTrue); + expect(await runs.trails('abcd1234', limit: 10), isEmpty); + }); +} diff --git a/cloud/server/test/telemetry_routes_test.dart b/cloud/server/test/telemetry_routes_test.dart new file mode 100644 index 000000000..368491cba --- /dev/null +++ b/cloud/server/test/telemetry_routes_test.dart @@ -0,0 +1,344 @@ +/// N7: a run sent with consent is played again here, kept as what it did, and +/// binned into the heatmap the editor draws. +/// +/// dart test test/telemetry_routes_test.dart +library; + +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter3d_models/src/http/telemetry_routes.dart'; +import 'package:flutter3d_models/src/telemetry/telemetry_service.dart'; +import 'package:flutter3d_models/src/telemetry/telemetry_store.dart'; +import 'package:flutter3d_sim/flutter3d_sim.dart'; +import 'package:shelf/shelf.dart'; +import 'package:shelf_router/shelf_router.dart'; +import 'package:test/test.dart'; +import 'package:vector_math/vector_math.dart'; + +/// A body that walks as the stick says: past x = 4 it has won, below x = -2 +/// it has lost. +final class _Walk implements HeadlessRun { + _Walk(this.input); + + final InputState input; + final Vector3 _at = Vector3.zero(); + + @override + RunOutcome outcome = RunOutcome.playing; + + @override + void step(double dt) { + if (outcome.isOver) return; + _at + ..x += input.moveAxis.x * 3.0 * dt + ..z += input.moveAxis.y * 3.0 * dt; + if (_at.x > 4.0) outcome = RunOutcome.won; + if (_at.x < -2.0) outcome = RunOutcome.lost; + } + + @override + Snapshot save() => Snapshot({ + 'x': _at.x, + 'z': _at.z, + 'outcome': outcome.name, + }); + + @override + Vector3 get position => _at; + + @override + void eye(Vector3 out) => out.setFrom(_at); + + @override + void aim(Vector3 out) => out.setValues(1.0, 0.0, 0.0); + + @override + String get summary => 'at ${_at.x}'; + + @override + Map get reading => {'x': _at.x}; +} + +final class _WalkGame implements HeadlessGame { + const _WalkGame(); + + @override + String get name => 'walk'; + + @override + Map get buttons => const {}; + + @override + EntityRegistry registry() => EntityRegistry(const []); + + @override + HeadlessRun start(Level level, CollisionWorld world, InputState input) => + _Walk(input); +} + +Level _level() => Level( + name: 'strip', + materials: {'stone': LevelMaterial()}, + brushes: [ + Brush( + centre: Vector3(0.0, -0.5, 0.0), + size: Vector3(20.0, 1.0, 4.0), + material: 'stone', + ), + ], + entities: const [], +); + +Demo _record({required double stick, int steps = 120}) { + final level = _level(); + final input = InputState(); + final world = CollisionWorld(); + level.addTo(world); + final run = const _WalkGame().start(level, world, input); + final start = run.save(); + final recorder = InputTapeRecorder(seed: 1); + final trace = DigestTrace(every: 10); + for (var step = 1; step <= steps; step++) { + input.setStickAxis(stick, 0.0); + recorder.record(input); + input.beginStep(); + run.step(1.0 / 60.0); + trace.observe(step, run.save().toJson()); + input.endStep(); + } + return Demo( + level: 'levels/strip.json', + levelHash: level.digestHex, + start: start, + tape: recorder.tape, + buildStamp: 'test', + checkpoints: trace, + ); +} + +String _upload(Demo demo, {String game = 'walk'}) => jsonEncode( + TelemetryUpload.prepare( + game: game, + demo: demo, + consent: TelemetryConsent.granted( + policy: '2026-10', + at: DateTime.utc(2026, 10, 1), + ), + policy: '2026-10', + ).upload!.toJson(), +); + +typedef _Server = ({MemoryTelemetryStore store, Handler handler}); + +_Server _server({Map? games}) { + final store = MemoryTelemetryStore(); + final service = TelemetryService( + games: games ?? const {'walk': _WalkGame()}, + levels: {_level().digestHex: _level()}, + store: store, + sampleEvery: 20, + ); + return ( + store: store, + // Through the mount `app.dart` uses, so the prefix is tested too. + handler: (Router()..mount('/api/telemetry/', telemetryRoutes(service).call)) + .call, + ); +} + +Future<(int, Map)> _call( + _Server server, + String method, + String path, [ + String? body, +]) async { + final response = await server.handler( + Request( + method, + Uri.parse('http://localhost/api/telemetry$path'), + body: body, + ), + ); + return ( + response.statusCode, + jsonDecode(await response.readAsString()) as Map, + ); +} + +void main() { + test('a run with consent is played again, kept as its trail, binned, and ' + 'erased with its key', () async { + // Mutation: storing the client's claim instead of the replay's outcome + // would keep a run nobody re-simulated; erasing without the key would + // let anybody delete anybody's run. + final server = _server(); + + final (status, receipt) = await _call( + server, + 'POST', + '/runs', + _upload(_record(stick: -1.0)), + ); + expect(status, 201, reason: '${receipt['says']}'); + expect(receipt['says'], contains('the input itself was not kept')); + final run = receipt['run']! as int; + final key = receipt['eraseKey']! as String; + final kept = server.store.rows[run]!; + expect(kept.outcome, 'lost'); + expect(kept.trail.last.$1, lessThan(-2.0)); + expect(kept.eraseKeySha256, isNot(key)); + expect(kept.policy, '2026-10'); + + await _call(server, 'POST', '/runs', _upload(_record(stick: 1.0))); + final (_, map) = await _call( + server, + 'GET', + '/heatmap?level=${_level().digestHex}&cell=2', + ); + final heatmap = Heatmap.fromJson(map); + expect(heatmap.outcomes, { + 'won': 1, + 'lost': 1, + 'unfinished': 0, + }); + expect(heatmap.ends.single.run, run); + expect(heatmap.cellSize, 2.0); + + final (wrong, _) = await _call(server, 'DELETE', '/runs/$run?key=nope'); + expect(wrong, 404); + expect(server.store.rows, contains(run)); + final (erased, _) = await _call(server, 'DELETE', '/runs/$run?key=$key'); + expect(erased, 200); + expect(server.store.rows, isNot(contains(run))); + }); + + test('a run without consent is refused and nothing is kept', () async { + // Mutation: a server that reads uploads whether or not they were agreed + // to. + final server = _server(); + final json = + jsonDecode(_upload(_record(stick: 1.0))) as Map + ..remove('consent'); + + final (status, answer) = await _call( + server, + 'POST', + '/runs', + jsonEncode(json), + ); + + expect(status, 400); + expect(answer['says'], contains('no consent')); + expect(server.store.rows, isEmpty); + }); + + test( + 'a run of a game or a level this server lacks is refused by name', + () async { + final server = _server(); + + final (game, gameAnswer) = await _call( + server, + 'POST', + '/runs', + _upload(_record(stick: 1.0), game: 'kart'), + ); + expect(game, 422); + expect(gameAnswer['says'], 'this server plays no kart; it plays walk'); + + final demo = _record(stick: 1.0); + final elsewhere = Demo( + level: 'levels/other.json', + levelHash: '0badc0de', + start: demo.start, + tape: demo.tape, + buildStamp: demo.buildStamp, + checkpoints: demo.checkpoints, + ); + final (level, levelAnswer) = await _call( + server, + 'POST', + '/runs', + _upload(elsewhere), + ); + expect(level, 422); + expect(levelAnswer['says'], contains('no level with hash 0badc0de')); + expect(server.store.rows, isEmpty); + }, + ); + + test('a tape edited after it was recorded is refused at the step it parts ' + 'from the run', () async { + // Mutation: keeping a run whose replay diverged would put a trail + // nobody played into the heatmap. + final server = _server(); + final honest = _record(stick: 1.0); + final forged = Demo( + level: honest.level, + levelHash: honest.levelHash, + start: honest.start, + tape: InputTape( + seed: 1, + frames: [ + ...honest.tape.frames.take(30), + const InputFrame(stickX: -1.0), + ...honest.tape.frames.skip(31), + ], + ), + buildStamp: honest.buildStamp, + checkpoints: honest.checkpoints, + ); + + final (status, answer) = await _call( + server, + 'POST', + '/runs', + _upload(forged), + ); + + expect(status, 422); + expect(answer['says'], contains('parts from the run at step 40')); + expect(answer['says'], contains('nothing was kept')); + expect(server.store.rows, isEmpty); + }); + + test('a server with no game says so rather than taking runs', () async { + final server = _server(games: const {}); + final (status, answer) = await _call( + server, + 'POST', + '/runs', + _upload(_record(stick: 1.0)), + ); + expect(status, 503); + expect(answer['says'], contains('no game')); + }); + + test('a heatmap names its level and a cell it can draw', () async { + final server = _server(); + final (none, _) = await _call(server, 'GET', '/heatmap'); + expect(none, 400); + final (bad, answer) = await _call(server, 'GET', '/heatmap?level=a&cell=0'); + expect(bad, 400); + expect(answer['says'], contains('"0"')); + final (empty, map) = await _call(server, 'GET', '/heatmap?level=a'); + expect(empty, 200); + expect(Heatmap.fromJson(map).runs, 0); + }); + + test('levels are read from a directory by digest, and a broken file is ' + 'named rather than stopping the rest', () { + final folder = Directory.systemTemp.createTempSync('telemetry_levels'); + addTearDown(() => folder.deleteSync(recursive: true)); + File( + '${folder.path}/strip.json', + ).writeAsStringSync(jsonEncode(_level().toJson())); + File('${folder.path}/broken.json').writeAsStringSync('{'); + + final (levels, problems) = readTelemetryLevels(folder.path); + + expect(levels.keys, [_level().digestHex]); + expect(problems.single, contains('broken.json')); + }); +} diff --git a/doc/plan-status.json b/doc/plan-status.json index 5e4aba497..076b54c66 100644 --- a/doc/plan-status.json +++ b/doc/plan-status.json @@ -537,9 +537,6 @@ "anim-18": { "RetargetClip": "регистр из текста строки, а не имя символа: функция называется retargetClip (строчная r) в packages/flutter3d_model_core/lib/src/retarget_clip.dart, уже существовала до S7 и используется без изменений экраном 14 (RetargetClipJobRequest в modeler_cubit.dart/retarget_wiring.dart строит задание поверх неё же). Формулировка плана устарела в регистре первой буквы, а не в существовании функции" }, - "ui-34d": { - "postMessage": "имя браузерного API, а не символа в дереве: строка называет `TransferableTypedData`/`postMessage` как механизм воркера, который по её же собственному тексту не строился — оба измерения p0-07/p0-08 не сработали. В дереве это слово встречалось только в копиях flutter_soloud's own worker.dart.js под build/unit_test_assets, которые verify_plan пропускает; в свежем checkout (2026-09-14, ветка modeler-ui) его нет нигде, и это правильно" - }, "pro-sim-01": { "flutter3d_cloth": "the row's own history: the package existed under this name until doc/package-merge-plan.md §3.1 folded it into flutter3d_physics, and the row says so in the same sentence — the name is cited as what it used to be called, not as a claim that it still exists" }, diff --git a/doc/postprocessing-parity.md b/doc/postprocessing-parity.md index f81ff2e1e..d9ec7671f 100644 --- a/doc/postprocessing-parity.md +++ b/doc/postprocessing-parity.md @@ -73,9 +73,9 @@ transcription, and a golden scene". That was written from the survey and is wrong by three. **A post effect costs two hand-written implementations**: the GLSL source in `flutter3d_shaders`, and its Dart transcription for the CPU rasteriser, which has no shaders at all. WebGL and WebGPU are not transpiled -by hand — `flutter3d_webgl/lib/src/glsl_translate.dart` and -`flutter3d_webgpu/lib/src/glsl_to_wgsl.dart` are general translators driven by -`tool/generate_shaders.dart`, and a new uniform or a new branch goes through +by hand — `flutter3d_shaders/lib/src/glsl_translate.dart` and +`flutter3d_shaders/lib/src/glsl_to_wgsl.dart` are general translators driven by +each backend's `tool/generate_shaders.dart`, and a new uniform or a new branch goes through them untouched. Measured rather than assumed, while landing `gfx-26n`: editing diff --git a/doc/upstream.md b/doc/upstream.md index fdf05a805..39d08233d 100644 --- a/doc/upstream.md +++ b/doc/upstream.md @@ -69,7 +69,7 @@ a thin-lens circle-of-confusion needs. Every post row keeps reading answer attached. **`gfx-58n` landed 2026-09-18, as a rule rather than a paragraph.** "The -surface buffer keeps carrying depth in metres" is one of the thirty-five +surface buffer keeps carrying depth in metres" is one of the thirty-six checks `tool/structure.dart` runs: it fails if `lib/color.glsl` stops writing `ViewDepth()` into the channel, and it fails if any shader declares a depth sampler. A rule can be read by somebody who never opened this document, which diff --git a/packages/education/chemlab/.gitignore b/packages/education/chemlab/.gitignore new file mode 100644 index 000000000..79f7ecabd --- /dev/null +++ b/packages/education/chemlab/.gitignore @@ -0,0 +1,48 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ +/coverage/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release + +# Widget Preview related +.widget_preview/ diff --git a/packages/education/chemlab/.metadata b/packages/education/chemlab/.metadata new file mode 100644 index 000000000..b15f453e2 --- /dev/null +++ b/packages/education/chemlab/.metadata @@ -0,0 +1,30 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "4cf24164269a5ebf0c16a028a00727d0e77bbb05" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: 4cf24164269a5ebf0c16a028a00727d0e77bbb05 + base_revision: 4cf24164269a5ebf0c16a028a00727d0e77bbb05 + - platform: macos + create_revision: 4cf24164269a5ebf0c16a028a00727d0e77bbb05 + base_revision: 4cf24164269a5ebf0c16a028a00727d0e77bbb05 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/packages/education/chemlab/CHANGELOG.md b/packages/education/chemlab/CHANGELOG.md new file mode 100644 index 000000000..1b3be3535 --- /dev/null +++ b/packages/education/chemlab/CHANGELOG.md @@ -0,0 +1,10 @@ +## Unreleased + +- **A flask leant and topped up no longer floods the bench.** The glass + turns towards its lean at a hand's pace (`Vessel.aimTilt`, followed by + `Bench.step`) rather than at once, which had laid the liquid's old level + out as a wave that emptied it at a lean holding all of it; and `pour` + fills a leaning glass only to what it holds as it leans. With + `flutter3d_physics`' bounded drops, breaking waves and an overfull glass + spilling over its edge, the stream, the drops in the air and the frame of + three and a half seconds are gone. diff --git a/packages/education/chemlab/README.md b/packages/education/chemlab/README.md index aa37c3197..4da9e4d1d 100644 --- a/packages/education/chemlab/README.md +++ b/packages/education/chemlab/README.md @@ -29,8 +29,10 @@ through with water's index of refraction and tints it, so a thicker layer is deeper in colour. Pouring is a new profile with a different level (`Bench.pour`). -The caustics are spot lights, one per vessel, tinted with its solution and -aimed at its liquid's shadow; light channels keep them on the bench. The +The shadows and caustics are geometric optics (`optics.dart`): each +horizontal cut of a vessel is a set of circles, sunbeams are followed through +them by Snell, Fresnel and Beer–Lambert and counted where they land, and the +result is painted on a shadow-only card on the bench. The reflections in the tabletop are the liquids and labels mirrored under a top that lets a little of them through. Refraction is the engine's own: glass and liquid read a copy of the opaque scene, offset by their index. @@ -62,7 +64,9 @@ Three things to know when doing the same: ## Tests `flutter test` checks the profiles, that the label strip's UVs run edge to -edge, the half turn, that pouring stays inside the glass, and a picture of the -bench drawn by the software backend with no GPU. The golden leaves the labels +edge, the half turn, that filling stops at the glass's limit, that sharing +pours half and loses nothing, that mixed dyes take the colour light through +both would, and a picture of the bench drawn by the software backend with no +GPU. The liquid itself is `flutter3d_physics`'s and is tested there. The golden leaves the labels off, since text is rasterised by the platform and differs between machines; the label test checks the card's own pixels instead. diff --git a/packages/education/chemlab/lib/chemlab.dart b/packages/education/chemlab/lib/chemlab.dart index b0f393a10..6de50fead 100644 --- a/packages/education/chemlab/lib/chemlab.dart +++ b/packages/education/chemlab/lib/chemlab.dart @@ -7,3 +7,4 @@ library; export 'src/bench.dart'; export 'src/glassware.dart'; export 'src/label.dart'; +export 'src/optics.dart'; diff --git a/packages/education/chemlab/lib/main.dart b/packages/education/chemlab/lib/main.dart index e726af35f..4ce2f3dcf 100644 --- a/packages/education/chemlab/lib/main.dart +++ b/packages/education/chemlab/lib/main.dart @@ -3,13 +3,14 @@ /// flutter run -d chrome /// flutter run -d macos /// -/// Glass turned from profiles, labels typeset in TeX and wrapped on, and a -/// slider that pours. Drag to turn round the bench, scroll to come closer, -/// pick a vessel and move the slider to fill or empty it. +/// Glass turned from profiles, labels typeset in TeX and wrapped on, and +/// liquid that keeps level and sloshes. Drag to turn round the bench, scroll +/// to come closer, pick a vessel and pour, tilt or tap it. library; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart' hide Material; +import 'package:flutter/scheduler.dart'; import 'package:flutter3d/flutter3d.dart'; import 'package:flutter3d_app/flutter3d_app.dart'; import 'package:vector_math/vector_math.dart' hide Colors; @@ -42,7 +43,8 @@ class BenchScreen extends StatefulWidget { State createState() => _BenchScreenState(); } -class _BenchScreenState extends State { +class _BenchScreenState extends State + with SingleTickerProviderStateMixin { final CameraNode _camera = CameraNode(name: 'eye'); late final RenderView _view = RenderView( camera: _camera, @@ -52,12 +54,13 @@ class _BenchScreenState extends State { // own limits let the camera go under the table and out of the room. late final OrbitController _orbit = OrbitController( _camera, - target: Vector3(0.1, 0.34, 0), - distance: 2.7, + // Life size: a bench of test tubes sixteen millimetres across. + target: Vector3(0.01, 0.034, 0), + distance: 0.27, yaw: 0.0, pitch: 0.2, - minDistance: 0.6, - maxDistance: 6.0, + minDistance: 0.06, + maxDistance: 0.6, )..rotateSensitivity = 0.005; static const double _lowestPitch = 0.05; @@ -77,6 +80,42 @@ class _BenchScreenState extends State { Object? _error; int _selected = 1; + /// Runs while a liquid moves, and stops when every one is still. + late final Ticker _ticker = createTicker(_tick); + Duration _last = Duration.zero; + + void _tick(Duration elapsed) { + final bench = _ready?.bench; + // A frame late is a frame long: held to a thirtieth, so a stall does not + // throw the liquid out of its glass. + final seconds = ((elapsed - _last).inMicroseconds / 1e6).clamp(0.0, 1 / 30); + _last = elapsed; + if (bench == null) return; + final moving = bench.step(seconds); + setState(() {}); + if (!moving) _ticker.stop(); + } + + /// The way the camera looks, which a vessel leans about so that it leans + /// in the picture rather than towards the eye. + Vector3 _lookingAlong() { + final m = _camera.worldMatrix; + return Vector3(-m.entry(0, 2), 0, -m.entry(2, 2)); + } + + /// Something has set a liquid moving: run the clock if it is not running. + void _stir() { + if (_ticker.isActive) return; + _last = Duration.zero; + _ticker.start(); + } + + @override + void dispose() { + _ticker.dispose(); + super.dispose(); + } + @override void initState() { super.initState(); @@ -87,7 +126,9 @@ class _BenchScreenState extends State { try { final device = await openDevice(width: 1280, height: 720); final renderer = Renderer.create(device: device); - final bench = Bench(device)..retire = renderer.releaseMeshAfterFrame; + final bench = Bench(device, photons: true) + ..retire = renderer.releaseMeshAfterFrame + ..retireTexture = renderer.releaseTextureAfterFrame; bench.scene.add(_camera); if (!mounted) return; setState(() => _ready = (renderer: renderer, bench: bench)); @@ -150,7 +191,7 @@ class _BenchScreenState extends State { renderer: renderer, scene: bench.scene, view: _view, - settings: () => benchSettings, + settings: () => bench.settings, onBeforeFrame: () => _orbit.syncProjectionDepth(_camera), presentFrame: presentFrame, ), @@ -164,8 +205,33 @@ class _BenchScreenState extends State { bench: bench, selected: _selected, onSelect: (i) => setState(() => _selected = i), - onPour: (level) => - setState(() => bench.pour(bench.vessels[_selected], level)), + onPour: (level) { + setState(() => bench.pour(bench.vessels[_selected], level)); + _stir(); + }, + onLean: (angle) { + setState( + () => bench.lean( + bench.vessels[_selected], + angle, + across: _lookingAlong(), + ), + ); + _stir(); + }, + onTap: () { + bench.tap(bench.vessels[_selected]); + _stir(); + }, + onShare: () { + setState( + () => bench.share( + bench.vessels[_selected], + across: _lookingAlong(), + ), + ); + _stir(); + }, ), ), ], @@ -180,12 +246,20 @@ class _Controls extends StatelessWidget { required this.selected, required this.onSelect, required this.onPour, + required this.onLean, + required this.onTap, + required this.onShare, }); final Bench bench; final int selected; final ValueChanged onSelect; final ValueChanged onPour; + final ValueChanged onLean; + final VoidCallback onTap; + + /// Pours the picked vessel into the clean tube until both hold the same. + final VoidCallback onShare; @override Widget build(BuildContext context) { @@ -213,7 +287,7 @@ class _Controls extends StatelessWidget { label: Text(bench.vessels[i].name), selected: i == selected, avatar: CircleAvatar( - backgroundColor: bench.vessels[i].colour, + backgroundColor: bench.colour(bench.vessels[i]), ), onSelected: (_) => onSelect(i), ), @@ -226,10 +300,54 @@ class _Controls extends StatelessWidget { const Text('Pour'), Expanded( child: Slider( - value: vessel.level, + // Held to the slider's range: a pour tips and empties + // the vessel past what the controls offer, and a slider + // given a value outside its range throws. + value: vessel.level.clamp(vessel.lowest, vessel.highest), min: vessel.lowest, max: vessel.highest, - onChanged: onPour, + onChanged: bench.busy ? null : onPour, + ), + ), + const Text('Tilt'), + Expanded( + child: Slider( + // Where the hand is going, not where the glass is yet: + // it turns at a hand's pace, and a slider that followed + // it would pull back under the finger. + value: vessel.aimTilt.clamp( + -bench.maxLean(vessel), + bench.maxLean(vessel), + ), + min: -bench.maxLean(vessel), + max: bench.maxLean(vessel), + onChanged: bench.busy ? null : onLean, + ), + ), + ], + ), + // The buttons on a line of their own, which wraps: sharing one + // with the sliders squeezed them to nothing in a narrow window. + Wrap( + spacing: 6, + runSpacing: 4, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + ActionChip( + visualDensity: VisualDensity.compact, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + label: const Text('Tap'), + onPressed: onTap, + ), + Tooltip( + message: + 'Pour into the clean tube at the front until the two ' + 'hold the same', + child: ActionChip( + visualDensity: VisualDensity.compact, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + label: const Text('Share with clean tube'), + onPressed: bench.canShare(vessel) ? onShare : null, ), ), Text( diff --git a/packages/education/chemlab/lib/src/bench.dart b/packages/education/chemlab/lib/src/bench.dart index 4dfb0e37d..e30e3d973 100644 --- a/packages/education/chemlab/lib/src/bench.dart +++ b/packages/education/chemlab/lib/src/bench.dart @@ -1,9 +1,23 @@ +import 'dart:math' as math; + import 'package:flutter/painting.dart' show Color; import 'package:flutter3d/flutter3d.dart'; +import 'package:flutter3d_physics/flutter3d_physics.dart' + show + FluidMedium, + FluidWorld, + LiquidBody, + LiquidLayer, + PlaneObstacle, + RevolvedVessel, + VesselVolumes, + fallTime, + overCircularLip; import 'package:vector_math/vector_math.dart'; import 'glassware.dart'; import 'label.dart'; +import 'optics.dart'; /// One piece of glass on the bench and what is in it. final class Vessel { @@ -11,13 +25,12 @@ final class Vessel { required this.name, required this.at, required this.glass, - required this.liquidAt, - required this.colour, - required this.level, + required this.inside, + required this.dye, + required this.startLevel, required this.lowest, required this.highest, this.solution, - this.focuses = true, this.foot, }); @@ -27,50 +40,84 @@ final class Vessel { /// Where its base stands. final Vector3 at; - /// Its outline, swept into the glass. + /// Its outline, swept into the glass, and the inside the liquid fills. final List glass; + final List inside; /// A plastic foot it stands on, swept with six segments; null for none. final List? foot; - /// The liquid's outline when it is filled to a level. - final List Function(double level) liquidAt; - - final Color colour; + /// The colour of the solution it starts with, which is also the dye that + /// colours it: see [Bench.colourOf]. White for none. + final Color dye; - /// How far it may be filled, in metres up from its base. + /// How full it starts, and how far it may be filled, in metres up from + /// its base. + final double startLevel; final double lowest; final double highest; /// What its label says; null for glass that wears none. final Solution? solution; - /// Whether its liquid throws a caustic on the bench. **The engine packs at - /// most eight lights per object**, and the bench already has the sun and - /// a fill: a ninth light pushes one out, and the sun's shadow went with - /// it. Six caustics fit; the cylinder's narrow column and the flask go - /// without. - final bool focuses; + /// How far it leans, in radians, and about which horizontal axis. State. + double tilt = 0.0; + Vector3 leanAxis = Vector3(1, 0, 0); - /// How full it is now. State: pouring changes it. - double level; + /// How far it was asked to lean: [Bench.step] turns [tilt] towards it at + /// a hand's pace rather than at once. + double aimTilt = 0.0; - late final MeshNode liquid; - MeshNode? label; + /// How far it is lifted out of the row to lean, from nought, standing in + /// its place, to one, clear of the other glass. State: [Bench.step] moves + /// it. + double lift = 0.0; + + /// The inside as the liquid sees it. + late final RevolvedVessel shape = RevolvedVessel(inside); + + /// What is in it, as the physics has it. + late final LiquidBody liquid; + + /// How full it is now, upright. + double get level => + liquid.volume > 0.0 ? shape.levelFor(liquid.volume) : shape.floor; - /// Its liquid and its label mirrored under the tabletop; see [Bench]. - late final MeshNode liquidReflection; + /// The lowest point of the mouth once the glass leans towards the front: + /// the edge of the inside, on the side it leans to. + Vector3 get lip => Vector3(0, inside.last.y, inside.last.x); + + /// The glass and all it carries, which tilts as one; and the same hung + /// upside down under the tabletop, for the reflections. + late final SceneNode body; + late final SceneNode mirror; + + /// One node per layer of liquid, and its reflection. + final List<({MeshNode node, MeshNode reflection, Color colour})> layers = []; + + MeshNode? label; MeshNode? labelReflection; - /// The bright patch its liquid focuses onto the bench, if it [focuses]; - /// see [Bench]. - LightNode? caustic; + /// The card on the bench that carries where its light went; see [Bench]. + late final MeshNode shadow; + + /// Its glass, as drawn. + late final MeshNode glassNode; + + /// What the liquid was drawn as: redrawn when any of it moves. + double _drawnVolume = -1.0; + double _drawnTilt = double.nan; + double _drawnLift = double.nan; } Vector3 _rgb(Color c) => Vector3(c.r, c.g, c.b); -/// Five labelled test tubes, a graduated cylinder, a beaker and an -/// Erlenmeyer flask, each holding a solution of its own colour. +/// The name of the empty tube at the front of the bench, which solutions +/// are poured into. +const String cleanTube = 'Clean tube'; + +/// Five labelled test tubes, a clean one, a graduated cylinder, a beaker and +/// an Erlenmeyer flask, each holding a solution of its own colour. List standardVessels() { final solutions = [ ( @@ -108,116 +155,138 @@ List standardVessels() { for (var i = 0; i < solutions.length; i++) Vessel( name: solutions[i].tex.replaceAll(RegExp(r'\\mathrm|[{}_]'), ''), - at: Vector3(-0.44 + i * 0.22, 0, 0.15), + at: Vector3(-0.044 + i * 0.022, 0, 0.015), glass: tubeProfile(), - liquidAt: tubeLiquidProfile, - colour: solutions[i].colour, - level: 0.36 + 0.05 * (i % 3), - lowest: 0.03, - highest: 0.8, + inside: tubeInside(), + dye: solutions[i].colour, + startLevel: 0.036 + 0.005 * (i % 3), + lowest: 0.003, + highest: 0.08, solution: solutions[i], ), + Vessel( + name: cleanTube, + at: Vector3(0.011, 0, 0.062), + glass: tubeProfile(), + inside: tubeInside(), + dye: const Color(0xFFFFFFFF), + startLevel: 0.0, + lowest: 0.0, + highest: 0.08, + ), Vessel( name: 'Cylinder', - focuses: false, - at: Vector3(-1.05, 0, -0.25), + at: Vector3(-0.105, 0, -0.025), glass: cylinderProfile(), foot: cylinderFoot(), - liquidAt: (level) => flatLiquidProfile(0.051, 0.034, level), - colour: const Color(0xFFF2D91A), - level: 0.62, - lowest: 0.05, - highest: 0.84, + inside: cylinderInside(), + dye: const Color(0xFFF2D91A), + startLevel: 0.062, + lowest: 0.005, + highest: 0.084, ), Vessel( name: 'Beaker', - at: Vector3(0.78, 0, -0.15), + at: Vector3(0.078, 0, -0.015), glass: beakerProfile(), - liquidAt: (level) => flatLiquidProfile(0.192, 0.004, level), - colour: const Color(0xFFF24D8C), - level: 0.27, - lowest: 0.02, - highest: 0.4, + inside: beakerInside(), + dye: const Color(0xFFF24D8C), + startLevel: 0.027, + lowest: 0.002, + highest: 0.04, ), Vessel( name: 'Flask', - focuses: false, - at: Vector3(1.32, 0, -0.45), + at: Vector3(0.132, 0, -0.045), glass: flaskProfile(), - liquidAt: flaskLiquidProfile, - colour: const Color(0xFFB35214), - level: 0.22, - lowest: 0.03, - highest: 0.4, + inside: flaskInside(), + dye: const Color(0xFFB35214), + startLevel: 0.022, + lowest: 0.003, + highest: 0.04, ), ]; } /// The bench: a table, the glass, the liquids, the labels and the light. /// -/// Each vessel is two lathes, its liquid drawn first and its glass after it -/// so the blended glass lies over what it holds. A tube also gets a label, a -/// third lathe just outside the glass, once [dress] has its picture. +/// **What is in the glass is physics** (`flutter3d_physics`): each vessel's +/// liquid is a `LiquidBody`, and the bench is one `FluidWorld` under Earth's +/// gravity, stepped at a fixed step — so a surface keeps level and rocks +/// when the glass is turned, a tap starts rings, liquid tipped far enough +/// runs over the lip as a stream that falls, thins, runs down the glass it +/// meets and lands, and what lands mixes. This class draws it +/// (`liquidMeshes`, `jetMesh`, `particleMesh`) and decides what the hand +/// does; the liquid decides the rest. +/// +/// **A solution's colour is its dyes'.** Each starting solution is a dye at +/// unit concentration whose absorbance is what makes [Vessel.dye] its colour +/// over [fade] of it, by Beer and Lambert; poured together, concentrations +/// add by volume (the physics keeps them) and so do absorbances, so a +/// mixture is the colour light through both would be. /// -/// **The caustics are a light, not a calculation.** A column of liquid is a -/// lens, and sunlight through it lands as a bright, tinted patch inside its -/// own shadow. The engine traces no photons, so each vessel carries a small -/// spot light of its solution's colour, aimed at where its liquid's shadow -/// falls and shining on the bench alone (light channel 1): the glass and the -/// liquids stay on channel 0 and are not lit by it. Pouring moves the patch -/// and makes it stronger or weaker with the height of the column. +/// **The shadows are worked out, not guessed.** A vessel is a surface of +/// revolution, so the sunlight through it can be followed by geometric +/// optics in each horizontal cut (see `optics.dart`) and painted on a card +/// on the bench along its shadow; or, with [photons], the engine follows +/// photons through the liquid instead. /// -/// **The reflections in the tabletop are geometry too.** Screen-space -/// reflections read only what the opaque pass drew, and the glass and the -/// liquids are drawn after it, so the tabletop reflected the labels and -/// nothing else. The table is flat, though, and a flat mirror has an exact -/// answer: each liquid and label again, scaled by -1 in height so it hangs -/// under the top, and a top that lets a twelfth of what is under it through. -/// The copies are opaque and unlit, so they are drawn before the top and -/// never sorted against it. +/// **The reflections in the tabletop are geometry too**: each liquid and +/// label again, scaled by −1 in height so it hangs under the top, laid over +/// it at a twelfth of its strength and only where it is behind it. + final class Bench { - Bench(this.device, {List? vessels}) - : vessels = vessels ?? standardVessels() { + Bench(this.device, {List? vessels, bool photons = false}) + : vessels = vessels ?? standardVessels(), + _photons = photons { for (final vessel in this.vessels) { - vessel.liquid = + vessel + ..body = (SceneNode(name: vessel.name)..setPositionFrom(vessel.at)) + ..mirror = (SceneNode(name: '${vessel.name} reflection') + ..setPositionFrom(vessel.at) + ..setScale(1, -1, 1)); + scene + ..add(vessel.body) + ..add(vessel.mirror); + final white = vessel.dye.toARGB32() == 0xFFFFFFFF; + vessel.liquid = LiquidBody( + shape: vessel.shape, + medium: FluidMedium.water, + volume: vessel.startLevel > vessel.shape.floor + ? vessel.shape.volumeUpTo(vessel.startLevel) + : 0.0, + concentrations: white ? const {} : {vessel.name: 1.0}, + modes: 8, + surfaceCells: 24, + wallThickness: glassThickness, + ); + if (!white) _dyes[vessel.name] = _absorbance(vessel.dye); + world.bodies.add(vessel.liquid); + vessel.shadow = MeshNode( - _lathe(vessel.liquidAt(vessel.level)), - liquid(_rgb(vessel.colour)), - name: '${vessel.name} liquid', + _card(vessel), + Material( + name: 'shadow card', + lighting: LightingModel.pbrLayered, + baseColor: Vector4(_lightScale, _lightScale, _lightScale, 1.0), + albedo: uploadRgba8(device, _picture(vessel)), + extensions: MaterialExtensions(transmission: 1.0, ior: 1.0), + ), + name: '${vessel.name} shadow', ) ..setPositionFrom(vessel.at) - ..lightChannels = _vesselChannel; - if (vessel.focuses) { - final caustic = vessel.caustic = LightNode( - type: LightType.spot, - name: '${vessel.name} caustic', - color: _rgb(vessel.colour) * 0.8 + Vector3.all(0.2), - outerConeAngle: 0.12, - innerConeAngle: 0.02, - )..channels = _benchChannel; - scene.add(caustic); - _focus(vessel); - } - vessel.liquidReflection = _mirrored( - vessel.liquid.mesh, - _reflection(albedo: null, colour: _rgb(vessel.colour)), - vessel, - ); - scene - ..add(vessel.liquid) - ..add(vessel.liquidReflection); - scene.add( - MeshNode( - _lathe(glassWall(vessel.glass)), - glass(), - name: '${vessel.name} glass', - ) - // Clear glass barely darkens what is behind it, and the engine's - // shadows are all or nothing, so the glass casts none and the - // coloured liquid inside it casts the shadow. - ..castsShadow = false - ..lightChannels = _vesselChannel - ..setPositionFrom(vessel.at), + ..shadowCasting = ShadowCastingMode.shadowsOnly; + if (!photons) scene.add(vessel.shadow); + vessel.body.add( + vessel.glassNode = + MeshNode( + _lathe(glassWall(vessel.glass)), + glass(), + name: '${vessel.name} glass', + ) + ..castsShadow = photons + ..receivesTranslucentShadows = false + ..lightChannels = _vesselChannel, ); final foot = vessel.foot; if (foot != null) { @@ -225,52 +294,55 @@ final class Bench { device, LatheShape(profile: foot, segments: 6).build(), ); - scene - ..add( - MeshNode(hexagon, footPlastic(), name: '${vessel.name} foot') - ..lightChannels = _vesselChannel - ..setPositionFrom(vessel.at), - ) - ..add( - _mirrored( - hexagon, - _reflection(albedo: null, colour: Vector3(0.2, 0.42, 0.8)), - vessel, - ), - ); + vessel.body.add( + MeshNode(hexagon, footPlastic(), name: '${vessel.name} foot') + ..lightChannels = _vesselChannel, + ); + vessel.mirror.add( + _mirrored( + hexagon, + _reflection(albedo: null, colour: Vector3(0.2, 0.42, 0.8)), + vessel, + ), + ); } + _place(vessel); + vessel.liquid.place(_turnOf(vessel), vessel.body.readPosition()); + _redraw(vessel); } final environment = EnvironmentMap.fromSky(device, labSky); if (environment != null) { scene ..environment = environment.texture ..environmentLevels = environment.levels - ..ambientIntensity = 1.0; + // The sun carries most of the light, as through a lab window: the + // coloured shadows tint only the sun's share, and under a sky as + // bright as the sun they were barely there. + ..ambientIntensity = 0.55; } scene ..add( MeshNode( DeviceMesh.upload( device, - CuboidShape(size: Vector3(6, 0.04, 4.4)).build(), + CuboidShape(size: Vector3(0.6, 0.004, 0.44)).build(), ), Material( name: 'bench', - baseColor: Vector4(0.2, 0.22, 0.25, 0.92), - alphaMode: MaterialAlphaMode.blend, - // Glossy enough for the screen-space reflections, which fade - // out by 0.25: the liquids show in the tabletop. + baseColor: Vector4(0.46, 0.45, 0.43, 1.0), roughness: 0.25, ), name: 'bench', ) - ..setPosition(0, -0.02, 0.6) - // Its own channel: the sun reaches every channel, the caustics - // only this one, and the fill only the glass. + ..setPosition(0, -0.002, 0.06) + // A floor casts nothing. + ..castsShadow = false + // Its own channel: the sun reaches every channel, and the fill + // only the glass. ..lightChannels = _benchChannel, ) ..add( - LightNode(name: 'sun', intensity: 2.4) + LightNode(name: 'sun', intensity: 2.6) ..setLocalForward(Vector3(-0.5, -1.0, -0.6)), ) ..add( @@ -285,6 +357,30 @@ final class Bench { final GraphicsDevice device; final Scene scene = Scene(); + + /// The liquids, under Earth's gravity, over the bench's top. + final FluidWorld world = FluidWorld( + gravity: Vector3(0, -9.81, 0), + particleSpacing: 0.001, + floor: [PlaneObstacle(normal: Vector3(0, 1, 0), offset: 0.0)], + ); + + /// Whether the shadows come from the engine's caustics + /// (`ShadowSettings.caustics`, drawn with [settings]) rather than from the + /// cards this bench works out itself. + bool get photons => _photons; + bool _photons; + + set photons(bool value) { + if (value == _photons) return; + _photons = value; + for (final vessel in vessels) { + _castBy(vessel); + } + } + + /// How this bench is drawn. + RenderSettings get settings => photons ? photonSettings : benchSettings; final List vessels; /// How a replaced mesh is let go of: after the frames still drawing it @@ -292,36 +388,750 @@ final class Bench { /// when there is none. void Function(DeviceMesh mesh)? retire; + /// How a replaced shadow picture is let go of: after the frames still + /// sampling it when there is a renderer + /// (`Renderer.releaseTextureAfterFrame`), and kept when there is none. + void Function(TextureHandle texture)? retireTexture; + void _releaseNow(DeviceMesh mesh) => device ..releaseGeometry(mesh.vertices) ..releaseGeometry(mesh.indices); + void _swap(MeshNode node, MeshData data) { + final old = node.mesh; + node.mesh = _upload(data); + if (old is DeviceMesh) (retire ?? _releaseNow)(old); + } + + /// [data] on the device, or, when it has nothing in it, one triangle too + /// small to see: Metal makes no buffer of no bytes, and a stream with no + /// parcels yet, or drops before the first, threw on every frame of a pour + /// and stopped it there. The software device takes an empty buffer, so the + /// tests never saw it. + DeviceMesh _upload(MeshData data) { + if (data.vertexCount > 0 && data.indexCount > 0) { + return DeviceMesh.upload(device, data); + } + final tiny = MeshBuilder(VertexLayout.standard); + final up = Vector3(0, 1, 0); + for (final p in [ + Vector3.zero(), + Vector3(1e-6, 0, 0), + Vector3(0, 0, 1e-6), + ]) { + tiny.addVertex(position: p, normal: up); + } + tiny.addTriangle(0, 1, 2); + return DeviceMesh.upload(device, tiny.build()); + } + DeviceMesh _lathe(List profile) => DeviceMesh.upload( device, LatheShape(profile: profile, segments: 64).build(), ); - /// Fills [vessel] to [level]: a new profile, cut higher or lower, and - /// nothing else. + // --------------------------------------------------------------- colour + + /// Each dye's absorbance per metre at unit concentration: what makes it + /// its colour over [fade] of it. + final Map _dyes = {}; + + static Vector3 _absorbance(Color colour) { + double mu(double c) => -math.log(c.clamp(1e-3, 1.0)) / fade; + return Vector3(mu(colour.r), mu(colour.g), mu(colour.b)); + } + + /// The colour of [layer]: light through [fade] of it, every dye in it + /// taking its share by Beer and Lambert. + Color colourOf(LiquidLayer layer) { + final absorb = Vector3.zero(); + layer.concentrations.forEach((dye, c) { + final mu = _dyes[dye]; + if (mu != null) absorb.addScaled(mu, c); + }); + double through(double mu) => math.exp(-mu * fade); + return Color.from( + alpha: 1.0, + red: through(absorb.x), + green: through(absorb.y), + blue: through(absorb.z), + ); + } + + /// The colour of what [vessel] holds at its surface. + Color colour(Vessel vessel) { + final layers = vessel.liquid.layers; + return layers.isEmpty ? vessel.dye : colourOf(layers.last); + } + + // ------------------------------------------------------------- the hand + + /// Fills [vessel] to [level]: liquid poured in or drawn off, the same + /// solution it holds. void pour(Vessel vessel, double level) { - vessel.level = level.clamp(vessel.lowest, vessel.highest); - final old = vessel.liquid.mesh; - vessel.liquid.mesh = _lathe(vessel.liquidAt(vessel.level)); - vessel.liquidReflection.mesh = vessel.liquid.mesh; - if (old is DeviceMesh) (retire ?? _releaseNow)(old); - _focus(vessel); + if (busy) return; + // A leaning glass holds less than it does upright: filled to its upright + // level, the rest ran straight over its lip — a flask tipped to its + // furthest and then topped up poured a stream on the bench, and the + // drops it broke into flew. Never more than it holds as it leans. + final upright = vessel.shape.volumeUpTo( + level.clamp(vessel.lowest, vessel.highest), + ); + final target = vessel.tilt == 0.0 + ? upright + : math.min(upright, 0.98 * vessel.shape.holds(_upAt(vessel.tilt))); + final now = vessel.liquid.volume; + if (target > now) { + final layers = vessel.liquid.layers; + vessel.liquid.pour( + target - now, + concentrations: layers.isEmpty + ? (vessel.dye.toARGB32() == 0xFFFFFFFF + ? const {} + : {vessel.name: 1.0}) + : layers.last.concentrations, + ); + } else if (target < now) { + vessel.liquid.drain(now - target); + } + _redraw(vessel); + if (!photons && vessel.tilt == 0.0) _repaint(vessel); + } + + /// The furthest [vessel] may lean either way: a little short of where what + /// it holds reaches the lip and would run out on the bench. + double maxLean(Vessel vessel) => math.max( + 0.05, + math.min(1.45, _tiltHolding(vessel, vessel.liquid.volume) - 0.08), + ); + + /// How far [vessel] must be lifted to lean as it does without passing + /// through the glass standing round it. + /// + /// Walked up its axis: wherever the tipped glass is over another vessel's + /// footprint, its underside there has to clear that vessel's top by a + /// millimetre. A vessel leaning away from its neighbours is hardly lifted; + /// one leaning over a row of tubes goes over their mouths. + double _clearance(Vessel vessel) { + if (vessel.tilt == 0.0) return 0.0; + final turn = Matrix3.zero() + ..setFrom( + Quaternion.axisAngle(vessel.leanAxis, vessel.tilt).asRotationMatrix(), + ); + final top = vessel.glass.map((p) => p.y).reduce(math.max); + final sideways = math.sin(vessel.tilt).abs(); + var lowest = double.infinity; + for (final p in [...vessel.glass, ...?vessel.foot]) { + for (var k = 0; k < 24; k++) { + final a = 2.0 * math.pi * k / 24; + lowest = math.min( + lowest, + turn + .transformed(Vector3(p.x * math.cos(a), p.y, p.x * math.sin(a))) + .y, + ); + } + } + var need = 0.0; + for (var i = 0; i <= 48; i++) { + final h = top * i / 48; + final r = radiusAt(vessel.glass, h) ?? 0.0; + final axis = turn.transformed(Vector3(0, h, 0)); + // Its underside here, standing on the bench before any lift. + final under = axis.y - lowest - r * sideways; + for (final other in vessels) { + if (identical(other, vessel) || other.tilt != 0.0) continue; + // Its footprint: the glass, or the foot it stands on if wider. + final reach = [ + ...other.glass, + ...?other.foot, + ].map((p) => p.x).reduce(math.max); + final dx = vessel.at.x + axis.x - other.at.x; + final dz = vessel.at.z + axis.z - other.at.z; + if (math.sqrt(dx * dx + dz * dz) > reach + r + 0.001) continue; + final otherTop = other.glass.map((p) => p.y).reduce(math.max); + need = math.max(need, otherTop + 0.001 - under); + } + } + return need; + } + + /// Leans [vessel] by [angle] radians about [across], a horizontal axis — + /// the way the camera looks, so it leans in the picture — after lifting + /// it out of the row ([step] lifts and lowers it). The glass turns at + /// once; the liquid keeps level and follows late, as liquid does. + void lean(Vessel vessel, double angle, {Vector3? across}) { + if (busy) return; + final limit = maxLean(vessel); + vessel.aimTilt = angle.clamp(-limit, limit); + if (across != null && vessel.tilt == 0.0) { + final flat = Vector3(across.x, 0, across.z); + if (flat.length2 > 1e-9) vessel.leanAxis = flat..normalize(); + } + // One vessel in the hand: leaning this one puts the last one back. + if (vessel.aimTilt != 0.0) { + for (final other in vessels) { + if (identical(other, vessel)) continue; + other.aimTilt = 0.0; + } + } + } + + /// How fast a hand turns a glass, radians a second: the pour's own pace. + /// + /// **Never at once.** Turned sixty-seven degrees in a frame, a flask's + /// liquid was laid out with its old level as a wave far past any water + /// makes, and most of it went over the lip at a lean that holds all of it + /// standing still. Turned at a hand's pace, the level follows the felt + /// gravity, and only the turn's own acceleration sets it rocking. + static const double _turnRate = 2.0; + + /// Puts [vessel]'s glass where its lean and lift say: turned about its + /// lean axis, raised by its lift, its lowest point on the bench when the + /// lift is nought. + void _place(Vessel vessel) { + final turn = Quaternion.axisAngle(vessel.leanAxis, vessel.tilt); + // The lowest point of the glass, and of its foot, once turned. + var lowest = double.infinity; + for (final p in [...vessel.glass, ...?vessel.foot]) { + for (var k = 0; k < 24; k++) { + final a = 2.0 * math.pi * k / 24; + final turned = turn.rotated( + Vector3(p.x * math.cos(a), p.y, p.x * math.sin(a)), + ); + lowest = math.min(lowest, turned.y); + } + } + final lift = _ease(vessel.lift) * _clearance(vessel); + final y = vessel.at.y - lowest + lift; + vessel.body + ..setRotation(turn) + ..setPosition(vessel.at.x, y, vessel.at.z); + // The mirror of a turn about a horizontal axis is the opposite turn. + vessel.mirror + ..setRotation(Quaternion.axisAngle(vessel.leanAxis, -vessel.tilt)) + ..setPosition(vessel.at.x, -y, vessel.at.z); + } + + /// [vessel]'s turn as a matrix, for the physics. + static Matrix3 _turnOf(Vessel vessel) { + final m = vessel.body.localMatrix; + return Matrix3( + m.entry(0, 0), + m.entry(1, 0), + m.entry(2, 0), // + m.entry(0, 1), + m.entry(1, 1), + m.entry(2, 1), // + m.entry(0, 2), + m.entry(1, 2), + m.entry(2, 2), + ); + } + + /// Knocks on [vessel]'s glass: half a millimetre of ripple where the + /// knock lands on the surface. + void tap(Vessel vessel) => vessel.liquid.surface.knock( + vessel.liquid.up * vessel.liquid.height, + 0.0005, + ); + + // ------------------------------------------------------------ the pour + + /// Whether a pour is under way, during which the controls wait. + bool get busy => _transfer != null; + _Transfer? _transfer; + + /// The tube solutions are poured into. + Vessel get clean => vessels.firstWhere((v) => v.name == cleanTube); + + /// Whether [from] can be poured into the [clean] tube until the two hold + /// the same: it has more, and half of both fits. + bool canShare(Vessel from) { + // The clean tube stands upright to be poured into. + if (busy || identical(from, clean) || clean.tilt != 0.0) return false; + final mine = from.liquid.volume; + final theirs = clean.liquid.volume; + return mine > theirs + 1e-8 && + (mine + theirs) / 2.0 <= clean.shape.capacity; + } + + /// Pours [from] into the [clean] tube until the two hold the same: lifts + /// it over the bench, tips it over the tube's mouth as far as it takes to + /// pour that much and no faster, and puts it back. [step] plays it; the + /// liquid itself runs, falls and lands by the physics. + /// + /// It tips about [across], a horizontal axis — the way the camera looks, + /// so that the pour leans in the picture: tipped towards the eye, a tube + /// seen from the front only seemed to rise and shorten. + void share(Vessel from, {Vector3? across}) { + if (!canShare(from)) return; + final mine = from.liquid.volume; + final theirs = clean.liquid.volume; + final flat = across == null ? null : Vector3(across.x, 0, across.z); + final axis = flat != null && flat.length2 > 1e-9 + ? (flat..normalize()) + : Vector3(1, 0, 0); + from + ..tilt = 0.0 + ..aimTilt = 0.0 + ..lift = 0.0 + ..leanAxis = axis; + _transfer = _Transfer( + from: from, + to: clean, + start: mine, + goal: (mine + theirs) / 2.0, + axis: axis, + )..spill = _tiltHolding(from, mine); + _castBy(from); + } + + /// How long each part of a pour takes: up, over, and back; the pour + /// itself is as long as the hand takes, about two seconds. + static const double _rise = 0.45; + static const double _over = 1.2; + static const double _pourTime = 2.0; + static const double _back = 1.4; + + /// The world's up seen from glass leaning [tilt] towards its local +z: + /// what it holds is the same whichever way a round glass leans. + static Vector3 _upAt(double tilt) => + Vector3(0, math.cos(tilt), -math.sin(tilt)); + + /// The lean at which [vessel] holds just [volume]: past it, the rest runs + /// out. Holding less the further it leans, so found by halving. + static double _tiltHolding(Vessel vessel, double volume) { + var lo = 0.0; + var hi = 2.8; + for (var i = 0; i < 36; i++) { + final mid = 0.5 * (lo + hi); + if (vessel.shape.holds(_upAt(mid)) > volume) { + lo = mid; + } else { + hi = mid; + } + } + return 0.5 * (lo + hi); + } + + static double _ease(double t) { + final x = t.clamp(0.0, 1.0); + return x * x * (3.0 - 2.0 * x); + } + + /// Places [vessel]'s glass with its base at [base], leaning [tilt]. + void _pose(Vessel vessel, Vector3 base, double tilt) { + vessel.body + ..setRotation(Quaternion.axisAngle(vessel.leanAxis, tilt)) + ..setPositionFrom(base); + vessel.mirror + ..setRotation(Quaternion.axisAngle(vessel.leanAxis, -tilt)) + ..setPosition(base.x, -base.y, base.z); + } + + /// One frame of the hand's part in a pour: where the glass is, and how + /// far it leans. The hand tips further while less runs than it wants and + /// back while more does, wanting the root of what is left, so the pour + /// ends at the edge of pouring; the flow itself is the liquid's. + void _handStep(_Transfer t, double seconds) { + final from = t.from; + final to = t.to; + t.time += seconds; + final raised = from.at + Vector3(0, 0.1, 0); + // **Lip to lip, as a chemist pours.** The lip goes a millimetre over the + // clean tube's rim, back from the middle by half of how far the stream + // is carried forward while it falls to the surface, so it enters through + // the mouth and lands near the middle; never further back than the + // inside of the wall. + // + // **Planned once, and held.** Worked out from the flow the hand means + // to pour, not the flow it sees: answering the flow as it came, the aim + // jumped when the liquid first ran, the hand jerked the glass with it, + // and the jerk threw the rest out. + final aim = t.aim ??= () { + final rim = to.glass.map((p) => p.y).reduce(math.max); + final most = 1.5 * (t.start - t.goal) / _pourTime; + final over = overCircularLip( + flow: most, + radius: from.lip.z, + tilt: t.spill, + g: 9.81, + ); + final thrown = + Vector3(0, math.cos(t.spill), math.sin(t.spill)) * over.speed; + final drift = + thrown.z * + fallTime(thrown, to.at.y + rim + 0.001, to.at.y + to.level); + final back = math.min(0.5 * drift, to.lip.z - 0.001); + return to.at + Vector3(0, rim + 0.001, 0) - t.towards * back; + }(); + // The lip is the side of the mouth that goes down: towards the pour. + final lip = t.towards * from.lip.z + Vector3(0, from.lip.y, 0); + // Turned by the matrix the scene node will be: `Quaternion.rotated` + // turns the other way round, and the lip came down a hand's width past + // the clean tube. + Vector3 baseFor(double tilt) => + aim - + Quaternion.axisAngle(t.axis, tilt).asRotationMatrix().transformed(lip); + + var base = from.at.clone(); + var tilt = 0.0; + final time = t.time; + if (time < _rise) { + base = from.at + (raised - from.at) * _ease(time / _rise); + } else if (time < _rise + _over) { + final e = _ease((time - _rise) / _over); + tilt = t.spill * e; + base = raised + (baseFor(tilt) - raised) * e; + } else if (t.poured == null) { + final start = _rise + _over; + if (t.hand == 0.0) t.hand = t.spill; + final amount = t.start - t.goal; + final most = 1.5 * amount / _pourTime; + final remaining = math.max(from.liquid.volume - t.goal, 0.0); + final wanted = + most * + _ease((time - start) / 0.6) * + math.min(1.0, math.sqrt(remaining / (0.8 * most))); + // Ahead of the liquid, not behind it: tipped to where what the glass + // holds below its lip is what is in it less the next eighth of a + // second's worth, so that much stands over the lip to run. A hand + // that answered the flow it saw was always late, and a glass this + // small, nearly on its side, empties in the time it takes to notice. + final target = math.max( + _tiltHolding(from, from.liquid.volume - wanted * 0.12), + t.spill, + ); + t.hand += (target - t.hand).clamp(-2.0 * seconds, 2.0 * seconds); + tilt = t.hand; + if (remaining <= 1e-10 || time - start > 3.0 * _pourTime) { + t + ..poured = time + ..end = tilt; + } + base = baseFor(tilt); + } else if (time < t.poured! + _back) { + final s = (time - t.poured!) / _back; + if (s < 0.6) { + final e = _ease(s / 0.6); + tilt = t.end * (1.0 - e); + base = baseFor(tilt) + (raised - baseFor(tilt)) * e; + } else { + base = raised + (from.at - raised) * _ease((s - 0.6) / 0.4); + } + } else if (world.jets.isEmpty) { + _finish(t); + return; + } else { + // Put down; what was in the air is still landing. + tilt = 0.0; + base = from.at.clone(); + } + _pose(from, base, tilt); + from.tilt = tilt; + t.tilt = tilt; + } + + /// The pour is over: the glass back where it stood, upright. + void _finish(_Transfer t) { + final from = t.from; + _transfer = null; + _pose(from, from.at, 0.0); + from + ..tilt = 0.0 + ..aimTilt = 0.0; + for (final vessel in [from, t.to]) { + _castBy(vessel); + if (!photons) _repaint(vessel); + } + } + + // ---------------------------------------------------------------- step + + /// The streams drawn, one per pouring vessel, and the drops. + final Map _streams = {}; + final Map _drops = {}; + + /// Moves everything on by [seconds], and says whether anything still + /// moves, so the caller knows to ask for another frame. + bool step(double seconds) { + var moving = false; + for (final vessel in vessels) { + if (identical(vessel, _transfer?.from)) continue; + if (vessel.tilt != vessel.aimTilt) { + final most = _turnRate * seconds; + vessel.tilt += (vessel.aimTilt - vessel.tilt).clamp(-most, most); + _place(vessel); + _castBy(vessel); + moving = true; + } + // Lifted out of the row while it leans, put back when it does not. + final wanted = vessel.tilt != 0.0 ? 1.0 : 0.0; + if (vessel.lift != wanted) { + final change = seconds / 0.6; + vessel.lift = wanted > vessel.lift + ? math.min(vessel.lift + change, wanted) + : math.max(vessel.lift - change, wanted); + _place(vessel); + moving = true; + } + } + final transfer = _transfer; + final before = transfer?.from.liquid.volume; + if (transfer != null) { + _handStep(transfer, seconds); + moving = true; + } + // Where the glass is at the end of this frame, stamped with that time: + // the frame runs a whole number of the world's fixed steps, and a frame + // of one step after one of three must not read as a jolt. + final now = world.time + seconds; + for (final vessel in vessels) { + vessel.liquid.place( + _turnOf(vessel), + vessel.body.readPosition(), + time: now, + ); + } + world.advance(seconds); + if (transfer != null && before != null && seconds > 0.0) { + transfer.flow = + math.max(before - transfer.from.liquid.volume, 0.0) / seconds; + } + for (final vessel in vessels) { + final liquid = vessel.liquid; + final still = liquid.surface.settled; + if (!still) moving = true; + if (!still || + liquid.volume != vessel._drawnVolume || + vessel.tilt != vessel._drawnTilt || + vessel.lift != vessel._drawnLift) { + _redraw(vessel); + } + } + if (_drawStreams()) moving = true; + return moving; + } + + /// Draws [vessel]'s liquid as it is now, layer by layer. + void _redraw(Vessel vessel) { + vessel + .._drawnVolume = vessel.liquid.volume + .._drawnTilt = vessel.tilt + .._drawnLift = vessel.lift; + final meshes = vessel.liquid.volume > 0.0 + ? liquidMeshes(vessel.liquid) + : const []; + // As many nodes as layers. + while (vessel.layers.length > meshes.length) { + final gone = vessel.layers.removeLast(); + gone.node.removeFromParent(); + gone.reflection.removeFromParent(); + } + for (var i = 0; i < meshes.length; i++) { + final colour = colourOf(meshes[i].layer); + final depth = 2.0 * vessel.shape.radiusAt(vessel.level); + if (i == vessel.layers.length) { + final node = + MeshNode( + _upload(meshes[i].mesh), + liquid(_rgb(colour), depth: depth), + name: '${vessel.name} liquid', + ) + ..lightChannels = _vesselChannel + ..castsShadow = photons || vessel.tilt != 0.0 + ..receivesTranslucentShadows = false; + final reflection = _mirrored( + node.mesh, + _reflection(albedo: null, colour: _rgb(colour)), + vessel, + ); + vessel.body.add(node); + vessel.mirror.add(reflection); + vessel.layers.add((node: node, reflection: reflection, colour: colour)); + continue; + } + final layer = vessel.layers[i]; + _swap(layer.node, meshes[i].mesh); + layer.reflection.mesh = layer.node.mesh; + if (layer.colour != colour) { + layer.node.material = liquid(_rgb(colour), depth: depth); + layer.reflection.material = _reflection( + albedo: null, + colour: _rgb(colour), + ); + vessel.layers[i] = ( + node: layer.node, + reflection: layer.reflection, + colour: colour, + ); + } + } + } + + /// Draws every stream in the air and every drop off the glass; says + /// whether there are any. + bool _drawStreams() { + var any = false; + final pouring = world.jets; + for (final source in _streams.keys.toList()) { + if (!pouring.containsKey(source)) { + _streams.remove(source)!.removeFromParent(); + } + } + pouring.forEach((source, jet) { + any = true; + final mesh = jetMesh(jet); + final from = vessels.firstWhere((v) => identical(v.liquid, source)); + final node = _streams.putIfAbsent(source, () { + final made = + MeshNode( + _upload(mesh), + liquid(_rgb(colour(from)), depth: 0.002)..doubleSided = true, + name: 'stream', + ) + ..castsShadow = false + ..lightChannels = _vesselChannel; + scene.add(made); + return made; + }); + _swap(node, mesh); + node.visible = mesh.triangleCount > 0; + }); + world.particles.forEach((medium, fluid) { + final node = _drops.putIfAbsent(medium, () { + final made = MeshNode( + _upload(particleMesh(const [], 0.0005)), + liquid(Vector3(0.9, 0.95, 1.0), depth: 0.001), + name: 'drops', + )..castsShadow = false; + scene.add(made); + return made; + }); + if (fluid.count > 0) any = true; + _swap(node, particleMesh(fluid.positions, 0.5 * fluid.spacing)); + node.visible = fluid.count > 0; + }); + return any; + } + + // -------------------------------------------------------------- shadows + + /// Who casts [vessel]'s shadow: the engine's photons, or this bench's card, + /// which is worked out for glass standing upright and is put away while it + /// leans — the glass and liquid then cast for themselves, as tinted + /// translucent casters. + void _castBy(Vessel vessel) { + final card = + !photons && vessel.tilt == 0.0 && !identical(vessel, _transfer?.from); + for (final layer in vessel.layers) { + layer.node.castsShadow = !card; + } + vessel.glassNode.castsShadow = !card; + if (card) { + if (vessel.shadow.parent == null) { + _repaint(vessel); + scene.add(vessel.shadow); + } + } else if (vessel.shadow.parent != null) { + scene.remove(vessel.shadow); + } + } + + void _repaint(Vessel vessel) { + final material = vessel.shadow.material; + final picture = material.albedo; + material.albedo = uploadRgba8(device, _picture(vessel)); + if (picture != null) retireTexture?.call(picture); + } + + /// The most a card's picture can brighten the bench: light gathered past + /// this, at a focus, is held to it. + static const double _lightScale = 3.0; + + /// The sun's elevation above the bench, and the way its light crosses it. + static final double _elevation = math.asin(-_sun.y); + static final Vector3 _across = (Vector3(_sun.x, 0, _sun.z)..normalize()); + + /// How far either side of its axis a vessel's card reaches: the light a + /// liquid spreads past its focus lands well outside the glass. + static double _halfWidth(Vessel vessel) => + 3.0 * vessel.glass.map((p) => p.x).reduce(math.max); + + static double _height(Vessel vessel) => + vessel.glass.map((p) => p.y).reduce(math.max); + + /// [vessel]'s liquid as an outline for the optics: the inside up to its + /// level, closed flat across. + static List _liquidOutline(Vessel vessel) { + final level = vessel.level; + return [ + for (final p in vessel.inside) + if (p.y < level) p, + Vector2(vessel.shape.radiusAt(level), level), + Vector2(0, level), + ]; + } + + /// Where [vessel]'s light goes, as its card shows it. + Rgba8Image _picture(Vessel vessel) => shadowPicture( + glass: vessel.glass, + liquid: vessel.liquid.volume > 0.0 ? _liquidOutline(vessel) : null, + wall: glassThickness, + height: _height(vessel), + glassIndex: 1.5, + liquidIndex: 1.33, + liquidAbsorption: absorptionFor(_rgb(colour(vessel)), fade), + elevation: _elevation, + halfWidth: _halfWidth(vessel), + scale: _lightScale, + ); + + /// The card [vessel]'s light is painted on: a strip lying a hair above the + /// bench from under its axis out along the light's way, as long as its + /// tallest point throws a shadow, and as wide as [_halfWidth] either side. + /// Two coincident layers, because the stage counts a caster as crossed by + /// two surfaces and takes a square root for each. + DeviceMesh _card(Vessel vessel) { + final half = _halfWidth(vessel); + final length = _height(vessel) / math.tan(_elevation); + final side = Vector3(-_across.z, 0, _across.x); + final builder = MeshBuilder(VertexLayout.standard); + final normal = Vector3(0, 1, 0); + final tangent = Vector4(side.x, side.y, side.z, 1); + for (var layer = 0; layer < 2; layer++) { + final base = layer * 4; + for (final (u, v) in const [ + (0.0, 0.0), + (1.0, 0.0), + (1.0, 1.0), + (0.0, 1.0), + ]) { + final at = side * ((u * 2.0 - 1.0) * half) + _across * (v * length); + builder.addVertex( + position: Vector3(at.x, 0.00015, at.z), + normal: normal, + texcoord: Vector2(u, v), + tangent: tangent, + ); + } + builder.addQuad(base, base + 1, base + 2, base + 3); + } + return DeviceMesh.upload(device, builder.build()); } - /// [mesh] hung upside down under the tabletop at [vessel]'s place. + /// [mesh] for [vessel]'s mirror, which hangs it upside down under the + /// tabletop. MeshNode _mirrored(MeshGeometry mesh, Material material, Vessel vessel) => MeshNode(mesh, material, name: '${vessel.name} reflection') - ..setPositionFrom(vessel.at) - ..setScale(1, -1, 1) ..castsShadow = false ..lightChannels = LightChannels.none; /// What a reflection is drawn with: flat, a little darker than the /// thing, and from both sides, since the mirroring turns it inside out. + /// Blended at a twelfth over the top, behind which it hangs, and drawn + /// only there: nearer than the top is not in the mirror. static Material _reflection({ required TextureHandle? albedo, required Vector3 colour, @@ -329,8 +1139,11 @@ final class Bench { name: 'reflection', lighting: LightingModel.unlit, albedo: albedo, - baseColor: Vector4(colour.x * 0.7, colour.y * 0.7, colour.z * 0.7, 1), + baseColor: Vector4(colour.x * 0.7, colour.y * 0.7, colour.z * 0.7, 0.08), + alphaMode: MaterialAlphaMode.blend, doubleSided: true, + depthWrite: false, + depthCompare: CompareFunction.greater, ); static const int _vesselChannel = 1 << 0; @@ -339,20 +1152,6 @@ final class Bench { /// Where sunlight goes: the sun node's forward. static final Vector3 _sun = Vector3(-0.5, -1.0, -0.6)..normalize(); - /// Aims [vessel]'s caustic at the middle of its liquid's shadow, along - /// the sunlight from half a metre back, as strong as the column is tall. - void _focus(Vessel vessel) { - final caustic = vessel.caustic; - if (caustic == null) return; - final middle = (vessel.lowest + vessel.level) * 0.5; - final lit = vessel.at + Vector3(0, middle, 0) + _sun * (middle / -_sun.y); - final from = lit - _sun * 0.5; - caustic - ..intensity = 4.0 * (vessel.level - vessel.lowest).clamp(0.0, 1.0) - ..setPosition(from.x, from.y, from.z) - ..lookAt(lit); - } - /// Wraps [image] round [vessel] as its label, and returns the texture it /// uploaded. /// @@ -364,21 +1163,60 @@ final class Bench { TextureHandle? dress(Vessel vessel, Rgba8Image image) { if (vessel.solution == null) return null; final texture = uploadRgba8(device, image); - final old = vessel.label; - if (old != null) scene.remove(old); - final oldReflection = vessel.labelReflection; - if (oldReflection != null) scene.remove(oldReflection); + vessel.label?.removeFromParent(); + vessel.labelReflection?.removeFromParent(); final band = DeviceMesh.upload(device, labelBand().build()); vessel.labelReflection = _mirrored( band, _reflection(albedo: texture, colour: Vector3.all(1)), vessel, ); - scene.add(vessel.labelReflection!); + vessel.mirror.add(vessel.labelReflection!); vessel.label = MeshNode(band, paper(texture), name: '${vessel.name} label') - ..setPositionFrom(vessel.at) + ..receivesTranslucentShadows = false ..lightChannels = _vesselChannel; - scene.add(vessel.label!); + vessel.body.add(vessel.label!); return texture; } } + +/// A pour from one vessel into another, under way: the hand's part. The +/// liquid's part — what runs, the stream, what lands — is the physics'. +final class _Transfer { + _Transfer({ + required this.from, + required this.to, + required this.start, + required this.goal, + required this.axis, + }) : towards = axis.cross(Vector3(0, 1, 0))..normalize(); + + final Vessel from; + final Vessel to; + + /// The horizontal axis the glass tips about, and the way it pours: a turn + /// about [axis] brings up round to [towards]. + final Vector3 axis; + final Vector3 towards; + + /// What [from] held at the start, and what it is to hold at the end. + final double start; + final double goal; + + /// Seconds since the pour began. + double time = 0.0; + + /// The lean at which it starts to run, the furthest it went, and the lean + /// the hand holds; when the pour was done, null until it is. + double spill = 0.0; + double end = 0.0; + double hand = 0.0; + double? poured; + + /// How fast it ran on the last step, and how far it leaned. + double flow = 0.0; + double tilt = 0.0; + + /// Where the hand holds the lip: over the clean tube's mouth. + Vector3? aim; +} diff --git a/packages/education/chemlab/lib/src/glassware.dart b/packages/education/chemlab/lib/src/glassware.dart index 176165cd3..257a6f238 100644 --- a/packages/education/chemlab/lib/src/glassware.dart +++ b/packages/education/chemlab/lib/src/glassware.dart @@ -11,36 +11,33 @@ import 'package:vector_math/vector_math.dart'; /// top is what makes the normals face out. A repeated point is a hard edge, /// which is how a lip or a base gets a crisp rim instead of a rounded one. -/// The radius of a test tube's wall, which its label and liquid follow. -const double tubeRadius = 0.08; +/// The radius of a test tube's wall, which its label follows: sixteen +/// millimetres across, as a real one is. Everything here is in metres and +/// life size — the liquid in it is worked out in SI (`flutter3d_physics`), +/// and a bench ten times the size would pour like one. +const double tubeRadius = 0.008; + +/// How thick the glass is. +const double glassThickness = 0.0005; /// A test tube's outside: a hemispherical bottom, a straight wall and a /// slight flare at the mouth. [glassWall] gives it its thickness and rim. -List tubeProfile({double radius = tubeRadius, double height = 0.9}) => +List tubeProfile({double radius = tubeRadius, double height = 0.09}) => [ ..._roundBottom(radius), Vector2(radius, height), - Vector2(radius * 1.06, height + 0.01), + Vector2(radius * 1.06, height + 0.001), ]; -/// A test tube's liquid: the same bottom a little inside the glass, cut at -/// [level] and closed with a meniscus. -List tubeLiquidProfile(double level, {double radius = 0.074}) => - [..._roundBottom(radius), ...meniscus(radius, level)]; - -/// The top of a liquid standing at [level] in glass of [radius]: water wets -/// glass, so it climbs the wall a few millimetres and dips towards the -/// middle. A flat cap reads as a solid; the curve and the bright line it -/// catches where it meets the wall are what make it read as liquid. -List meniscus(double radius, double level, {double rise = 0.008}) => - [ - Vector2(radius, level + rise), - Vector2(radius, level + rise), - Vector2(radius * 0.88, level + rise * 0.4), - Vector2(radius * 0.66, level + rise * 0.12), - Vector2(radius * 0.33, level + rise * 0.02), - Vector2(0, level), - ]; +/// A test tube's inside: the same bottom a glass's thickness in, straight +/// up to the mouth. +List tubeInside({double radius = tubeRadius, double height = 0.09}) { + final r = radius - glassThickness; + return [ + for (final p in _roundBottom(r)) Vector2(p.x, p.y + glassThickness), + Vector2(r, height - 0.001), + ]; +} List _roundBottom(double radius) => [ for (var i = 0; i <= 8; i++) @@ -50,50 +47,61 @@ List _roundBottom(double radius) => [ ), ]; -/// A beaker: a flat base, a straight wall and a flared rim. +/// A beaker: a flat base, a straight wall and a flared rim; fifty +/// millilitres. List beakerProfile() => [ Vector2(0, 0), - Vector2(0.2, 0), - Vector2(0.2, 0), - Vector2(0.2, 0.42), - Vector2(0.218, 0.44), + Vector2(0.02, 0), + Vector2(0.02, 0), + Vector2(0.02, 0.042), + Vector2(0.0218, 0.044), +]; + +/// The beaker's inside. +List beakerInside() => [ + Vector2(0, glassThickness), + Vector2(0.02 - glassThickness, glassThickness), + Vector2(0.02 - glassThickness, 0.041), ]; /// An Erlenmeyer flask: a flat base, a cone and a neck. List flaskProfile() => [ Vector2(0, 0), - Vector2(0.26, 0), - Vector2(0.26, 0), - Vector2(0.27, 0.02), - Vector2(0.09, 0.42), - Vector2(0.075, 0.46), - Vector2(0.075, 0.62), - Vector2(0.09, 0.62), - Vector2(0.09, 0.635), + Vector2(0.026, 0), + Vector2(0.026, 0), + Vector2(0.027, 0.002), + Vector2(0.009, 0.042), + Vector2(0.0075, 0.046), + Vector2(0.0075, 0.062), + Vector2(0.009, 0.062), + Vector2(0.009, 0.0635), ]; -/// The flask's liquid up to [level], which may be anywhere on the cone. -List flaskLiquidProfile(double level) { - // The cone runs from radius 0.262 at 0.02 to 0.09 at 0.42, a hair inside - // the glass. - final t = ((level - 0.02) / 0.4).clamp(0.0, 1.0); - final top = 0.255 + (0.085 - 0.255) * t; - return [ - Vector2(0, 0.004), - Vector2(0.255, 0.004), - Vector2(0.255, 0.004), - Vector2(0.255, 0.02), - ...meniscus(top, level), - ]; -} +/// The flask's inside: the floor, the cone and the neck, a glass's +/// thickness in. +List flaskInside() => [ + Vector2(0, glassThickness), + Vector2(0.0255, glassThickness), + Vector2(0.0255, 0.002), + Vector2(0.0085, 0.042), + Vector2(0.007, 0.046), + Vector2(0.007, 0.061), +]; /// A graduated cylinder's glass: a long narrow tube standing on [cylinderFoot]. List cylinderProfile() => [ - Vector2(0, 0.03), - Vector2(0.056, 0.03), - Vector2(0.056, 0.03), - Vector2(0.056, 0.86), - Vector2(0.07, 0.88), + Vector2(0, 0.003), + Vector2(0.0056, 0.003), + Vector2(0.0056, 0.003), + Vector2(0.0056, 0.086), + Vector2(0.007, 0.088), +]; + +/// The cylinder's inside. +List cylinderInside() => [ + Vector2(0, 0.0034), + Vector2(0.0051, 0.0034), + Vector2(0.0051, 0.085), ]; /// The cylinder's foot: a flat hexagonal slab, swept with six segments. @@ -104,11 +112,11 @@ List cylinderProfile() => [ /// on a hexagonal plastic foot, which also stops them rolling away. List cylinderFoot() => [ Vector2(0, 0), - Vector2(0.14, 0), - Vector2(0.14, 0), - Vector2(0.14, 0.03), - Vector2(0.14, 0.03), - Vector2(0, 0.03), + Vector2(0.014, 0), + Vector2(0.014, 0), + Vector2(0.014, 0.003), + Vector2(0.014, 0.003), + Vector2(0, 0.003), ]; /// The foot's plastic: opaque, a little glossy, laboratory blue. @@ -118,19 +126,9 @@ Material footPlastic() => Material( roughness: 0.45, ); -/// Liquid standing on a flat floor at [floor]: a disc of [radius] raised to -/// [level], with a meniscus on top. -List flatLiquidProfile(double radius, double floor, double level) => - [ - Vector2(0, floor), - Vector2(radius, floor), - Vector2(radius, floor), - ...meniscus(radius, level), - ]; - /// Where a tube's label starts and ends, and how much of the turn it covers. -const double labelFrom = 0.52; -const double labelTo = 0.66; +const double labelFrom = 0.052; +const double labelTo = 0.066; const double labelWrap = 3.4; // about 195 degrees /// Width over height of the label as it sits on the glass: the arc it covers @@ -142,7 +140,7 @@ double labelAspect({ double from = labelFrom, double to = labelTo, double wrap = labelWrap, -}) => (radius + 0.002) * wrap / (to - from); +}) => (radius + 0.0002) * wrap / (to - from); /// A label wrapped round part of a tube: a strip of the same surface of /// revolution, just outside the glass. @@ -164,8 +162,8 @@ LatheShape labelBand({ name: 'label', segments: 24, profile: [ - Vector2(radius + 0.002, from), - Vector2(radius + 0.002, to), + Vector2(radius + 0.0002, from), + Vector2(radius + 0.0002, to), ], startAngle: facing - wrap / 2, sweepAngle: wrap, @@ -175,26 +173,46 @@ LatheShape labelBand({ Material glass() => Material( name: 'glass', lighting: LightingModel.pbrLayered, - baseColor: Vector4(0.92, 0.97, 1.0, 0.12), - roughness: 0.04, + // **Seen by what it reflects, and how much is a matter of taste.** A thin + // pane that transmits is laid over what is behind it and lets through + // what it does not reflect (see `g_pane` in the engine), so its alpha + // weighs what it reflects and adds. At 0.22 an empty tube was a ghost; at + // one, with every reflection whole, the glass read heavier than the + // liquids in it. Halfway is what the bench is lit with. The tint is + // laboratory glass's faint green-grey. + baseColor: Vector4(0.96, 0.985, 0.975, 0.55), + roughness: 0.03, alphaMode: MaterialAlphaMode.blend, doubleSided: true, - // A few millimetres of thickness, so what is behind the glass is bent a - // little rather than seen straight through a sheet with no depth. - extensions: MaterialExtensions(transmission: 0.95, ior: 1.5, thickness: 0.01), + // Thin-walled, as glTF means it: no thickness. A tube's wall is a few + // millimetres of glass round air, and given a volume the engine's caustics + // would follow light through it as through a solid glass rod. + // Transmission one: clear glass absorbs next to nothing over a wall, and + // what it does lose to reflection the Fresnel term already counts. + extensions: MaterialExtensions(transmission: 1.0, ior: 1.5, specular: 1.0), ); -/// A solution: clear liquid that light passes through, tinted by what it +/// A solution: clear liquid that light passes through, coloured by what it /// holds, with water's index of refraction. /// -/// The colour goes into the attenuation as well as the base colour, so a -/// thicker layer is a deeper colour, as it is in a real tube: the round -/// bottom and the edges read darker than the middle. -Material liquid(Vector3 colour) => Material( +/// **It reads as liquid because of what it does to the light, not its +/// paint.** Fully transmitting, so what is behind it shows through, bent; +/// a convex body ([MaterialExtensions.convexVolume]) [depth] across, so the +/// path through it, and with it the colour and the bend, is deepest down the +/// middle and fades to the silhouette, where a painted cylinder stays one +/// flat colour to its edge; and a wet, glossy surface with a clear coat. The +/// colour is the volume's: [colour] after [fade] of it, with a base nearly +/// white so it is not given twice. +Material liquid(Vector3 colour, {double depth = 0.0148}) => Material( name: 'liquid', lighting: LightingModel.pbrLayered, - baseColor: Vector4(colour.x, colour.y, colour.z, 0.8), - roughness: 0.03, + baseColor: Vector4( + 0.75 + 0.25 * colour.x, + 0.75 + 0.25 * colour.y, + 0.75 + 0.25 * colour.z, + 1.0, + ), + roughness: 0.02, alphaMode: MaterialAlphaMode.blend, // Blended surfaces leave the depth buffer alone, so the see-through // tabletop, drawn after the liquids, laid itself over their lower half. @@ -202,25 +220,61 @@ Material liquid(Vector3 colour) => Material( depthWrite: true, extensions: MaterialExtensions( ior: 1.33, - transmission: 0.35, - thickness: 0.06, + transmission: 1.0, + thickness: depth, + convexVolume: true, attenuationColor: colour, - attenuationDistance: 0.08, + attenuationDistance: fade, specular: 1.0, - // A wet surface: a clear coat over the colour gives the sharp highlight - // a liquid has and a painted solid does not. clearcoat: 1.0, - clearcoatRoughness: 0.02, + clearcoatRoughness: 0.0, ), ); +/// How far light goes through a solution before it is the solution's colour: +/// three centimetres, about two tubes across. +const double fade = 0.03; + /// Paper, wearing [label] once it has been drawn. Material paper([TextureHandle? label]) => Material(name: 'label', albedo: label, roughness: 0.8); -/// How the bench is drawn: under [labSky]. The tabletop's reflections are -/// mirrored geometry (see `Bench`), so no screen-space pass is needed. -final RenderSettings benchSettings = RenderSettings(sky: labSky); +/// How the bench is drawn: under [labSky], with see-through casters shading +/// the sun by what their material lets through +/// (`ShadowSettings.translucentCasters`): the glass casts a faint shadow with +/// darker edges, and each liquid a shadow of its own colour. The tabletop's +/// reflections are mirrored geometry (see `Bench`), so no screen-space pass +/// is needed. +/// +/// The shadows are fitted to a bench, not a level: sixty centimetres of view +/// rather than sixty metres, as far as the camera goes, and a normal offset +/// of two millimetres rather than two centimetres, which on glass this size +/// is most of a tube. The map is twice the default's edge: at a metre and +/// 1024 texels a tube's shadow edge stepped by most of a millimetre, which a +/// close look at a sixteen-millimetre tube shows as stairs. +final RenderSettings benchSettings = RenderSettings( + sky: labSky, + shadows: const ShadowSettings( + translucentCasters: true, + resolution: 2048, + viewDistance: 0.6, + normalOffset: 0.002, + ), +); + +/// [benchSettings] with the engine's own caustics on, for a `Bench` made with +/// `photons: true`. +final RenderSettings photonSettings = RenderSettings( + sky: labSky, + shadows: const ShadowSettings( + translucentCasters: true, + caustics: true, + causticPhotons: 128, + resolution: 2048, + viewDistance: 0.6, + normalOffset: 0.002, + ), +); /// The room the bench stands in, as a sky: bright overhead, a pale horizon /// and a darker floor. Glass shows almost nothing of itself; it shows what is @@ -244,7 +298,10 @@ final SkySettings labSky = SkySettings( /// ring. Real glass has a wall: the inside is the outline moved in along its /// normal, walked top to bottom so its normals face the cavity, and the two /// meet in a half circle that catches the light. -List glassWall(List outer, {double thickness = 0.004}) { +List glassWall( + List outer, { + double thickness = glassThickness, +}) { // Repeated points mark hard edges; for offsetting, each point's normal // comes from its nearest neighbours that are somewhere else. final points = [ diff --git a/packages/education/chemlab/lib/src/optics.dart b/packages/education/chemlab/lib/src/optics.dart new file mode 100644 index 000000000..94aac889d --- /dev/null +++ b/packages/education/chemlab/lib/src/optics.dart @@ -0,0 +1,322 @@ +import 'dart:math' as math; +import 'dart:typed_data'; + +import 'package:flutter3d/flutter3d.dart'; +import 'package:vector_math/vector_math.dart'; + +/// Where sunlight goes after it meets a vessel: geometric optics, worked out +/// for the shapes on this bench. +/// +/// **Every vessel here is a surface of revolution**, so cut across at any +/// height it is a set of circles round one centre: the outside of the glass, +/// its inside four millimetres in, and the liquid's edge. A sunbeam crossing +/// that cut is a straight line bent at each circle by Snell's law, partly +/// reflected away by Fresnel's, totally reflected where it meets a thinner +/// medium too steeply, and dimmed inside the liquid by Beer and Lambert over +/// the length it actually travels there. Followed to the bench, the beams +/// pile up where the liquid focuses them and thin out at the sides: the bright +/// line and the dark edges of a real test tube's shadow, and its colour, which +/// is deeper where the light went through more of the solution. +/// +/// The sun is not square to the tube: it comes down at an elevation, and a +/// ray meeting a cylinder at an angle to its axis bends in the cut as if each +/// medium had Bravais's index, √(n² − cos² γ) / sin γ for a ray at γ to the +/// axis, while the length it travels is the cut's length over sin γ. That is +/// exact for a straight wall and close for the curved bottom and the flask's +/// cone, where the wall leans. + +/// One medium inside a circle of [radius], as far out as the next one: its +/// Bravais [index] in the cut, which bends the ray's direction there, and its +/// [real] index, which Fresnel's reflection is worked out from. +typedef _Layer = ({ + double radius, + double index, + double real, + Vector3 absorption, +}); + +/// What fell on the bench across one cut: [bins] values of red, green and +/// blue, in units of the light that would have fallen with nothing there, over +/// lateral positions from −[halfWidth] to +[halfWidth] metres. +final class OpticsCut { + OpticsCut(this.bins, this.halfWidth) + : red = Float64List(bins), + green = Float64List(bins), + blue = Float64List(bins); + + final int bins; + final double halfWidth; + final Float64List red; + final Float64List green; + final Float64List blue; +} + +/// The cosine of a ray's angle to the horizontal inside a medium of index +/// [n], for sunlight at an elevation whose sine is [sinE]. +double _tilt(double sinE, double n) { + final s = sinE / n; + return math.sqrt(math.max(1.0 - s * s, 0.0)); +} + +/// The radius of [profile] at height [height], walking up from its base; null +/// where the profile does not reach that height. +double? radiusAt(List profile, double height) { + for (var i = 0; i + 1 < profile.length; i++) { + final a = profile[i]; + final b = profile[i + 1]; + if ((b.y - a.y).abs() < 1e-9) continue; + final lo = math.min(a.y, b.y); + final hi = math.max(a.y, b.y); + if (height < lo || height > hi) continue; + final t = (height - a.y) / (b.y - a.y); + return a.x + (b.x - a.x) * t; + } + return null; +} + +/// Beer–Lambert absorption per metre that leaves [colour] after [distance]. +Vector3 absorptionFor(Vector3 colour, double distance) { + if (!distance.isFinite || distance <= 0.0) return Vector3.zero(); + double mu(double c) => -math.log(c.clamp(1e-4, 1.0)) / distance; + return Vector3(mu(colour.x), mu(colour.y), mu(colour.z)); +} + +/// Follows [rays] parallel sunbeams across one cut of a vessel. +/// +/// [outer] is the glass's outside radius at this height, or null where there +/// is no glass; [wall] its thickness; [liquid] the liquid's radius, or null +/// above the level. [elevation] is the sun's angle above the bench, and +/// [reach] how far, along the light's way across the bench, the bench point +/// this cut throws its shadow on lies from the vessel's axis. +OpticsCut traceCut({ + required double? outer, + required double wall, + required double? liquid, + required double glassIndex, + required double liquidIndex, + required Vector3 liquidAbsorption, + required double elevation, + required double reach, + required double halfWidth, + int bins = 128, + int rays = 1600, +}) { + final cut = OpticsCut(bins, halfWidth); + final binWidth = 2.0 * halfWidth / bins; + final spacing = 2.0 * halfWidth / rays; + final weight = spacing / binWidth; + + void land(double y, double r, double g, double b) { + final index = ((y + halfWidth) / binWidth).floor(); + if (index < 0 || index >= bins) return; + cut.red[index] += r * weight; + cut.green[index] += g * weight; + cut.blue[index] += b * weight; + } + + // The media from the inside out. Air between the liquid and the glass when + // the liquid stops short of it, as it does by two millimetres here. + final cosE = math.cos(elevation); + final sinE = math.sin(elevation); + double bravais(double n) => + math.sqrt(math.max(n * n - sinE * sinE, 0.0)) / cosE; + final layers = <_Layer>[]; + final inner = outer == null ? null : math.max(outer - wall, 0.0); + if (liquid != null && liquid > 0.0) { + layers.add(( + radius: inner == null ? liquid : math.min(liquid, inner), + index: bravais(liquidIndex), + real: liquidIndex, + absorption: liquidAbsorption, + )); + } + if (outer != null && inner != null) { + if (inner > 0.0) { + layers.add(( + radius: inner, + index: 1.0, + real: 1.0, + absorption: Vector3.zero(), + )); + } + layers.add(( + radius: outer, + index: bravais(glassIndex), + real: glassIndex, + absorption: Vector3.zero(), + )); + } + final edge = layers.isEmpty ? 0.0 : layers.last.radius; + + _Layer? mediumAt(double r) { + for (final layer in layers) { + if (r < layer.radius) return layer; + } + return null; + } + + for (var k = 0; k < rays; k++) { + final b = -halfWidth + (k + 0.5) * spacing; + if (b.abs() >= edge) { + land(b, 1.0, 1.0, 1.0); + continue; + } + // In from upstream, along +x, b across. + var px = -edge - 1.0; + var py = b; + var dx = 1.0; + var dy = 0.0; + var r = 1.0, g = 1.0, bl = 1.0; + var escaped = false; + for (var event = 0; event < 24; event++) { + // The nearest circle ahead. + var tBest = double.infinity; + var hit = -1; + for (var i = 0; i < layers.length; i++) { + final radius = layers[i].radius; + final along = px * dx + py * dy; + final c = px * px + py * py - radius * radius; + final disc = along * along - c; + if (disc < 0.0) continue; + final root = math.sqrt(disc); + for (final t in [-along - root, -along + root]) { + if (t > 1e-9 && t < tBest) { + tBest = t; + hit = i; + } + } + } + if (hit < 0) { + escaped = true; + break; + } + // What the stretch to it crossed, dimmed by its absorption. + final midX = px + dx * tBest * 0.5; + final midY = py + dy * tBest * 0.5; + final here = mediumAt(math.sqrt(midX * midX + midY * midY)); + if (here != null) { + // The ray's tilt inside the medium is its own: n · sin of the + // elevation is what Snell keeps across a vertical wall. + final path = tBest / _tilt(sinE, here.real); + r *= math.exp(-here.absorption.x * path); + g *= math.exp(-here.absorption.y * path); + bl *= math.exp(-here.absorption.z * path); + } + px += dx * tBest; + py += dy * tBest; + // Which side is which: the normal faces the medium the ray is in. + final len = math.sqrt(px * px + py * py); + var nx = px / len; + var ny = py / len; + var cosI = -(dx * nx + dy * ny); + final outward = cosI < 0.0; + if (outward) { + nx = -nx; + ny = -ny; + cosI = -cosI; + } + final n1 = here?.index ?? 1.0; + final beyondRadius = outward ? len * 1.000001 : len * 0.999999; + final beyond = mediumAt(beyondRadius); + final n2 = beyond?.index ?? 1.0; + final eta = n1 / n2; + final k2 = 1.0 - eta * eta * (1.0 - cosI * cosI); + if (k2 < 0.0) { + // Total internal reflection: all of it turns back inside. + dx += 2.0 * cosI * nx; + dy += 2.0 * cosI * ny; + continue; + } + final cosT = math.sqrt(k2); + // Fresnel for unpolarised light, at the angle the ray really meets the + // surface in space and with the media's real indices: the Bravais ones + // say where the ray goes in the cut, not how much glass reflects. The + // wall is vertical and the ray comes down at the sun's elevation, so + // the true cosine is the cut's times cos E. What is reflected leaves + // the shadow. + final m1 = here?.real ?? 1.0; + final m2 = beyond?.real ?? 1.0; + final cosTrue = cosI * _tilt(sinE, m1); + final sinT2 = (m1 / m2) * (m1 / m2) * (1.0 - cosTrue * cosTrue); + var transmitted = 0.0; + if (sinT2 < 1.0) { + final cosTrueT = math.sqrt(1.0 - sinT2); + final rs = + (m1 * cosTrue - m2 * cosTrueT) / (m1 * cosTrue + m2 * cosTrueT); + final rp = + (m2 * cosTrue - m1 * cosTrueT) / (m2 * cosTrue + m1 * cosTrueT); + transmitted = 1.0 - 0.5 * (rs * rs + rp * rp); + } + r *= transmitted; + g *= transmitted; + bl *= transmitted; + final tx = eta * dx + (eta * cosI - cosT) * nx; + final ty = eta * dy + (eta * cosI - cosT) * ny; + final tl = math.sqrt(tx * tx + ty * ty); + dx = tx / tl; + dy = ty / tl; + } + if (!escaped || dx <= 1e-6) continue; + // Out across the bench to where this cut's shadow falls. + land(py + dy / dx * (reach - px), r, g, bl); + } + + // A little smoothing: sixteen hundred rays into a hundred and twenty-eight + // bins leaves the noise of the binning itself. + for (final channel in [cut.red, cut.green, cut.blue]) { + for (var pass = 0; pass < 2; pass++) { + final copy = Float64List.fromList(channel); + for (var i = 0; i < bins; i++) { + final a = copy[math.max(i - 1, 0)]; + final c = copy[math.min(i + 1, bins - 1)]; + channel[i] = 0.25 * a + 0.5 * copy[i] + 0.25 * c; + } + } + } + return cut; +} + +/// The picture a vessel's shadow card carries: [cuts] rows from the base to +/// [height], each a [traceCut] at that height, light stored over [scale] so +/// that light gathered past one, up to [scale], survives eight bits. +Rgba8Image shadowPicture({ + required List glass, + required List? liquid, + required double wall, + required double height, + required double glassIndex, + required double liquidIndex, + required Vector3 liquidAbsorption, + required double elevation, + required double halfWidth, + required double scale, + int bins = 128, + int cuts = 64, +}) { + final pixels = Uint8List(bins * cuts * 4); + final cotE = math.cos(elevation) / math.sin(elevation); + for (var row = 0; row < cuts; row++) { + final h = height * (row + 0.5) / cuts; + final cut = traceCut( + outer: radiusAt(glass, h), + wall: wall, + liquid: liquid == null ? null : radiusAt(liquid, h), + glassIndex: glassIndex, + liquidIndex: liquidIndex, + liquidAbsorption: liquidAbsorption, + elevation: elevation, + reach: h * cotE, + halfWidth: halfWidth, + bins: bins, + ); + for (var i = 0; i < bins; i++) { + final at = (row * bins + i) * 4; + int byte(double value) => (value / scale * 255.0).round().clamp(0, 255); + pixels[at] = byte(cut.red[i]); + pixels[at + 1] = byte(cut.green[i]); + pixels[at + 2] = byte(cut.blue[i]); + pixels[at + 3] = 255; + } + } + return Rgba8Image(width: bins, height: cuts, pixels: pixels); +} diff --git a/packages/education/chemlab/macos/.gitignore b/packages/education/chemlab/macos/.gitignore new file mode 100644 index 000000000..746adbb6b --- /dev/null +++ b/packages/education/chemlab/macos/.gitignore @@ -0,0 +1,7 @@ +# Flutter-related +**/Flutter/ephemeral/ +**/Pods/ + +# Xcode-related +**/dgph +**/xcuserdata/ diff --git a/packages/education/chemlab/macos/Flutter/Flutter-Debug.xcconfig b/packages/education/chemlab/macos/Flutter/Flutter-Debug.xcconfig new file mode 100644 index 000000000..c2efd0b60 --- /dev/null +++ b/packages/education/chemlab/macos/Flutter/Flutter-Debug.xcconfig @@ -0,0 +1 @@ +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/packages/education/chemlab/macos/Flutter/Flutter-Release.xcconfig b/packages/education/chemlab/macos/Flutter/Flutter-Release.xcconfig new file mode 100644 index 000000000..c2efd0b60 --- /dev/null +++ b/packages/education/chemlab/macos/Flutter/Flutter-Release.xcconfig @@ -0,0 +1 @@ +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/packages/education/chemlab/macos/Flutter/GeneratedPluginRegistrant.swift b/packages/education/chemlab/macos/Flutter/GeneratedPluginRegistrant.swift new file mode 100644 index 000000000..cccf817a5 --- /dev/null +++ b/packages/education/chemlab/macos/Flutter/GeneratedPluginRegistrant.swift @@ -0,0 +1,10 @@ +// +// Generated file. Do not edit. +// + +import FlutterMacOS +import Foundation + + +func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { +} diff --git a/packages/education/chemlab/macos/Runner.xcodeproj/project.pbxproj b/packages/education/chemlab/macos/Runner.xcodeproj/project.pbxproj new file mode 100644 index 000000000..475c5d26e --- /dev/null +++ b/packages/education/chemlab/macos/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,729 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXAggregateTarget section */ + 33CC111A2044C6BA0003C045 /* Flutter Assemble */ = { + isa = PBXAggregateTarget; + buildConfigurationList = 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */; + buildPhases = ( + 33CC111E2044C6BF0003C045 /* ShellScript */, + ); + dependencies = ( + ); + name = "Flutter Assemble"; + productName = FLX; + }; +/* End PBXAggregateTarget section */ + +/* Begin PBXBuildFile section */ + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; }; + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; }; + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; }; + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC10EC2044A3C60003C045; + remoteInfo = Runner; + }; + 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC111A2044C6BA0003C045; + remoteInfo = FLX; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 33CC110E2044A8840003C045 /* Bundle Framework */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Bundle Framework"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; + 33CC10ED2044A3C60003C045 /* chemlab.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "chemlab.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; + 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; + 33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = ""; }; + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = ""; }; + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = ""; }; + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = ""; }; + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = ""; }; + 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; + 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; + 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 331C80D2294CF70F00263BE5 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EA2044A3C60003C045 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C80D6294CF71000263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C80D7294CF71000263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 33BA886A226E78AF003329D5 /* Configs */ = { + isa = PBXGroup; + children = ( + 33E5194F232828860026EE4D /* AppInfo.xcconfig */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */, + ); + path = Configs; + sourceTree = ""; + }; + 33CC10E42044A3C60003C045 = { + isa = PBXGroup; + children = ( + 33FAB671232836740065AC1E /* Runner */, + 33CEB47122A05771004F2AC0 /* Flutter */, + 331C80D6294CF71000263BE5 /* RunnerTests */, + 33CC10EE2044A3C60003C045 /* Products */, + D73912EC22F37F3D000D13A0 /* Frameworks */, + ); + sourceTree = ""; + }; + 33CC10EE2044A3C60003C045 /* Products */ = { + isa = PBXGroup; + children = ( + 33CC10ED2044A3C60003C045 /* chemlab.app */, + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 33CC11242044D66E0003C045 /* Resources */ = { + isa = PBXGroup; + children = ( + 33CC10F22044A3C60003C045 /* Assets.xcassets */, + 33CC10F42044A3C60003C045 /* MainMenu.xib */, + 33CC10F72044A3C60003C045 /* Info.plist */, + ); + name = Resources; + path = ..; + sourceTree = ""; + }; + 33CEB47122A05771004F2AC0 /* Flutter */ = { + isa = PBXGroup; + children = ( + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */, + ); + path = Flutter; + sourceTree = ""; + }; + 33FAB671232836740065AC1E /* Runner */ = { + isa = PBXGroup; + children = ( + 33CC10F02044A3C60003C045 /* AppDelegate.swift */, + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */, + 33E51913231747F40026EE4D /* DebugProfile.entitlements */, + 33E51914231749380026EE4D /* Release.entitlements */, + 33CC11242044D66E0003C045 /* Resources */, + 33BA886A226E78AF003329D5 /* Configs */, + ); + path = Runner; + sourceTree = ""; + }; + D73912EC22F37F3D000D13A0 /* Frameworks */ = { + isa = PBXGroup; + children = ( + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C80D4294CF70F00263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 331C80D1294CF70F00263BE5 /* Sources */, + 331C80D2294CF70F00263BE5 /* Frameworks */, + 331C80D3294CF70F00263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C80DA294CF71000263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C80D5294CF71000263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 33CC10EC2044A3C60003C045 /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 33CC10E92044A3C60003C045 /* Sources */, + 33CC10EA2044A3C60003C045 /* Frameworks */, + 33CC10EB2044A3C60003C045 /* Resources */, + 33CC110E2044A8840003C045 /* Bundle Framework */, + 3399D490228B24CF009A79C7 /* ShellScript */, + ); + buildRules = ( + ); + dependencies = ( + 33CC11202044C79F0003C045 /* PBXTargetDependency */, + ); + name = Runner; + packageProductDependencies = ( + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, + ); + productName = Runner; + productReference = 33CC10ED2044A3C60003C045 /* chemlab.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 33CC10E52044A3C60003C045 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastSwiftUpdateCheck = 0920; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C80D4294CF70F00263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 33CC10EC2044A3C60003C045; + }; + 33CC10EC2044A3C60003C045 = { + CreatedOnToolsVersion = 9.2; + LastSwiftMigration = 1100; + ProvisioningStyle = Automatic; + SystemCapabilities = { + com.apple.Sandbox = { + enabled = 1; + }; + }; + }; + 33CC111A2044C6BA0003C045 = { + CreatedOnToolsVersion = 9.2; + ProvisioningStyle = Manual; + }; + }; + }; + buildConfigurationList = 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 33CC10E42044A3C60003C045; + packageReferences = ( + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */, + ); + productRefGroup = 33CC10EE2044A3C60003C045 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 33CC10EC2044A3C60003C045 /* Runner */, + 331C80D4294CF70F00263BE5 /* RunnerTests */, + 33CC111A2044C6BA0003C045 /* Flutter Assemble */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C80D3294CF70F00263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EB2044A3C60003C045 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */, + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3399D490228B24CF009A79C7 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename && \"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh embed\n"; + }; + 33CC111E2044C6BF0003C045 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + Flutter/ephemeral/FlutterInputs.xcfilelist, + ); + inputPaths = ( + Flutter/ephemeral/tripwire, + ); + outputFileListPaths = ( + Flutter/ephemeral/FlutterOutputs.xcfilelist, + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C80D1294CF70F00263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10E92044A3C60003C045 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */, + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */, + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C80DA294CF71000263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC10EC2044A3C60003C045 /* Runner */; + targetProxy = 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */; + }; + 33CC11202044C79F0003C045 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC111A2044C6BA0003C045 /* Flutter Assemble */; + targetProxy = 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 33CC10F42044A3C60003C045 /* MainMenu.xib */ = { + isa = PBXVariantGroup; + children = ( + 33CC10F52044A3C60003C045 /* Base */, + ); + name = MainMenu.xib; + path = Runner; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 331C80DB294CF71000263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = dev.pleion.chemlab.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/chemlab.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/chemlab"; + }; + name = Debug; + }; + 331C80DC294CF71000263BE5 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = dev.pleion.chemlab.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/chemlab.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/chemlab"; + }; + name = Release; + }; + 331C80DD294CF71000263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = dev.pleion.chemlab.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/chemlab.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/chemlab"; + }; + name = Profile; + }; + 338D0CE9231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 12.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Profile; + }; + 338D0CEA231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Profile; + }; + 338D0CEB231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Profile; + }; + 33CC10F92044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 12.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 33CC10FA2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 12.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Release; + }; + 33CC10FC2044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 33CC10FD2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + 33CC111C2044C6BA0003C045 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Debug; + }; + 33CC111D2044C6BA0003C045 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C80DB294CF71000263BE5 /* Debug */, + 331C80DC294CF71000263BE5 /* Release */, + 331C80DD294CF71000263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10F92044A3C60003C045 /* Debug */, + 33CC10FA2044A3C60003C045 /* Release */, + 338D0CE9231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10FC2044A3C60003C045 /* Debug */, + 33CC10FD2044A3C60003C045 /* Release */, + 338D0CEA231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC111C2044C6BA0003C045 /* Debug */, + 33CC111D2044C6BA0003C045 /* Release */, + 338D0CEB231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = { + isa = XCSwiftPackageProductDependency; + productName = FlutterGeneratedPluginSwiftPackage; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 33CC10E52044A3C60003C045 /* Project object */; +} diff --git a/packages/education/chemlab/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/packages/education/chemlab/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 000000000..18d981003 --- /dev/null +++ b/packages/education/chemlab/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/packages/education/chemlab/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/packages/education/chemlab/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 000000000..55c0ecb7a --- /dev/null +++ b/packages/education/chemlab/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,117 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/education/chemlab/macos/Runner.xcworkspace/contents.xcworkspacedata b/packages/education/chemlab/macos/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 000000000..1d526a16e --- /dev/null +++ b/packages/education/chemlab/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/packages/education/chemlab/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/packages/education/chemlab/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 000000000..18d981003 --- /dev/null +++ b/packages/education/chemlab/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/packages/education/chemlab/macos/Runner/AppDelegate.swift b/packages/education/chemlab/macos/Runner/AppDelegate.swift new file mode 100644 index 000000000..b3c176141 --- /dev/null +++ b/packages/education/chemlab/macos/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import Cocoa +import FlutterMacOS + +@main +class AppDelegate: FlutterAppDelegate { + override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + return true + } + + override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { + return true + } +} diff --git a/packages/education/chemlab/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/packages/education/chemlab/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 000000000..a2ec33f19 --- /dev/null +++ b/packages/education/chemlab/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,68 @@ +{ + "images" : [ + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_16.png", + "scale" : "1x" + }, + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "2x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "1x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_64.png", + "scale" : "2x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_128.png", + "scale" : "1x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "2x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "1x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "2x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "1x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_1024.png", + "scale" : "2x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/packages/education/chemlab/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/packages/education/chemlab/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png new file mode 100644 index 000000000..82b6f9d9a Binary files /dev/null and b/packages/education/chemlab/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png differ diff --git a/packages/education/chemlab/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/packages/education/chemlab/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png new file mode 100644 index 000000000..13b35eba5 Binary files /dev/null and b/packages/education/chemlab/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png differ diff --git a/packages/education/chemlab/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/packages/education/chemlab/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png new file mode 100644 index 000000000..0a3f5fa40 Binary files /dev/null and b/packages/education/chemlab/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png differ diff --git a/packages/education/chemlab/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/packages/education/chemlab/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png new file mode 100644 index 000000000..bdb57226d Binary files /dev/null and b/packages/education/chemlab/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png differ diff --git a/packages/education/chemlab/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png b/packages/education/chemlab/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png new file mode 100644 index 000000000..f083318e0 Binary files /dev/null and b/packages/education/chemlab/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png differ diff --git a/packages/education/chemlab/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/packages/education/chemlab/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png new file mode 100644 index 000000000..326c0e72c Binary files /dev/null and b/packages/education/chemlab/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png differ diff --git a/packages/education/chemlab/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/packages/education/chemlab/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png new file mode 100644 index 000000000..2f1632cfd Binary files /dev/null and b/packages/education/chemlab/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png differ diff --git a/packages/education/chemlab/macos/Runner/Base.lproj/MainMenu.xib b/packages/education/chemlab/macos/Runner/Base.lproj/MainMenu.xib new file mode 100644 index 000000000..80e867a4e --- /dev/null +++ b/packages/education/chemlab/macos/Runner/Base.lproj/MainMenu.xib @@ -0,0 +1,343 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/education/chemlab/macos/Runner/Configs/AppInfo.xcconfig b/packages/education/chemlab/macos/Runner/Configs/AppInfo.xcconfig new file mode 100644 index 000000000..72d54ffa5 --- /dev/null +++ b/packages/education/chemlab/macos/Runner/Configs/AppInfo.xcconfig @@ -0,0 +1,14 @@ +// Application-level settings for the Runner target. +// +// This may be replaced with something auto-generated from metadata (e.g., pubspec.yaml) in the +// future. If not, the values below would default to using the project name when this becomes a +// 'flutter create' template. + +// The application's name. By default this is also the title of the Flutter window. +PRODUCT_NAME = chemlab + +// The application's bundle identifier +PRODUCT_BUNDLE_IDENTIFIER = dev.pleion.chemlab + +// The copyright displayed in application information +PRODUCT_COPYRIGHT = Copyright © 2026 dev.pleion. All rights reserved. diff --git a/packages/education/chemlab/macos/Runner/Configs/Debug.xcconfig b/packages/education/chemlab/macos/Runner/Configs/Debug.xcconfig new file mode 100644 index 000000000..36b0fd946 --- /dev/null +++ b/packages/education/chemlab/macos/Runner/Configs/Debug.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Debug.xcconfig" +#include "Warnings.xcconfig" diff --git a/packages/education/chemlab/macos/Runner/Configs/Release.xcconfig b/packages/education/chemlab/macos/Runner/Configs/Release.xcconfig new file mode 100644 index 000000000..dff4f4956 --- /dev/null +++ b/packages/education/chemlab/macos/Runner/Configs/Release.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Release.xcconfig" +#include "Warnings.xcconfig" diff --git a/packages/education/chemlab/macos/Runner/Configs/Warnings.xcconfig b/packages/education/chemlab/macos/Runner/Configs/Warnings.xcconfig new file mode 100644 index 000000000..42bcbf478 --- /dev/null +++ b/packages/education/chemlab/macos/Runner/Configs/Warnings.xcconfig @@ -0,0 +1,13 @@ +WARNING_CFLAGS = -Wall -Wconditional-uninitialized -Wnullable-to-nonnull-conversion -Wmissing-method-return-type -Woverlength-strings +GCC_WARN_UNDECLARED_SELECTOR = YES +CLANG_UNDEFINED_BEHAVIOR_SANITIZER_NULLABILITY = YES +CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE +CLANG_WARN__DUPLICATE_METHOD_MATCH = YES +CLANG_WARN_PRAGMA_PACK = YES +CLANG_WARN_STRICT_PROTOTYPES = YES +CLANG_WARN_COMMA = YES +GCC_WARN_STRICT_SELECTOR_MATCH = YES +CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES +CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES +GCC_WARN_SHADOW = YES +CLANG_WARN_UNREACHABLE_CODE = YES diff --git a/packages/education/chemlab/macos/Runner/DebugProfile.entitlements b/packages/education/chemlab/macos/Runner/DebugProfile.entitlements new file mode 100644 index 000000000..dddb8a30c --- /dev/null +++ b/packages/education/chemlab/macos/Runner/DebugProfile.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.cs.allow-jit + + com.apple.security.network.server + + + diff --git a/packages/education/chemlab/macos/Runner/Info.plist b/packages/education/chemlab/macos/Runner/Info.plist new file mode 100644 index 000000000..b4ee5c3f9 --- /dev/null +++ b/packages/education/chemlab/macos/Runner/Info.plist @@ -0,0 +1,36 @@ + + + + + FLTEnableFlutterGPU + + FLTEnableImpeller + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIconFile + + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + NSHumanReadableCopyright + $(PRODUCT_COPYRIGHT) + NSMainNibFile + MainMenu + NSPrincipalClass + NSApplication + + diff --git a/packages/education/chemlab/macos/Runner/MainFlutterWindow.swift b/packages/education/chemlab/macos/Runner/MainFlutterWindow.swift new file mode 100644 index 000000000..3cc05eb23 --- /dev/null +++ b/packages/education/chemlab/macos/Runner/MainFlutterWindow.swift @@ -0,0 +1,15 @@ +import Cocoa +import FlutterMacOS + +class MainFlutterWindow: NSWindow { + override func awakeFromNib() { + let flutterViewController = FlutterViewController() + let windowFrame = self.frame + self.contentViewController = flutterViewController + self.setFrame(windowFrame, display: true) + + RegisterGeneratedPlugins(registry: flutterViewController) + + super.awakeFromNib() + } +} diff --git a/packages/education/chemlab/macos/Runner/Release.entitlements b/packages/education/chemlab/macos/Runner/Release.entitlements new file mode 100644 index 000000000..852fa1a47 --- /dev/null +++ b/packages/education/chemlab/macos/Runner/Release.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.security.app-sandbox + + + diff --git a/packages/education/chemlab/macos/RunnerTests/RunnerTests.swift b/packages/education/chemlab/macos/RunnerTests/RunnerTests.swift new file mode 100644 index 000000000..61f3bd1fc --- /dev/null +++ b/packages/education/chemlab/macos/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Cocoa +import FlutterMacOS +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/packages/education/chemlab/pubspec.yaml b/packages/education/chemlab/pubspec.yaml index c8b394964..d57b1d627 100644 --- a/packages/education/chemlab/pubspec.yaml +++ b/packages/education/chemlab/pubspec.yaml @@ -14,6 +14,9 @@ dependencies: path: ../../flutter3d flutter3d_app: path: ../../flutter3d_app + # The liquids: what is in each glass, how it moves, pours and mixes. + flutter3d_physics: + path: ../../flutter3d_physics # TeX in pure Dart, for the formulas on the labels: K_2Cr_2O_7 set with # real subscripts instead of drawn by hand. flutter_math_fork: ^0.7.4 diff --git a/packages/education/chemlab/test/bench_test.dart b/packages/education/chemlab/test/bench_test.dart index e3c1b9a6a..bfd549665 100644 --- a/packages/education/chemlab/test/bench_test.dart +++ b/packages/education/chemlab/test/bench_test.dart @@ -1,3 +1,4 @@ +import 'dart:math' as math; import 'dart:typed_data'; import 'package:chemlab/chemlab.dart'; @@ -76,19 +77,19 @@ void main() { expect(ink, greaterThan(2000)); }); - test('pouring cuts the liquid at the new level, within its glass', () async { + test('pouring fills to the level asked, within its glass', () async { final kit = cpuTestDevice(width: 8, height: 8); final bench = Bench(kit.device); final beaker = bench.vessels.firstWhere((v) => v.name == 'Beaker'); - final before = beaker.liquid.mesh; + final before = beaker.layers.single.node.mesh; - bench.pour(beaker, 0.3); - expect(beaker.level, 0.3); - expect(identical(beaker.liquid.mesh, before), isFalse); + bench.pour(beaker, 0.03); + expect(beaker.level, closeTo(0.03, 1e-9)); + expect(identical(beaker.layers.single.node.mesh, before), isFalse); // Mutation: drop the clamp, and a beaker holds more than it can. bench.pour(beaker, 5); - expect(beaker.level, beaker.highest); + expect(beaker.level, closeTo(beaker.highest, 1e-9)); }); test('the bench stays inside the eight lights an object is lit by', () { @@ -100,21 +101,33 @@ void main() { expect(lights.length, lessThanOrEqualTo(LightBuffer.maxLights)); }); - test('pouring moves the caustic and its reflection with the liquid', () { + test('pouring paints the shadow again and moves the reflection', () { final kit = cpuTestDevice(width: 8, height: 8); final bench = Bench(kit.device); final tube = bench.vessels.first; - final caustic = tube.caustic!; + final before = tube.shadow.material.albedo; - bench.pour(tube, tube.lowest + 0.05); - final (intensity, position) = (caustic.intensity, caustic.readPosition()); bench.pour(tube, tube.highest); - // A taller column throws a stronger patch, further from the tube. - expect(caustic.intensity, greaterThan(intensity)); - expect(caustic.readPosition(), isNot(position)); + // Mutation: keep the picture painted for the old level, and the shadow + // shows the liquid where it was. + expect(tube.shadow.material.albedo, isNot(same(before))); + expect(tube.shadow.shadowCasting, ShadowCastingMode.shadowsOnly); // Mutation: leave the reflection on the old mesh, and it shows the // level before the pour. - expect(identical(tube.liquidReflection.mesh, tube.liquid.mesh), isTrue); + final layer = tube.layers.single; + expect(identical(layer.reflection.mesh, layer.node.mesh), isTrue); + }); + + test('the glass and the liquid leave their shadow to the card', () { + // Mutation: let the glass cast as well, and its own coarse shadow lies + // over the worked-out one. + final kit = cpuTestDevice(width: 8, height: 8); + final bench = Bench(kit.device); + final tube = bench.vessels.first; + final glass = tube.glassNode; + expect(glass.castsShadow, isFalse); + expect(tube.layers.single.node.castsShadow, isFalse); + expect(glass.receivesTranslucentShadows, isFalse); }); test( @@ -134,12 +147,13 @@ void main() { expect(tube.label, isNull); expect(bench.dress(tube, picture), isNotNull); expect(tube.label!.material.albedo, isNotNull); - expect(bench.scene.root.children, contains(tube.label)); + expect(tube.body.children, contains(tube.label)); // Drawn again, the old label goes. final first = tube.label!; bench.dress(tube, picture); - expect(bench.scene.root.children, isNot(contains(first))); + expect(tube.body.children, isNot(contains(first))); + expect(first.parent, isNull); expect(bench.dress(beaker, picture), isNull); expect(beaker.label, isNull); @@ -158,11 +172,190 @@ void main() { build: (request) { final bench = Bench(request.device); final camera = CameraNode(name: 'eye') - ..setPosition(0.1, 0.85, 2.55) - ..lookAt(Vector3(0.1, 0.34, 0)); + ..setPosition(0.01, 0.085, 0.255) + ..lookAt(Vector3(0.01, 0.034, 0)); return (scene: bench.scene, camera: camera); }, ); await expectMatchesGolden(frame, 'test/goldens/bench.png'); }); + + test('a tap rings the surface, and the rings die away', () { + final kit = cpuTestDevice(width: 8, height: 8); + final bench = Bench(kit.device); + final tube = bench.vessels[1]; + bench.tap(tube); + expect(bench.step(1 / 60), isTrue); + var seconds = 0.0; + while (bench.step(1 / 60)) { + seconds += 1 / 60; + expect(seconds, lessThan(30)); + } + }); + + group('leaning and topping up — what the overflowing flask did', () { + // Reported from the bench: a flask leant to its furthest and topped up + // threw a stream, thirty thousand drops eleven metres up and a frame of + // three and a half seconds. Three causes, each held here; the drops + // themselves are held in `flutter3d_physics`. + + test('leant to its furthest, a glass spills nothing', () { + // Mutation: turn the glass at once in `lean` — its old level is laid + // out as a wave no water makes, and four fifths go over the lip. + final kit = cpuTestDevice(width: 8, height: 8); + final bench = Bench(kit.device); + final flask = bench.vessels.firstWhere((v) => v.name == 'Flask'); + final held = flask.liquid.volume; + bench.lean(flask, bench.maxLean(flask)); + for (var i = 0; i < 120; i++) { + bench.step(1 / 60); + expect(bench.world.jets, isEmpty, reason: 'it ran at frame $i'); + } + expect(flask.tilt, closeTo(bench.maxLean(flask), 1e-9)); + expect(flask.liquid.volume, closeTo(held, held * 1e-9)); + }); + + test('a lean is turned at a hand\'s pace, not at once', () { + final kit = cpuTestDevice(width: 8, height: 8); + final bench = Bench(kit.device); + final flask = bench.vessels.firstWhere((v) => v.name == 'Flask'); + bench.lean(flask, 1.0); + expect(flask.tilt, 0.0); + expect(flask.aimTilt, 1.0); + bench.step(0.1); + expect(flask.tilt, closeTo(0.2, 1e-9)); + }); + + test('topped up while it leans, a glass fills only to what it holds', () { + // Mutation: fill to the upright level in `pour` — the rest runs + // straight over the lip. + final kit = cpuTestDevice(width: 8, height: 8); + final bench = Bench(kit.device); + final flask = bench.vessels.firstWhere((v) => v.name == 'Flask'); + bench.lean(flask, bench.maxLean(flask)); + for (var i = 0; i < 90; i++) { + bench.step(1 / 60); + } + final everything = bench.world.volume; + bench.pour(flask, flask.highest); + final added = bench.world.volume - everything; + expect(added, greaterThan(0.0), reason: 'it was topped up'); + for (var i = 0; i < 120; i++) { + bench.step(1 / 60); + expect(bench.world.jets, isEmpty, reason: 'it ran at frame $i'); + } + var drops = 0; + bench.world.particles.forEach((_, f) => drops += f.count); + expect(drops, 0); + }); + }); + + test('sharing pours half into the clean tube, lip over the rim', () { + final kit = cpuTestDevice(width: 8, height: 8); + final bench = Bench(kit.device); + final from = bench.vessels[2]; + final to = bench.clean; + final total = from.liquid.volume; + final everything = bench.world.volume; + expect(bench.canShare(from), isTrue); + bench.share(from); + var seconds = 0.0; + var poured = false; + while (bench.busy) { + final before = from.liquid.volume; + bench.step(1 / 60); + seconds += 1 / 60; + expect(seconds, lessThan(30)); + if (from.liquid.volume < before - 1e-12) { + poured = true; + // While it runs, its lip is over the clean tube's mouth. + // Its own matrix: it hangs off the root, and the world's is only + // brought up to date when the scene is drawn. + final lip = from.body.localMatrix.transformed3(from.lip); + final off = Vector2(lip.x - to.at.x, lip.z - to.at.z).length; + expect(off, lessThan(to.lip.z)); + } + } + while (bench.world.volume > 0 && bench.world.jets.isNotEmpty) { + bench.step(1 / 60); + } + expect(poured, isTrue); + // The two hold about the same, and nothing is lost: what is not in them + // is drops on the bench. + final mine = from.liquid.volume; + final theirs = to.liquid.volume; + expect(mine, closeTo(total / 2, total * 0.05)); + expect(theirs, closeTo(total / 2, total * 0.05)); + expect(bench.world.volume, closeTo(everything, everything * 1e-9)); + // And the clean tube holds the colour poured into it. + expect(bench.colour(to), bench.colour(from)); + }); + + test('two dyes poured together are the colour light through both is', () { + final kit = cpuTestDevice(width: 8, height: 8); + final bench = Bench(kit.device); + final blue = bench.vessels[1]; + final orange = bench.vessels[3]; + final to = bench.clean; + to.liquid.pour(1e-6, concentrations: {blue.name: 1.0}); + to.liquid.pour(1e-6, concentrations: {orange.name: 1.0}); + final mixed = bench.colour(to); + // Half of each: each dye's absorbance at half strength, added. + final expected = math.sqrt(blue.dye.r * orange.dye.r); + expect(mixed.r, closeTo(expected, 0.01)); + }); + + test('a pour drawn at uneven frames still pours half', () { + // A browser's frames are anything from a 90th to a 30th of a second. + // Mutation: stamp the glass's place with the liquid's own clock, and + // the frames that ran one fixed step against those that ran eight read + // as jolts: the liquid is thrown out of the glass and the clean tube + // gets little of it. + final kit = cpuTestDevice(width: 8, height: 8); + final bench = Bench(kit.device); + final from = bench.vessels[2]; + final to = bench.clean; + final total = from.liquid.volume; + bench.share(from); + const frames = [1 / 60, 1 / 40, 1 / 90, 1 / 30, 1 / 75]; + var i = 0; + var seconds = 0.0; + while (bench.busy) { + final frame = frames[i++ % frames.length]; + bench.step(frame); + seconds += frame; + expect(seconds, lessThan(30)); + } + expect(from.liquid.volume, closeTo(total / 2, total * 0.05)); + expect(to.liquid.volume, closeTo(total / 2, total * 0.05)); + }); + + test('a pour never asks the device for a buffer of no bytes', () { + // Metal makes none, and a stream with no parcels yet threw on every + // frame of a pour in the macOS app. The software device takes one, so + // this looks at the meshes rather than waiting for a throw. Mutation: + // upload the empty stream as it is. + final kit = cpuTestDevice(width: 8, height: 8); + final bench = Bench(kit.device); + final from = bench.vessels[1]; + bench.share(from); + void check(SceneNode node) { + if (node is MeshNode && node.mesh is DeviceMesh) { + expect( + (node.mesh as DeviceMesh).vertexCount, + greaterThan(0), + reason: node.name, + ); + } + node.children.forEach(check); + } + + var seconds = 0.0; + while (bench.world.jets.isEmpty) { + bench.step(1 / 60); + seconds += 1 / 60; + expect(seconds, lessThan(10)); + } + check(bench.scene.root); + }); } diff --git a/packages/education/chemlab/test/glassware_test.dart b/packages/education/chemlab/test/glassware_test.dart index c3f7b061a..c1b89862e 100644 --- a/packages/education/chemlab/test/glassware_test.dart +++ b/packages/education/chemlab/test/glassware_test.dart @@ -21,18 +21,20 @@ void main() { }); } - test('a liquid is closed at its level, climbing the wall a little', () { - // Mutation: drop the cap, and the liquid is an open cup; flatten the - // meniscus, and it reads as a solid. - for (final (level, profile) in [ - (0.4, tubeLiquidProfile(0.4)), - (0.25, flatLiquidProfile(0.19, 0.004, 0.25)), - (0.2, flaskLiquidProfile(0.2)), + test("an inside starts on the axis and stays within its glass", () { + // Mutation: give the tube's inside the glass's own radius, and liquid + // stands in the glass rather than in the tube. + for (final (inside, glass) in [ + (tubeInside(), tubeProfile()), + (beakerInside(), beakerProfile()), + (flaskInside(), flaskProfile()), + (cylinderInside(), cylinderProfile()), ]) { - expect(profile.last, Vector2(0, level)); - final wall = profile[profile.length - 6]; - expect(wall.y, greaterThan(level)); - expect(wall.x, greaterThan(0)); + expect(inside.first.x, 0); + double widest(List p) => + p.map((q) => q.x).reduce((a, b) => a > b ? a : b); + expect(widest(inside), lessThan(widest(glass))); + expect(inside.first.y, greaterThanOrEqualTo(glass.first.y)); } }); @@ -48,22 +50,23 @@ void main() { expect(top, greaterThan(outer.last.y)); }); - test('a liquid stays inside its glass', () { - // Mutation: give the tube's liquid the glass's own radius. - final glass = tubeProfile() - .map((p) => p.x) - .reduce((a, b) => a > b ? a : b); - final inside = tubeLiquidProfile( - 0.6, - ).map((p) => p.x).reduce((a, b) => a > b ? a : b); - expect(inside, lessThan(tubeRadius)); - expect(glass, greaterThan(inside)); + test('the flask narrows with the cone as it rises', () { + final flask = flaskInside(); + double radiusAtHeight(double h) { + for (var i = 0; i + 1 < flask.length; i++) { + final a = flask[i], b = flask[i + 1]; + if (h >= a.y && h <= b.y && b.y > a.y) { + return a.x + (b.x - a.x) * (h - a.y) / (b.y - a.y); + } + } + return 0; + } + + expect(radiusAtHeight(0.035), lessThan(radiusAtHeight(0.008))); }); - test('the flask liquid narrows with the cone as it rises', () { - final low = flaskLiquidProfile(0.08)[4].x; - final high = flaskLiquidProfile(0.35)[4].x; - expect(high, lessThan(low)); + test('a test tube is life size: sixteen millimetres across', () { + expect(2 * tubeRadius, closeTo(0.016, 1e-12)); }); }); diff --git a/packages/education/chemlab/test/goldens/bench.png b/packages/education/chemlab/test/goldens/bench.png index aac4b0834..fa03e0669 100644 Binary files a/packages/education/chemlab/test/goldens/bench.png and b/packages/education/chemlab/test/goldens/bench.png differ diff --git a/packages/education/chemlab/test/optics_test.dart b/packages/education/chemlab/test/optics_test.dart new file mode 100644 index 000000000..8dcf1d905 --- /dev/null +++ b/packages/education/chemlab/test/optics_test.dart @@ -0,0 +1,83 @@ +import 'dart:math' as math; + +import 'package:chemlab/chemlab.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:vector_math/vector_math.dart'; + +/// The light that fell across [cut], in the units of an empty bench. +double _total(List channel, OpticsCut cut) => + channel.fold(0.0, (a, b) => a + b) * 2.0 * cut.halfWidth / cut.bins; + +OpticsCut _cut({double? liquid, Vector3? absorption, double reach = 0.4}) => + traceCut( + outer: 0.08, + wall: 0.004, + liquid: liquid, + glassIndex: 1.5, + liquidIndex: 1.33, + liquidAbsorption: absorption ?? Vector3.zero(), + elevation: 52 * math.pi / 180, + reach: reach, + halfWidth: 0.3, + ); + +void main() { + test('nothing in the way leaves the bench as lit as it was', () { + final cut = traceCut( + outer: null, + wall: 0.004, + liquid: null, + glassIndex: 1.5, + liquidIndex: 1.33, + liquidAbsorption: Vector3.zero(), + elevation: 0.9, + reach: 0.4, + halfWidth: 0.3, + ); + for (var i = 2; i < cut.bins - 2; i++) { + expect(cut.red[i], closeTo(1.0, 0.02)); + } + }); + + test('an empty tube loses only what its four surfaces reflect', () { + // Clear glass absorbs nothing here, so what is missing from the bench is + // what Fresnel turned back: a few per cent at each of four surfaces, and + // more at the grazing edges. Mutation: drop the reflection, and nothing + // is missing; reflect at every surface twice, and too much is. + final cut = _cut(); + final missing = 0.6 - _total(cut.red, cut); + expect(missing, greaterThan(0.16 * 0.08)); + expect(missing, lessThan(0.16 * 0.5)); + }); + + test('a filled tube gathers light into a line and darkens its sides', () { + // Water inside the glass makes the cut a lens: beams through the middle + // cross and spread, and the bench under the tube's shadow has places + // brighter than an empty bench and places much darker. Mutation: leave + // the liquid's index at one, and nothing gathers. + final cut = _cut(liquid: 0.074, reach: 0.12); + final peak = cut.red.reduce(math.max); + final low = cut.red.reduce(math.min); + expect(peak, greaterThan(1.3)); + expect(low, lessThan(0.5)); + }); + + test('a coloured solution colours what it lets through', () { + // Copper sulphate keeps blue and takes red. Mutation: apply one + // absorption to all three channels, and the ratio stays one. + final blue = absorptionFor(Vector3(0.2, 0.45, 0.95), 0.08); + final cut = _cut(liquid: 0.074, absorption: blue); + // Compared as a difference: both lose what Fresnel reflects and what the + // liquid spreads past the edge of the window after its focus, and only + // red is taken by the solution as well. + final takenRed = 0.6 - _total(cut.red, cut); + final takenBlue = 0.6 - _total(cut.blue, cut); + expect(takenRed - takenBlue, greaterThan(0.03)); + }); + + test('the radius of a profile is read where the profile reaches', () { + final profile = [Vector2(0, 0), Vector2(0.2, 0), Vector2(0.2, 0.4)]; + expect(radiusAt(profile, 0.2), closeTo(0.2, 1e-6)); + expect(radiusAt(profile, 0.5), isNull); + }); +} diff --git a/packages/flame_flutter3d/lib/src/physics/physics_step_component.dart b/packages/flame_flutter3d/lib/src/physics/physics_step_component.dart index 03c867b32..a3bfac9a4 100644 --- a/packages/flame_flutter3d/lib/src/physics/physics_step_component.dart +++ b/packages/flame_flutter3d/lib/src/physics/physics_step_component.dart @@ -61,7 +61,7 @@ final class PhysicsStepComponent extends Component }) : step = step ?? FixedStep(); /// The bodies this steps. - final Dynamics dynamics; + final RigidDynamics dynamics; /// The world whose contacts this dispatches after the step. final CollisionWorld world; diff --git a/packages/flame_flutter3d/lib/src/physics/rigid_body_component.dart b/packages/flame_flutter3d/lib/src/physics/rigid_body_component.dart index a4a756c11..ed9df20b1 100644 --- a/packages/flame_flutter3d/lib/src/physics/rigid_body_component.dart +++ b/packages/flame_flutter3d/lib/src/physics/rigid_body_component.dart @@ -82,7 +82,7 @@ class RigidBodyComponent extends Object3dComponent /// to bump into. Taken out when the component is gone, not when Flame /// moves it to a new parent, and never from inside a contact: Flame /// removes components at the start of a frame, between steps. - final Dynamics? removeFrom; + final RigidDynamics? removeFrom; final Vector3 _before = Vector3.zero(); final Vector3 _drawn = Vector3.zero(); diff --git a/packages/flutter3d/CHANGELOG.md b/packages/flutter3d/CHANGELOG.md index 46b500666..a0fc0c756 100644 --- a/packages/flutter3d/CHANGELOG.md +++ b/packages/flutter3d/CHANGELOG.md @@ -1,3 +1,24 @@ +## Unreleased + +- **Listed for the web.** The engine ran in the browser through + `flutter3d_webgl`, but pub.dev listed it without the web, because pub.dev + decides by imports and `loadModelAsset` imported `dart:io` to name + `FileSystemException`. It now asks `flutter3d_core`'s `isMissingFile` + instead, which is the same test natively and false in the browser. The + pubspec declares all six platforms. + +- **`generatedMaterialPathFor`**: where the build hook writes a `.f3dmat`'s + compiled bundle, `flutter3d_generated/.f3dshaders`. + +- **`ModelInstance.adopt` draws a new version of a model in the nodes an + instance already has.** Surfaces are matched by the name of the node that + draws them and their place there; each matched `MeshNode` takes the new + mesh and material and the nodes stay, with their transforms, animation and + children. `ModelSwap` reports what matched, what the new version dropped + (left drawn as it was) and what it added (needs instantiating again). +- `bindMaterial` takes a `name` for the bound material, the file's own by + default; a level passes the name its surfaces use. + ## 0.8.3+1 **Resolves on Flutter 3.44 and Dart 3.12.0.** The constraints asked for Dart diff --git a/packages/flutter3d/README.md b/packages/flutter3d/README.md index ad02f2f60..297cdb04e 100644 --- a/packages/flutter3d/README.md +++ b/packages/flutter3d/README.md @@ -104,7 +104,7 @@ What works today: `CUBICSPLINE` with authored tangents), slerped rotations, an `AnimationPlayer` with play/pause/seek/speed and once/loop/ping-pong, and the decoded node hierarchy rebuilt on instantiation so an animated parent carries its subtree; -- 1616 tests covering projection, scene, sorting, debug draw, raycasting, +- 1666 tests covering projection, scene, sorting, debug draw, raycasting, animation, skinning, lighting, render targets, BVH, LOD, glTF, OBJ and `.f3d`, plus a real frame drawn through `flutter3d_cpu`'s software rasteriser for the ones that need one, all without a GPU. The geometry the engine is written in @@ -322,7 +322,7 @@ lib/src/engine/assets/ glTF, OBJ and .f3d decoders, isolate loading, ca example/lib/ the demo, and the frame capture hook skills/ the conventions, as agent skills — see below bin/skills.dart what copies them into a project that uses this -test/ 1616 tests, all runnable without a GPU +test/ 1666 tests, all runnable without a GPU ``` The GLSL is not here. Every shader this package draws with lives in diff --git a/packages/flutter3d/example/lib/main.dart b/packages/flutter3d/example/lib/main.dart index 25341ac93..610d7b252 100644 --- a/packages/flutter3d/example/lib/main.dart +++ b/packages/flutter3d/example/lib/main.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:math' as math; +import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; @@ -791,8 +792,12 @@ class _SpikePageState extends State // layered model, unlit cards, Lambert floors — and would all be turned // back into plain PBR here. if (_stagedContent == null) { + final parameters = _golden?.scene.materialParameters ?? const {}; for (final mesh in _scene.meshes) { mesh.material.lighting = _lighting; + for (final MapEntry(:key, :value) in parameters.entries) { + mesh.material.parameters[key] = Float32List.fromList(value); + } } } @@ -874,19 +879,10 @@ class _SpikePageState extends State if (overlay == null || golden == null) return; if (!bounds.min.x.isFinite) return; - final world = _camera.worldMatrix.storage; - final eye = Vector3(world[12], world[13], world[14]); - final right = Vector3(world[0], world[1], world[2]); - final up = Vector3(world[4], world[5], world[6]); - final projection = _camera.projection; - final fovY = projection is PerspectiveProjection - ? projection.fovYRadians - : math.pi / 4; - overlay.lookFrom( - eye: eye, - right: right, - up: up, - pixel: 2 * math.tan(fovY / 2) / golden.height, + overlay.lookThrough( + _camera, + golden.width.toDouble(), + golden.height.toDouble(), ); // Drawn on the model's own bounds rather than an assumed unit cube: the diff --git a/packages/flutter3d/example/lib/src/spike/backend_cpu.dart b/packages/flutter3d/example/lib/src/spike/backend_cpu.dart index 781b4488a..158cc4825 100644 --- a/packages/flutter3d/example/lib/src/spike/backend_cpu.dart +++ b/packages/flutter3d/example/lib/src/spike/backend_cpu.dart @@ -13,6 +13,7 @@ /// fixture. library; +import 'package:flutter3d_app/flutter3d_app.dart' show materialLanguageCompiler; import 'package:flutter3d_cpu/flutter3d_cpu.dart'; import 'package:flutter3d_hardware/flutter3d_hardware.dart'; @@ -32,4 +33,8 @@ GraphicsDevice createCpuBackend({required int width, required int height}) => ...builtinCpuShaders(), 'ExampleStripes': const CpuStage.fragment(ExampleStripesShader()), }), + // `P8`: a bundle built from a `.f3dmat` carries its source, and this + // compiles it — the `material-language` scene's stage, with no Dart + // twin written for it. + materialCompiler: materialLanguageCompiler, ); diff --git a/packages/flutter3d/example/lib/src/spike/backend_web.dart b/packages/flutter3d/example/lib/src/spike/backend_web.dart index 1594c7699..faf71b6b0 100644 --- a/packages/flutter3d/example/lib/src/spike/backend_web.dart +++ b/packages/flutter3d/example/lib/src/spike/backend_web.dart @@ -13,7 +13,7 @@ import 'golden_store.dart'; /// /// Read from the URL, exactly the way the golden scene and the record/compare /// direction are read, and for exactly the same arithmetic: one dart2js run -/// serves seventy-eight scenes because the scene is a query parameter, and one +/// serves ninety-five scenes because the scene is a query parameter, and one /// dart2js run serves the browser backends because this is one too. A /// `--dart-define` here would be a build per backend, which is the whole saving /// of the browser golden stand spent on a single word. diff --git a/packages/flutter3d/example/lib/src/spike/golden_extras.dart b/packages/flutter3d/example/lib/src/spike/golden_extras.dart index 912e46821..4d0248c29 100644 --- a/packages/flutter3d/example/lib/src/spike/golden_extras.dart +++ b/packages/flutter3d/example/lib/src/spike/golden_extras.dart @@ -46,6 +46,63 @@ abstract final class GoldenExtras { usesFogInfo: true, ); + /// The example's material written in the engine's language — `P8`: + /// `shaders/rim_glow.f3dmat`, compiled by `tool/build_material.dart` into + /// every GPU section with its source beside them for the software backend. + static const String rimGlowBundle = 'assets/shaders/rim_glow.f3dshaders'; + + /// The lighting model `BundledMaterials` reads out of [rimGlowBundle]'s + /// source, written as a constant because a golden scene is one; the + /// example's `material_language_test.dart` holds the two equal, so this + /// is the program's answer and not a second description of it. + static const LightingModel rimGlow = LightingModel( + 'RimGlow', + 'RimGlow', + usesAlbedoTexture: true, + usesMaterialMaps: false, + usesMetallicRoughnessMap: false, + usesMetallic: false, + usesLightList: false, + usesMaterialParameters: true, + ); + + /// The example's lighting hook — `P8`: `shaders/toon_hook.f3dmat`, a + /// material whose `light` block answers each light inside the engine's + /// light loop, compiled beside [rimGlowBundle]. + static const String toonHookBundle = 'assets/shaders/toon_hook.f3dshaders'; + + /// [toonHookBundle]'s lighting model, held to the source by the example's + /// `material_language_test.dart` as [rimGlow] is: a lit model, so the maps, + /// the shadows and the light list, which the `light` block makes it. + static const LightingModel toonHook = LightingModel( + 'ToonHook', + 'ToonHook', + usesAlbedoTexture: true, + usesMaterialMaps: true, + usesMetallicRoughnessMap: false, + usesMetallic: false, + usesLightList: true, + usesMaterialParameters: true, + ); + + /// The example's instance attributes — `P8`: `shaders/instance_tint.f3dmat`, + /// a material reading each copy's own numbers as `instance`. + static const String instanceTintBundle = + 'assets/shaders/instance_tint.f3dshaders'; + + /// [instanceTintBundle]'s lighting model, held to the source by the + /// example's `material_language_test.dart`. + static const LightingModel instanceTint = LightingModel( + 'InstanceTint', + 'InstanceTint', + usesAlbedoTexture: true, + usesMaterialMaps: false, + usesMetallicRoughnessMap: false, + usesMetallic: false, + usesLightList: false, + usesMaterialParameters: false, + ); + /// Simulated seconds before the frame is drawn. /// /// Chosen so the burst is mid-flight: at zero every particle sits on the diff --git a/packages/flutter3d/example/lib/src/spike/golden_runner.dart b/packages/flutter3d/example/lib/src/spike/golden_runner.dart index a2849360a..cfa4d22c6 100644 --- a/packages/flutter3d/example/lib/src/spike/golden_runner.dart +++ b/packages/flutter3d/example/lib/src/spike/golden_runner.dart @@ -25,8 +25,8 @@ import 'png.dart'; /// a match and 1 on a mismatch, so `tool/golden.sh` can launch the built /// application once per scene and read the code. The same three names still work /// as `--dart-define`s for a run driven by hand, but the environment is what the -/// harness uses, because a define is a compile-time input and seventy-eight -/// of them are seventy-eight builds. +/// harness uses, because a define is a compile-time input and seventy-nine +/// of them are seventy-nine builds. final class GoldenRunner { GoldenRunner._(this.scene, {required this.update, required this.directory}); @@ -122,7 +122,7 @@ final class GoldenRunner { scene, // The store's answer wins where it has one. A desktop run takes the // direction from the environment and a browser run from the URL, for the - // same reason on both: one build has to serve seventy-eight scenes in both + // same reason on both: one build has to serve ninety-five scenes in both // directions, and anything the compiler sees is another build. update: updateOverride ?? diff --git a/packages/flutter3d/example/lib/src/spike/golden_scene.dart b/packages/flutter3d/example/lib/src/spike/golden_scene.dart index 75ac2f944..768045889 100644 --- a/packages/flutter3d/example/lib/src/spike/golden_scene.dart +++ b/packages/flutter3d/example/lib/src/spike/golden_scene.dart @@ -99,6 +99,7 @@ final class GoldenScene { this.lightmapped = false, this.anisotropicFloor = false, this.shaderBundle, + this.materialParameters = const >{}, this.autoExposure = const AutoExposureSettings(), this.xray = const XraySettings(), this.reflectionProbe = false, @@ -173,6 +174,12 @@ final class GoldenScene { /// three. See `GoldenExtras.exampleShaderBundle`. final String? shaderBundle; + /// Values for the model's `Material.parameters`, by member — `P8`: what a + /// [lighting] whose stage declares `MaterialParams` is drawn with. Set off + /// the stage's defaults, so the picture shows the value reached the GPU + /// rather than the default the source was compiled with. + final Map> materialParameters; + /// Silhouettes for the nodes on a layer, and — when the mask names one — /// the model replaced by a wall with two cubes about it. See /// `GoldenExtras.xrayRoom`. diff --git a/packages/flutter3d/example/lib/src/spike/golden_scenes.dart b/packages/flutter3d/example/lib/src/spike/golden_scenes.dart index 8a364e6dd..7975894fa 100644 --- a/packages/flutter3d/example/lib/src/spike/golden_scenes.dart +++ b/packages/flutter3d/example/lib/src/spike/golden_scenes.dart @@ -1072,6 +1072,33 @@ final List kGoldenScenes = [ configure: GoldenStages.easuHalf, ), + // P1. `shadow-teapot` with its edges smoothed by SMAA 1x. + const GoldenScene( + name: 'smaa-teapot', + source: 'obj: Teapot', + bloom: false, + configure: GoldenStages.smaaTeapot, + ), + + // P2. A bright panel's reflections thrown across the middle of the frame. + const GoldenScene( + name: 'lens-flare', + source: 'Cube', + lights: {'none'}, + shadows: false, + ground: false, + stage: GoldenStages.lensFlare, + configure: GoldenStages.lensFlareSettings, + ), + + // P2. `shadow-teapot` through a barrel lens. + const GoldenScene( + name: 'lens-distortion', + source: 'obj: Teapot', + bloom: false, + configure: GoldenStages.lensDistortion, + ), + // R6. A turning wheel blurred along its own motion. const GoldenScene( name: 'motion-blur-spin', @@ -1140,6 +1167,177 @@ final List kGoldenScenes = [ ground: false, stage: GoldenStages.smokeSixWay, ), + + // P3. Decals on a floor: one in a shadow and against a box, one over it, + // one that glows. + const GoldenScene( + name: 'decal-floor', + source: 'Cube', + lights: {'none'}, + bloom: false, + ground: false, + stage: GoldenStages.decalFloor, + configure: GoldenStages.decalFloorSettings, + ), + + // P4. A mirror in the floor, and a monitor showing a second camera. + const GoldenScene( + name: 'planar-mirror', + source: 'Cube', + lights: {'none'}, + bloom: false, + ground: false, + stage: GoldenStages.planarMirror, + configure: GoldenStages.planarMirrorSettings, + ), + const GoldenScene( + name: 'render-texture', + source: 'Cube', + lights: {'none'}, + bloom: false, + ground: false, + stage: GoldenStages.renderTexture, + ), + + // P5. The physical sky at dusk over blocks standing in ground fog, and the + // same sky after dark with its stars out. + const GoldenScene( + name: 'sky-physical-dusk', + source: 'Cube', + lights: {'sun'}, + shadows: false, + bloom: false, + ground: false, + stage: GoldenStages.skyPhysicalDusk, + configure: GoldenStages.skyPhysicalDuskSettings, + ), + const GoldenScene( + name: 'sky-physical-night', + source: 'Cube', + lights: {'none'}, + shadows: false, + bloom: false, + ground: false, + stage: GoldenStages.skyPhysicalNight, + configure: GoldenStages.skyPhysicalNightSettings, + ), + + // P8. One source in the material language, drawn on all four backends: a + // GPU section each, and the software set compiling the source the bundle + // carries. + const GoldenScene( + name: 'material-language', + source: 'obj: Teapot', + lighting: GoldenExtras.rimGlow, + shaderBundle: GoldenExtras.rimGlowBundle, + // Orange over the source's blue: the value set on the material, through + // `MaterialParams`, on every backend. + materialParameters: >{ + 'rimColor': [1.0, 0.45, 0.1], + }, + shadows: false, + bloom: false, + ground: false, + ), + + // P8. Instance attributes: one batch of five spheres, each copy coloured and + // rimmed by the four numbers the game gave it, through a `.f3dmat` that + // reads them as `instance`. + const GoldenScene( + name: 'material-instance-data', + source: 'Cube', + shaderBundle: GoldenExtras.instanceTintBundle, + stage: GoldenStages.materialInstanceData, + shadows: false, + bloom: false, + ground: false, + ), + + // P10. A scene written as widgets and mounted into the runner's scene: + // Material3D, Mirror3D, Decal3D and Particles3D beside Mesh3D. + const GoldenScene( + name: 'widget-scene', + source: 'Cube', + bloom: false, + ground: false, + stage: GoldenStages.widgetScene, + configure: GoldenStages.widgetSceneSettings, + ), + + // P8. A lighting hook: the teapot in a `.f3dmat` whose `light` block cuts + // n·l into three bands inside the engine's light loop, the sun's shadow on + // the ground beside it, and a rim the fragment body adds to `lit`. The + // bands, the shadow across the spout and the rim each show a part of the + // hook reached the backend. + const GoldenScene( + name: 'material-light-hook', + source: 'obj: Teapot', + lighting: GoldenExtras.toonHook, + shaderBundle: GoldenExtras.toonHookBundle, + materialParameters: >{ + 'paint': [0.25, 0.45, 0.85], + 'rimColor': [1.0, 0.85, 0.6], + }, + // The sun alone, so the bands are one light's. + lights: {'sun'}, + bloom: false, + ), + + // P7. Leaf cards cut from a soft alpha: a hard cutoff on Impeller and the + // software set, multisample coverage on WebGL2 and WebGPU. + const GoldenScene( + name: 'alpha-to-coverage', + source: 'Cube', + lights: {'none'}, + shadows: false, + bloom: false, + ground: false, + stage: GoldenStages.alphaToCoverage, + ), + + // P7. Metal spheres on a floor under a sky in light fog, through an + // orthographic camera. + const GoldenScene( + name: 'orthographic-metal', + source: 'Cube', + shadows: false, + bloom: false, + ground: false, + stage: GoldenStages.orthographicMetal, + configure: GoldenStages.orthographicMetalSettings, + ), + + // P7. Shadow cascades through an orthographic camera: slabs of the view + // along its axis rather than spheres round the eye. + const GoldenScene( + name: 'orthographic-shadows', + source: 'Cube', + bloom: false, + ground: false, + stage: GoldenStages.orthographicShadows, + configure: GoldenStages.orthographicShadowsSettings, + ), + + // P7. Billboards, splats and mesh particles through an orthographic camera + // in fog: fogged by depth from the eye's plane, every column alike. + const GoldenScene( + name: 'orthographic-particles', + source: 'Cube', + shadows: false, + bloom: false, + ground: false, + stage: GoldenStages.orthographicParticles, + configure: GoldenStages.orthographicParticlesSettings, + ), + + // P6. `shadow-teapot` wiped at the middle: lit on the left, the shading + // normal on the right. + const GoldenScene( + name: 'debug-view-split', + source: 'obj: Teapot', + bloom: false, + configure: GoldenStages.debugViewSplit, + ), ]; /// Looks up a scene by name. diff --git a/packages/flutter3d/example/lib/src/spike/golden_stages.dart b/packages/flutter3d/example/lib/src/spike/golden_stages.dart index f5f5c8aaf..ed6057ff7 100644 --- a/packages/flutter3d/example/lib/src/spike/golden_stages.dart +++ b/packages/flutter3d/example/lib/src/spike/golden_stages.dart @@ -2,7 +2,10 @@ import 'dart:math' as math; import 'dart:typed_data'; import 'package:flutter/services.dart' show rootBundle; +import 'package:flutter/widgets.dart' show Widget; import 'package:flutter3d/flutter3d.dart'; +import 'package:flutter3d_app/flutter3d_app.dart' + show Decal3D, Material3D, Mesh3D, Mirror3D, Particles3D, SceneWidgets; // The two bakes are plain Dart and reach a browser build, but the package's // barrel also exports the converter, which does not; importing the two // libraries alone is what keeps the web build of this demo compiling. @@ -14,6 +17,7 @@ import 'package:flutter3d_mesh/flutter3d_mesh.dart'; import 'package:flutter3d_particles/flutter3d_particles.dart'; import 'package:vector_math/vector_math.dart'; +import 'golden_extras.dart'; import 'golden_scene.dart'; /// The arrangements the golden scenes 0.8 added draw, and the settings they draw @@ -1118,6 +1122,422 @@ abstract final class GoldenStages { spatialUpscale: const SpatialUpscaleSettings(enabled: true), ); + // ------------------------------------------------------------------ P1 + + /// `smaa-teapot`: `shadow-teapot`'s silhouette and shadow edge smoothed by + /// SMAA 1x on the finished picture. + static RenderSettings smaaTeapot(RenderSettings settings) => + settings.copyWith( + antiAlias: settings.antiAlias.copyWith( + enabled: true, + method: EdgeSmoothing.smaa, + ), + ); + + // ------------------------------------------------------------------ P2 + + /// `lens-flare`: one small, very bright panel up and to the left on black, + /// and the ghosts and ring its reflections throw across the middle of the + /// frame. The glow is kept tight so the ghosts read as discs. + static Future lensFlare(GoldenStage stage) async { + final device = stage.device; + return GoldenStaged( + nodes: [ + _slab( + device, + Vector3(0.25, 0.25, 0.05), + Vector3(-1.1, 0.7, -3.0), + Material( + baseColor: Vector4(0.0, 0.0, 0.0, 1.0), + emissive: Vector3(30.0, 26.0, 20.0), + ), + )..shadowCasting = ShadowCastingMode.off, + ], + everyFrame: (_, _) => + _look(stage.camera, Vector3(0.0, 0.0, 1.0), Vector3(0.0, 0.0, -3.0)), + ); + } + + static RenderSettings lensFlareSettings(RenderSettings settings) => + settings.copyWith( + bloom: settings.bloom.copyWith( + enabled: true, + intensity: 0.05, + levels: 3, + scatter: 0.5, + lensFlare: const LensFlareSettings(enabled: true, intensity: 3.0), + ), + ); + + /// `lens-distortion`: `shadow-teapot` through a barrel lens, the floor's + /// straight edges bowed and the corners held where they were. + static RenderSettings lensDistortion(RenderSettings settings) => + settings.copyWith(look: settings.look.copyWith(distortion: 0.3)); + + // ------------------------------------------------------------------ P7 + + /// `alpha-to-coverage`: six leaf cards lying at different turns, each a + /// disc of green whose alpha falls from one at its middle to nought at its + /// rim, masked at a half with `Material.alphaToCoverage`. Where the device + /// can — WebGL2 and WebGPU, in their multisampled scene pass — the disc's + /// edge is the resolve's smooth one; Impeller and the software rasteriser + /// draw the same disc cut hard at the cutoff, so this scene's references + /// differ between the two pairs at the edges and nowhere else. + static Future alphaToCoverage(GoldenStage stage) async { + final device = stage.device; + const size = 64; + final pixels = Uint8List(size * size * 4); + for (var y = 0; y < size; y++) { + for (var x = 0; x < size; x++) { + final dx = (x + 0.5) / size - 0.5; + final dy = (y + 0.5) / size - 0.5; + final r = math.sqrt(dx * dx + dy * dy); + final i = (y * size + x) * 4; + pixels + ..[i] = 60 + ..[i + 1] = 170 + ..[i + 2] = 70 + ..[i + 3] = ((1.0 - r / 0.5).clamp(0.0, 1.0) * 255).round(); + } + } + final leaf = device.createTextureFromPixels( + width: size, + height: size, + format: TextureFormat.r8g8b8a8UNormInt, + pixels: ByteData.sublistView(pixels), + )!; + final card = DeviceMesh.upload( + device, + const PlaneShape(width: 1.6, depth: 1.6).build(), + ); + final material = Material( + lighting: LightingModel.unlit, + albedo: leaf, + alphaMode: MaterialAlphaMode.mask, + alphaCutoff: 0.5, + alphaToCoverage: true, + doubleSided: true, + ); + return GoldenStaged( + nodes: [ + for (var i = 0; i < 6; i++) + MeshNode(card, material) + ..setPosition((i % 3 - 1) * 1.7, (i ~/ 3 - 0.5) * 1.6, 0.0) + ..setRotationYawPitchRoll(i * 0.4, math.pi / 2 - 0.3 * i, i * 0.5), + ], + everyFrame: (_, _) => + _look(stage.camera, Vector3(0.0, 0.0, 6.0), Vector3.zero()), + ); + } + + /// `orthographic-metal`: three metal spheres, rough to smooth, on a floor + /// that ends under a sky, in light fog, through an orthographic camera + /// looking down at them from the corner — the three things that read the + /// eye's position as a point the light travels to. Their highlights sit + /// in the same place on each sphere rather than sliding towards where the + /// eye's point projects, the fog lies flat rather than in rings round it, + /// and the sky is a gradient seen through a sixty-degree lens rather than + /// one colour. + static Future orthographicMetal(GoldenStage stage) async { + final device = stage.device; + stage.sun.setLocalForward(Vector3(-1.0, -2.0, -1.5).normalized()); + return GoldenStaged( + nodes: [ + _slab( + device, + Vector3(6.0, 0.2, 6.0), + Vector3(0.0, -0.1, 0.0), + Material(baseColor: Vector4(0.6, 0.6, 0.58, 1.0), roughness: 0.9), + ), + for (final (i, roughness) in [0.6, 0.35, 0.15].indexed) + _sphere( + device, + Vector3(-1.5 + i * 1.5, 0.5, 0.0), + Material( + baseColor: Vector4(0.9, 0.75, 0.5, 1.0), + metallic: 1.0, + roughness: roughness, + ), + ), + ], + everyFrame: (_, _) => stage.camera + ..projection = const OrthographicProjection(height: 5.0, far: 100.0) + ..setPosition(7.0, 6.0, 9.0) + ..lookAt(Vector3(0.0, 0.3, 0.0)), + ); + } + + static RenderSettings orthographicMetalSettings(RenderSettings settings) => + settings.copyWith( + sky: const SkySettings(enabled: true), + fog: FogSettings(color: Vector3(0.7, 0.75, 0.8), density: 0.03), + ); + + /// `orthographic-shadows`: a level far longer than the view — a floor a + /// hundred and sixty metres long with posts down all of it — seen from + /// above through an orthographic camera zoomed in on its middle, under + /// three shadow cascades. Through this lens the near cascades are slabs of + /// what the frame shows; when they were spheres sized by distance from the + /// eye they covered the air before it, every shadow fell to the cascade + /// fitted to the whole level, and the posts' shadows went to mush. + static Future orthographicShadows(GoldenStage stage) async { + final device = stage.device; + stage.sun.setLocalForward(Vector3(-0.6, -1.6, 0.5).normalized()); + final floor = Material( + baseColor: Vector4(0.42, 0.42, 0.4, 1.0), + roughness: 0.9, + ); + final post = Material( + baseColor: Vector4(0.8, 0.45, 0.3, 1.0), + roughness: 0.6, + ); + return GoldenStaged( + nodes: [ + _slab( + device, + Vector3(12.0, 0.2, 160.0), + Vector3(0.0, -0.1, 0.0), + floor, + ), + for (var i = 0; i < 32; i++) + _slab( + device, + Vector3(0.6, 2.4, 0.6), + Vector3(i.isEven ? -2.0 : 2.0, 1.2, -77.5 + i * 5.0), + post, + ), + ], + everyFrame: (_, _) => stage.camera + ..projection = const OrthographicProjection(height: 17.0, far: 400.0) + ..setPosition(9.0, 14.0, -26.0) + ..lookAt(Vector3(0.0, 0.0, 1.0)), + ); + } + + static RenderSettings orthographicShadowsSettings(RenderSettings settings) => + settings.copyWith(shadows: settings.shadows.copyWith(cascades: 3)); + + /// `orthographic-particles`: three rows across a dark wall in fog, seen + /// through an orthographic camera — billboards above, splats in the + /// middle, mesh particles below, five of each at the same depth. Through + /// this lens the eye is only where the camera was put along its axis, so + /// every column must fog alike; measured from the eye's position, as the + /// three stages did, the outer columns came out darker than the middle + /// one, and the shards' faces dimmer. + static Future orthographicParticles(GoldenStage stage) async { + final device = stage.device; + stage.sun.visible = false; + ParticleEffect still(double size, Vector4 colour) => ParticleEffect( + count: 1, + emitter: const SphereEmitter(speed: Range.exact(0.0)), + lifetime: const Range.exact(10.0), + size: Range.exact(size), + color: colour, + ); + final billboards = ParticleSystem(capacity: 8, seed: 1); + final shards = ParticleSystem(capacity: 8, seed: 1); + for (var i = 0; i < 5; i++) { + final x = -3.0 + i * 1.5; + billboards.burst( + still(0.9, Vector4(1.0, 0.6, 0.2, 1.0)), + Vector3(x, 1.4, -5.0), + ); + shards.burst( + still(0.35, Vector4(0.3, 0.8, 1.0, 1.0)), + Vector3(x, -1.4, -5.0), + ); + } + final splats = SplatCloud( + centres: Float32List.fromList([ + for (var i = 0; i < 5; i++) ...[-3.0 + i * 1.5, 0.0, -5.0], + ]), + colours: Float32List.fromList([ + for (var i = 0; i < 5; i++) ...[0.9, 0.3, 0.6, 1.0], + ]), + scales: Float32List.fromList([ + for (var i = 0; i < 5; i++) ...[0.25, 0.25, 0.25], + ]), + rotations: Float32List.fromList([ + for (var i = 0; i < 5; i++) ...[0.0, 0.0, 0.0, 1.0], + ]), + ); + stage.renderer + ..addContributor(ParticleContributor(billboards)) + ..addContributor(SplatContributor(splats)) + ..addContributor( + MeshParticleContributor( + shards, + mesh: DeviceMesh.upload( + device, + CuboidShape(size: Vector3.all(1.0)).build(), + ), + ), + ); + return GoldenStaged( + nodes: [ + _slab( + device, + Vector3(20.0, 20.0, 1.0), + Vector3(0.0, 0.0, -9.5), + _unlit(Vector4(0.15, 0.15, 0.15, 1.0)), + ), + ], + everyFrame: (_, _) => stage.camera + ..projection = const OrthographicProjection(height: 5.0, far: 100.0) + ..setPosition(0.0, 0.0, 0.0) + ..lookAt(Vector3(0.0, 0.0, -1.0)), + ); + } + + static RenderSettings orthographicParticlesSettings( + RenderSettings settings, + ) => settings.copyWith( + fog: FogSettings(color: Vector3(0.35, 0.4, 0.5), density: 0.12), + ); + + /// `material-instance-data`: five spheres, one instanced batch drawn with + /// `shaders/instance_tint.f3dmat`, each copy given its own four numbers — + /// a colour round the hue circle and a rim growing left to right. One draw; + /// what differs between the copies is only what the game handed each. + static Future materialInstanceData(GoldenStage stage) async { + final device = stage.device; + final batch = InstancedMeshNode( + DeviceMesh.upload( + device, + SphereShape(radius: 0.5, segments: 48, rings: 24).build(), + ), + Material(lighting: GoldenExtras.instanceTint), + capacity: 5, + name: 'copies', + ); + const colours = <(double, double, double)>[ + (0.95, 0.3, 0.25), + (0.95, 0.75, 0.2), + (0.35, 0.85, 0.35), + (0.25, 0.6, 0.95), + (0.7, 0.35, 0.9), + ]; + for (final (i, (r, g, b)) in colours.indexed) { + batch.addInstance( + Matrix4.translationValues(-2.4 + i * 1.2, 0.0, 0.0), + data: Vector4(r, g, b, i / 4.0), + ); + } + return GoldenStaged( + nodes: [batch], + everyFrame: (_, _) => + _look(stage.camera, Vector3(0.0, 0.8, 6.8), Vector3.zero()), + ); + } + + // ----------------------------------------------------------------- P10 + + /// `widget-scene`: `planar-mirror`'s room written as widgets and mounted + /// into the runner's own scene through `SceneWidgets.mount` — a floor and + /// two boxes under shared `Material3D`s, a mirror whose surface is the + /// `Mesh3D` below its `Mirror3D`, a ball with a material of its own, an + /// orange `Decal3D` on the floor and a `Particles3D` plume over the post, + /// advanced a sixtieth of a second a frame from a fixed seed. Every widget + /// `P10` adds draws in it, through the graph the imperative scenes use. + static Future widgetScene(GoldenStage stage) async { + stage.sun.setLocalForward(Vector3(-0.6, -1.0, -0.4).normalized()); + stage.scene.ambientIntensity = 0.2; + final mount = SceneWidgets.mount( + scene: stage.scene, + renderer: stage.renderer, + device: stage.device, + children: [ + Material3D( + lighting: LightingModel.lambert, + baseColor: Vector4(0.45, 0.45, 0.45, 1.0), + children: [ + Mesh3D( + shape: CuboidShape(size: Vector3(14.0, 0.2, 14.0)), + position: Vector3(0.0, -0.1, 0.0), + name: 'floor', + ), + ], + ), + Mirror3D( + position: Vector3(0.0, 0.005, 0.3), + tint: Vector3(0.85, 0.9, 1.0), + children: [ + Material3D( + lighting: LightingModel.unlit, + baseColor: Vector4(0.02, 0.02, 0.03, 1.0), + children: [ + Mesh3D( + shape: const PlaneShape(width: 3.2, depth: 2.4), + name: 'mirror', + ), + ], + ), + ], + ), + // Two boxes, one material: the batch the widget shares. + Material3D( + baseColor: Vector4(0.8, 0.15, 0.1, 1.0), + children: [ + Mesh3D( + shape: CuboidShape(size: Vector3(0.8, 0.8, 0.8)), + position: Vector3(-0.7, 0.4, -0.3), + ), + Mesh3D( + shape: CuboidShape(size: Vector3(0.25, 1.6, 0.25)), + position: Vector3(0.2, 0.8, -1.2), + ), + ], + ), + Mesh3D( + shape: SphereShape(radius: 0.45, segments: 48, rings: 24), + material: Material(baseColor: Vector4(0.15, 0.3, 0.85, 1.0)), + position: Vector3(0.6, 0.45, 0.2), + ), + // Stamped down its y axis, so with no rotation it lies on the floor. + Decal3D( + position: Vector3(1.6, 0.0, 1.2), + scale: Vector3(0.9, 0.6, 0.9), + color: Vector4(1.0, 0.5, 0.1, 1.0), + ), + Particles3D( + position: Vector3(0.2, 1.7, -1.2), + effect: ParticleEffect( + count: 1, + emitter: const SphereEmitter(speed: Range(0.3, 0.6)), + lifetime: const Range.exact(1.2), + size: const Range.exact(0.18), + color: Vector4(1.0, 0.7, 0.3, 1.0), + ), + perSecond: 40.0, + seed: 7, + ), + ], + ); + return GoldenStaged( + nodes: const [], + everyFrame: (_, _) { + mount.advance(1.0 / 60.0); + _look(stage.camera, Vector3(0.4, 2.2, 4.6), Vector3(0.0, 0.4, -0.2)); + }, + ); + } + + static RenderSettings widgetSceneSettings(RenderSettings settings) => + settings.copyWith( + planarReflections: const PlanarReflectionSettings(enabled: true), + decals: const DecalSettings(enabled: true), + ); + + // ------------------------------------------------------------------ P6 + + /// `debug-view-split`: the lit teapot left of the middle and its shading + /// normal right of it, in one draw — the wipe `DebugViewSettings.split` + /// makes, and the composite leaving the right half out of the tone curve. + static RenderSettings debugViewSplit(RenderSettings settings) => + settings.copyWith( + debugView: const DebugViewSettings(view: DebugView.normal, split: 0.5), + ); + // ------------------------------------------------------------------ R6 /// `motion-blur-spin`: a wheel of three spokes round a hub, turning at a @@ -1578,4 +1998,355 @@ abstract final class GoldenStages { _look(stage.camera, Vector3(0.0, 0.0, 4.0), Vector3.zero()), ); } + + // ------------------------------------------------------------------ P3 + + /// `decal-floor`: three decals on a floor beside a box under the sun — + /// `decal_test.dart`'s floor, with each of its claims in one frame. + /// + /// A ring with a hole in it, half in the box's shadow and reaching up the + /// box's side, which its angle limit keeps it off; an orange square of a + /// higher order over the ring's corner; and a cyan stripe that glows. The + /// ring's picture is drawn here, in Dart, so every backend reads the same + /// texels, and its four quarters are four colours so a mirrored picture + /// shows. + static Future decalFloor(GoldenStage stage) async { + final device = stage.device; + stage.sun.setLocalForward(Vector3(-0.9, -1.0, -0.3).normalized()); + // Enough sky in the shadow for the ring's colours to show there too. + stage.scene.ambientIntensity = 0.25; + const size = 64; + final pixels = Uint8List(size * size * 4); + for (var y = 0; y < size; y++) { + for (var x = 0; x < size; x++) { + final dx = (x + 0.5) / size - 0.5; + final dy = (y + 0.5) / size - 0.5; + final r = math.sqrt(dx * dx + dy * dy); + final quarter = (dx < 0.0 ? 0 : 1) + (dy < 0.0 ? 0 : 2); + final colour = const >[ + [220, 40, 40], + [40, 180, 60], + [50, 80, 220], + [240, 230, 210], + ][quarter]; + final at = (y * size + x) * 4; + pixels + ..[at] = colour[0] + ..[at + 1] = colour[1] + ..[at + 2] = colour[2] + ..[at + 3] = r > 0.18 && r < 0.48 ? 255 : 0; + } + } + final ring = device.createTextureFromPixels( + width: size, + height: size, + format: TextureFormat.r8g8b8a8UNormInt, + pixels: ByteData.sublistView(pixels), + )!; + return GoldenStaged( + nodes: [ + _slab( + device, + Vector3(14.0, 0.2, 14.0), + Vector3(0.0, -0.1, 0.0), + Material( + name: 'floor', + lighting: LightingModel.lambert, + baseColor: Vector4(0.45, 0.45, 0.45, 1.0), + ), + ), + _slab( + device, + Vector3(1.0, 1.0, 1.0), + Vector3(-0.2, 0.5, -0.6), + Material(name: 'box', baseColor: Vector4(0.6, 0.6, 0.65, 1.0)), + ), + DecalNode(texture: ring, name: 'ring') + ..setScale(3.0, 1.2, 3.0) + ..setRotationYawPitchRoll(0.4, 0.0, 0.0) + ..setPosition(0.3, 0.0, 0.2), + DecalNode(color: Vector4(1.0, 0.55, 0.1, 0.85), order: 1, name: 'tag') + ..setScale(0.9, 0.6, 0.9) + ..setPosition(1.4, 0.0, 1.0), + DecalNode( + color: Vector4(0.2, 0.9, 1.0, 1.0), + emissive: Vector3.all(1.5), + name: 'glow', + ) + ..setScale(2.4, 0.6, 0.25) + ..setPosition(-0.6, 0.0, 1.9), + ], + everyFrame: (_, _) => + _look(stage.camera, Vector3(1.0, 4.0, 5.0), Vector3(0.0, 0.0, 0.2)), + ); + } + + static RenderSettings decalFloorSettings(RenderSettings settings) => + settings.copyWith(decals: const DecalSettings(enabled: true)); + // ------------------------------------------------------------------ P4 + + /// The floor `planar-mirror` and `render-texture` stand on: grey, lit. + static MeshNode _p4Floor(GraphicsDevice device) => _slab( + device, + Vector3(14.0, 0.2, 14.0), + Vector3(0.0, -0.1, 0.0), + Material( + name: 'floor', + lighting: LightingModel.lambert, + baseColor: Vector4(0.45, 0.45, 0.45, 1.0), + ), + ); + + /// What both P4 scenes look at: a red box, a blue ball and a yellow post, + /// lit by the sun, so a picture of them has shading and shadows to get + /// right and no two of its sides look alike. + static List _p4Props(GraphicsDevice device) => [ + _slab( + device, + Vector3(0.8, 0.8, 0.8), + Vector3(-0.7, 0.4, -0.3), + Material(name: 'box', baseColor: Vector4(0.8, 0.15, 0.1, 1.0)), + ), + _sphere( + device, + Vector3(0.6, 0.45, 0.2), + Material(name: 'ball', baseColor: Vector4(0.15, 0.3, 0.85, 1.0)), + radius: 0.45, + ), + _slab( + device, + Vector3(0.25, 1.6, 0.25), + Vector3(0.2, 0.8, -1.2), + Material(name: 'post', baseColor: Vector4(0.9, 0.75, 0.15, 1.0)), + ), + ]; + + /// `planar-mirror`: a black mirror set into the floor, the props standing + /// on and around it — `planar_reflection_test.dart`'s mirror under a sun. + /// + /// A slightly blue tint, so the reflection cannot be mistaken for a hole + /// in the floor showing the world upside down, and the reflection at half + /// the view's resolution, which is the default and the path a game takes. + static Future planarMirror(GoldenStage stage) async { + final device = stage.device; + stage.sun.setLocalForward(Vector3(-0.6, -1.0, -0.4).normalized()); + stage.scene.ambientIntensity = 0.2; + final mirror = MeshNode( + DeviceMesh.upload( + device, + const PlaneShape(width: 3.2, depth: 2.4).build(), + ), + Material( + name: 'mirror', + lighting: LightingModel.unlit, + baseColor: Vector4(0.02, 0.02, 0.03, 1.0), + ), + name: 'mirror', + )..setPosition(0.0, 0.005, 0.3); + return GoldenStaged( + nodes: [ + _p4Floor(device), + mirror, + ..._p4Props(device), + PlanarReflectorNode( + surfaces: [mirror], + tint: Vector3(0.85, 0.9, 1.0), + name: 'reflector', + )..setPosition(0.0, 0.005, 0.0), + ], + everyFrame: (_, _) => + _look(stage.camera, Vector3(0.4, 2.2, 4.6), Vector3(0.0, 0.4, -0.2)), + ); + } + + static RenderSettings planarMirrorSettings(RenderSettings settings) => + settings.copyWith( + planarReflections: const PlanarReflectionSettings(enabled: true), + ); + + /// `render-texture`: a monitor on a stand showing what a camera off to the + /// side sees of the props — `planar_reflection_test.dart`'s screen. + /// + /// The screen is unlit, so it shows the picture as the camera took it, and + /// left out of the camera's own picture, which would otherwise hold the + /// screen's back. + static Future renderTexture(GoldenStage stage) async { + final device = stage.device; + stage.sun.setLocalForward(Vector3(-0.6, -1.0, -0.4).normalized()); + stage.scene.ambientIntensity = 0.2; + final watcher = CameraNode(name: 'watcher') + ..projection = const PerspectiveProjection( + fovYRadians: math.pi / 4, + near: 0.1, + far: 50.0, + ) + ..setPosition(-3.2, 1.6, 2.0) + ..lookAt(Vector3(0.0, 0.5, -0.4)); + final picture = RenderTexture.create( + device, + camera: watcher, + width: 128, + height: 96, + clearColor: Vector4(0.35, 0.45, 0.6, 1.0), + ); + // A plane stood up to face the eye: its v runs down the screen, the way + // a picture's rows do. A box's sides run v up and would show it upside + // down, as they would a photograph. + const turn = -0.5; + final facing = Quaternion.axisAngle(Vector3(0.0, 1.0, 0.0), turn); + final screen = MeshNode( + DeviceMesh.upload( + device, + const PlaneShape(width: 1.6, depth: 1.2).build(), + ), + Material( + name: 'screen', + lighting: LightingModel.unlit, + albedo: picture.texture, + ), + name: 'screen', + )..setPosition(1.9, 1.3, -0.6); + screen.setRotation( + facing * Quaternion.axisAngle(Vector3(1.0, 0.0, 0.0), math.pi / 2), + ); + final back = _slab( + device, + Vector3(1.7, 1.3, 0.04), + Vector3(1.9 + 0.03 * math.sin(-turn), 1.3, -0.6 - 0.03 * math.cos(turn)), + Material(name: 'bezel', baseColor: Vector4(0.1, 0.1, 0.12, 1.0)), + )..setRotation(facing); + final stand = _slab( + device, + Vector3(0.12, 0.7, 0.12), + Vector3(1.9, 0.35, -0.6), + Material(name: 'stand', baseColor: Vector4(0.2, 0.2, 0.22, 1.0)), + ); + picture.excluded.addAll([screen, back, stand]); + stage.scene.addRenderTexture(picture); + return GoldenStaged( + nodes: [ + _p4Floor(device), + ..._p4Props(device), + screen, + back, + stand, + watcher, + ], + everyFrame: (_, _) => + _look(stage.camera, Vector3(0.6, 2.0, 4.8), Vector3(0.6, 0.7, -0.4)), + ); + } + + // ------------------------------------------------------------------ P5 + + /// The sun for `sky-physical-dusk`, four degrees up and ahead of the camera. + static Vector3 get _duskSun => Vector3(0.35, 0.07, -0.93).normalized(); + + /// The sun for `sky-physical-night`, eight degrees below the horizon: the + /// glow it leaves behind on one side, and the stars out everywhere else. + static Vector3 get _nightSun => Vector3(0.6, -0.139, -0.79).normalized(); + + static const PhysicalSky _air = PhysicalSky(starBrightness: 2.0); + + /// A floor reaching to the far plane, and blocks of rising height going + /// away from the camera through ground fog — `sky_physical_test.dart`'s + /// claims, in one frame: the sky reddens towards the sun, the disc is + /// red, and the fog is thick at the foot of each block and thin at its top. + static Future skyPhysicalDusk(GoldenStage stage) async { + final device = stage.device; + final toSun = _duskSun; + stage.sun + ..setLocalForward(-toSun) + ..color.setFrom(_air.sunlight(toSun)) + ..intensity = 3.0; + final grey = Material( + baseColor: Vector4(0.55, 0.55, 0.55, 1.0), + roughness: 0.8, + ); + return GoldenStaged( + nodes: [ + _slab( + device, + Vector3(800.0, 0.1, 800.0), + Vector3(0.0, -0.05, 0.0), + Material(baseColor: Vector4(0.3, 0.32, 0.28, 1.0), roughness: 0.9), + ), + for (final (i, distance) in [6.0, 12.0, 24.0, 48.0].indexed) + _slab( + device, + Vector3(1.6, 1.5 + i * 1.5, 1.6), + Vector3(-2.5 + i * 1.8, 0.75 + i * 0.75, -distance), + grey, + ), + ], + everyFrame: (_, _) => _look( + stage.camera, + Vector3(0.0, 1.6, 4.0), + Vector3(0.6, 2.4, -20.0), + fovY: math.pi / 3, + far: 500.0, + ), + ); + } + + static RenderSettings skyPhysicalDuskSettings(RenderSettings settings) { + final sky = SkySettings( + enabled: true, + directionToSun: _duskSun, + sunAngularRadiusDegrees: 1.2, + sunSoftnessDegrees: 0.3, + sunIntensity: 2.0, + physical: _air, + ); + // The fog fades to the sky across the camera's line of sight, so the far + // blocks sink into the horizon rather than into a grey of their own. + final horizon = sky.sample(Vector3(-0.93, 0.02, -0.35)); + return settings.copyWith( + sky: sky, + // Under the default 1.6 the horizon a few degrees from a setting sun is + // white; half that keeps the band of colour in it readable. + exposure: 0.8, + fog: FogSettings(color: horizon, density: 0.035, heightFalloff: 0.5), + ); + } + + /// A floor and a block under a sky an hour after sunset, looking up past + /// the glow — `sky_physical_test.dart`'s stars. + static Future skyPhysicalNight(GoldenStage stage) async { + final device = stage.device; + stage.sun.visible = false; + stage.scene.ambientIntensity = 0.02; + return GoldenStaged( + nodes: [ + _slab( + device, + Vector3(800.0, 0.1, 800.0), + Vector3(0.0, -0.05, 0.0), + Material(baseColor: Vector4(0.2, 0.2, 0.2, 1.0)), + ), + _slab( + device, + Vector3(2.0, 3.0, 2.0), + Vector3(-3.0, 1.5, -10.0), + Material(baseColor: Vector4(0.2, 0.2, 0.2, 1.0)), + ), + ], + everyFrame: (_, _) => _look( + stage.camera, + Vector3(0.0, 1.6, 0.0), + Vector3(3.0, 5.0, -10.0), + fovY: math.pi / 3, + far: 500.0, + ), + ); + } + + static RenderSettings skyPhysicalNightSettings(RenderSettings settings) => + settings.copyWith( + sky: SkySettings( + enabled: true, + directionToSun: _nightSun, + physical: _air, + ), + ); } diff --git a/packages/flutter3d/example/lib/src/spike/golden_store_web.dart b/packages/flutter3d/example/lib/src/spike/golden_store_web.dart index aeacfce16..3b5757266 100644 --- a/packages/flutter3d/example/lib/src/spike/golden_store_web.dart +++ b/packages/flutter3d/example/lib/src/spike/golden_store_web.dart @@ -40,7 +40,7 @@ void reportLine(String message) { /// The scene named in the page's URL, if any. /// /// A run-time choice here, where the desktop path takes a compile-time define. -/// The reason is arithmetic: the suite is seventy-eight scenes, and rebuilding +/// The reason is arithmetic: the suite is ninety-five scenes, and rebuilding /// the bundle for each is seventy-eight dart2js runs to compare seventy-eight /// pictures. One build and seventy-eight navigations is the same information /// in a fraction of the time. @@ -58,7 +58,7 @@ String? get sceneOverride { /// Whether this run records rather than compares, from the page's URL. /// /// A run-time choice for the same reason [sceneOverride] is one: the suite is -/// seventy-eight scenes and rebuilding for each would be seventy-eight dart2js +/// ninety-five scenes and rebuilding for each would be seventy-eight dart2js /// runs. One build serves both directions, and the URL says which. bool get updateOverride => Uri.base.queryParameters['update'] == '1'; diff --git a/packages/flutter3d/example/lib/widgets_main.dart b/packages/flutter3d/example/lib/widgets_main.dart new file mode 100644 index 000000000..76fe549df --- /dev/null +++ b/packages/flutter3d/example/lib/widgets_main.dart @@ -0,0 +1,82 @@ +/// `minimal_main.dart`'s scene written as widgets — `P10`. +/// +/// flutter run -t lib/widgets_main.dart +/// +/// The same sphere and point light, without opening a device, keeping a +/// renderer or building the graph by hand: `Scene3D` does those, and the +/// scene is the widgets below it. Tap to turn the sphere from rough copper to +/// polished blue and back. The rebuild keeps the sphere's node and its +/// material object — `Material3D` writes the new values into the one it made +/// — which is what a game relying on either across a rebuild needs. +library; + +import 'package:flutter/material.dart' hide Material; +import 'package:flutter3d/flutter3d.dart' hide Material; +import 'package:flutter3d_app/flutter3d_app.dart'; +import 'package:vector_math/vector_math.dart' show Vector3, Vector4; + +void main() => runApp(const WidgetsApp3D()); + +class WidgetsApp3D extends StatelessWidget { + const WidgetsApp3D({super.key, this.onCreated}); + + /// Handed the scene once it exists; the smoke test reads the graph here. + final void Function(Scene3DController controller)? onCreated; + + @override + Widget build(BuildContext context) => MaterialApp( + debugShowCheckedModeBanner: false, + home: WidgetsPage(onCreated: onCreated), + ); +} + +class WidgetsPage extends StatefulWidget { + const WidgetsPage({super.key, this.onCreated}); + + final void Function(Scene3DController controller)? onCreated; + + @override + State createState() => _WidgetsPageState(); +} + +class _WidgetsPageState extends State { + bool _polished = false; + + @override + Widget build(BuildContext context) => GestureDetector( + // Opaque: the surface the frame is shown on takes no hits of its own. + behavior: HitTestBehavior.opaque, + onTap: () => setState(() => _polished = !_polished), + child: Scene3D( + // Drawn when this page rebuilds; nothing here moves on its own. + continuous: false, + onCreated: widget.onCreated, + placeholder: const ColoredBox(key: Key('opening'), color: Colors.black), + children: [ + Camera3D(position: Vector3(0.0, 1.2, 3.5), target: Vector3.zero()), + // One point light and nothing else, so the falloff is the picture. + Light3D.point( + position: Vector3(2.0, 2.5, 2.0), + color: Vector3(0.9, 0.95, 1.0), + intensity: 16.0, + range: 20.0, + ), + Material3D( + key: const ValueKey('sphere material'), + baseColor: _polished + ? Vector4(0.25, 0.45, 0.9, 1.0) + : Vector4(0.9, 0.42, 0.28, 1.0), + roughness: _polished ? 0.1 : 0.35, + metallic: _polished ? 1.0 : 0.0, + children: const [ + Mesh3D( + key: ValueKey('sphere'), + shape: SphereShape(radius: 1.0), + name: 'sphere', + ), + ], + ), + ], + ), + ); +} diff --git a/packages/flutter3d/example/pubspec.yaml b/packages/flutter3d/example/pubspec.yaml index 27127151b..841d1ceb6 100644 --- a/packages/flutter3d/example/pubspec.yaml +++ b/packages/flutter3d/example/pubspec.yaml @@ -70,6 +70,14 @@ dev_dependencies: flutter_test: sdk: flutter flutter_lints: ^6.0.0 + # `tool/build_material.dart`: the engine's shaders, resolved for the + # material it compiles — `P8`. + flutter3d_shaders: + path: ../../flutter3d_shaders + # `test/material_language_test.dart`: the language's parser, to hold the + # golden's lighting model to its source. + flutter3d_core: + path: ../../flutter3d_core flutter: uses-material-design: true @@ -87,3 +95,8 @@ flutter: # refuses to build without it rather than shipping a demo whose # `loaded-shader` scene cannot find its bytes. - assets/shaders/example.f3dshaders + # And the material written in the engine's language, which the same + # script compiles from shaders/rim_glow.f3dmat — `P8`. + - assets/shaders/rim_glow.f3dshaders + - assets/shaders/toon_hook.f3dshaders + - assets/shaders/instance_tint.f3dshaders diff --git a/packages/flutter3d/example/shaders/instance_tint.f3dmat b/packages/flutter3d/example/shaders/instance_tint.f3dmat new file mode 100644 index 000000000..789ac8457 --- /dev/null +++ b/packages/flutter3d/example/shaders/instance_tint.f3dmat @@ -0,0 +1,15 @@ +// The example's instance attributes — `P8`: a material written in the +// engine's material language that reads each copy's own four numbers, as +// `InstancedMeshNode.setInstanceData` gave them. `tool/build_material.dart` +// compiles it beside the others, and the `material-instance-data` golden +// draws one batch of it on all four backends. +// +// The rgb is the copy's colour and the w how strongly its rim glows, so the +// one draw shows two things only the game knew about each copy. +material InstanceTint { + fragment { + let facing = clamp(nDotV, 0.0, 1.0); + let rim = pow(1.0 - facing, 3.0) * instance.w; + return vec4(instance.rgb * (0.25 + 0.75 * facing) + vec3(rim), 1.0); + } +} diff --git a/packages/flutter3d/example/shaders/rim_glow.f3dmat b/packages/flutter3d/example/shaders/rim_glow.f3dmat new file mode 100644 index 000000000..70ec30a8f --- /dev/null +++ b/packages/flutter3d/example/shaders/rim_glow.f3dmat @@ -0,0 +1,22 @@ +// The example's material written in the engine's material language — `P8`. +// `tool/build_material.dart` compiles it into +// `assets/shaders/rim_glow.f3dshaders`, with a section for each GPU backend +// and this text beside them for the software one, and the +// `material-language` golden draws it on all four from this one source. +// +// A shaded body and a rim where the surface turns away from the eye: it +// reads the albedo, the facing and the colour it is tinted with, which is +// enough to show the stage ran on each backend and agrees with the others. +// The rim's colour is a uniform, a member of `MaterialParams`: the golden +// sets it orange over this blue default, so the picture shows the value a +// game set reached every backend. +material RimGlow { + param float rimPower = 3.0; + uniform vec3 rimColor = vec3(0.2, 0.5, 1.0); + + fragment { + let facing = clamp(nDotV, 0.0, 1.0); + let rim = pow(1.0 - facing, rimPower); + return vec4(albedo * (0.25 + 0.75 * facing) + rimColor * rim, 1.0); + } +} diff --git a/packages/flutter3d/example/shaders/toon_hook.f3dmat b/packages/flutter3d/example/shaders/toon_hook.f3dmat new file mode 100644 index 000000000..3f17b71c7 --- /dev/null +++ b/packages/flutter3d/example/shaders/toon_hook.f3dmat @@ -0,0 +1,28 @@ +// The example's lighting hook — `P8`: a material written in the engine's +// material language that answers each light itself, inside the engine's +// light loop. `tool/build_material.dart` compiles it beside `rim_glow`, and +// the `material-light-hook` golden draws it on all four backends from this +// one source. +// +// The `light` block runs once per light. What it returns is how the surface +// answers that light; the engine multiplies it by the light's radiance, its +// n·l and its shadow, as it does for every lit model. Here n·l is cut into +// three bands and divided back out, so the bands are what is left — a cel +// look. The fragment body reads `lit`, the surface lit through that block +// with the ambient and the emissive added as the engine adds them, and puts +// a rim on top. +material ToonHook { + param float bands = 3.0; + uniform vec3 paint = vec3(0.25, 0.45, 0.85); + uniform vec3 rimColor = vec3(1.0, 0.85, 0.6); + + light { + let banded = floor(nDotL * bands + 0.5) / bands; + return albedo * paint * banded / max(nDotL, 0.001); + } + + fragment { + let rim = pow(1.0 - clamp(nDotV, 0.0, 1.0), 4.0); + return vec4(lit + rimColor * rim * 0.5, 1.0); + } +} diff --git a/packages/flutter3d/example/test/material_language_test.dart b/packages/flutter3d/example/test/material_language_test.dart new file mode 100644 index 000000000..eebb7784a --- /dev/null +++ b/packages/flutter3d/example/test/material_language_test.dart @@ -0,0 +1,47 @@ +/// The `material-language` golden's lighting model is the program's own +/// answer — `P8`. +/// +/// flutter test test/material_language_test.dart +/// +/// A golden scene is a constant, so `GoldenExtras.rimGlow` is written out by +/// hand. This holds it to what `BundledMaterials` builds from the source the +/// bundle carries, flag by flag, so a change to `shaders/rim_glow.f3dmat` +/// that samples a map or reads the metalness cannot leave the scene binding +/// what the stage no longer declares. +library; + +import 'dart:io'; + +import 'package:flutter3d/flutter3d.dart'; +import 'package:flutter3d_core/formats.dart'; +import 'package:flutter3d_example/src/spike/golden_extras.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + for (final (file, written) in <(String, LightingModel)>[ + ('shaders/rim_glow.f3dmat', GoldenExtras.rimGlow), + // `P8`: the lighting hook, a lit model by its `light` block. + ('shaders/toon_hook.f3dmat', GoldenExtras.toonHook), + ('shaders/instance_tint.f3dmat', GoldenExtras.instanceTint), + ]) { + test('the golden binds what $file reads', () { + final program = parseMaterial(File(file).readAsStringSync()); + final built = describeMaterial( + program, + ).lightingModel(label: program.name, shaderName: program.name); + + expect(written.shaderName, built.shaderName); + expect(written.vertexShaderName, built.vertexShaderName); + expect(written.usesFragInfo, built.usesFragInfo); + expect(written.usesFogInfo, built.usesFogInfo); + expect(written.usesAlbedoTexture, built.usesAlbedoTexture); + expect(written.usesMaterialMaps, built.usesMaterialMaps); + expect(written.usesMetallicRoughnessMap, built.usesMetallicRoughnessMap); + expect(written.usesMetallic, built.usesMetallic); + expect(written.usesLightList, built.usesLightList); + expect(written.usesMaterialParameters, built.usesMaterialParameters); + expect(written.usesEnvironment, built.usesEnvironment); + expect(written.vertexStageMorphs, built.vertexStageMorphs); + }); + } +} diff --git a/packages/flutter3d/example/test/widgets_smoke_test.dart b/packages/flutter3d/example/test/widgets_smoke_test.dart new file mode 100644 index 000000000..7f3ffc134 --- /dev/null +++ b/packages/flutter3d/example/test/widgets_smoke_test.dart @@ -0,0 +1,35 @@ +/// The widget-written example opens, draws, and keeps its nodes across a tap +/// — `P10`. +/// +/// flutter test test/widgets_smoke_test.dart +library; + +import 'package:flutter/widgets.dart'; +import 'package:flutter3d_app/flutter3d_app.dart'; +import 'package:flutter3d_example/widgets_main.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('the widgets example draws, and a tap changes the material it ' + 'already has', (tester) async { + // Mutation: make `_Material3DState._material` a new engine material on + // every read — the tap leaves the sphere with another object, and a game + // holding the first is left changing one nothing draws. + Scene3DController? controller; + await tester.pumpWidget(WidgetsApp3D(onCreated: (c) => controller = c)); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('opening')), findsNothing); + + final sphere = controller!.scene.meshes.single; + final material = sphere.material; + expect(material.roughness, 0.35); + + await tester.tap(find.byType(Scene3D)); + await tester.pumpAndSettle(); + + expect(identical(controller!.scene.meshes.single, sphere), isTrue); + expect(identical(sphere.material, material), isTrue); + expect(material.roughness, 0.1); + expect(material.metallic, 1.0); + }); +} diff --git a/packages/flutter3d/example/tool/build_material.dart b/packages/flutter3d/example/tool/build_material.dart new file mode 100644 index 000000000..0b25b4689 --- /dev/null +++ b/packages/flutter3d/example/tool/build_material.dart @@ -0,0 +1,43 @@ +// Compiles the example's `.f3dmat` sources into loadable shader bundles — +// `P8`. Run by `tool/build_shaders.sh` after the hand-written bundle. +// +// dart run tool/build_material.dart +// +// The same `compileMaterial` the build hook runs for a game's +// `assets_src/**/*.f3dmat`, called directly: this example does not use the +// hook, and its golden runner wants the bundle at a fixed asset path. +import 'dart:io'; + +import 'package:flutter3d_build/flutter3d_build.dart'; +import 'package:flutter3d_shaders/compile.dart'; + +void main() { + final example = Directory.current; + final engine = loadShaders(from: example.path); + final compilers = MaterialCompilers.locate(); + final scratch = Directory.systemTemp.createTempSync('f3dmat'); + try { + for (final file in Directory('${example.path}/shaders').listSync()) { + if (file is! File || !file.path.endsWith('.f3dmat')) continue; + final name = file.uri.pathSegments.last.replaceAll('.f3dmat', ''); + final bundle = compileMaterial( + file.readAsStringSync(), + source: file.path, + engine: engine, + compilers: compilers, + scratch: scratch, + ); + final out = File('${example.path}/assets/shaders/$name.f3dshaders') + ..writeAsBytesSync(bundle.encode().buffer.asUint8List()); + stdout.writeln( + 'wrote ${out.path}: ${bundle.stages.length} stage, sections ' + '${bundle.sections.keys.join(', ')}', + ); + } + } on MaterialBuildException catch (error) { + stderr.writeln(error); + exit(1); + } finally { + scratch.deleteSync(recursive: true); + } +} diff --git a/packages/flutter3d/example/tool/build_shaders.sh b/packages/flutter3d/example/tool/build_shaders.sh index 3d11524a3..a91f2442a 100755 --- a/packages/flutter3d/example/tool/build_shaders.sh +++ b/packages/flutter3d/example/tool/build_shaders.sh @@ -71,3 +71,8 @@ fi ${WEBGPU_SECTION[@]+"${WEBGPU_SECTION[@]}"} \ --name example \ --out "$EXAMPLE/assets/shaders/example.f3dshaders") + +# `P8`: the example's materials written in the engine's material language, +# compiled the way the build hook compiles a game's — every GPU section and +# the source beside them for the software backend. +"$DART" run tool/build_material.dart diff --git a/packages/flutter3d/lib/src/engine/assets/load_model_asset.dart b/packages/flutter3d/lib/src/engine/assets/load_model_asset.dart index 5016472e1..10bda4e3b 100644 --- a/packages/flutter3d/lib/src/engine/assets/load_model_asset.dart +++ b/packages/flutter3d/lib/src/engine/assets/load_model_asset.dart @@ -4,8 +4,6 @@ /// every build after the first `init` actually ships. library; -import 'dart:io' show FileSystemException; - import 'package:flutter/foundation.dart' show FlutterError, debugPrint, kDebugMode, kIsWeb; import 'package:flutter3d_core/flutter3d_core.dart'; @@ -31,6 +29,19 @@ String generatedAssetPathFor(String sourcePath) { return 'flutter3d_generated/$withoutExtension.f3d'; } +/// Where the build hook wrote [sourcePath]'s compiled material — `P8`: +/// `assets_src/fx/rim.f3dmat` is `flutter3d_generated/fx/rim.f3dshaders`, +/// the mapping `AssetLayout.materialPlan` computes in `flutter3d_build`, kept +/// beside [generatedAssetPathFor] for the reason that one is reimplemented. +String generatedMaterialPathFor(String sourcePath) { + final relative = sourcePath.startsWith(_sourceDirPrefix) + ? sourcePath.substring(_sourceDirPrefix.length) + : sourcePath; + final dot = relative.lastIndexOf('.'); + final withoutExtension = dot < 0 ? relative : relative.substring(0, dot); + return 'flutter3d_generated/$withoutExtension.f3dshaders'; +} + /// The device class every [loadModelAsset] reads as when its caller names /// none — `N7`. Null, the default, reads the single `.f3d` a build without /// classes writes. @@ -99,8 +110,9 @@ Future loadModelAsset( ); } on FlutterError { // No file for this class: the single one below. - } on FileSystemException { - // The same, read from disk. + } catch (error) { + // The same, read from disk; anything else is not a missing file. + if (!isMissingFile(error)) rethrow; } } try { @@ -109,7 +121,8 @@ Future loadModelAsset( ); } on FlutterError { return _fallback(sourcePath, generatedPath, debugMode, fallbackSource); - } on FileSystemException { + } catch (error) { + if (!isMissingFile(error)) rethrow; return _fallback(sourcePath, generatedPath, debugMode, fallbackSource); } } diff --git a/packages/flutter3d/lib/src/engine/assets/material_loader.dart b/packages/flutter3d/lib/src/engine/assets/material_loader.dart index 51d067f13..14346e4d8 100644 --- a/packages/flutter3d/lib/src/engine/assets/material_loader.dart +++ b/packages/flutter3d/lib/src/engine/assets/material_loader.dart @@ -80,10 +80,15 @@ Future loadMaterialDocument( /// field here to hang a sampler off, so the encoder binds it with the /// device's default. Only whether it carries a mip chain survives, because /// that is part of the texture rather than of the sampler. +/// +/// [name] is what the material is called once bound, the file's own name by +/// default. A level passes the name its surfaces use for it, because that is +/// the one a tool addresses it by (`ext.flutter3d.material.set`). Future bindMaterial( MaterialDocument document, { required GraphicsDevice device, required AssetUriResolver resolveUri, + String? name, LightingModel lighting = LightingModel.pbr, List? warnings, ImageDecoder decodeImage = defaultImageDecoder, @@ -192,7 +197,7 @@ Future bindMaterial( } return Material( - name: surface.name, + name: name ?? surface.name, lighting: document.lighting ?? (surface.lightingModel ?? diff --git a/packages/flutter3d/lib/src/engine/assets/model_instance.dart b/packages/flutter3d/lib/src/engine/assets/model_instance.dart index cbc3feed7..c469da3b5 100644 --- a/packages/flutter3d/lib/src/engine/assets/model_instance.dart +++ b/packages/flutter3d/lib/src/engine/assets/model_instance.dart @@ -25,10 +25,12 @@ final class ModelInstance { required this.skeletons, required this.player, this.variants = const [], - this._drawn = const <(MeshNode, ModelPart)>[], + List<(MeshNode, ModelPart)> drawn = const <(MeshNode, ModelPart)>[], + this._slots = const {}, Material Function(Material)? materialFor, PointerTargets? pointerTargets, - }) : _materialFor = materialFor ?? _same, + }) : _drawn = List<(MeshNode, ModelPart)>.of(drawn), + _materialFor = materialFor ?? _same, pointerTargets = pointerTargets ?? PointerTargets(); static Material _same(Material material) => material; @@ -37,8 +39,15 @@ final class ModelInstance { final List variants; /// Each mesh node with the part it was built from, for [selectVariant]. + /// + /// Its entries are replaced by [adopt], so a variant chosen after a swap + /// dresses the new parts. final List<(MeshNode, ModelPart)> _drawn; + /// Where in [_drawn] each drawn surface sits, by [ModelAssetInstantiate]'s + /// slot key: the node's name and which of its surfaces or levels this is. + final Map _slots; + /// The instance's own copy of an asset material, or the material itself /// when materials are shared — see [ModelAssetInstantiate.instantiate]. final Material Function(Material) _materialFor; @@ -79,6 +88,54 @@ final class ModelInstance { return true; } + /// Draws [next]'s meshes and materials in this instance's nodes, keeping + /// the nodes themselves: where the game put them, what the animation is + /// doing to them, what hangs from them. + /// + /// **For a model edited while the game runs.** The file changed on disk, or + /// arrived over the VM service; instantiating it again would build new + /// nodes, and every reference the game holds to the old ones — a hand a + /// sword hangs from, a node a camera follows — would point at a model no + /// longer drawn. So the surfaces are matched instead, by the name of the + /// node that draws them and their place among that node's surfaces, and + /// each matched [MeshNode] is handed the new geometry and material. + /// + /// What does not follow: a surface under a node [next] names differently, + /// or one it adds, is reported in [ModelSwap.added] and needs the model + /// instantiated again; one [next] no longer has is left drawn as it was and + /// reported in [ModelSwap.kept]. A node moved in the file stays where it + /// was here, since here it is the game's. The skeleton stays the one the + /// instance was built with, bound to the same nodes. + /// + /// The variant chosen with [selectVariant] is chosen again from [next]'s + /// parts. The old asset is not released: other instances may still draw it + /// until they adopt too, and the caller that loaded it knows when none do. + ModelSwap adopt(ModelAsset next) { + final nextSlots = next._slotParts(); + final kept = []; + var swapped = 0; + for (final MapEntry(key: slot, value: index) in _slots.entries) { + final part = nextSlots[slot]; + if (part == null) { + kept.add(slot); + continue; + } + final (mesh, _) = _drawn[index]; + mesh.mesh = part.mesh; + _drawn[index] = (mesh, part); + swapped++; + } + selectVariant(_variant); + return ModelSwap( + swapped: swapped, + kept: List.unmodifiable(kept), + added: List.unmodifiable([ + for (final slot in nextSlots.keys) + if (!_slots.containsKey(slot)) slot, + ]), + ); + } + /// Lets animation pointer tracks aimed at the file's light [index] drive /// [light]. Instantiating a model creates no lights, so a clip that /// animates one reaches only a light the caller has put in its place. @@ -103,6 +160,28 @@ final class ModelInstance { void removeFromScene() => root.removeFromParent(); } +/// What one [ModelInstance.adopt] did, by slot key: a node's name, then `/` +/// and which of its surfaces (`s0`, `s1`, …) or level surfaces (`l0`, …). +final class ModelSwap { + const ModelSwap({ + required this.swapped, + required this.kept, + required this.added, + }); + + /// Surfaces drawn from the new asset now. + final int swapped; + + /// Surfaces the new asset does not have, still drawn as they were. + final List kept; + + /// Surfaces only the new asset has; instantiate it again to draw them. + final List added; + + /// Whether the instance now draws everything the new asset has. + bool get complete => added.isEmpty; +} + /// `instantiate` and its helpers, added to [ModelAsset]. /// /// Named and public — unlike the `part`s in the gltf/OBJ/f3d decoders, where @@ -179,6 +258,7 @@ extension ModelAssetInstantiate on ModelAsset { final created = List.filled(nodes.length, null); final meshNodes = []; final drawn = <(MeshNode, ModelPart)>[]; + final slots = {}; final materials = {}; // Skeletons are attached after the walk: a joint may be created later than // the mesh that references it, so binding as we go would capture nulls. @@ -218,7 +298,7 @@ extension ModelAssetInstantiate on ModelAsset { parentNode.add(node); created[index] = node; - MeshNode? addSurface(int surfaceIndex) { + MeshNode? addSurface(int surfaceIndex, String slot) { if (surfaceIndex < 0 || surfaceIndex >= parts.length) return null; final part = parts[surfaceIndex]; final mesh = MeshNode( @@ -227,6 +307,9 @@ extension ModelAssetInstantiate on ModelAsset { name: part.name, ); meshNodes.add(mesh); + if (_slotKey(model.name, slot) case final String key) { + slots.putIfAbsent(key, () => drawn.length); + } drawn.add((mesh, part)); if (part.skinIndex != null) pendingSkins.add((mesh, part.skinIndex!)); @@ -262,11 +345,11 @@ extension ModelAssetInstantiate on ModelAsset { (lod) => lod.surfaceIndices.length == 1 || lod.impostor != null, ); if (model.lods.isNotEmpty && singleSurfaceLevels) { - final baseMesh = addSurface(model.surfaces.single); + final baseMesh = addSurface(model.surfaces.single, 's0'); if (baseMesh != null) { final levels = [ LodLevel(node: baseMesh, maxScreenFraction: 2.0), - for (final lod in model.lods) + for (final (level, lod) in model.lods.indexed) // `C4`: the card a chain ends in, drawn from the atlases the // asset uploaded once for every instance. if (impostors[lod.impostor] case final ImpostorPart part) @@ -282,7 +365,8 @@ extension ModelAssetInstantiate on ModelAsset { maxScreenFraction: lod.maxScreenFraction, ) else if (lod.surfaceIndices.length == 1) - if (addSurface(lod.surfaceIndices.single) case final MeshNode m) + if (addSurface(lod.surfaceIndices.single, 'l$level') + case final MeshNode m) // The group sits at the node with no transform of its // own, so the node's units the error is measured in are // the group's. @@ -295,8 +379,8 @@ extension ModelAssetInstantiate on ModelAsset { node.add(LodGroup(levels: levels, name: model.name)); } } else { - for (final surfaceIndex in model.surfaces) { - final mesh = addSurface(surfaceIndex); + for (final (slot, surfaceIndex) in model.surfaces.indexed) { + final mesh = addSurface(surfaceIndex, 's$slot'); if (mesh != null) node.add(mesh); } } @@ -348,6 +432,7 @@ extension ModelAssetInstantiate on ModelAsset { ), variants: variants, drawn: drawn, + slots: slots, materialFor: materialFor, pointerTargets: pointerTargets, ); @@ -392,6 +477,36 @@ extension ModelAssetInstantiate on ModelAsset { }; static Material _copyMaterial(Material source) => source.copy(); + + /// The part each slot of this asset draws, keyed as [instantiate] keys + /// them, for [ModelInstance.adopt] to match against. + Map _slotParts() { + final found = {}; + void put(String? node, String slot, int surface) { + if (surface < 0 || surface >= parts.length) return; + if (_slotKey(node, slot) case final String key) { + found.putIfAbsent(key, () => parts[surface]); + } + } + + for (final node in nodes) { + for (final (slot, surface) in node.surfaces.indexed) { + put(node.name, 's$slot', surface); + } + for (final (level, lod) in node.lods.indexed) { + if (lod.surfaceIndices.length == 1) { + put(node.name, 'l$level', lod.surfaceIndices.single); + } + } + } + return found; + } + + /// A drawn surface's name across two versions of one file: the node's name + /// and its place there. An unnamed node matches nothing, since two + /// unnamed nodes cannot be told apart. + static String? _slotKey(String? node, String slot) => + node == null ? null : '$node/$slot'; } /// A weights track reaching every primitive of one split mesh. diff --git a/packages/flutter3d/pubspec.yaml b/packages/flutter3d/pubspec.yaml index b10172b8c..d96f8908c 100644 --- a/packages/flutter3d/pubspec.yaml +++ b/packages/flutter3d/pubspec.yaml @@ -15,6 +15,18 @@ resolution: workspace environment: sdk: ">=3.12.0 <4.0.0" +# Declared rather than left to pub.dev's detector, which reads imports: the +# engine draws through flutter3d_impeller on every native platform and +# through flutter3d_webgl in the browser, and its asset layer picks isolates +# and files or their absence at compile time (`platform/` in flutter3d_core). +platforms: + android: + ios: + linux: + macos: + web: + windows: + dependencies: flutter: sdk: flutter diff --git a/packages/flutter3d/test/alpha_to_coverage_test.dart b/packages/flutter3d/test/alpha_to_coverage_test.dart new file mode 100644 index 000000000..70ff51d59 --- /dev/null +++ b/packages/flutter3d/test/alpha_to_coverage_test.dart @@ -0,0 +1,128 @@ +/// A masked material's edge as multisample coverage — `P7`, +/// `Material.alphaToCoverage`. +/// +/// flutter test test/alpha_to_coverage_test.dart +/// +/// Two backends of four can, so these claims are about what the engine asks +/// of a device and what it tells the caller: the coverage switched on for the +/// material that wants it and off again before anything else, the cutoff +/// handed to the shader as the coverage encoding only when the call was made, +/// and a refusal said out loud where the device cannot. +library; + +import 'dart:typed_data'; + +import 'package:flutter3d_core/geometry.dart'; +import 'package:flutter3d_core/src/engine/geometry/device_mesh.dart'; +import 'package:flutter3d_core/src/engine/render/material.dart'; +import 'package:flutter3d_core/src/engine/render/render_view.dart'; +import 'package:flutter3d_core/src/engine/render/renderer.dart'; +import 'package:flutter3d_core/src/engine/scene/camera_node.dart'; +import 'package:flutter3d_core/src/engine/scene/mesh_node.dart'; +import 'package:flutter3d_core/src/engine/scene/scene.dart'; +import 'package:flutter3d_hardware/flutter3d_hardware.dart'; +import 'package:flutter3d_hardware/testing.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:vector_math/vector_math.dart'; + +/// A frame of one cube of [material] on a device that [supported] says can +/// or cannot, and what came of it. +({FrameResult result, FakePass scenePass}) _frame( + Material material, { + bool supported = true, +}) { + final device = FakeBackend(supportsAlphaToCoverage: supported); + final texel = device.createTextureFromPixels( + width: 1, + height: 1, + format: TextureFormat.r8g8b8a8UNormInt, + pixels: ByteData(4), + )!; + final renderer = Renderer.create( + device: device, + fallbackAlbedo: texel, + fallbackNormal: texel, + ); + final scene = Scene(name: 'coverage'); + final camera = CameraNode(name: 'eye')..setPosition(0.0, 0.0, 4.0); + scene.root + ..add(camera) + ..add( + MeshNode( + DeviceMesh.upload(device, CuboidShape(size: Vector3.all(1.0)).build()), + material, + name: 'leaf', + ), + ); + final result = renderer.render( + width: 64, + height: 48, + scene: scene, + views: [RenderView(camera: camera)], + settings: const RenderSettings(bloom: BloomSettings(enabled: false)), + ); + // The pass that drew the leaf: the one that bound its material's block. + final scenePass = device.passes.firstWhere( + (FakePass pass) => pass.recordedOf().any( + (RecordedUniformBlock b) => b.block == 'FragInfo', + ), + ); + return (result: result, scenePass: scenePass); +} + +Material _leaf({bool coverage = true}) => Material( + name: 'leaf', + alphaMode: MaterialAlphaMode.mask, + alphaCutoff: 0.4, + alphaToCoverage: coverage, +); + +/// The calls the pass made to `setAlphaToCoverage`, in order. +List _coverageCalls(FakePass pass) => [ + for (final call in pass.recordedOf()) call.enabled, +]; + +/// `FragInfo.material2.x` as the leaf's draw bound it. +double _cutoffBound(FakePass pass) => pass + .recordedOf() + .lastWhere((RecordedUniformBlock b) => b.block == 'FragInfo') + .members['material2']![0]; + +void main() { + test('a leaf that asks gets coverage, and the pass turns it off after', () { + // Mutation: drop the reset before the contributors in + // `renderer_scene_pass.dart`, and the pass ends with coverage on for + // whatever the particles draw next. + final frame = _frame(_leaf()); + expect(_coverageCalls(frame.scenePass), [true, false]); + expect(frame.result.alphaToCoverageDeclined, isFalse); + }); + + test('the shader is told to keep the fragment only when coverage is on', () { + // Mutation: write `1 + cutoff` whatever the device answered, and the + // shader keeps every fragment on a device that cannot spread them, which + // draws the leaf's whole card. + expect(_cutoffBound(_frame(_leaf()).scenePass), closeTo(1.4, 1e-6)); + expect( + _cutoffBound(_frame(_leaf(), supported: false).scenePass), + closeTo(0.4, 1e-6), + ); + }); + + test('a device that cannot says so, and draws the hard cutoff', () { + // Mutation: drop the `coverageDeclined` line in the mesh encoder, and a + // leaf drawn as a cut-out on Impeller looks like a success. + final frame = _frame(_leaf(), supported: false); + expect(_coverageCalls(frame.scenePass), isEmpty); + expect(frame.result.alphaToCoverageDeclined, isTrue); + }); + + test('a scene that does not ask emits nothing, and refuses nothing', () { + // Unset means no call at all, as the depth test's tracker promises: a + // backend that never hears of coverage is drawn as it always was. + final frame = _frame(_leaf(coverage: false)); + expect(_coverageCalls(frame.scenePass), isEmpty); + expect(frame.result.alphaToCoverageDeclined, isFalse); + expect(_cutoffBound(frame.scenePass), closeTo(0.4, 1e-6)); + }); +} diff --git a/packages/flutter3d/test/ambient_test.dart b/packages/flutter3d/test/ambient_test.dart index bb140c44b..15a4fadf3 100644 --- a/packages/flutter3d/test/ambient_test.dart +++ b/packages/flutter3d/test/ambient_test.dart @@ -141,7 +141,7 @@ void main() { // approximately, bit for bit. // // Mutation: default either end of the hemisphere to anything but white. - // Seventy-eight goldens move at once. + // Ninety-five goldens move at once. final lit = await _pixels(_ball(), _noSky); final top = _band(lit, 0.15, 0.35); final bottom = _band(lit, 0.65, 0.85); diff --git a/packages/flutter3d/test/anisotropy_test.dart b/packages/flutter3d/test/anisotropy_test.dart index 14a8cdc09..712a7d85e 100644 --- a/packages/flutter3d/test/anisotropy_test.dart +++ b/packages/flutter3d/test/anisotropy_test.dart @@ -24,7 +24,7 @@ /// which spends the taps and throws away what they bought — contrast drops /// *below* the trilinear baseline and the first assertion fails; and letting /// a sampler that asked for one reach the new path at all, which would move a -/// backend seventy-eight golden scenes are recorded on. +/// backend ninety-five golden scenes are recorded on. /// /// **One they do not catch, said rather than hidden:** taking the taps along /// the *short* axis instead of the long one passes every line here. The @@ -213,7 +213,7 @@ void main() { }); test('asking for one tap is the old sampler exactly', () async { - // What makes this safe to add to the backend seventy-eight golden scenes are + // What makes this safe to add to the backend ninety-five golden scenes are // recorded on. Not "close": the same bytes, because with `anisotropy` at // one the new path is not reached at all. expect(await _floorAt(1), await _floorAt(1)); diff --git a/packages/flutter3d/test/atmosphere_test.dart b/packages/flutter3d/test/atmosphere_test.dart index 972a90b98..b8896f1b0 100644 --- a/packages/flutter3d/test/atmosphere_test.dart +++ b/packages/flutter3d/test/atmosphere_test.dart @@ -32,6 +32,24 @@ void main() { expect(dusk.fog.density, closeTo(0.01, 1e-6)); }); + test('a height fog blends with the rest of the air and reaches the fog', () { + // `P5`. Mutation: leave the two new fields out of `lerp`, or out of the + // `fog` getter. A morning mist that is meant to lift through the day + // either never lifts or never lies on the ground at all. + final mist = Atmosphere( + sky: Vector3(0.6, 0.6, 0.6), + fogDensity: 0.05, + fogHeightFalloff: 0.2, + fogBaseHeight: 2.0, + sunColor: Vector3(1.0, 1.0, 1.0), + ); + final half = Atmosphere.lerp(_noon(), mist, 0.5); + expect(half.fogHeightFalloff, closeTo(0.1, 1e-9)); + expect(half.fogBaseHeight, closeTo(1.0, 1e-9)); + expect(half.fog.heightFalloff, closeTo(0.1, 1e-9)); + expect(half.fog.baseHeight, closeTo(1.0, 1e-9)); + }); + test('a day goes round, blending the last key back into the first', () { final day = AtmosphereCycle(<(double, Atmosphere)>[ (0.0, _noon()), diff --git a/packages/flutter3d/test/auto_batch_test.dart b/packages/flutter3d/test/auto_batch_test.dart index 16b78e235..5ef7a1823 100644 --- a/packages/flutter3d/test/auto_batch_test.dart +++ b/packages/flutter3d/test/auto_batch_test.dart @@ -22,7 +22,7 @@ /// three backends.** It computes in Dart doubles; Impeller, WebGL and WebGPU /// compute in 32-bit floats, where those two expressions have far less room /// before they part. Nothing here can run them, so the setting stays off by -/// default — which is also what keeps the seventy-eight goldens where they are. +/// default — which is also what keeps the ninety-five goldens where they are. library; import 'dart:math' as math; @@ -191,7 +191,7 @@ void main() { }); test('nothing is batched unless it is asked for', () async { - // Which is what keeps the seventy-eight goldens where they are: the default + // Which is what keeps the ninety-five goldens where they are: the default // frame is the frame it always was, to the byte. final device = _device(); final scene = _field(device, count: 100); diff --git a/packages/flutter3d/test/binding_contract_test.dart b/packages/flutter3d/test/binding_contract_test.dart index 0eec9ef69..a4f913132 100644 --- a/packages/flutter3d/test/binding_contract_test.dart +++ b/packages/flutter3d/test/binding_contract_test.dart @@ -27,9 +27,13 @@ import 'package:vector_math/vector_math.dart'; /// The declared slots [draw] left unbound, one line each. List _unbound( - void Function(FakeBackend device, Renderer renderer) draw, -) { - final device = FakeBackend(stageBindings: stageBindings); + void Function(FakeBackend device, Renderer renderer) draw, { + int attachments = 2, +}) { + final device = FakeBackend( + stageBindings: stageBindings, + maxColorAttachments: attachments, + ); final renderer = Renderer.create(device: device); draw(device, renderer); return device.bindingViolations.toSet().toList()..sort(); @@ -143,4 +147,103 @@ void main() { }); expect(unbound, isEmpty, reason: unbound.join('\n')); }); + + test('nor does the decal pass, with pictures and without', () { + // `P3`: four picture slots a draw binds whether or not a decal names + // them, and two buffers out of the scene pass. Three attachments, or the + // pass is refused before it binds anything. + // + // Mutation: binding only the slots a batch fills leaves three of the four + // pictures unbound, which is a crash on Metal. + final ran = []; + final unbound = _unbound((FakeBackend device, Renderer renderer) { + final picture = device.createTextureFromPixels( + width: 4, + height: 4, + format: TextureFormat.r8g8b8a8UNormInt, + pixels: ByteData(4 * 4 * 4), + )!; + final scene = Scene() + ..add( + MeshNode( + DeviceMesh.upload( + device, + CuboidShape(size: Vector3(4.0, 0.2, 4.0)).build(), + ), + Material(name: 'floor'), + )..setPosition(0.0, -0.1, 0.0), + ) + ..add(DecalNode(texture: picture)..setScale(2.0, 1.0, 2.0)) + ..add(DecalNode()..setScale(1.0, 1.0, 1.0)) + ..add( + CameraNode() + ..setPosition(0.0, 4.0, 2.0) + ..lookAt(Vector3.zero()), + ); + final result = renderer.render( + width: 64, + height: 64, + scene: scene, + views: [RenderView(camera: scene.cameras.single)], + settings: const RenderSettings(decals: DecalSettings(enabled: true)), + ); + ran.addAll(result.passes.map((p) => p.name)); + }, attachments: 3); + expect(ran, contains('decals')); + expect(unbound, isEmpty, reason: unbound.join('\n')); + }); + + test('nor does a planar reflection, or a camera into a texture', () { + // `P4`: the mirrored camera draws the lit scene, the reflection is laid + // over a floor through its own stage with its picture and its block, and + // a render texture's light is encoded through a full-screen stage. All + // three under a shadowed sun, which is what a lit capture binds most of. + // + // Mutation: binding the reflection without its block, or the floor's + // albedo slot to a stage that dropped it, is a violation here and a + // crash on Metal. + final ran = []; + final unbound = _unbound((FakeBackend device, Renderer renderer) { + final floor = MeshNode( + DeviceMesh.upload(device, const PlaneShape(width: 6, depth: 6).build()), + Material(name: 'floor'), + ); + final camera = CameraNode() + ..setPosition(0.0, 3.0, 4.0) + ..lookAt(Vector3.zero()); + final scene = Scene() + ..add(floor) + ..add( + MeshNode( + DeviceMesh.upload( + device, + CuboidShape(size: Vector3.all(0.6)).build(), + ), + Material(name: 'box'), + )..setPosition(0.0, 0.8, 0.0), + ) + ..add(PlanarReflectorNode(surfaces: [floor])) + ..add( + LightNode(intensity: 3.0) + ..setPosition(2.0, 3.0, 4.0) + ..lookAt(Vector3.zero()), + ) + ..add(camera) + ..addRenderTexture( + RenderTexture.create(device, camera: camera, width: 16, height: 16), + ); + final result = renderer.render( + width: 64, + height: 64, + scene: scene, + views: [RenderView(camera: camera)], + settings: const RenderSettings( + planarReflections: PlanarReflectionSettings(enabled: true), + ), + ); + ran.addAll(result.passes.map((p) => p.name)); + }); + expect(ran, containsAll(['planar reflections', 'render textures'])); + expect(unbound, isEmpty, reason: unbound.join('\n')); + }); } diff --git a/packages/flutter3d/test/cascade_test.dart b/packages/flutter3d/test/cascade_test.dart index be9a2fd3d..43b1b29c7 100644 --- a/packages/flutter3d/test/cascade_test.dart +++ b/packages/flutter3d/test/cascade_test.dart @@ -18,6 +18,8 @@ /// * nothing is left unshadowed by a gap between volumes. library; +import 'dart:math' as math; + import 'package:flutter3d/flutter3d.dart'; import 'package:flutter3d/parity_scene.dart'; import 'package:flutter3d_cpu/flutter3d_cpu.dart'; @@ -123,6 +125,33 @@ Set _shadowed(List grid) => { if (grid[i] > 20 && grid[i] < 170) i, }; +/// A floor two hundred metres square — a level far larger than any frame +/// that looks down on part of it — with a post every six metres round the +/// middle, so there is a shadow in every corner of such a frame. +Scene _postYard() { + final scene = Scene(); + final device = CpuDevice( + width: 4, + height: 4, + shaders: CpuShaderLibrary(builtinCpuShaders()), + ); + MeshNode block(Vector3 size, Vector3 at) => MeshNode( + DeviceMesh.upload(device, CuboidShape(size: size).build()), + Material(baseColor: Vector4(0.8, 0.8, 0.8, 1.0)), + )..setPositionFrom(at); + scene.add(block(Vector3(200.0, 1.0, 200.0), Vector3(0.0, -0.5, 0.0))); + for (var x = -24.0; x <= 24.0; x += 6.0) { + for (var z = -24.0; z <= 24.0; z += 6.0) { + scene.add(block(Vector3(0.8, 3.0, 0.8), Vector3(x, 1.5, z))); + } + } + scene.add( + LightNode(type: LightType.directional, intensity: 1.1, castsShadow: true) + ..setLocalForward(Vector3(-0.5, -0.8, 0.3)), + ); + return scene; +} + void main() { test('the default is three tiles of a thousand, not one of two', () { // These two numbers only mean anything together, which is why one test @@ -512,4 +541,119 @@ void main() { expect(bias[i], greaterThanOrEqualTo(1.0 / 2048.0), reason: 'cascade $i'); } }); + + group('through an orthographic lens — P7', () { + ({Scene scene, CameraNode camera}) orthoRoom() { + final room = _longRoom(length: 400.0); + room.camera.projection = const OrthographicProjection(height: 14.0); + return room; + } + + test( + 'the cascades are slabs of the view, not of the air before it', + () async { + // Mutation: size the orthographic cascades by distance from the eye, as + // the perspective ones are, and the near ones shrink to a few metres + // round a point the view is far wider than: the frame's corners fall + // through to the scene's whole map. + final room = orthoRoom(); + final engine = _engine(); + await _grid(engine, room, const ShadowSettings(cascades: 3)); + final radii = engine.renderer.debugCascadeRadii; + final centres = engine.renderer.debugCascadeCentres; + expect(radii, hasLength(3)); + + // Every near slab is at least as wide as the frame's half-diagonal, + // seven metres high at this aspect, and far narrower than the scene. + final across = 7.0 * math.sqrt(1.0 + math.pow(_width / _height, 2)); + for (final radius in radii.take(2)) { + expect(radius, greaterThanOrEqualTo(across - 1e-6)); + expect(radius, lessThan(radii.last)); + } + + // And they stand one after the other along the view axis. + final forward = room.camera.readForward(); + final eye = room.camera.readWorldPosition(); + final depth0 = (centres[0] - eye).dot(forward); + final depth1 = (centres[1] - eye).dot(forward); + expect(depth0, greaterThan(0.0)); + expect(depth1, greaterThan(depth0)); + }, + ); + + test('stepping the camera back along its axis changes nothing', () async { + // The property that makes a lens orthographic, held to under cascades: + // the slabs are cut from what the frame shows and the shader picks one + // by depth along the axis, so where the eye stands on that axis moves + // nothing in the world. Picked by distance from the eye instead, a + // fragment towards a corner of the frame counts as further away the + // nearer the camera stands, and crosses into the next cascade. + // + // Mutation: pick by distance in `cpu_shaders_shadow_directional.dart` + // (and `shadow.glsl`) through an orthographic lens. + final scene = _postYard(); + const shadows = ShadowSettings(cascades: 3, viewDistance: 24.0); + // Looking almost straight down on the middle of the yard from six + // metres up: the frame is twenty-five metres to a corner and the posts' + // tops three metres away, so distance from the eye and depth along the + // axis part company at the corners — and the floor is near enough + // square to the axis that nothing in the frame is behind the near + // plane at either stand. + final axis = Vector3(0.1, -1.0, 0.1)..normalize(); + final clear = Vector3(0.0, 6.0, 0.0); + final camera = CameraNode(); + scene.add(camera); + Future> from(double back) async { + final eye = clear - axis.scaled(back); + camera + ..projection = const OrthographicProjection(height: 30.0, far: 400.0) + ..setPositionFrom(eye) + ..lookAt(eye + axis); + final engine = _engine(); + final frame = engine.renderer.render( + width: _width, + height: _height, + scene: scene, + views: [RenderView(camera: camera)], + settings: const RenderSettings( + shadows: shadows, + bloom: BloomSettings(enabled: false), + ), + ); + return (await engine.device.readPixels( + frame.frame, + ))!.buffer.asUint8List(); + } + + final near = await from(0.0); + final far = await from(40.0); + var moved = 0; + for (var i = 0; i < near.length; i++) { + if ((near[i] - far[i]).abs() > 8) moved++; + } + expect(moved, lessThanOrEqualTo(4)); + }); + + test('three cascades shadow the same things as one', () async { + // Mutation: give the orthographic cascades the perspective path's + // spheres, and the slab a fragment stands in is not one any cascade + // covers but the last: the shadow moves or goes. + final room = orthoRoom(); + final single = await _grid( + _engine(), + room, + const ShadowSettings(cascades: 1), + ); + final many = await _grid( + _engine(), + room, + const ShadowSettings(cascades: 3), + ); + final wasDark = _shadowed(single); + final isDark = _shadowed(many); + expect(wasDark, isNotEmpty, reason: 'there was no shadow to compare'); + expect(isDark.difference(wasDark).length, lessThanOrEqualTo(2)); + expect(wasDark.difference(isDark).length, lessThanOrEqualTo(2)); + }); + }); } diff --git a/packages/flutter3d/test/caustics_test.dart b/packages/flutter3d/test/caustics_test.dart new file mode 100644 index 000000000..4a15e3049 --- /dev/null +++ b/packages/flutter3d/test/caustics_test.dart @@ -0,0 +1,132 @@ +/// `ShadowSettings.caustics`: light through a refracting caster is followed +/// to where it lands. +/// +/// flutter test test/caustics_test.dart +/// +/// A glass ball over a floor, the sun above it. A ball of glass is a lens: it +/// gathers the light that falls on it into a bright spot under it, brighter +/// than the floor around, and leaves a dark ring round the spot. Without +/// caustics the engine can only take light away, so nothing under the ball is +/// brighter than the lit floor. +library; + +import 'dart:math' as math; + +import 'package:flutter3d/flutter3d.dart'; +import 'package:flutter3d_cpu/flutter3d_cpu.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:vector_math/vector_math.dart'; + +const int _size = 96; + +Future> _frame({required bool caustics, bool ball = true}) async { + final device = CpuDevice( + width: _size, + height: _size, + shaders: CpuShaderLibrary(builtinCpuShaders()), + ); + final renderer = Renderer.create(device: device); + final scene = Scene() + ..add( + MeshNode( + DeviceMesh.upload( + device, + CuboidShape(size: Vector3(4, 0.1, 4)).build(), + ), + Material(name: 'floor', baseColor: Vector4(0.8, 0.8, 0.8, 1.0)), + )..setPosition(0.0, -0.05, 0.0), + ) + ..add( + LightNode(intensity: 0.4, castsShadow: true) + ..setLocalForward(Vector3(0.0, -1.0, 0.02)), + ) + ..add( + CameraNode() + ..setPosition(0.0, 3.0, 0.01) + ..lookAt(Vector3.zero()), + ); + if (ball) { + scene.add( + MeshNode( + DeviceMesh.upload( + device, + const SphereShape(radius: 0.3, segments: 48, rings: 24).build(), + ), + Material( + name: 'glass ball', + lighting: LightingModel.pbrLayered, + baseColor: Vector4(1.0, 1.0, 1.0, 1.0), + extensions: MaterialExtensions( + transmission: 1.0, + ior: 1.5, + thickness: 0.6, + ), + ), + ) + ..setPosition(0.0, 0.6, 0.0) + // Seen from above it would hide its own spot. + ..shadowCasting = ShadowCastingMode.shadowsOnly, + ); + } + final frame = renderer.render( + width: _size, + height: _size, + scene: scene, + views: [ + RenderView( + camera: scene.cameras.single, + clearColor: Vector4(0.0, 0.0, 0.0, 1.0), + ), + ], + settings: RenderSettings( + shadows: ShadowSettings( + translucentCasters: true, + caustics: caustics, + cascades: 1, + causticPhotons: 96, + ), + look: const LookSettings(dither: 0), + ), + ); + final bytes = await device.readPixels(frame.frame); + return [for (var i = 0; i < _size * _size * 4; i++) bytes!.getUint8(i)]; +} + +/// The brightest and darkest red within [radius] pixels of the middle. +(int, int) _extremes(List frame, double radius) { + var hi = 0, lo = 255; + for (var y = 0; y < _size; y++) { + for (var x = 0; x < _size; x++) { + final dx = x - _size / 2, dy = y - _size / 2; + if (math.sqrt(dx * dx + dy * dy) > radius) continue; + final r = frame[(y * _size + x) * 4]; + hi = math.max(hi, r); + lo = math.min(lo, r); + } + } + return (hi, lo); +} + +void main() { + test( + 'a glass ball gathers the sun into a spot brighter than the floor', + () async { + final floor = await _frame(caustics: true, ball: false); + final (litHi, _) = _extremes(floor, 20); + final lens = await _frame(caustics: true); + final (hi, lo) = _extremes(lens, 20); + // Mutation: drop the photon pass, and nothing is brighter than the lit + // floor; drop the stop in the transmittance stage, and nothing is darker. + expect(hi, greaterThan(litHi + 10)); + expect(lo, lessThan(litHi - 20)); + }, + ); + + test('without caustics nothing under the ball is brighter', () async { + final floor = await _frame(caustics: false, ball: false); + final (litHi, _) = _extremes(floor, 20); + final plain = await _frame(caustics: false); + final (hi, _) = _extremes(plain, 20); + expect(hi, lessThanOrEqualTo(litHi + 1)); + }); +} diff --git a/packages/flutter3d/test/draw_journal_test.dart b/packages/flutter3d/test/draw_journal_test.dart new file mode 100644 index 000000000..6853da5a7 --- /dev/null +++ b/packages/flutter3d/test/draw_journal_test.dart @@ -0,0 +1,175 @@ +/// `P12`: every draw of one frame written down, when somebody asked for it. +/// +/// flutter test test/draw_journal_test.dart +/// +/// The counters on `FrameResult.passes` say a pass drew forty things; the +/// journal says which forty, with what. It costs an allocation per draw, so it +/// is on for the one captured frame and off in every other — and these tests +/// hold both halves of that, plus the arithmetic that makes the list honest: +/// the draws a pass described and the draws it only counted add up to the +/// draws it reported. +library; + +import 'dart:typed_data'; + +import 'package:flutter3d/flutter3d.dart'; +import 'package:flutter3d_cpu/flutter3d_cpu.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:vector_math/vector_math.dart'; + +const int _size = 24; + +({CpuDevice device, Renderer renderer, Scene scene, DeviceMesh mesh}) _built() { + final device = CpuDevice( + width: _size, + height: _size, + shaders: CpuShaderLibrary(builtinCpuShaders()), + ); + final mesh = DeviceMesh.upload( + device, + CuboidShape(size: Vector3.all(1.4)).build(), + ); + final scene = Scene() + ..add( + MeshNode( + mesh, + Material(name: 'crate', baseColor: Vector4(0.9, 0.5, 0.2, 1.0)), + name: 'crate', + ), + ) + ..add( + LightNode(intensity: 6.0) + ..setPosition(2.0, 3.0, 4.0) + ..lookAt(Vector3.zero()), + ) + ..add( + CameraNode() + ..setPosition(0.0, 0.0, 4.0) + ..lookAt(Vector3.zero()), + ); + return ( + device: device, + renderer: Renderer.create(device: device), + scene: scene, + mesh: mesh, + ); +} + +Future _capture( + ({CpuDevice device, Renderer renderer, Scene scene, DeviceMesh mesh}) it, { + bool draws = false, + Float32List? Function(TextureHandle texture)? readFloats, +}) { + final capture = it.renderer.captureNextFrame( + draws: draws, + readFloats: readFloats, + ); + it.renderer.render( + width: _size, + height: _size, + scene: it.scene, + views: [ + RenderView( + camera: it.scene.cameras.single, + clearColor: Vector4(0.0, 0.0, 0.0, 1.0), + ), + ], + ); + return capture; +} + +void main() { + test( + 'a journaled frame names the mesh and material each draw used', + () async { + final it = _built(); + final capture = await _capture(it, draws: true); + + final crate = capture.draws.where((d) => d.kind == 'mesh').single; + expect(crate.pass, 'scene'); + expect(crate.mesh, 'crate'); + expect(crate.material, 'crate'); + expect(crate.vertices, it.mesh.vertexCount); + expect(crate.indices, it.mesh.indexCount); + expect(crate.triangles, it.mesh.indexCount ~/ 3); + expect(crate.uniforms['baseColor'], [ + closeTo(0.9, 1e-6), + closeTo(0.5, 1e-6), + closeTo(0.2, 1e-6), + 1.0, + ]); + expect(crate.uniforms['mvp'], hasLength(16)); + expect(crate.state['cull'], 'back'); + // Mutation: recording `draws.length` before the add, or the pass before + // `beginPass`, numbers every draw zero or files it under the wrong pass. + expect( + capture.draws.map((d) => d.index), + List.generate(capture.draws.length, (i) => i), + ); + }, + ); + + test( + 'described and undescribed draws add up to what each pass counted', + () async { + // **The journal does not describe a full-screen triangle**, and must not + // pretend the pass drew nothing either. Mutation: dropping `endPass` + // leaves `undetailedDraws` empty, and the composite's draws vanish from a + // list that claims to be the frame's. + final capture = await _capture(_built(), draws: true); + + for (final pass in capture.passes) { + final described = capture.draws + .where((d) => d.pass == pass.name) + .length; + expect( + described + (capture.undetailedDraws[pass.name] ?? 0), + pass.drawCalls, + reason: 'pass ${pass.name}', + ); + } + expect(capture.passNamed('composite')!.drawCalls, greaterThan(0)); + expect(capture.undetailedDraws, contains('composite')); + }, + ); + + test('a capture that did not ask for draws journals none', () async { + // The journal is for the one frame; the frame after it pays nothing. + // Mutation: leaving `passState.journal` set from `_captureDraws` without + // clearing it between requests journals every later capture too. + final it = _built(); + await _capture(it, draws: true); + final plain = await _capture(it); + + expect(plain.draws, isEmpty); + expect(plain.undetailedDraws, isEmpty); + expect(plain.passNamed('scene')!.drawCalls, greaterThan(0)); + }); + + test('a capture carries each pass its cost', () async { + final capture = await _capture(_built()); + final scene = capture.passNamed('scene')!; + + expect(scene.drawCalls, greaterThan(0)); + expect(scene.triangles, greaterThan(0)); + }); + + test('readFloats puts each output its own floats beside its bytes', () async { + // On the software backend, whose targets are floats already: what lets a + // NaN be told apart from the byte it clamps to. + final it = _built(); + final capture = await _capture(it, readFloats: it.device.readHdrPixels); + final image = capture + .passNamed('scene')! + .images + .firstWhere((i) => i.pixels != null); + + expect(image.floats, isNotNull); + expect(image.floats!.length, image.width * image.height * 4); + final plain = await _capture(it); + expect( + plain.passNamed('scene')!.images.every((i) => i.floats == null), + isTrue, + ); + }); +} diff --git a/packages/flutter3d/test/draw_order_test.dart b/packages/flutter3d/test/draw_order_test.dart new file mode 100644 index 000000000..9a71431a6 --- /dev/null +++ b/packages/flutter3d/test/draw_order_test.dart @@ -0,0 +1,134 @@ +/// An order between nodes that share a material — `P7`, `MeshNode.drawOrder`. +/// +/// flutter test test/draw_order_test.dart +/// +/// The material's bucket was the only explicit order there was, and two nodes +/// sharing a material could not be put in one without a copy of it. These +/// claims are about the sort key and about the batching that reads the sorted +/// list: neither may let a node's order be undone. +library; + +import 'package:flutter3d/flutter3d.dart' hide Material; +import 'package:flutter3d/flutter3d.dart' as engine show Material; +import 'package:flutter3d_cpu/flutter3d_cpu.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:vector_math/vector_math.dart'; + +/// Boxes in a row along the view axis, nearest first, all of [material], +/// each named by its order in [orders]. +({Scene scene, CameraNode camera}) _row( + List orders, + engine.Material material, { + MeshGeometry? geometry, +}) { + final scene = Scene(); + final mesh = + geometry ?? CpuMesh(CuboidShape(size: Vector3(1.0, 1.0, 1.0)).build()); + for (var i = 0; i < orders.length; i++) { + scene.add( + MeshNode(mesh, material, name: 'node $i') + ..drawOrder = orders[i] + // Spread along the view axis, so a depth term that outranked the + // order would put them in another order. + ..setPosition(0.0, 0.0, 10.0 + i * 5.0), + ); + } + final camera = scene.add(CameraNode())..lookAt(Vector3(0.0, 0.0, 1.0)); + return (scene: scene, camera: camera); +} + +/// The names of the drawn nodes in the order the list sorted them, opaque +/// half or transparent half. +List _sorted( + List orders, { + engine.Material? material, + bool transparent = false, +}) { + final world = _row( + orders, + material ?? + engine.Material( + lighting: LightingModel.unlit, + baseColor: Vector4(1.0, 1.0, 1.0, transparent ? 0.5 : 1.0), + alphaMode: transparent + ? MaterialAlphaMode.blend + : MaterialAlphaMode.opaque, + ), + ); + final view = RenderView(camera: world.camera); + final list = RenderList() + ..build( + world.scene, + view, + viewMatrix: world.camera.viewMatrix, + frustum: Frustum.matrix(world.camera.viewProjection(1.0)), + ) + ..sort(view); + return [ + for (final index in transparent ? list.transparent : list.opaque) + list.itemAt(index).requireNode.name ?? '?', + ]; +} + +void main() { + test('nodes sharing a material are drawn in their order, lowest first', () { + // Mutation: leave `drawOrder` out of the bucket in `_sortKey`, and the + // shared material sorts them by depth, nearest first. + expect(_sorted([2, -1, 0]), ['node 1', 'node 2', 'node 0']); + }); + + test('the order holds in the transparent half too', () { + // Far to near is the transparent half's own sort, so without the order + // the farthest node would come first. + expect(_sorted([-1, 0, 1], transparent: true), [ + 'node 0', + 'node 1', + 'node 2', + ]); + }); + + test("a node's order adds to its material's bucket", () { + final material = engine.Material( + lighting: LightingModel.unlit, + drawBucket: 3, + ); + // Bucket three plus minus four is minus one: before an ordinary node of + // the same material at nought, which is three. + expect(_sorted([0, -4], material: material), [ + 'node 1', + 'node 0', + ]); + }); + + test('nodes of different orders are never one instanced draw', () { + // Mutation: drop the order from the batching's run test, and a run that + // crosses from one order to the next is drawn at the first one's place. + // Six on each side, enough for a run either side to batch on its own. + const size = 48; + final device = CpuDevice( + width: size, + height: size, + shaders: CpuShaderLibrary(builtinCpuShaders()), + ); + final mesh = DeviceMesh.upload(device, CuboidShape().build()); + final world = _row( + [for (var i = 0; i < 12; i++) i < 6 ? 0 : 1], + engine.Material(lighting: LightingModel.unlit), + geometry: mesh, + ); + FrameResult draw({required bool batched}) => + Renderer.create(device: device).render( + width: size, + height: size, + scene: world.scene, + views: [RenderView(camera: world.camera)], + settings: RenderSettings(batchIdenticalDraws: batched), + ); + final batched = draw(batched: true); + final plain = draw(batched: false); + expect(batched.batchedDraws, 12); + // Two instanced draws stand for twelve: ten calls fewer. One run of + // twelve across the boundary would be eleven fewer. + expect(plain.drawCalls - batched.drawCalls, 10); + }); +} diff --git a/packages/flutter3d/test/environment_map_test.dart b/packages/flutter3d/test/environment_map_test.dart index a2767ec8c..d4d7086d6 100644 --- a/packages/flutter3d/test/environment_map_test.dart +++ b/packages/flutter3d/test/environment_map_test.dart @@ -9,9 +9,11 @@ /// what most of this file is about. library; +import 'dart:convert'; import 'dart:typed_data'; import 'package:flutter3d/flutter3d.dart'; +import 'package:flutter3d_cpu/flutter3d_cpu.dart'; import 'package:flutter_test/flutter_test.dart'; const int _size = 8; @@ -302,4 +304,67 @@ void main() { ); }); }); + + group('an environment from a file', () { + final device = CpuDevice( + width: 4, + height: 4, + shaders: CpuShaderLibrary(builtinCpuShaders()), + ); + + /// A two-by-one Radiance file, every pixel twice as bright as white. + final hdr = Uint8List.fromList([ + ...utf8.encode('#?RADIANCE\nFORMAT=32-bit_rle_rgbe\n\n-Y 1 +X 2\n'), + for (var i = 0; i < 2; i++) ...[128, 128, 128, 130], + ]); + + test('reads a .hdr itself and anything else through the decoder', () async { + // Mutation: hand every file to the decoder and a `.hdr`, which + // `dart:ui` does not read, never lights anything. + final decoded = []; + Future decode(Uint8List bytes) async { + decoded.add(bytes.length); + return Rgba8Image(width: 2, height: 1, pixels: Uint8List(8)); + } + + final fromHdr = await EnvironmentMap.fromEncoded( + device, + hdr, + decodeImage: decode, + size: 4, + levels: 2, + ); + expect(fromHdr?.levels, 2); + expect(decoded, isEmpty); + + final fromPng = await EnvironmentMap.fromEncoded( + device, + Uint8List.fromList([0x89, 0x50, 0x4e, 0x47]), + decodeImage: decode, + size: 4, + levels: 2, + ); + expect(fromPng, isNotNull); + expect(decoded, [4]); + }); + + test('is null for bytes neither reader takes', () async { + final none = await EnvironmentMap.fromEncoded( + device, + Uint8List.fromList([1, 2, 3]), + decodeImage: (Uint8List bytes) async => null, + ); + expect(none, isNull); + }); + + test('clamps a Radiance value above one to white', () { + // Mutation: scale by 255 without the clamp and the byte runs past + // white, so the brightest part of the sky lights least. + final pixels = EnvironmentMap.hdrToRgba8(readHdr(hdr)); + expect( + [for (var i = 0; i < 4; i++) pixels.getUint8(i)], + [255, 255, 255, 255], + ); + }); + }); } diff --git a/packages/flutter3d/test/fog_test.dart b/packages/flutter3d/test/fog_test.dart index b3063caf6..836c8e95b 100644 --- a/packages/flutter3d/test/fog_test.dart +++ b/packages/flutter3d/test/fog_test.dart @@ -151,7 +151,7 @@ void main() { test('no fog is byte-identical to the fog nobody asked for', () async { // The early return at zero density is not an optimisation. The golden sets - // — seventy-eight scenes, zero-pixel threshold, three backends — are all + // — ninety-five scenes, zero-pixel threshold, three backends — are all // recorded with the default `FogSettings()`, and this is the property that // lets them stay recorded. final none = FogSettings(); diff --git a/packages/flutter3d/test/goldens/alpha-to-coverage.png b/packages/flutter3d/test/goldens/alpha-to-coverage.png new file mode 100644 index 000000000..a83cc44ef Binary files /dev/null and b/packages/flutter3d/test/goldens/alpha-to-coverage.png differ diff --git a/packages/flutter3d/test/goldens/debug-view-split.png b/packages/flutter3d/test/goldens/debug-view-split.png new file mode 100644 index 000000000..274a31f94 Binary files /dev/null and b/packages/flutter3d/test/goldens/debug-view-split.png differ diff --git a/packages/flutter3d/test/goldens/decal-floor.png b/packages/flutter3d/test/goldens/decal-floor.png new file mode 100644 index 000000000..a22e15067 Binary files /dev/null and b/packages/flutter3d/test/goldens/decal-floor.png differ diff --git a/packages/flutter3d/test/goldens/lens-distortion.png b/packages/flutter3d/test/goldens/lens-distortion.png new file mode 100644 index 000000000..c97f9bf46 Binary files /dev/null and b/packages/flutter3d/test/goldens/lens-distortion.png differ diff --git a/packages/flutter3d/test/goldens/lens-flare.png b/packages/flutter3d/test/goldens/lens-flare.png new file mode 100644 index 000000000..158c9b26f Binary files /dev/null and b/packages/flutter3d/test/goldens/lens-flare.png differ diff --git a/packages/flutter3d/test/goldens/material-instance-data.png b/packages/flutter3d/test/goldens/material-instance-data.png new file mode 100644 index 000000000..8da982bed Binary files /dev/null and b/packages/flutter3d/test/goldens/material-instance-data.png differ diff --git a/packages/flutter3d/test/goldens/material-language.png b/packages/flutter3d/test/goldens/material-language.png new file mode 100644 index 000000000..ab4eb77c3 Binary files /dev/null and b/packages/flutter3d/test/goldens/material-language.png differ diff --git a/packages/flutter3d/test/goldens/material-light-hook.png b/packages/flutter3d/test/goldens/material-light-hook.png new file mode 100644 index 000000000..44dda75ef Binary files /dev/null and b/packages/flutter3d/test/goldens/material-light-hook.png differ diff --git a/packages/flutter3d/test/goldens/orthographic-metal.png b/packages/flutter3d/test/goldens/orthographic-metal.png new file mode 100644 index 000000000..6185c32e8 Binary files /dev/null and b/packages/flutter3d/test/goldens/orthographic-metal.png differ diff --git a/packages/flutter3d/test/goldens/orthographic-particles.png b/packages/flutter3d/test/goldens/orthographic-particles.png new file mode 100644 index 000000000..745d01f20 Binary files /dev/null and b/packages/flutter3d/test/goldens/orthographic-particles.png differ diff --git a/packages/flutter3d/test/goldens/orthographic-shadows.png b/packages/flutter3d/test/goldens/orthographic-shadows.png new file mode 100644 index 000000000..0b993aac6 Binary files /dev/null and b/packages/flutter3d/test/goldens/orthographic-shadows.png differ diff --git a/packages/flutter3d/test/goldens/planar-mirror.png b/packages/flutter3d/test/goldens/planar-mirror.png new file mode 100644 index 000000000..ea98c6147 Binary files /dev/null and b/packages/flutter3d/test/goldens/planar-mirror.png differ diff --git a/packages/flutter3d/test/goldens/render-texture.png b/packages/flutter3d/test/goldens/render-texture.png new file mode 100644 index 000000000..fd4f68b6b Binary files /dev/null and b/packages/flutter3d/test/goldens/render-texture.png differ diff --git a/packages/flutter3d/test/goldens/sky-physical-dusk.png b/packages/flutter3d/test/goldens/sky-physical-dusk.png new file mode 100644 index 000000000..ef9f113ea Binary files /dev/null and b/packages/flutter3d/test/goldens/sky-physical-dusk.png differ diff --git a/packages/flutter3d/test/goldens/sky-physical-night.png b/packages/flutter3d/test/goldens/sky-physical-night.png new file mode 100644 index 000000000..9c04163e2 Binary files /dev/null and b/packages/flutter3d/test/goldens/sky-physical-night.png differ diff --git a/packages/flutter3d/test/goldens/smaa-teapot.png b/packages/flutter3d/test/goldens/smaa-teapot.png new file mode 100644 index 000000000..dbf463c57 Binary files /dev/null and b/packages/flutter3d/test/goldens/smaa-teapot.png differ diff --git a/packages/flutter3d/test/goldens/widget-scene.png b/packages/flutter3d/test/goldens/widget-scene.png new file mode 100644 index 000000000..30ec8a655 Binary files /dev/null and b/packages/flutter3d/test/goldens/widget-scene.png differ diff --git a/packages/flutter3d/test/grade_test.dart b/packages/flutter3d/test/grade_test.dart index 247cd834b..ecf1f22c0 100644 --- a/packages/flutter3d/test/grade_test.dart +++ b/packages/flutter3d/test/grade_test.dart @@ -63,7 +63,7 @@ Future> _ramp(LookSettings look, {int width = 256}) async { void main() { test('every default is an exact identity', () async { - // The promise seventy-eight goldens rest on. Neutral for these three is + // The promise ninety-five goldens rest on. Neutral for these three is // (0,0,0) added, (1,1,1) as an exponent and (1,1,1) multiplied, and the // composite has to take all three without moving a byte. final plain = await _ramp(const LookSettings()); diff --git a/packages/flutter3d/test/halation_test.dart b/packages/flutter3d/test/halation_test.dart index 910292c87..af5a23248 100644 --- a/packages/flutter3d/test/halation_test.dart +++ b/packages/flutter3d/test/halation_test.dart @@ -87,7 +87,7 @@ void main() { test('zero is an exact identity, and is the default', () async { // The multiplier is one on every channel at zero, so the chain comes out - // byte for byte as it did — which is what seventy-eight goldens need. + // byte for byte as it did — which is what ninety-five goldens need. expect(const BloomSettings().halation, 0.0); final a = await _glow(halation: 0.0); final b = await _glow(halation: 0.0); diff --git a/packages/flutter3d/test/instanced_mesh_node_test.dart b/packages/flutter3d/test/instanced_mesh_node_test.dart index 749133a1c..76e2f43cd 100644 --- a/packages/flutter3d/test/instanced_mesh_node_test.dart +++ b/packages/flutter3d/test/instanced_mesh_node_test.dart @@ -61,7 +61,53 @@ void main() { node.readTransform(2, read); expect(read, Matrix4.identity()); - expect(node.instanceData.sublist(44, 48), [1.0, 1.0, 1.0, 1.0]); + const at = 2 * InstancedMeshNode.floatsPerInstance; + expect(node.instanceData.sublist(at + 12, at + 16), [ + 1.0, + 1.0, + 1.0, + 1.0, + ]); + expect(node.instanceData.sublist(at + 16, at + 20), [ + 0.0, + 0.0, + 0.0, + 0.0, + ], reason: 'an instance\'s own numbers start at nought'); + }); + + test('holds four numbers of the game\'s own after the colour — P8', () { + // What `i_data` reads, at byte 64 of the record: a material's + // `instance`. Mutation: write them at the colour's offset. + final node = InstancedMeshNode(_unitCube(), Material(), capacity: 2); + node.addInstance( + Matrix4.identity(), + color: Vector4(0.5, 0.5, 0.5, 1.0), + data: Vector4(0.1, 0.2, 0.3, 0.4), + ); + expect(node.instanceData.sublist(12, 16), [0.5, 0.5, 0.5, 1.0]); + final data = node.instanceData.sublist(16, 20); + for (final (i, value) in [0.1, 0.2, 0.3, 0.4].indexed) { + expect(data[i], closeTo(value, 1e-6)); + } + final read = Vector4.zero(); + node.readInstanceData(0, read); + expect(read.y, closeTo(0.2, 1e-6)); + expect(InstancedMeshNode.strideInBytes, 80); + }); + + test('a slot taken again starts from nought, and a released one carries ' + 'its numbers to where it moved', () { + final node = InstancedMeshNode(_unitCube(), Material(), capacity: 2); + final first = node.acquire(data: Vector4(1.0, 0.0, 0.0, 0.0)); + final second = node.acquire(data: Vector4(0.0, 2.0, 0.0, 0.0)); + node.release(first); + final read = Vector4.zero(); + node.readInstanceData(second.index, read); + expect(read.y, 2.0, reason: 'the moved slot kept its numbers'); + final third = node.acquire(); + node.readInstanceData(third.index, read); + expect(read, Vector4.zero(), reason: 'a fresh slot starts at nought'); }); test('refuses what it cannot hold', () { diff --git a/packages/flutter3d/test/irradiance_bounce_test.dart b/packages/flutter3d/test/irradiance_bounce_test.dart index cb3325c58..f1fb7fcb1 100644 --- a/packages/flutter3d/test/irradiance_bounce_test.dart +++ b/packages/flutter3d/test/irradiance_bounce_test.dart @@ -136,7 +136,7 @@ double _tint(Uint8List rgba) { void main() { test('off, the picture is the bytes it was', () async { // The clause every recorded frame depends on. A field is null by default, - // so this is the path seventy-eight goldens take. + // so this is the path ninety-five goldens take. final a = await _draw(field: false, redWall: true); final b = await _draw(field: false, redWall: true); expect(a, orderedEquals(b)); diff --git a/packages/flutter3d/test/masked_shadow_test.dart b/packages/flutter3d/test/masked_shadow_test.dart index 9ea6e71e2..2455e3972 100644 --- a/packages/flutter3d/test/masked_shadow_test.dart +++ b/packages/flutter3d/test/masked_shadow_test.dart @@ -223,7 +223,7 @@ void main() { test('a caster with no map keeps the shadow it always cast', () async { // **The half that lets this land.** A material that is not cut out goes // through the stage it has always gone through, with no sampler in the - // pipeline and no texture bound per draw, so the seventy-eight goldens + // pipeline and no texture bound per draw, so the ninety-five goldens // recorded against that stage cannot move. Asking for MASK with no map to // read is the same case: there is nothing to cut out. final plain = await _frame(masked: false, withTexture: false); diff --git a/packages/flutter3d/test/material_vertex_stage_test.dart b/packages/flutter3d/test/material_vertex_stage_test.dart index 0eda03c58..dc0e64a69 100644 --- a/packages/flutter3d/test/material_vertex_stage_test.dart +++ b/packages/flutter3d/test/material_vertex_stage_test.dart @@ -172,7 +172,7 @@ void main() { // **The half that lets this land**, and the reason the seam is a null field // rather than a new required one: a material saying nothing about its vertex // stage goes through the pipeline it has always gone through, which is also - // why the seventy-eight goldens cannot move. + // why the ninety-five goldens cannot move. final withSeam = await _draw(lighting: _plain); final device = CpuDevice( diff --git a/packages/flutter3d/test/mesh_overlay_test.dart b/packages/flutter3d/test/mesh_overlay_test.dart index e7b61f625..13b4ed35c 100644 --- a/packages/flutter3d/test/mesh_overlay_test.dart +++ b/packages/flutter3d/test/mesh_overlay_test.dart @@ -8,6 +8,7 @@ /// edge sit on its own face, which is `mesh-overlay` in the golden set. library; +import 'dart:math' as math; import 'dart:typed_data'; import 'package:flutter3d/flutter3d.dart'; @@ -75,6 +76,41 @@ double widthOf(List points) { } void main() { + group('lookThrough — P7', () { + MeshOverlay through(Projection projection) { + final camera = CameraNode(projection: projection) + ..setPosition(0.0, 0.0, 10.0); + return MeshOverlay( + vertexShader: const ShaderHandle(backend: 0, name: 'DebugLineVertex'), + fragmentShader: const ShaderHandle(backend: 1, name: 'DebugLine'), + )..lookThrough(camera, 400.0, 300.0); + } + + test('through a perspective lens, the pixel a field of view gives', () { + const fov = 0.9; + final overlay = through(const PerspectiveProjection(fovYRadians: fov)); + final expected = 2 * math.tan(fov / 2) / 300.0; + // Ten units away, so ten of the pixel at one unit. + expect( + overlay.worldSize(1.0, Vector3.zero()), + closeTo(expected * 10.0, 1e-6), + ); + }); + + test('through an orthographic lens, the height over the viewport, at ' + 'every distance', () { + // Mutation: work the pixel out from a field of view, as the example + // did — the size grows with distance and is a quarter turn's at one. + final overlay = through(const OrthographicProjection(height: 6.0)); + for (final z in [0.0, -40.0]) { + expect( + overlay.worldSize(1.0, Vector3(0.0, 0.0, z)), + closeTo(6.0 / 300.0, 1e-6), + ); + } + }); + }); + group('the three batches', () { test('an empty overlay has nothing to draw', () { final overlay = looking(); diff --git a/packages/flutter3d/test/model_adopt_test.dart b/packages/flutter3d/test/model_adopt_test.dart new file mode 100644 index 000000000..3e05564ef --- /dev/null +++ b/packages/flutter3d/test/model_adopt_test.dart @@ -0,0 +1,186 @@ +/// A model edited while the game runs is drawn in the nodes the game holds. +/// +/// flutter test test/model_adopt_test.dart +/// +/// `ModelInstance.adopt` matches surfaces by the name of the node that draws +/// them and their place there, so the nodes survive and only what they draw +/// changes. Drawn on the CPU device, because "the new model is on screen" is +/// a claim about pixels. +/// +/// Mutation: drop the `selectVariant` call at the end of `adopt` and the +/// frame stays red; key the slots on the part's name instead of the node's +/// and the renamed-part case finds nothing to swap. +library; + +import 'dart:typed_data'; + +import 'package:flutter3d/flutter3d.dart'; +import 'package:flutter3d_cpu/flutter3d_cpu.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:vector_math/vector_math.dart'; + +const int _size = 32; + +MeshData _quad(double half) => MeshData( + layout: VertexLayout.standard, + vertices: Float32List.fromList([ + // position, normal, texcoord, tangent, colour + -half, -half, 0, 0, 0, 1, 0, 0, 1, 0, 0, 1, 1, 1, 1, 1, + half, -half, 0, 0, 0, 1, 1, 0, 1, 0, 0, 1, 1, 1, 1, 1, + half, half, 0, 0, 0, 1, 1, 1, 1, 0, 0, 1, 1, 1, 1, 1, + -half, half, 0, 0, 0, 1, 0, 1, 1, 0, 0, 1, 1, 1, 1, 1, + ]), + indices: Uint32List.fromList([0, 1, 2, 0, 2, 3]), +); + +/// A hull and, when [withFlag], a flag: one quad each, unlit in [colour]. +PlainModelDocument _ship( + Vector4 colour, { + bool withFlag = false, + String hull = 'hull', + String? partName, +}) => PlainModelDocument( + surfaces: [ + ModelSurface(mesh: _quad(1.0), materialIndex: 0, name: partName), + if (withFlag) ModelSurface(mesh: _quad(0.1), materialIndex: 0), + ], + materials: [SurfaceMaterial(baseColor: colour, unlit: true)], + nodes: [ + ModelNode(name: hull, surfaces: [0]), + if (withFlag) ModelNode(name: 'flag', surfaces: [1]), + ], +); + +({CpuDevice device, Renderer renderer}) _engine() { + final device = CpuDevice( + width: _size, + height: _size, + shaders: CpuShaderLibrary(builtinCpuShaders()), + ); + final flat = device.createTextureFromPixels( + width: 1, + height: 1, + format: TextureFormat.r8g8b8a8UNormInt, + pixels: ByteData.sublistView(Uint8List.fromList([128, 128, 255, 255])), + )!; + return ( + device: device, + renderer: Renderer.create(device: device, fallbackNormal: flat), + ); +} + +Future> _centre( + ({CpuDevice device, Renderer renderer}) engine, + Scene scene, +) async { + final camera = CameraNode() + ..setPosition(0.0, 0.0, 3.0) + ..lookAt(Vector3.zero()); + final frame = engine.renderer.render( + width: _size, + height: _size, + scene: scene, + views: [RenderView(camera: camera)], + settings: const RenderSettings(bloom: BloomSettings(enabled: false)), + ); + final pixels = (await engine.device.readPixels( + frame.frame, + ))!.buffer.asUint8List(); + final i = (_size ~/ 2 * _size + _size ~/ 2) * 4; + return pixels.sublist(i, i + 4); +} + +void main() { + test('the new model is drawn in the nodes the game already holds', () async { + final engine = _engine(); + final red = await ModelAsset.fromDocument( + _ship(Vector4(1.0, 0.0, 0.0, 1.0)), + device: engine.device, + ); + final blue = await ModelAsset.fromDocument( + _ship(Vector4(0.0, 0.0, 1.0, 1.0)), + device: engine.device, + ); + final scene = Scene(); + final ship = red.instantiate(scene); + final hull = ship.nodes.single..setPosition(0.0, 0.0, 0.5); + final mesh = ship.meshes.single; + expect((await _centre(engine, scene))[0], greaterThan(200)); + + final swap = ship.adopt(blue); + + expect(swap.swapped, 1); + expect(swap.complete, isTrue); + expect(ship.nodes.single, same(hull)); + expect(ship.meshes.single, same(mesh)); + expect( + hull.localMatrix.getTranslation().z, + 0.5, + reason: 'where the game put it stays', + ); + expect(mesh.mesh, same(blue.parts.single.mesh)); + final pixel = await _centre(engine, scene); + expect(pixel[2], greaterThan(200)); + // The tone map lifts the other channels as full blue nears white, so + // red is held to a third of blue, as the variants test holds it. + expect(pixel[0], lessThan(pixel[2] ~/ 3 + 1)); + }); + + test('a part renamed in the file is still found by its node', () async { + final engine = _engine(); + final before = await ModelAsset.fromDocument( + _ship(Vector4(1.0, 0.0, 0.0, 1.0), partName: 'Cube'), + device: engine.device, + ); + final after = await ModelAsset.fromDocument( + _ship(Vector4(0.0, 1.0, 0.0, 1.0), partName: 'Cube.001'), + device: engine.device, + ); + final ship = before.instantiate(Scene()); + + expect(ship.adopt(after).swapped, 1); + expect(ship.meshes.single.material, same(after.parts.single.material)); + }); + + test('what the new file adds is reported, and what it drops stays', () async { + final engine = _engine(); + final plain = await ModelAsset.fromDocument( + _ship(Vector4(1.0, 0.0, 0.0, 1.0)), + device: engine.device, + ); + final flagged = await ModelAsset.fromDocument( + _ship(Vector4(0.0, 1.0, 0.0, 1.0), withFlag: true, hull: 'keel'), + device: engine.device, + ); + final ship = plain.instantiate(Scene()); + final old = ship.meshes.single.mesh; + + final swap = ship.adopt(flagged); + + expect(swap.swapped, 0); + expect(swap.kept, ['hull/s0']); + expect(swap.added, unorderedEquals(['keel/s0', 'flag/s0'])); + expect(swap.complete, isFalse); + expect(ship.meshes.single.mesh, same(old)); + }); + + test( + 'an unshared instance adopts copies, not the asset\'s materials', + () async { + final engine = _engine(); + final red = await ModelAsset.fromDocument( + _ship(Vector4(1.0, 0.0, 0.0, 1.0)), + device: engine.device, + ); + final blue = await ModelAsset.fromDocument( + _ship(Vector4(0.0, 0.0, 1.0, 1.0)), + device: engine.device, + ); + final ship = red.instantiate(Scene(), shareMaterials: false)..adopt(blue); + + final material = ship.meshes.single.material; + expect(material, isNot(same(blue.parts.single.material))); + expect(material.baseColor.z, 1.0); + }, + ); +} diff --git a/packages/flutter3d/test/per_view_exposure_test.dart b/packages/flutter3d/test/per_view_exposure_test.dart index 928ab0c2c..6260efe1f 100644 --- a/packages/flutter3d/test/per_view_exposure_test.dart +++ b/packages/flutter3d/test/per_view_exposure_test.dart @@ -157,7 +157,7 @@ void main() { // **The strongest form of "this costs nothing where it is not wanted".** // One view is the whole frame, so its rectangle is the whole histogram // and its exposure is the frame's. The composite takes its single-draw - // path, and seventy-eight goldens stay where they are. + // path, and ninety-five goldens stay where they are. final off = await frame( const RenderSettings(autoExposure: AutoExposureSettings(enabled: true)), ); diff --git a/packages/flutter3d/test/render_scale_test.dart b/packages/flutter3d/test/render_scale_test.dart index 61a8419b2..f967ef86a 100644 --- a/packages/flutter3d/test/render_scale_test.dart +++ b/packages/flutter3d/test/render_scale_test.dart @@ -36,7 +36,7 @@ import 'package:vector_math/vector_math.dart'; void main() { test('one is the whole resolution, and is the default', () { - // Seventy-eight goldens are recorded at the size they asked for. A default + // Ninety-five goldens are recorded at the size they asked for. A default // that shrank anything would move every one of them. expect(const RenderSettings().renderScale, 1.0); expect(_drawn(1.0), (width: 64, height: 64)); diff --git a/packages/flutter3d/test/scene_test.dart b/packages/flutter3d/test/scene_test.dart index 7c112c9fd..4f8e62c76 100644 --- a/packages/flutter3d/test/scene_test.dart +++ b/packages/flutter3d/test/scene_test.dart @@ -121,6 +121,32 @@ void main() { expect(child.readWorldPosition().x, closeTo(5.0, 1e-6)); }, ); + + test('reading the local matrix after a move does not hide the move', () { + // A frame draws the node, which fills the world cache; then the node + // moves and something asks its local matrix before the next frame — + // the chemistry bench hands it to the liquid. Mutation: let + // `_recomputeLocal` clear the dirty flag without marking the world + // stale, and the world matrix keeps the old place: the glass rose for + // the liquid and stayed in its rack on screen. + final root = SceneNode(); + final node = SceneNode(); + root.add(node); + expect(node.readWorldPosition().y, closeTo(0.0, 1e-6)); + + node + ..setPosition(0.0, 0.1, 0.0) + ..setRotation(Quaternion.axisAngle(Vector3(1, 0, 0), 1.0)); + expect(node.localMatrix.getTranslation().y, closeTo(0.1, 1e-6)); + expect(node.readWorldPosition().y, closeTo(0.1, 1e-6)); + + // And the same for a node with no parent. + final alone = SceneNode(); + expect(alone.readWorldPosition().x, closeTo(0.0, 1e-6)); + alone.setPosition(2.0, 0.0, 0.0); + expect(alone.localMatrix.getTranslation().x, closeTo(2.0, 1e-6)); + expect(alone.readWorldPosition().x, closeTo(2.0, 1e-6)); + }); }); group('hierarchy', () { diff --git a/packages/flutter3d/test/sharpen_test.dart b/packages/flutter3d/test/sharpen_test.dart index 512e848dd..a02124cf7 100644 --- a/packages/flutter3d/test/sharpen_test.dart +++ b/packages/flutter3d/test/sharpen_test.dart @@ -95,7 +95,7 @@ int _steepest(List row) { void main() { test('zero is an exact identity, and is the default', () async { - // Seventy-eight goldens go through this pass. The shader returns the centre + // Ninety-five goldens go through this pass. The shader returns the centre // untouched at zero rather than running a kernel that rounds to nothing, // because "rounds to nothing" is a claim about the target's bit depth. expect(const AntiAliasSettings().sharpen, 0.0); diff --git a/packages/flutter3d/test/sky_physical_test.dart b/packages/flutter3d/test/sky_physical_test.dart new file mode 100644 index 000000000..e92e66568 --- /dev/null +++ b/packages/flutter3d/test/sky_physical_test.dart @@ -0,0 +1,410 @@ +/// The physical sky and the height fog — `P5`. +/// +/// flutter test test/sky_physical_test.dart +/// +/// The sky exists three times: in `sky_physical.frag`, in `PhysicalSky.look` +/// for the things that cannot ask a GPU, and in the software rasteriser's +/// `SkyPhysicalShader`. The frames here are drawn by the third and checked +/// against the second, which is what catches a varying read from the wrong +/// lane: the picture stays a plausible sky and only the comparison moves. +/// +/// The rest are claims about the air that hold whatever the coefficients are +/// tuned to: a sunset is red towards the sun, the stars come out at night and +/// not at noon, and fog that lies on the ground is thinner looking up. +library; + +import 'dart:io'; +import 'dart:math' as math; +import 'dart:typed_data'; + +import 'package:flutter3d/flutter3d.dart' hide Material; +import 'package:flutter3d/flutter3d.dart' as engine show Material; +import 'package:flutter3d_cpu/flutter3d_cpu.dart'; +import 'package:flutter3d_cpu/testing.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:vector_math/vector_math.dart'; + +// Odd, so that the centre pixel's centre is the camera's axis. With an even +// size it sits half a pixel off, which near the horizon at dusk is enough +// height for the blue to move by more than the comparison allows. +const int _width = 97; +const int _height = 73; + +/// A unit vector [degrees] above the horizon, towards +x. +Vector3 _sunAt(double degrees) { + final radians = degrees * math.pi / 180.0; + return Vector3(math.cos(radians), math.sin(radians), 0.0); +} + +SkySettings _sky({ + bool enabled = true, + double sunDegrees = 30.0, + double sunIntensity = 0.0, + PhysicalSky air = const PhysicalSky(), +}) => SkySettings( + enabled: enabled, + directionToSun: _sunAt(sunDegrees), + sunAngularRadiusDegrees: 2.0, + sunSoftnessDegrees: 0.5, + sunIntensity: sunIntensity, + physical: air, +); + +({CpuDevice device, Renderer renderer}) _engine() { + final it = cpuTestDevice(width: _width, height: _height); + return ( + device: it.device, + renderer: Renderer.create( + device: it.device, + fallbackAlbedo: it.albedo, + fallbackNormal: it.normal, + ), + ); +} + +CameraNode _camera(Vector3 looking) { + final camera = CameraNode( + projection: const PerspectiveProjection( + fovYRadians: 1.0, + near: 0.3, + far: 500.0, + ), + )..lookAt(looking); + return camera; +} + +Future _draw( + ({CpuDevice device, Renderer renderer}) it, + CameraNode camera, { + SkySettings sky = const SkySettings(), +}) async { + final scene = Scene()..add(camera); + final result = it.renderer.render( + width: _width, + height: _height, + scene: scene, + views: [ + RenderView(camera: camera, clearColor: Vector4(0.0, 0.0, 0.0, 1.0)), + ], + settings: RenderSettings( + sky: sky, + bloom: const BloomSettings(enabled: false), + tonemap: false, + ), + ); + final pixels = await it.device.readPixels(result.frame); + expect(pixels, isNotNull, reason: 'the frame could not be read back'); + return pixels!.buffer.asUint8List(); +} + +/// The pixel at the centre of [frame], which is where the camera points. +Vector3 _centre(Uint8List frame) { + final at = ((_height ~/ 2) * _width + _width ~/ 2) * 4; + return Vector3( + frame[at] / 255.0, + frame[at + 1] / 255.0, + frame[at + 2] / 255.0, + ); +} + +/// The composite pass's encode, which is where linear light becomes bytes. +double _linearToSrgb(double value) { + final clamped = value.clamp(0.0, 1.0); + return clamped <= 0.0031308 + ? clamped * 12.92 + : 1.055 * math.pow(clamped, 1.0 / 2.4).toDouble() - 0.055; +} + +/// [SkySettings.sample] in [direction], put through what the frame puts the +/// shader's answer through: the default exposure, then the encode. +Vector3 _expected(SkySettings sky, Vector3 direction) { + final linear = sky.sample(direction)..scale(1.6); + return Vector3( + _linearToSrgb(linear.x), + _linearToSrgb(linear.y), + _linearToSrgb(linear.z), + ); +} + +/// Pixels brighter than their four neighbours by [margin] in green: points +/// of light, which a smooth sky has none of. +int _points(Uint8List frame, {int margin = 30}) { + int at(int x, int y) => frame[(y * _width + x) * 4 + 1]; + var count = 0; + for (var y = 1; y < _height - 1; y++) { + for (var x = 1; x < _width - 1; x++) { + final here = at(x, y); + final around = math.max( + math.max(at(x - 1, y), at(x + 1, y)), + math.max(at(x, y - 1), at(x, y + 1)), + ); + if (here > around + margin) count++; + } + } + return count; +} + +void main() { + group('the sky', () { + test('switched off, it draws nothing, air or no air', () async { + // Mutation: test `physical != null` in the sky pass before `enabled`. + // Every golden scene that sets no sky would stay as it is, and a game + // that turned its sky off at night would keep drawing it. + final camera = _camera(Vector3(0.0, 0.2, 1.0)); + final off = await _draw(_engine(), camera); + final unset = await _draw(_engine(), camera, sky: _sky(enabled: false)); + expect(unset, off); + }); + + for (final (name, sunDegrees, looking, intensity) + in <(String, double, Vector3, double)>[ + ('noon, away from the sun', 50.0, Vector3(-1.0, 0.15, 0.3), 0.0), + ('noon, straight up', 50.0, Vector3(0.001, 1.0, 0.0), 0.0), + ('dusk, into the setting sun', 3.0, _sunAt(3.0), 4.0), + ('the ground, below the horizon', 40.0, Vector3(0.0, -0.4, 1.0), 0.0), + ]) { + test('the software stage draws what the model says: $name', () async { + // Mutation: read any lane of the vertices one place over in + // `SkyPhysicalShader` — the Mie scale height as the extinction, say. + // The sky stays a sky, a little hazier or a little bluer, and only + // this comparison sees it. + final sky = _sky(sunDegrees: sunDegrees, sunIntensity: intensity); + final frame = await _draw(_engine(), _camera(looking), sky: sky); + final drawn = _centre(frame); + final expected = _expected(sky, looking); + for (final channel in [0, 1, 2]) { + expect( + (drawn[channel] - expected[channel]).abs(), + lessThan(0.02), + reason: 'channel $channel: drawn $drawn, the model $expected', + ); + } + }); + } + + test('noon is blue overhead and dusk is red towards the sun', () { + // Mutation: give Rayleigh the same coefficient in every channel, or + // drop the extinction of the light on its way in. Either way the sky + // keeps its brightness and loses the colours that make it a time of day. + const air = PhysicalSky(); + final zenith = air.radiance(Vector3(0.0, 1.0, 0.0), _sunAt(50.0)); + expect(zenith.z, greaterThan(zenith.x * 2.5), reason: '$zenith'); + + final sunset = air.radiance(_sunAt(5.0), _sunAt(2.0)); + expect(sunset.x, greaterThan(sunset.z * 3.0), reason: '$sunset'); + + // Towards and away at the same height above the horizon: lower down + // the path through the air is longer and the sky brighter, so a pair + // at different heights measures the height as much as the sun. The + // model gives about a quarter; haze that scattered every way alike + // would give three quarters. + final towards = air.radiance(Vector3(1.0, 0.05, 0.0), _sunAt(2.0)); + final away = air.radiance(Vector3(-1.0, 0.05, 0.0), _sunAt(2.0)); + expect( + away.x + away.y + away.z, + lessThan((towards.x + towards.y + towards.z) / 3.0), + reason: 'the sky away from a setting sun is the dark half', + ); + }); + + test('the sun goes red as it sets, and out below the horizon', () { + // Mutation: draw the disc without the air in front of it, which is how + // the gradient sky draws its own. A noon sun at the horizon. + const air = PhysicalSky(); + final high = air.sunlight(_sunAt(60.0)); + final low = air.sunlight(_sunAt(2.0)); + expect(high.x / high.z, lessThan(1.3), reason: 'white at noon: $high'); + expect(low.x / low.z, greaterThan(4.0), reason: 'red at dusk: $low'); + expect(air.sunlight(_sunAt(-3.0)).length, 0.0); + + final sky = _sky(sunDegrees: 2.0, sunIntensity: 10.0); + final disc = sky.sample(_sunAt(2.0)); + final beside = sky.sample(_sunAt(8.0)); + expect( + disc.x - beside.x, + greaterThan((disc.z - beside.z) * 4.0), + reason: 'the disc adds red, not white: $disc against $beside', + ); + }); + + test('the ground is lit while the sun is up and dark once it is down', () { + // Mutation: drop the ground term. The lower half of an environment map + // built from the sky goes black at noon, and every floor that reflects + // it reflects a hole. + const air = PhysicalSky(); + final down = Vector3(0.3, -1.0, 0.0); + final day = air.look(down, _sunAt(40.0)); + final night = air.look(down, _sunAt(-15.0)); + expect(day.ground, isTrue); + expect(day.radiance.y, greaterThan(0.05), reason: '${day.radiance}'); + expect(night.radiance.y, lessThan(1e-4), reason: '${night.radiance}'); + }); + + test('stars come out at night, and not by day', () async { + // Mutation: drop the night fade, or test the sun's height the wrong + // way round. Stars at noon are points brighter than a smooth sky, and + // a night with no stars is a black frame. + final up = _camera(Vector3(0.2, 1.0, 0.4)); + final night = await _draw(_engine(), up, sky: _sky(sunDegrees: -20.0)); + final day = await _draw(_engine(), up, sky: _sky(sunDegrees: 40.0)); + final none = await _draw( + _engine(), + up, + sky: _sky( + sunDegrees: -20.0, + air: const PhysicalSky(starBrightness: 0.0), + ), + ); + expect(_points(night), greaterThan(5)); + expect(_points(day), 0); + expect(_points(none), 0); + }); + + test('the sample a probe or a fog colour takes has no stars in it', () { + // Mutation: add the stars to `SkySettings.sample`. An environment map + // thirty-two texels a side turns one star into a bright texel, and a + // night floor reflects it as a lamp. + final sky = _sky(sunDegrees: -20.0); + final starless = _sky( + sunDegrees: -20.0, + air: const PhysicalSky(starBrightness: 0.0), + ); + for (var i = 0; i < 64; i++) { + final direction = Vector3( + math.cos(i * 0.37), + 0.3 + (i % 7) * 0.1, + math.sin(i * 0.37), + ); + expect(sky.sample(direction), starless.sample(direction)); + } + }); + + test('the depth the sky sits at matches sky.vert', () { + // Read out of the shader source: the physical stage is the gradient's + // depth by a second copy of a literal, and a second copy is a second + // chance for the sky to sit in front of the world. + String depthIn(String file) => + RegExp(r'gl_Position = vec4\(position, ([0-9.]+), 1\.0\);') + .firstMatch( + File('../flutter3d_shaders/shaders/$file').readAsStringSync(), + )! + .group(1)!; + expect(depthIn('sky_physical.vert'), depthIn('sky.vert')); + }); + }); + + group('height fog', () { + test('thins upwards from its base height', () { + // Mutation: fold the base height in with the wrong sign. The fog lies + // on the ceiling. + const fog = FogSettings( + density: 0.1, + heightFalloff: 0.5, + baseHeight: 3.0, + ); + expect(fog.densityAt(3.0), closeTo(0.1, 1e-12)); + expect(fog.densityAt(5.0), closeTo(0.1 * math.exp(-1.0), 1e-12)); + expect(fog.densityAt(1.0), closeTo(0.1 * math.exp(1.0), 1e-12)); + expect(const FogSettings(density: 0.1).densityAt(1000.0), 0.1); + expect( + const FogSettings(density: 0.1, heightFalloff: -1.0).densityAt(9.0), + 0.1, + reason: 'a negative falloff is held at nought', + ); + }); + + test('a ray through it is fogged by the air it crosses', () async { + // The closed form in `ApplyFog` against the integral done the long + // way: a wall seen up a slope through a height fog has to come out as + // a wall seen through a flat fog of the mean density along that ray. + // + // Mutation: take `k` from the eye down to the surface instead of up to + // it, or drop the integral and fog by the density at the eye. The + // first fogs a hill as thick as a valley; the second is a flat fog. + const distance = 60.0; + final slope = Vector3(0.0, 0.25, 1.0).normalized(); + const height = FogSettings(density: 0.03, heightFalloff: 0.12); + + // The mean density over the ray, by the midpoint rule. + const steps = 4000; + var sum = 0.0; + for (var i = 0; i < steps; i++) { + sum += height.densityAt(slope.y * distance * (i + 0.5) / steps); + } + final flat = FogSettings(density: sum / steps); + + final up = await _wallMean(height, slope, distance); + final even = await _wallMean(flat, slope, distance); + final eye = await _wallMean( + const FogSettings(density: 0.03), + slope, + distance, + ); + expect((up - even).abs(), lessThan(1.5), reason: 'up $up, even $even'); + expect(up, greaterThan(eye + 20.0), reason: 'thinner than at the eye'); + }); + + test('with no falloff it is the flat fog, to the byte', () async { + // The property every golden with fog in it rests on. + final slope = Vector3(0.0, 0.25, 1.0).normalized(); + final a = await _wallMean(const FogSettings(density: 0.03), slope, 60.0); + final b = await _wallMean( + const FogSettings(density: 0.03, baseHeight: 40.0), + slope, + 60.0, + ); + expect(a, b); + }); + }); +} + +/// The mean of the red channel over a grey wall facing the camera, +/// [distance] metres along [looking], through [fog] of black. +Future _wallMean( + FogSettings fog, + Vector3 looking, + double distance, +) async { + final it = _engine(); + final scene = Scene(); + final camera = _camera(looking); + scene + ..add(camera) + ..add( + MeshNode( + DeviceMesh.upload( + it.device, + CuboidShape(size: Vector3(2.0, 2.0, 0.1)).build(), + ), + engine.Material( + name: 'wall', + baseColor: Vector4(0.5, 0.5, 0.5, 1.0), + lighting: LightingModel.unlit, + ), + name: 'wall', + )..setPosition( + looking.x * distance, + looking.y * distance, + looking.z * distance, + ), + ); + final result = it.renderer.render( + width: _width, + height: _height, + scene: scene, + views: [ + RenderView(camera: camera, clearColor: Vector4(0.0, 0.0, 0.0, 1.0)), + ], + settings: RenderSettings( + fog: fog.copyWith(color: Vector3.zero()), + bloom: const BloomSettings(enabled: false), + tonemap: false, + ), + ); + final pixels = (await it.device.readPixels( + result.frame, + ))!.buffer.asUint8List(); + // The centre pixel only: the wall is small at sixty metres, and the + // centre is where the ray is the one the integral was taken along. + return pixels[((_height ~/ 2) * _width + _width ~/ 2) * 4].toDouble(); +} diff --git a/packages/flutter3d/test/sky_procedural_test.dart b/packages/flutter3d/test/sky_procedural_test.dart index 864609085..967ac78b1 100644 --- a/packages/flutter3d/test/sky_procedural_test.dart +++ b/packages/flutter3d/test/sky_procedural_test.dart @@ -167,7 +167,7 @@ double _mean(Uint8List rgba, {required int channel}) { void main() { _hardEdgedSun(); test('no sky is byte-identical to the sky nobody asked for', () async { - // The property seventy-eight goldens rest on: a frame with no sky in it is + // The property ninety-five goldens rest on: a frame with no sky in it is // the frame this renderer has always drawn. // // Mutation: drop the early return. The sky is drawn into every scene that diff --git a/packages/flutter3d/test/ssao_blur_test.dart b/packages/flutter3d/test/ssao_blur_test.dart index ec770e01d..1004b2ab5 100644 --- a/packages/flutter3d/test/ssao_blur_test.dart +++ b/packages/flutter3d/test/ssao_blur_test.dart @@ -75,7 +75,7 @@ void main() { test('no taps is no pass, and is the default', () async { // The occlusion is already off by default, and this is a second // full-screen pass over it — so nothing pays for either until two things - // are switched on. Seventy-eight goldens depend on the frame being what it + // are switched on. Ninety-five goldens depend on the frame being what it // was. expect(const AmbientOcclusionSettings().blurTaps, 0); expect(await _frame(blurTaps: 0), await _frame(blurTaps: 0)); diff --git a/packages/flutter3d/test/translucent_shadow_test.dart b/packages/flutter3d/test/translucent_shadow_test.dart new file mode 100644 index 000000000..b94cefab1 --- /dev/null +++ b/packages/flutter3d/test/translucent_shadow_test.dart @@ -0,0 +1,332 @@ +/// `ShadowSettings.translucentCasters`: a see-through caster shades the sun's +/// light by what its material lets through. +/// +/// flutter test test/translucent_shadow_test.dart +/// +/// **A shadow map holds one depth per texel**, so until this a pane of clear +/// glass cast a shadow as dark as a wall, and a blue liquid a grey one. The +/// claims, each against the same floor and the same card over it: +/// +/// * clear glass still darkens the floor, and much less than an opaque card; +/// * a blue liquid darkens it to blue: what reaches the floor is bluer than +/// the light that fell; +/// * with nothing see-through in the scene, turning it on changes no pixel. +library; + +import 'dart:typed_data'; + +import 'package:flutter3d/flutter3d.dart'; +import 'package:flutter3d_cpu/flutter3d_cpu.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:vector_math/vector_math.dart'; + +const int _size = 64; + +/// A floor and a card over it, lit at forty-five degrees so the shadow lands +/// clear of the card's footprint, seen from above. +Future> _frame({ + required Material? card, + required bool translucent, + bool casting = true, + TextureHandle Function(CpuDevice device)? map, + ShadowCastingMode? mode, + double sun = 1.0, +}) async { + final device = CpuDevice( + width: _size, + height: _size, + shaders: CpuShaderLibrary(builtinCpuShaders()), + ); + final renderer = Renderer.create(device: device); + final scene = Scene() + ..add( + MeshNode( + DeviceMesh.upload( + device, + CuboidShape(size: Vector3(6, 0.1, 6)).build(), + ), + Material(name: 'floor', baseColor: Vector4(0.9, 0.9, 0.9, 1.0)), + )..setPosition(0.0, -1.0, 0.0), + ) + ..add( + LightNode(intensity: sun, castsShadow: true) + ..setPosition(4.0, 5.0, 0.01) + ..lookAt(Vector3.zero()), + ) + ..add( + CameraNode() + ..setPosition(0.0, 5.0, 0.01) + ..lookAt(Vector3.zero()), + ); + if (card != null) { + if (map != null) card.albedo = map(device); + scene.add( + MeshNode( + DeviceMesh.upload( + device, + // A painted card is one surface: a slab's underside reads its + // map mirrored, and would put the black half over the white. + map != null + ? const PlaneShape(width: 2, depth: 2).build() + : CuboidShape(size: Vector3(2, 0.05, 2)).build(), + ), + card, + ) + ..setPosition(0.0, 1.0, 0.0) + ..shadowCasting = + mode ?? (casting ? ShadowCastingMode.on : ShadowCastingMode.off), + ); + } + final frame = renderer.render( + width: _size, + height: _size, + scene: scene, + views: [ + RenderView( + camera: scene.cameras.single, + clearColor: Vector4(0.0, 0.0, 0.0, 1.0), + ), + ], + settings: RenderSettings( + shadows: ShadowSettings(translucentCasters: translucent), + look: const LookSettings(dither: 0), + ), + ); + final bytes = await device.readPixels(frame.frame); + return [for (var i = 0; i < _size * _size * 4; i++) bytes!.getUint8(i)]; +} + +Material _opaque() => + Material(name: 'wall', baseColor: Vector4(0.8, 0.8, 0.8, 1.0)); + +Material _glass() => Material( + name: 'glass', + lighting: LightingModel.pbrLayered, + baseColor: Vector4(0.95, 0.97, 1.0, 0.12), + alphaMode: MaterialAlphaMode.blend, + doubleSided: true, + extensions: MaterialExtensions(transmission: 0.95, ior: 1.5), +); + +Material _blueLiquid() => Material( + name: 'liquid', + lighting: LightingModel.pbrLayered, + baseColor: Vector4(0.15, 0.3, 0.95, 0.8), + alphaMode: MaterialAlphaMode.blend, + extensions: MaterialExtensions(transmission: 0.6, ior: 1.33), +); + +/// The floor pixels a solid card shadows: where [solid] is darker than [lit]. +List _shadowed(List solid, List lit) => [ + for (var i = 0; i < solid.length; i += 4) + if (solid[i] < lit[i] - 8) i, +]; + +/// The mean red, green and blue over [pixels] of [frame]. +(double, double, double) _mean(List frame, List pixels) { + var r = 0.0, g = 0.0, b = 0.0; + for (final i in pixels) { + r += frame[i]; + g += frame[i + 1]; + b += frame[i + 2]; + } + final n = pixels.length.toDouble(); + return (r / n, g / n, b / n); +} + +void main() { + test('clear glass casts a faint shadow, an opaque card a dark one', () async { + final lit = await _frame( + card: _opaque(), + translucent: true, + casting: false, + ); + final solid = await _frame(card: _opaque(), translucent: true); + final asBefore = await _frame(card: _glass(), translucent: false); + final glass = await _frame(card: _glass(), translucent: true); + final region = _shadowed(solid, lit); + expect(region.length, greaterThan(40), reason: 'the fixture casts nothing'); + + final (litR, _, _) = _mean(lit, region); + final (solidR, _, _) = _mean(solid, region); + final (beforeR, _, _) = _mean(asBefore, region); + final (glassR, _, _) = _mean(glass, region); + + // Off, the glass is a wall — the defect. Mutation: draw see-through + // casters into the atlas regardless of the setting, and this moves. + expect(beforeR, closeTo(solidR, 2.0)); + // On, it lets most of the light through, but not all of it: four + // surfaces each reflect some away. Mutation: skip the Fresnel loss, and + // the shadow is gone; draw the glass as opaque, and it is a wall again. + expect(glassR, greaterThan(solidR + 0.5 * (litR - solidR))); + expect(glassR, lessThan(litR - 1.0)); + }); + + test('a blue liquid casts a blue shadow', () async { + final lit = await _frame( + card: _opaque(), + translucent: true, + casting: false, + ); + final solid = await _frame(card: _opaque(), translucent: true); + final liquid = await _frame(card: _blueLiquid(), translucent: true); + final region = _shadowed(solid, lit); + + final (litR, _, litB) = _mean(lit, region); + final (r, _, b) = _mean(liquid, region); + // Bluer than the light that fell: the red is taken more than the blue. + // Mutation: write the same number into all three channels, and the + // ratio stays the floor's. + expect(b / r, greaterThan(1.3 * litB / litR)); + // And darker than no shadow at all. + expect(r, lessThan(litR - 8.0)); + }); + + test('with nothing see-through, turning it on changes no pixel', () async { + final off = await _frame(card: _opaque(), translucent: false); + final on = await _frame(card: _opaque(), translucent: true); + // Mutation: clear the atlas's free channels to ones, or set the flag on + // a draw whose atlas carries none, and the floor goes black. + expect(on, off); + }); + + test('a painted card can take light away and gather it', () async { + // A caster that is only a shadow, carrying a picture of where the light + // went: black on one half, white on the other, and a colour of two, so + // the white half lets twice the light through. Mutation: hold the colour + // or the stored value to one, and nothing is brightened; drop the map, + // and both halves read alike. + TextureHandle halves(CpuDevice device) { + final pixels = Uint8List.fromList([ + 0, 0, 0, 255, /**/ 255, 255, 255, 255, // + 0, 0, 0, 255, /**/ 255, 255, 255, 255, + ]); + return device.createTextureFromPixels( + width: 2, + height: 2, + pixels: ByteData.sublistView(pixels), + format: TextureFormat.r8g8b8a8UNormInt, + )!; + } + + // A dimmer sun than the other tests, so the lit floor sits where the + // tone curve still has room above it; and every card a shadow only, so + // none of them hides the half of its shadow the light falls past it on. + final lit = await _frame(card: null, translucent: true, sun: 0.3); + final solid = await _frame( + card: _opaque(), + translucent: true, + sun: 0.3, + mode: ShadowCastingMode.shadowsOnly, + ); + final region = _shadowed(solid, lit); + final painted = await _frame( + sun: 0.3, + card: Material( + name: 'lens picture', + lighting: LightingModel.pbrLayered, + baseColor: Vector4(2.0, 2.0, 2.0, 1.0), + extensions: MaterialExtensions(transmission: 1.0, ior: 1.0), + ), + translucent: true, + map: halves, + mode: ShadowCastingMode.shadowsOnly, + ); + var darker = 0, brighter = 0; + for (final i in region) { + if (painted[i] < lit[i] - 20) darker++; + if (painted[i] > lit[i] + 3) brighter++; + } + expect(darker, greaterThan(region.length ~/ 5)); + expect(brighter, greaterThan(region.length ~/ 5)); + }); + + test('an empty glass tube has a hairline edge, not a dark band', () async { + // A thin-walled glass tube standing in the sun. At its silhouette the + // light grazes the walls and is reflected nearly whole, but reflected at + // grazing it is hardly turned, and lands beside where it would have. + // Mutation: count every reflected ray as lost again, and the darkest + // pixel falls to under two thirds of the lit floor. + const size = 128; + Future> draw(Material? material) async { + final device = CpuDevice( + width: size, + height: size, + shaders: CpuShaderLibrary(builtinCpuShaders()), + ); + final scene = Scene() + ..add( + MeshNode( + DeviceMesh.upload( + device, + CuboidShape(size: Vector3(6, 0.1, 6)).build(), + ), + Material(name: 'floor', baseColor: Vector4(0.9, 0.9, 0.9, 1.0)), + )..setPosition(0.0, -1.0, 0.0), + ) + ..add( + LightNode(castsShadow: true) + ..setPosition(4.0, 5.0, 0.01) + ..lookAt(Vector3.zero()), + ) + ..add( + CameraNode() + ..setPosition(-0.8, 5.0, 0.01) + ..lookAt(Vector3(-0.8, 0.0, 0.0)), + ); + if (material != null) { + scene.add( + MeshNode( + DeviceMesh.upload( + device, + LatheShape( + profile: [Vector2(0.3, -0.95), Vector2(0.3, 0.6)], + segments: 48, + ).build(), + ), + material, + )..shadowCasting = ShadowCastingMode.shadowsOnly, + ); + } + final frame = Renderer.create(device: device).render( + width: size, + height: size, + scene: scene, + views: [RenderView(camera: scene.cameras.single)], + settings: const RenderSettings( + shadows: ShadowSettings(translucentCasters: true), + look: LookSettings(dither: 0), + ), + ); + final bytes = await device.readPixels(frame.frame); + return [ + for (var i = 0; i < size * size * 4; i++) bytes!.getUint8(i), + ]; + } + + final lit = await draw(null); + final solid = await draw(_opaque()..doubleSided = true); + final glass = await draw( + Material( + name: 'glass', + lighting: LightingModel.pbrLayered, + baseColor: Vector4(0.97, 0.99, 1.0, 0.22), + alphaMode: MaterialAlphaMode.blend, + doubleSided: true, + extensions: MaterialExtensions(transmission: 1.0, ior: 1.5), + ), + ); + final region = _shadowed(solid, lit); + expect(region.length, greaterThan(100), reason: 'the tube casts nothing'); + final (litR, _, _) = _mean(lit, region); + final darkest = region.map((i) => glass[i]).reduce((a, b) => a < b ? a : b); + expect(darkest, greaterThan(0.75 * litR)); + }); + + test('copyWith keeps the setting', () { + // `copyWith` has dropped fields before, and `settingsFrom` calls it every + // frame, so a field it forgets is a setting nobody can turn on. + const settings = ShadowSettings(translucentCasters: true); + expect(settings.copyWith(strength: 0.5).translucentCasters, isTrue); + }); +} diff --git a/packages/flutter3d_app/CHANGELOG.md b/packages/flutter3d_app/CHANGELOG.md index 8fd5c1901..376206c25 100644 --- a/packages/flutter3d_app/CHANGELOG.md +++ b/packages/flutter3d_app/CHANGELOG.md @@ -1,3 +1,132 @@ +## Unreleased + +- **The rest of the scene as widgets** — `P10`. `Material3D` is a material + as a widget: the `Mesh3D`s below it without a material of their own are + drawn with the one engine material it makes, changed in place on a + rebuild so they keep it and the batching keeps seeing one. + `ReflectionProbe3D`, `Decal3D`, `Mirror3D` (the meshes directly below it + are its surfaces, joining and leaving with their widgets) and + `Particles3D` (an effect at a rate, emitted at the node and advanced with + the scene) join `Mesh3D`, `Light3D`, `Camera3D` and `Model3D`; + `Contributor3D` adds any pass contributor while it is in the tree. + `SceneWidgets.mount` builds the same widgets into a scene somebody else + draws, with no screen — a golden runner, a test, a game with its own loop. + `Scene3D` now disposes the renderer it made and the device it opened. + `example/lib/widgets_main.dart` is `minimal_main.dart` written this way, + and `widget-scene` is drawn from widgets in all four golden sets. + +- **The software backend draws a material's lighting hook.** + `MaterialProgramStage` applies the maps and gathers the lights through + the material's `light` block with `accumulateLights`, then hands the + fragment body `lit` — the same sum the emitted shader makes. A `light` + block returning the albedo draws pixel for pixel what the built-in + Lambert does. + +- **`HotMaterials.bind(material, name)`** keeps a material drawn with a + bundled `.f3dmat` across hot reloads — `P8`. After an edit the hook + compiled, a bound material gets the lighting model the new source + describes and a default for a uniform it now declares, keeping the values + the game set; an edit that starts sampling a map or declares a uniform + used to reach only the materials the game gave the new model by hand. + +- **A scene written as widgets** — `P10`. `Scene3D` opens a device, makes a + renderer and a scene and draws them; `Mesh3D`, `Model3D` (loaded + asynchronously, with a placeholder, its animation clip, speed and pause as + properties), `Light3D`, `Camera3D` and `Node3D` below it each own one node + of that scene. Reconciled by Flutter's own keys, so a rebuild keeps each + node and what a game set on it; a shape made fresh in `build` is uploaded + again only when its vertices change. Suffixed `3D` because `SceneNode` is + the engine's own class. + +- **`HotSwap.loadMaterial`** loads the bundle the build hook compiled a + `.f3dmat` into and watches it — `P8`: the library for the renderer, the + materials' lighting models and parameters, and an edit drawn by the next + frame after a hot reload, on the software backend too. + +- **The software material stage reads a `uniform`** from the draw's + `MaterialParams`, as the GLSL does. + +- **`MaterialProgramStage` and `materialLanguageCompiler` moved here** from + `flutter3d_testing`, so a game can depend on them, and the software backend + this package registers compiles a bundle's material-language stages. + +- **A photo goes somewhere.** `takePhoto` draws with `capturePhoto`, encodes + with `PngStripWriter` and puts the file on a `PhotoShelf`, abandoning it if + the capture fails. `FilePhotoShelf` writes into the player's Pictures folder + on a desktop and the game's own folder on a phone, through a `.part` file and + a rename. `BrowserPhotoShelf` offers the share sheet where the browser takes + files and downloads otherwise. `defaultPhotoShelf` picks the platform's. + +- **A tool attached to a running game can read the game's own frame.** + `registerRenderExtensions` puts `ext.flutter3d.render.passes`, + `passOutput`, `draws`, `draw`, `readPixel`, `scanNan` and `stats` on the VM + service, each answered from a capture of the next frame the game draws, so + what comes back is the GPU frame with its passes, targets and draws rather + than a second picture drawn on the software backend. The answers are + worked out by `renderPasses`, `renderDraws` and the rest, plain functions of + a `FrameCapture` that a test calls directly. `scanNan` reports a float + target it could only read as bytes as unread rather than clean: a NaN + clamps to an ordinary byte on every hardware readback. + +- **A texture repainted under a running game is drawn at whatever size it + now is.** `HotSwap.loadTexture` and `registerTexture` watch an image file; + a swap after it changed uploads the new picture as a texture of its own, + puts it where the old one was in every material of the registered scenes + and in their environment, and releases the old one once no frame in flight + samples it. `SwappableTexture.changes` tells anything else holding it. + `LevelLoader` watches every map a level names, and `LoadedLevel.dispose` + stops. `ext.flutter3d.assets.put` accepts an image registered this way. + +- **A shader parameter dragged in the editor changes the next frame.** + `HotSwap.setMaterial` takes `parameters/` beside the built-in + fields and writes the numbers into the list `Material.parameters` already + holds, which the renderer binds every frame. A parameter the material was + not loaded with, or a list of another length, is refused with what the + material does have. A level material that defers to a `.fmat` is now + called by the level's name for it rather than the file's, so the editor + reaches it by the name it knows. + +- **A sky panorama edited under a running game lights it again.** + `HotSwap.loadEnvironment` builds the prefiltered cube a `.hdr` or an image + asset makes, whose roughest level is also the irradiance, and + `SwappableEnvironment.applyTo` puts it on a scene. A swap after the file + changed prefilters it again and puts the new cube, with its level count, + on every registered scene that was lit by the old one; the old cube goes + back to the device after its frames, as a swapped texture does. + `registerEnvironment` takes an environment built some other way, the + report lists what changed under `environments`, and + `ext.flutter3d.assets.put` accepts a panorama registered either way. + +**A hot reload shows the shaders it reloaded.** Flutter 3.47 reinitializes an +asset-loaded shader bundle on hot reload, which the engine's own bundle is, +but a pipeline linked from the old code went on drawing it: the library +changed and the picture did not. `HotSwap` relinks every renderer +it knows of on a reload, and refreshes the bundles an application loaded +from bytes and registered with `registerLibrary`, which Flutter's reload +never reaches. A bundle that does not load keeps the last one that did and +is named in the report. `SceneSurface` registers its renderer and reloads +from `reassemble`, so a game drawn through it, the Flame bridge's included, +needs nothing more; a tool reaches the same reload as +`ext.flutter3d.hotSwap`. Debug builds only: in profile and release it holds +nothing. + +**A model edited while the game runs is drawn in the nodes the game +holds.** `HotSwap.loadModel` hands back a `SwappableModel`; instances made +through it adopt the new file on the next hot reload, keeping their nodes, +transforms and animation. `ext.flutter3d.assets.put` sends a model's bytes +over the VM service instead, for a device whose disk the editor cannot write. +A model that does not build keeps the version that did. + +**A material dragged in an inspector changes the next frame.** +`HotSwap.setMaterial` (`ext.flutter3d.material.set` over the VM service) +sets base colour, emissive, roughness, metallic, normal scale or alpha cutoff +on every material of that name in the scenes registered — `SceneSurface` +registers its own — and keeps them as overrides that a model swap does not +undo. `clearMaterial` lets them go. + +`SceneSurface` is a `StatefulWidget` now, for `reassemble`. Its constructor +and parameters are unchanged. + ## 0.8.1+1 **Resolves on Flutter 3.44 and Dart 3.12.0.** The constraints asked for Dart diff --git a/packages/flutter3d_app/README.md b/packages/flutter3d_app/README.md index a3c0e0f8d..89f3306a0 100644 --- a/packages/flutter3d_app/README.md +++ b/packages/flutter3d_app/README.md @@ -14,11 +14,42 @@ final device = await openDevice(width: 1280, height: 720); | | | |---|---| | `openDevice` / `presentFrame` | Which backend a build draws through, and the runtime fallback to the software rasteriser when Impeller will not start. | +| `Scene3D` and its children | A scene written as widgets: `Mesh3D`, `Model3D`, `Light3D`, `Camera3D`, `Node3D`, each owning one node of an ordinary `Scene`. A rebuild with the same keys keeps the nodes. | | `SceneSurface` | The widget that hands a frame to Flutter. Its settings are a function called per frame, not an object, so anything derived from where the camera ended up is derived after it got there. | | `WidgetSurface` | A live Flutter widget drawn onto a quad in the scene. | | `LevelLoader` | A level document turned into mesh nodes, lights, probes and a collision world. Problems come back as `LoadedLevel.issues` rather than exceptions: a missing wall texture leaves the surface flat and says so. | | `Storage` / `BinaryStorage` | A document kept where each platform keeps such things, behind one interface. | +## A scene as widgets + +```dart +Scene3D( + children: [ + Camera3D(position: Vector3(2.0, 2.0, 4.0), target: Vector3.zero()), + Light3D.directional(direction: Vector3(-1.0, -2.0, -1.5)), + Mesh3D( + key: const ValueKey('crate'), + shape: CuboidShape(), + material: crate, + position: Vector3(0.0, 0.5, 0.0), + ), + Model3D( + source: 'assets_src/robot.glb', + animation: 'Walk', + placeholder: Mesh3D(shape: CuboidShape(), material: grey), + ), + ], +) +``` + +Each widget owns one node of an ordinary `Scene`, made once and given the +widget's properties on every build, so a widget that keeps its key keeps its +node and everything a game set on it. A property left null is left as the node +has it. `Scene3D.onCreated` hands over the device, the renderer and the scene +for whatever the widgets do not say; a game written against the imperative API +keeps it. A frame drawn from widgets is the frame the same scene draws when +built by hand, to the byte — `test/scene_widgets_test.dart` holds that. + ## Universal, and that is the rule for what goes in The modeller, the level editor, the lessons and every game use this package, so diff --git a/packages/flutter3d_app/lib/flutter3d_app.dart b/packages/flutter3d_app/lib/flutter3d_app.dart index 3e2e526c0..193819b0f 100644 --- a/packages/flutter3d_app/lib/flutter3d_app.dart +++ b/packages/flutter3d_app/lib/flutter3d_app.dart @@ -42,7 +42,11 @@ library; export 'src/backend_native.dart' if (dart.library.js_interop) 'src/backend_web.dart' show kFixedResolution, openDevice, presentFrame; +export 'src/declarative/scene_widgets.dart'; export 'src/diagnostics/issues.dart'; +export 'src/diagnostics/render_extensions.dart'; +export 'src/diagnostics/render_inspection.dart'; +export 'src/hot_swap/hot_swap.dart'; export 'src/level/level_loader.dart'; export 'src/level/model_visuals.dart'; export 'src/level/prop_visuals.dart'; @@ -50,6 +54,8 @@ export 'src/level/shared_meshes.dart'; export 'src/level/surface_mesh.dart'; export 'src/level/terrain_tiles.dart'; export 'src/level/visibility_culler.dart'; +export 'src/materials/material_language_stage.dart'; +export 'src/photo/photo_shelf.dart'; export 'src/storage/storage.dart'; export 'src/surface/did_not_start.dart'; export 'src/surface/frame_clock.dart'; diff --git a/packages/flutter3d_app/lib/src/backend_native.dart b/packages/flutter3d_app/lib/src/backend_native.dart index 5a884fd38..224f1643c 100644 --- a/packages/flutter3d_app/lib/src/backend_native.dart +++ b/packages/flutter3d_app/lib/src/backend_native.dart @@ -16,6 +16,7 @@ import 'package:flutter3d_hardware/flutter3d_hardware.dart'; import 'package:flutter3d_impeller/flutter3d_impeller.dart'; import 'cpu_frame_presenter.dart'; +import 'materials/material_language_stage.dart'; import 'surface/scene_surface.dart'; /// Whether this build renders at a fixed internal resolution. @@ -38,7 +39,8 @@ void _ensureRegistered() { if (_registered) return; _registered = true; ensureGpuBackendRegistered(); - ensureCpuBackendRegistered(); + // `P8`: a bundle built from a `.f3dmat` draws here with no Dart of its own. + ensureCpuBackendRegistered(materialCompiler: materialLanguageCompiler); // The one presenter this file still registers directly: `CpuFrame` moved // here from `flutter3d_cpu` once that package went flat (mcp-02n), so // nowhere else can build it. diff --git a/packages/flutter3d_app/lib/src/declarative/scene_widgets.dart b/packages/flutter3d_app/lib/src/declarative/scene_widgets.dart new file mode 100644 index 000000000..0e4d4ec42 --- /dev/null +++ b/packages/flutter3d_app/lib/src/declarative/scene_widgets.dart @@ -0,0 +1,799 @@ +/// A scene written as widgets — `P10`. +/// +/// Scene3D( +/// children: [ +/// const Camera3D(position: ..., target: ...), +/// const Light3D.directional(direction: ...), +/// Mesh3D( +/// key: const ValueKey('crate'), +/// shape: const CuboidShape(), +/// material: crate, +/// position: Vector3(0.0, 0.5, 0.0), +/// ), +/// Model3D(source: 'assets_src/robot.glb', animation: 'Walk'), +/// ], +/// ) +/// +/// **Over the imperative graph, not beside it.** Every widget here owns one +/// node of an ordinary [Scene] — a [SceneNode], a [MeshNode], a [LightNode], +/// a [CameraNode] — made when the widget is first built, given the widget's +/// properties on every rebuild, and taken out of the scene when the widget +/// goes. Flutter's own reconciliation decides which is which, so a widget +/// that keeps its key across a rebuild keeps its node: its transform, its +/// animation's place, everything a game set on it imperatively. A game that +/// wants the graph itself reaches it through [Scene3DController] and keeps +/// every imperative call it had; nothing here is a second engine. +/// +/// **Named with a `3D` suffix.** The plan called them `SceneNode` and +/// `SceneMesh`; `SceneNode` is the engine's own class, exported beside these +/// from the same import, and two classes of one name there is a compile +/// error in every file that uses both. +library; + +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:flutter/foundation.dart' show listEquals; +import 'package:flutter/rendering.dart' show RenderPositionedBox; +import 'package:flutter/scheduler.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter3d/flutter3d.dart' hide Material; +import 'package:flutter3d/flutter3d.dart' as engine show Material; +import 'package:flutter3d_particles/flutter3d_particles.dart'; +import 'package:vector_math/vector_math.dart'; + +import '../backend_native.dart' + if (dart.library.js_interop) '../backend_web.dart' + show openDevice, presentFrame; +import '../surface/scene_surface.dart'; + +part 'scene_widgets_features.dart'; +part 'scene_widgets_mount.dart'; + +/// What the widgets of one scene share, whoever is drawing it: a [Scene3D], +/// or a [SceneWidgetsMount] over a scene a game draws itself. +abstract interface class _SceneHost { + Scene get scene; + Renderer get renderer; + + /// The [Camera3D]s built, in order; the last is the one drawn through. + List get cameras; + + /// What advances between frames: models' animations, particles. + Set<_Animated> get animated; +} + +/// What a [Scene3D] has made, for a game that wants the graph underneath. +final class Scene3DController { + Scene3DController._(this.device, this.renderer, this.scene, this._owner); + + final GraphicsDevice device; + final Renderer renderer; + final Scene scene; + final _Scene3DState _owner; + + /// The camera the frame is drawn through: the [Camera3D] built last, or a + /// default one five metres back looking at the origin when there is none. + CameraNode get camera => _owner._camera; +} + +/// The root of a scene written as widgets — `P10`. +/// +/// Opens a device through `openDevice` unless [device] is given, makes a +/// [Renderer] and a [Scene], builds [children] into the scene, and draws it +/// through [SceneSurface]. [placeholder] is shown while the device opens. +/// +/// **One frame per vsync while [continuous]**, which a scene with an +/// animation needs; with it off a frame is drawn when this widget is built +/// again, which is enough for a scene that changes only when its parent's +/// state does. +class Scene3D extends StatefulWidget { + const Scene3D({ + super.key, + this.children = const [], + this.settings = const RenderSettings(), + this.clearColor, + this.device, + this.materials, + this.continuous = true, + this.placeholder = const SizedBox.expand(), + this.onCreated, + this.onFrame, + this.presenter, + }); + + /// The scene's contents: [Node3D], [Mesh3D], [Model3D], [Light3D] and + /// [Camera3D], nested as the graph is. + final List children; + + /// What every frame is drawn with. + final RenderSettings settings; + + /// The colour behind everything, linear RGBA; the view's default if null. + final Vector4? clearColor; + + /// The device to draw on; `openDevice`'s answer if null. + final GraphicsDevice? device; + + /// A loaded shader library layered over the engine's, for materials whose + /// lighting model names a stage only it has — `HotMaterials.library`. + final ShaderLibrary? materials; + + final bool continuous; + + final Widget placeholder; + + /// Called once, when the scene exists and before its first frame. + final void Function(Scene3DController controller)? onCreated; + + /// Called before every frame with the seconds since the last one — after + /// the animations have advanced and before the frame is drawn. + final void Function(double seconds)? onFrame; + + /// `presentFrame`, normally; a test without a backend passes its own. + final FramePresenter? presenter; + + @override + State createState() => _Scene3DState(); +} + +class _Scene3DState extends State + with SingleTickerProviderStateMixin + implements _SceneHost { + Scene3DController? _controller; + late final Scene _scene = Scene(); + late final CameraNode _defaultCamera = CameraNode() + ..setPosition(0.0, 0.0, 5.0) + ..lookAt(Vector3.zero()); + final List _cameras = []; + final Set<_Animated> _animated = <_Animated>{}; + Ticker? _ticker; + Duration _last = Duration.zero; + double _pending = 0.0; + Object? _error; + + CameraNode get _camera => _cameras.isEmpty ? _defaultCamera : _cameras.last; + + @override + Scene get scene => _scene; + + @override + Renderer get renderer => _controller!.renderer; + + @override + List get cameras => _cameras; + + @override + Set<_Animated> get animated => _animated; + + @override + void initState() { + super.initState(); + _scene.add(_defaultCamera); + unawaited(_open()); + if (widget.continuous) _startTicker(); + } + + Future _open() async { + try { + final device = + widget.device ?? await openDevice(width: 1280, height: 720); + if (!mounted) return; + final renderer = Renderer.create( + device: device, + materials: widget.materials, + ); + setState(() { + _controller = Scene3DController._(device, renderer, _scene, this); + }); + widget.onCreated?.call(_controller!); + } on Object catch (error) { + if (mounted) setState(() => _error = error); + } + } + + void _startTicker() { + _ticker ??= createTicker((Duration elapsed) { + _pending += (elapsed - _last).inMicroseconds / 1e6; + _last = elapsed; + if (_controller != null) setState(() {}); + })..start(); + } + + @override + void didUpdateWidget(Scene3D oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.continuous && _ticker == null) { + _startTicker(); + } else if (!widget.continuous && _ticker != null) { + _ticker!.dispose(); + _ticker = null; + } + } + + @override + void dispose() { + _ticker?.dispose(); + // What this widget made, it gives back: the renderer always, the device + // only when it opened it — one handed in belongs to whoever handed it. + if (_controller case final controller?) { + controller.renderer.dispose(); + if (widget.device == null) controller.device.dispose(); + } + super.dispose(); + } + + /// The animations advance by what passed since the last frame, then the + /// game's own [Scene3D.onFrame]: one place for "before the frame". + void _beforeFrame() { + final seconds = _pending; + _pending = 0.0; + for (final animated in _animated) { + animated.advance(seconds); + } + widget.onFrame?.call(seconds); + } + + @override + Widget build(BuildContext context) { + final error = _error; + if (error != null) { + return ErrorWidget('the scene could not open a device: $error'); + } + final controller = _controller; + if (controller == null) return widget.placeholder; + return Stack( + fit: StackFit.expand, + children: [ + // Built first and never painted: the children's whole job is the + // nodes they keep in the scene. The surface draws in layout, after + // every one of them has been built for this frame. + Offstage( + child: _Scene3DScope( + host: this, + device: controller.device, + parent: _scene.root, + child: _Children(widget.children), + ), + ), + SceneSurface( + renderer: controller.renderer, + scene: _scene, + view: RenderView(camera: _camera, clearColor: widget.clearColor), + settings: () => widget.settings, + onBeforeFrame: _beforeFrame, + presentFrame: widget.presenter ?? presentFrame, + ), + ], + ); + } +} + +/// Children laid out nowhere: they paint nothing and take no space. +class _Children extends StatelessWidget { + const _Children(this.children); + + final List children; + + @override + Widget build(BuildContext context) => switch (children.length) { + 0 => const SizedBox.shrink(), + _ => Stack(children: children), + }; +} + +/// What a widget below a [Scene3D] needs: the scene's host, the device its +/// meshes upload to, and the node it hangs its own from. +class _Scene3DScope extends InheritedWidget { + const _Scene3DScope({ + required this.host, + required this.device, + required this.parent, + required super.child, + }); + + final _SceneHost host; + final GraphicsDevice device; + final SceneNode parent; + + static _Scene3DScope of(BuildContext context) { + final scope = context.dependOnInheritedWidgetOfExactType<_Scene3DScope>(); + if (scope == null) { + throw FlutterError( + 'A 3D widget was built outside a Scene3D. Mesh3D, Node3D, Model3D, ' + 'Light3D and Camera3D belong in Scene3D.children or below one.', + ); + } + return scope; + } + + @override + bool updateShouldNotify(_Scene3DScope oldWidget) => + !identical(parent, oldWidget.parent) || + !identical(device, oldWidget.device); +} + +/// A node with a transform, and children hung from it. +/// +/// [position], [rotation] and [scale] are applied on every build, and a +/// property left null is left as the node has it, so a game may move a node +/// imperatively and the widget will not put it back unless it says where. +abstract class Spatial3D extends StatefulWidget { + const Spatial3D({ + super.key, + this.position, + this.rotation, + this.scale, + this.visible = true, + this.children = const [], + }); + + final Vector3? position; + final Quaternion? rotation; + final Vector3? scale; + final bool visible; + final List children; +} + +abstract class _SpatialState + extends State { + N? _node; + SceneNode? _parent; + _Scene3DScope? _scope; + + N get node => _node!; + + /// Makes this widget's node, once. + N createNode(_Scene3DScope scope); + + /// What this widget's own properties say about its node, past the + /// transform. Called on every build. + void apply(N node, _Scene3DScope scope) {} + + /// Told the node now hangs from another parent, after a keyed reparent. + void reparented(N node) {} + + void _applyAll() { + final node = _node!; + final widget = this.widget; + if (widget.position case final p?) node.setPositionFrom(p); + if (widget.rotation case final r?) node.setRotation(r); + if (widget.scale case final s?) node.setScale(s.x, s.y, s.z); + node.visible = widget.visible; + apply(node, _scope!); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final scope = _Scene3DScope.of(context); + _scope = scope; + if (_node == null) { + _node = createNode(scope); + scope.parent.add(_node!); + _parent = scope.parent; + _applyAll(); + } else if (!identical(_parent, scope.parent)) { + // Moved to another parent by a keyed reparent: the node follows, + // keeping everything it had. + _node!.removeFromParent(); + scope.parent.add(_node!); + _parent = scope.parent; + reparented(_node!); + } + } + + @override + void didUpdateWidget(W oldWidget) { + super.didUpdateWidget(oldWidget); + _applyAll(); + } + + @override + void dispose() { + _node?.removeFromParent(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => _Scene3DScope( + host: _scope!.host, + device: _scope!.device, + parent: node, + child: _Children(widget.children), + ); +} + +/// A node with nothing of its own to draw: a group, a pivot. +class Node3D extends Spatial3D { + const Node3D({ + super.key, + super.position, + super.rotation, + super.scale, + super.visible, + super.children, + this.name, + }); + + final String? name; + + @override + State createState() => _Node3DState(); +} + +class _Node3DState extends _SpatialState { + @override + SceneNode createNode(_Scene3DScope scope) => SceneNode(name: widget.name); +} + +/// A mesh drawn with a material. +/// +/// [shape] is built and uploaded to the scene's device when the widget is +/// first built. A rebuild handed another shape object builds it again and +/// uploads it only when its vertices or indices differ, so a shape made +/// fresh in `build` costs a build, not an upload. [geometry], when given, is +/// used as it is and [shape] is not read. [material] is the engine's own, so a game that +/// keeps it may change it in place between frames; with none, the mesh is +/// drawn with the nearest [Material3D] above it — `P10`. +/// +/// Directly below a [Mirror3D], the mesh is one of its reflecting surfaces. +class Mesh3D extends Spatial3D { + const Mesh3D({ + super.key, + this.shape, + this.geometry, + this.material, + super.position, + super.rotation, + super.scale, + super.visible, + super.children, + this.castsShadow = true, + this.drawOrder = 0, + this.name, + }) : assert( + shape != null || geometry != null, + 'a Mesh3D draws a shape or a geometry', + ); + + final Shape? shape; + final MeshGeometry? geometry; + final engine.Material? material; + final bool castsShadow; + + /// See `MeshNode.drawOrder`. + final int drawOrder; + final String? name; + + @override + State createState() => _Mesh3DState(); +} + +class _Mesh3DState extends _SpatialState { + /// The shape object last built, and what it built: a rebuild handed a new + /// object is built again and uploaded only when its vertices or its + /// indices are not the ones on the device. + Shape? _built; + MeshData? _data; + + MeshGeometry _geometry(_Scene3DScope scope) { + final given = widget.geometry; + if (given != null) return given; + final shape = widget.shape!; + final data = shape.build(); + _built = shape; + _data = data; + return DeviceMesh.upload(scope.device, data); + } + + /// The mirror this mesh is a surface of, while it hangs directly from one. + PlanarReflectorNode? _reflector; + + engine.Material _material() => + widget.material ?? + _MaterialScope.maybeOf(context) ?? + (throw FlutterError( + 'A Mesh3D was given no material and has no Material3D above it.', + )); + + @override + MeshNode createNode(_Scene3DScope scope) => + MeshNode(_geometry(scope), _material(), name: widget.name); + + void _joinMirror(MeshNode node) { + final now = switch (node.parent) { + final PlanarReflectorNode mirror => mirror, + _ => null, + }; + if (identical(now, _reflector)) return; + _reflector?.surfaces.remove(node); + now?.surfaces.add(node); + _reflector = now; + } + + @override + void reparented(MeshNode node) => _joinMirror(node); + + @override + void dispose() { + if (_node case final node?) _reflector?.surfaces.remove(node); + super.dispose(); + } + + @override + void apply(MeshNode node, _Scene3DScope scope) { + if (widget.geometry case final given?) { + if (!identical(node.mesh, given)) node.mesh = given; + _built = null; + _data = null; + } else if (!identical(_built, widget.shape)) { + final shape = widget.shape!; + final data = shape.build(); + final previous = _data; + _built = shape; + if (previous == null || + !listEquals(previous.vertices, data.vertices) || + !listEquals(previous.indices, data.indices)) { + _data = data; + node.mesh = DeviceMesh.upload(scope.device, data); + } + } + node + ..material = _material() + ..castsShadow = widget.castsShadow + ..drawOrder = widget.drawOrder; + _joinMirror(node); + } +} + +/// A light: directional by default, or a point or a spot. +class Light3D extends Spatial3D { + const Light3D({ + super.key, + this.type = LightType.directional, + this.direction, + this.color, + this.intensity = 1.0, + this.range = 0.0, + this.castsShadow, + this.outerConeAngle, + super.position, + super.children, + }); + + /// A light from infinitely far along [direction], the way it shines. + const Light3D.directional({ + super.key, + required this.direction, + this.color, + this.intensity = 1.0, + this.castsShadow, + }) : type = LightType.directional, + range = 0.0, + outerConeAngle = null, + super(position: null); + + /// A light at [position] reaching [range] metres; nought is unbounded. + const Light3D.point({ + super.key, + required super.position, + this.color, + this.intensity = 1.0, + this.range = 0.0, + this.castsShadow, + }) : type = LightType.point, + direction = null, + outerConeAngle = null; + + final LightType type; + + /// Which way the light shines; applied to directional and spot lights. + final Vector3? direction; + + /// Linear RGB; white if null. + final Vector3? color; + final double intensity; + final double range; + + /// Null leaves the engine's default: a directional light casts, others not. + final bool? castsShadow; + + /// A spot's cone, in radians. + final double? outerConeAngle; + + @override + State createState() => _Light3DState(); +} + +class _Light3DState extends _SpatialState { + @override + LightNode createNode(_Scene3DScope scope) => + LightNode(type: widget.type, castsShadow: widget.castsShadow); + + @override + void apply(LightNode node, _Scene3DScope scope) { + node + ..type = widget.type + ..intensity = widget.intensity + ..range = widget.range; + node.color.setFrom(widget.color ?? Vector3(1.0, 1.0, 1.0)); + if (widget.castsShadow case final casts?) node.castsShadow = casts; + if (widget.outerConeAngle case final cone?) node.outerConeAngle = cone; + if (widget.direction case final direction?) { + node.setLocalForward(direction.normalized()); + } + } +} + +/// A camera the frame is drawn through. +/// +/// The [Camera3D] built last is the one drawn through; with none, the scene +/// has a default five metres back looking at the origin. [target], when +/// given, is looked at after [position] is applied. +class Camera3D extends Spatial3D { + const Camera3D({ + super.key, + super.position, + super.rotation, + this.target, + this.projection = const PerspectiveProjection(), + super.children, + }); + + final Vector3? target; + final Projection projection; + + @override + State createState() => _Camera3DState(); +} + +class _Camera3DState extends _SpatialState { + @override + CameraNode createNode(_Scene3DScope scope) { + final camera = CameraNode(projection: widget.projection); + scope.host.cameras.add(camera); + return camera; + } + + @override + void apply(CameraNode node, _Scene3DScope scope) { + node.projection = widget.projection; + if (widget.target case final target?) node.lookAt(target); + } + + @override + void dispose() { + _scope?.host.cameras.remove(_node); + super.dispose(); + } +} + +/// Something the scene advances by the seconds between frames. +abstract interface class _Animated { + void advance(double seconds); +} + +/// A model loaded from where the build hook converted [source] — or from +/// [load], when given — and instantiated into the scene. +/// +/// [placeholder], a 3D widget, stands in until the model has loaded, and is +/// taken out when it has; with none, nothing does. [animation] names the +/// clip to play — null plays none — at [speed], paused while [playing] is +/// false; changing either changes the clip in place, where it is. +class Model3D extends Spatial3D { + const Model3D({ + super.key, + this.source, + this.load, + this.placeholder, + this.animation, + this.playing = true, + this.speed = 1.0, + this.onLoaded, + super.position, + super.rotation, + super.scale, + super.visible, + super.children, + }) : assert( + source != null || load != null, + 'a Model3D loads a source path or calls a loader', + ); + + /// A path under `assets_src/`, read as `loadModelAsset` reads it. + final String? source; + + /// Makes the asset on the scene's device, in place of reading [source]. + final Future Function(GraphicsDevice device)? load; + + final Widget? placeholder; + final String? animation; + final bool playing; + final double speed; + + /// Called once with the instance the model became. + final void Function(ModelInstance instance)? onLoaded; + + @override + State createState() => _Model3DState(); +} + +class _Model3DState extends _SpatialState + implements _Animated { + ModelInstance? _instance; + String? _playingClip; + + @override + SceneNode createNode(_Scene3DScope scope) { + unawaited(_loadInto(scope)); + scope.host.animated.add(this); + return SceneNode(name: widget.source ?? 'model'); + } + + Future _loadInto(_Scene3DScope scope) async { + final asset = switch (widget.load) { + final load? => await load(scope.device), + null => await ModelAsset.fromDocument( + await loadModelAsset(widget.source!), + device: scope.device, + ), + }; + if (!mounted) return; + final instance = asset.instantiate(scope.host.scene, parent: node); + setState(() => _instance = instance); + _applyAnimation(); + widget.onLoaded?.call(instance); + } + + void _applyAnimation() { + final player = _instance?.player; + if (player == null) return; + final clip = widget.animation; + if (clip == null) { + if (player.isPlaying) player.stop(); + _playingClip = null; + return; + } + if (clip != _playingClip) { + final index = player.clipNames.indexOf(clip); + if (index < 0) { + throw ArgumentError.value( + clip, + 'animation', + 'the model has the clips ${player.clipNames.join(', ')}', + ); + } + player.play(index); + _playingClip = clip; + } + player.speed = widget.speed; + } + + @override + void advance(double seconds) { + final player = _instance?.player; + if (player == null || !widget.playing || _playingClip == null) return; + player.update(seconds); + } + + @override + void apply(SceneNode node, _Scene3DScope scope) => _applyAnimation(); + + @override + void dispose() { + _scope?.host.animated.remove(this); + super.dispose(); + } + + @override + Widget build(BuildContext context) => _Scene3DScope( + host: _scope!.host, + device: _scope!.device, + parent: node, + child: _Children([ + if (_instance == null) ?widget.placeholder, + ...widget.children, + ]), + ); +} diff --git a/packages/flutter3d_app/lib/src/declarative/scene_widgets_features.dart b/packages/flutter3d_app/lib/src/declarative/scene_widgets_features.dart new file mode 100644 index 000000000..ca8d4e539 --- /dev/null +++ b/packages/flutter3d_app/lib/src/declarative/scene_widgets_features.dart @@ -0,0 +1,447 @@ +part of 'scene_widgets.dart'; + +// -------------------------------------------------------------- materials + +/// A material, as a widget — `P10`: the meshes below it that name no +/// material of their own are drawn with it. +/// +/// Material3D( +/// baseColor: Vector4(0.8, 0.3, 0.2, 1.0), +/// roughness: 0.4, +/// children: [ +/// Mesh3D(shape: CuboidShape(), position: ...), +/// Mesh3D(shape: SphereShape(), position: ...), +/// ], +/// ) +/// +/// **One engine [engine.Material], made once and changed in place.** A +/// rebuild with other properties writes them into the same object, so the +/// meshes keep drawing with it and the renderer's batching keeps seeing one +/// material — which is what lets a field of crates under one [Material3D] be +/// one draw. As with every widget here, a key keeps it across a rebuild. +/// +/// A property left null is left as the material has it, so a game may still +/// change the object imperatively — through [onCreated] — and the widget +/// will not put it back unless it says what it should be. +class Material3D extends StatefulWidget { + const Material3D({ + super.key, + this.lighting = LightingModel.pbr, + this.baseColor, + this.metallic, + this.roughness, + this.emissive, + this.emissiveStrength, + this.albedo, + this.normal, + this.metallicRoughness, + this.occlusion, + this.emissiveTexture, + this.alphaMode, + this.alphaCutoff, + this.doubleSided, + this.parameters = const >{}, + this.name, + this.onCreated, + this.children = const [], + }); + + /// How it is lit: one of the engine's models, or one a bundle describes — + /// `BundledMaterials`. + final LightingModel lighting; + + /// Linear RGBA. + final Vector4? baseColor; + final double? metallic; + final double? roughness; + + /// Linear RGB. + final Vector3? emissive; + final double? emissiveStrength; + final TextureHandle? albedo; + final TextureHandle? normal; + final TextureHandle? metallicRoughness; + final TextureHandle? occlusion; + final TextureHandle? emissiveTexture; + final MaterialAlphaMode? alphaMode; + final double? alphaCutoff; + final bool? doubleSided; + + /// Values for the `uniform`s of a material written in the language — `P8`: + /// written into `Material.parameters` on every build. A name not given is + /// left as the material has it. + final Map> parameters; + final String? name; + + /// Called once with the material, when it is made. + final void Function(engine.Material material)? onCreated; + + final List children; + + @override + State createState() => _Material3DState(); +} + +class _Material3DState extends State { + late final engine.Material _material = engine.Material( + name: widget.name, + lighting: widget.lighting, + ); + + @override + void initState() { + super.initState(); + _apply(); + widget.onCreated?.call(_material); + } + + @override + void didUpdateWidget(Material3D oldWidget) { + super.didUpdateWidget(oldWidget); + _apply(); + } + + void _apply() { + final m = _material..lighting = widget.lighting; + if (widget.baseColor case final c?) m.baseColor.setFrom(c); + if (widget.metallic case final v?) m.metallic = v; + if (widget.roughness case final v?) m.roughness = v; + if (widget.emissive case final c?) m.emissive.setFrom(c); + if (widget.emissiveStrength case final v?) m.emissiveStrength = v; + if (widget.albedo case final t?) m.albedo = t; + if (widget.normal case final t?) m.normal = t; + if (widget.metallicRoughness case final t?) m.metallicRoughness = t; + if (widget.occlusion case final t?) m.occlusion = t; + if (widget.emissiveTexture case final t?) m.emissiveTexture = t; + if (widget.alphaMode case final v?) m.alphaMode = v; + if (widget.alphaCutoff case final v?) m.alphaCutoff = v; + if (widget.doubleSided case final v?) m.doubleSided = v; + for (final MapEntry(:key, :value) in widget.parameters.entries) { + m.parameters[key] = Float32List.fromList(value); + } + } + + @override + Widget build(BuildContext context) => + _MaterialScope(material: _material, child: _Children(widget.children)); +} + +class _MaterialScope extends InheritedWidget { + const _MaterialScope({required this.material, required super.child}); + + final engine.Material material; + + static engine.Material? maybeOf(BuildContext context) => + context.dependOnInheritedWidgetOfExactType<_MaterialScope>()?.material; + + @override + bool updateShouldNotify(_MaterialScope oldWidget) => + !identical(material, oldWidget.material); +} + +// ------------------------------------------------------- probes and decals + +/// A reflection probe: the scene around [position] captured into a cube that +/// the surfaces within [radius] reflect — `ReflectionProbeNode`. +/// +/// [faceSize], [levels], [near] and [far] shape the capture and are read when +/// the probe is first built; [radius] and [intensity] follow every build. +class ReflectionProbe3D extends Spatial3D { + const ReflectionProbe3D({ + super.key, + super.position, + this.radius = 0.0, + this.intensity = 1.0, + this.faceSize = 64, + this.levels = 4, + this.near = 0.05, + this.far = 200.0, + this.name, + }); + + /// How far the probe reaches; nought reaches everything. + final double radius; + final double intensity; + final int faceSize; + final int levels; + final double near; + final double far; + final String? name; + + @override + State createState() => _ReflectionProbe3DState(); +} + +class _ReflectionProbe3DState + extends _SpatialState { + @override + ReflectionProbeNode createNode(_Scene3DScope scope) => ReflectionProbeNode( + radius: widget.radius, + intensity: widget.intensity, + faceSize: widget.faceSize, + levels: widget.levels, + near: widget.near, + far: widget.far, + name: widget.name, + ); + + @override + void apply(ReflectionProbeNode node, _Scene3DScope scope) { + node + ..radius = widget.radius + ..intensity = widget.intensity; + } +} + +/// A decal: [texture], tinted [color], stamped down the node's local y axis +/// onto whatever lies inside its box — `DecalNode`. The box is the node's +/// scale, a unit cube at scale one: x and z are the picture's size, y how +/// deep it reaches, so one with no rotation lies on the floor. +class Decal3D extends Spatial3D { + const Decal3D({ + super.key, + super.position, + super.rotation, + super.scale, + super.visible, + this.texture, + this.color, + this.emissive, + this.region, + this.order = 0, + this.angleLimit = 1.3, + this.angleFade = 0.2, + this.depthFade = 0.1, + this.name, + }); + + final TextureHandle? texture; + + /// Linear RGBA; white if null. + final Vector4? color; + + /// Linear RGB; none if null. + final Vector3? emissive; + + /// The part of [texture] it shows, as `(u0, v0, u1, v1)`; all of it if null. + final Vector4? region; + + /// Higher draws over lower where two decals overlap. + final int order; + final double angleLimit; + final double angleFade; + final double depthFade; + final String? name; + + @override + State createState() => _Decal3DState(); +} + +class _Decal3DState extends _SpatialState { + @override + DecalNode createNode(_Scene3DScope scope) => DecalNode(name: widget.name); + + @override + void apply(DecalNode node, _Scene3DScope scope) { + node + ..texture = widget.texture + ..color = widget.color?.clone() ?? Vector4(1.0, 1.0, 1.0, 1.0) + ..emissive = widget.emissive?.clone() ?? Vector3.zero() + ..region = widget.region?.clone() ?? Vector4(0.0, 0.0, 1.0, 1.0) + ..order = widget.order + ..angleLimit = widget.angleLimit + ..angleFade = widget.angleFade + ..depthFade = widget.depthFade; + } +} + +// ----------------------------------------------------------------- mirrors + +/// A flat mirror — `PlanarReflectorNode`. The [Mesh3D]s directly below it +/// are the surfaces it is seen in, each joining and leaving as its widget +/// does; the mirror's plane is theirs. +/// +/// Planar reflections are drawn when the frame's `ReflectionSettings` ask +/// for them, as for an imperatively built mirror. +class Mirror3D extends Spatial3D { + const Mirror3D({ + super.key, + super.position, + super.rotation, + super.scale, + super.visible, + this.reflectance = 1.0, + this.strength = 1.0, + this.tint, + this.resolution = 0.5, + this.clipOffset = 0.01, + this.name, + super.children, + }); + + /// F0 at normal incidence: one is a perfect mirror. + final double reflectance; + final double strength; + + /// Linear RGB the reflection is multiplied by; white if null. + final Vector3? tint; + + /// The reflection's size as a fraction of the view's. + final double resolution; + final double clipOffset; + final String? name; + + @override + State createState() => _Mirror3DState(); +} + +class _Mirror3DState extends _SpatialState { + @override + PlanarReflectorNode createNode(_Scene3DScope scope) => + PlanarReflectorNode(name: widget.name); + + @override + void apply(PlanarReflectorNode node, _Scene3DScope scope) { + node + ..reflectance = widget.reflectance + ..strength = widget.strength + ..resolution = widget.resolution + ..clipOffset = widget.clipOffset; + node.tint.setFrom(widget.tint ?? Vector3(1.0, 1.0, 1.0)); + } +} + +// ------------------------------------------------- contributors, particles + +/// Draws [contributor] with the scene while this widget is in the tree — +/// `Renderer.addContributor` and `removeContributor`, kept by Flutter's +/// reconciliation. For a contributor this package has no widget for. +class Contributor3D extends StatefulWidget { + const Contributor3D({super.key, required this.contributor}); + + final PassContributor contributor; + + @override + State createState() => _Contributor3DState(); +} + +class _Contributor3DState extends State { + _SceneHost? _host; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final host = _Scene3DScope.of(context).host; + if (identical(host, _host)) return; + _host?.renderer.removeContributor(widget.contributor); + host.renderer.addContributor(widget.contributor); + _host = host; + } + + @override + void didUpdateWidget(Contributor3D oldWidget) { + super.didUpdateWidget(oldWidget); + if (identical(oldWidget.contributor, widget.contributor)) return; + final renderer = _host?.renderer; + if (renderer == null) return; + renderer + ..removeContributor(oldWidget.contributor) + ..addContributor(widget.contributor); + } + + @override + void dispose() { + _host?.renderer.removeContributor(widget.contributor); + super.dispose(); + } + + @override + Widget build(BuildContext context) => const SizedBox.shrink(); +} + +/// Particles emitted at this node's place — `P10`: [effect] at [perSecond] +/// a second, simulated between frames and drawn as billboards, through a +/// `ParticleSystem` and a `ParticleContributor` this widget owns. +/// +/// [perSecond] and [effect] follow every build, so a torch is lit by raising +/// one and a different flame by passing another; emission stops at nought +/// and what was already in the air finishes its life. Moving the node moves +/// where new particles are born. [system], when given, is simulated and +/// drawn in place of one of this widget's own — for a game that also bursts +/// into it imperatively. +class Particles3D extends Spatial3D { + const Particles3D({ + super.key, + super.position, + super.visible, + required this.effect, + this.perSecond = 0.0, + this.capacity = 512, + this.seed = 0, + this.texture, + this.system, + super.children, + }); + + final ParticleEffect effect; + final double perSecond; + + /// How many particles can be alive at once; read when first built. + final int capacity; + + /// Makes the simulation reproducible; read when first built. + final int seed; + + /// A sprite for every particle; the procedural disc if null. + final TextureHandle? texture; + final ParticleSystem? system; + + @override + State createState() => _Particles3DState(); +} + +class _Particles3DState extends _SpatialState + implements _Animated { + late final ParticleSystem _system = + widget.system ?? + ParticleSystem(capacity: widget.capacity, seed: widget.seed); + ParticleContributor? _contributor; + _SceneHost? _host; + + @override + SceneNode createNode(_Scene3DScope scope) { + final host = scope.host; + _contributor = host.renderer.addContributor( + ParticleContributor(_system, texture: widget.texture), + ); + host.animated.add(this); + _host = host; + return SceneNode(name: 'particles'); + } + + @override + void advance(double seconds) { + final node = _node; + if (node == null) return; + _system + ..emit( + this, + widget.effect, + node.readWorldPosition(), + perSecond: widget.visible ? widget.perSecond : 0.0, + ) + ..advance(seconds); + } + + @override + void dispose() { + final host = _host; + if (host != null) { + host.animated.remove(this); + if (_contributor case final contributor?) { + host.renderer.removeContributor(contributor); + } + } + super.dispose(); + } +} diff --git a/packages/flutter3d_app/lib/src/declarative/scene_widgets_mount.dart b/packages/flutter3d_app/lib/src/declarative/scene_widgets_mount.dart new file mode 100644 index 000000000..671dfdd15 --- /dev/null +++ b/packages/flutter3d_app/lib/src/declarative/scene_widgets_mount.dart @@ -0,0 +1,130 @@ +part of 'scene_widgets.dart'; + +/// Widgets built into a scene somebody else draws — `P10`. +/// +/// [Scene3D] opens a device, makes a renderer and draws a frame each vsync; +/// a game with a loop of its own — a golden runner, a test, an engine +/// embedded in another — has all of that already and wants only the graph. +/// This builds [children] into [scene]'s root, on [device], with [renderer] +/// for what a widget adds to the frame (particles, a contributor), and keeps +/// them there until [SceneWidgetsMount.dispose]: the same widgets, the same +/// reconciliation, the same nodes. +/// +/// final mount = SceneWidgets.mount( +/// scene: scene, +/// renderer: renderer, +/// device: device, +/// children: [Camera3D(...), Mesh3D(...)], +/// ); +/// // each frame: +/// mount.advance(seconds); +/// renderer.render(..., views: [RenderView(camera: mount.camera!)]); +/// +/// **No screen, and no binding of its own.** The widgets paint nothing — a +/// [Scene3D] builds them under an `Offstage` for the same reason — so they +/// are built by a [BuildOwner] of this mount's and never laid out. A widget +/// that rebuilds itself, a [Model3D] whose asset arrived, is rebuilt on the +/// next microtask. +abstract final class SceneWidgets { + static SceneWidgetsMount mount({ + required Scene scene, + required Renderer renderer, + required GraphicsDevice device, + List children = const [], + }) => SceneWidgetsMount._(scene, renderer, device)..update(children); +} + +/// What [SceneWidgets.mount] built, kept until [dispose]. +final class SceneWidgetsMount { + SceneWidgetsMount._(this.scene, this.renderer, this.device) + : _host = _MountHost(scene, renderer) { + _owner = BuildOwner( + focusManager: FocusManager(), + onBuildScheduled: _scheduleBuild, + ); + } + + final Scene scene; + final Renderer renderer; + final GraphicsDevice device; + final _MountHost _host; + + late final BuildOwner _owner; + final RenderPositionedBox _root = RenderPositionedBox(); + RenderObjectToWidgetElement? _element; + bool _buildScheduled = false; + bool _disposed = false; + + /// The [Camera3D] built last, or null when there is none. + CameraNode? get camera => + _host.cameras.isEmpty ? null : _host.cameras.last; + + /// Builds [children] in place of what was built before, keeping by key and + /// type what Flutter keeps. + void update(List children) { + if (_disposed) throw StateError('this mount was disposed'); + final adapter = RenderObjectToWidgetAdapter( + container: _root, + child: Directionality( + textDirection: TextDirection.ltr, + child: _Scene3DScope( + host: _host, + device: device, + parent: scene.root, + child: _Children(children), + ), + ), + ); + _element = adapter.attachToRenderTree(_owner, _element); + _build(); + } + + /// Advances what the widgets animate — models' clips, particles — by + /// [seconds], as a [Scene3D] does before each frame. + void advance(double seconds) { + for (final it in List<_Animated>.of(_host.animated)) { + it.advance(seconds); + } + } + + /// Takes every node the widgets made out of the scene, and every + /// contributor out of the renderer. + void dispose() { + if (_disposed) return; + update(const []); + _disposed = true; + } + + void _scheduleBuild() { + if (_buildScheduled || _disposed) return; + _buildScheduled = true; + scheduleMicrotask(() { + _buildScheduled = false; + if (!_disposed) _build(); + }); + } + + void _build() { + final element = _element; + if (element == null) return; + _owner + ..buildScope(element) + ..finalizeTree(); + } +} + +final class _MountHost implements _SceneHost { + _MountHost(this.scene, this.renderer); + + @override + final Scene scene; + + @override + final Renderer renderer; + + @override + final List cameras = []; + + @override + final Set<_Animated> animated = <_Animated>{}; +} diff --git a/packages/flutter3d_app/lib/src/diagnostics/render_extensions.dart b/packages/flutter3d_app/lib/src/diagnostics/render_extensions.dart new file mode 100644 index 000000000..bf5067b28 --- /dev/null +++ b/packages/flutter3d_app/lib/src/diagnostics/render_extensions.dart @@ -0,0 +1,170 @@ +/// Puts the renderer's own frame on the VM service as `ext.flutter3d.render.*` +/// — `P12`. +/// +/// **The frame the player sees, not a second one drawn for the tool.** The +/// diagnostic renderers in `flutter3d_sim_mcp` and `flutter3d_editor_mcp` draw +/// a level again on the software backend at 320×200, which answers "what is +/// in the level" and nothing about why the game's own frame is black, has a +/// hole in it, or costs twice what it did yesterday. Those are questions about +/// the GPU frame that actually ran, with its passes, its targets and its +/// draws, and only the running game has it. So every request here is a +/// `Renderer.captureNextFrame(draws: true)`: the next frame the game draws +/// anyway, recorded pass by pass, and answered by the functions in +/// `render_inspection.dart`. +/// +/// ## What a caller sees +/// +/// Seven extensions, each `ext.flutter3d.render.`, string parameters as +/// the protocol requires: +/// +/// * `passes`, `draws`, `stats`, `scanNan` — take a fresh capture unless +/// `fresh=false` and one is held. +/// * `passOutput`, `draw`, `readPixel` — read the capture already held, so an +/// index from `draws` or a pass from `passes` means the same frame; pass +/// `fresh=true` to take a new one. With none held they take one. +/// +/// Every answer carries `frame`, a count of captures this game has taken, so +/// a caller can see whether two answers are about the same frame. +/// +/// **Harmless where the VM service is off**, the same way the timeline's are: +/// `registerExtension` adds an entry nothing ever asks for, and nothing is +/// captured until something does. +library; + +import 'dart:async'; +import 'dart:convert'; +import 'dart:developer' as developer; + +import 'package:flutter3d/flutter3d.dart'; +import 'package:flutter3d_cpu/flutter3d_cpu.dart' show CpuDevice; + +import 'render_inspection.dart'; + +/// Registers `ext.flutter3d.render.*` against whatever [renderer] answers at +/// the moment of each request — null while the game's renderer has not +/// opened, which is refused with that reason. +/// +/// Calling it again replaces the getter rather than registering twice, which +/// `registerExtension` would refuse: a game that builds its renderer again, +/// or a second screen with its own, points the same extensions at the new +/// one. +void registerRenderExtensions( + Renderer? Function() renderer, { + Duration timeout = const Duration(seconds: 2), +}) { + _inspector.renderer = renderer; + _inspector.timeout = timeout; + if (_registered) return; + _registered = true; + + void answer( + String verb, { + required bool freshByDefault, + required Map Function( + FrameCapture capture, + Map parameters, + ) + ask, + }) { + developer.registerExtension('ext.flutter3d.render.$verb', ( + method, + parameters, + ) async { + final fresh = switch (parameters['fresh']) { + 'true' => true, + 'false' => false, + _ => freshByDefault, + }; + final taken = await _inspector.capture(fresh: fresh); + if (taken case _Unavailable(:final why)) { + return developer.ServiceExtensionResponse.error( + developer.ServiceExtensionResponse.extensionError, + why, + ); + } + final (frame, capture) = (taken as _Taken).value; + final result = ask(capture, parameters); + if (isRefusal(result)) { + return developer.ServiceExtensionResponse.error( + developer.ServiceExtensionResponse.invalidParams, + result['refused']! as String, + ); + } + return developer.ServiceExtensionResponse.result( + jsonEncode({'frame': frame, ...result}), + ); + }); + } + + answer('passes', freshByDefault: true, ask: (c, _) => renderPasses(c)); + answer('draws', freshByDefault: true, ask: renderDraws); + answer('stats', freshByDefault: true, ask: (c, _) => renderStats(c)); + answer('scanNan', freshByDefault: true, ask: renderScanNan); + answer('passOutput', freshByDefault: false, ask: renderPassOutput); + answer('draw', freshByDefault: false, ask: renderDraw); + answer('readPixel', freshByDefault: false, ask: renderReadPixel); +} + +bool _registered = false; +final _Inspector _inspector = _Inspector(); + +sealed class _Capture { + const _Capture(); +} + +final class _Taken extends _Capture { + const _Taken(this.value); + final (int, FrameCapture) value; +} + +final class _Unavailable extends _Capture { + const _Unavailable(this.why); + final String why; +} + +/// The one capture the extensions share, and the renderer it came from. +final class _Inspector { + Renderer? Function() renderer = () => null; + Duration timeout = const Duration(seconds: 2); + + (int, FrameCapture)? _held; + Renderer? _heldFrom; + int _frames = 0; + + Future<_Capture> capture({required bool fresh}) async { + final current = renderer(); + if (current == null) { + return const _Unavailable( + 'the game has no renderer yet; ask again once it is drawing', + ); + } + final held = _held; + if (!fresh && held != null && identical(_heldFrom, current)) { + return _Taken(held); + } + final device = current.device; + final FrameCapture capture; + try { + capture = await current + .captureNextFrame( + draws: true, + // Only the software backend keeps its targets as floats; on the + // others a NaN is reported unread rather than absent. + readFloats: device is CpuDevice ? device.readHdrPixels : null, + ) + .timeout(timeout); + } on TimeoutException { + return _Unavailable( + 'no frame was drawn within ${timeout.inMilliseconds} ms; a capture ' + 'is the next frame the game draws, so the game has to be drawing — ' + 'a paused game that stops presenting draws nothing to capture', + ); + } on Object catch (error) { + return _Unavailable('the capture failed: $error'); + } + final taken = (++_frames, capture); + _held = taken; + _heldFrom = current; + return _Taken(taken); + } +} diff --git a/packages/flutter3d_app/lib/src/diagnostics/render_inspection.dart b/packages/flutter3d_app/lib/src/diagnostics/render_inspection.dart new file mode 100644 index 000000000..ae2c3e57d --- /dev/null +++ b/packages/flutter3d_app/lib/src/diagnostics/render_inspection.dart @@ -0,0 +1,477 @@ +/// The answers `ext.flutter3d.render.*` gives, worked out of one +/// [FrameCapture] — `P12`. +/// +/// **Functions from a capture and a parameter map to a JSON map, and nothing +/// else.** The VM service is a transport: what it carries is "which pass", +/// "which pixel", "which draw", and every one of those questions can be asked +/// of a capture built by hand in a test. Kept apart from the registration in +/// `render_extensions.dart` so that the part with the decisions in it — what an +/// unknown pass is told, what a pixel past the edge is told, what a NaN looks +/// like in JSON — is tested without a VM, a window or a GPU. +/// +/// **A refusal is an answer**: every function returns `{"refused": "..."}` +/// rather than throwing when it cannot do what was asked, and the sentence +/// names what was asked, why it cannot be, and what would work. +library; + +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flutter3d/flutter3d.dart'; +import 'package:flutter3d_cpu/flutter3d_cpu.dart' show encodePng; + +/// What a refusal looks like, so the registration can tell one apart. +Map refusal(String why) => {'refused': why}; + +/// Whether [answer] is a refusal from one of the functions here. +bool isRefusal(Map answer) => answer['refused'] is String; + +/// `passes`: every pass of the frame, in the order it ran. +Map renderPasses(FrameCapture capture) => { + 'width': capture.width, + 'height': capture.height, + 'passes': >[ + for (final (index, pass) in capture.passes.indexed) + { + 'index': index, + 'name': pass.name, + 'active': pass.active, + 'reads': pass.reads, + 'optionalReads': pass.optionalReads, + 'writes': pass.writes, + 'keeps': pass.keeps, + 'outputs': >[ + for (final image in pass.images) _describeImage(image), + ], + 'micros': pass.micros, + 'drawCalls': pass.drawCalls, + 'triangles': pass.triangles, + }, + ], +}; + +/// `passOutput`: one output of one pass as a PNG, base64. +/// +/// [parameters]: `pass` (an index or a name), `resource` (optional; the +/// pass's first output when absent). The PNG holds the eight-bit readback, +/// premultiplied, which is what every backend can hand back — see +/// `GraphicsDevice.readPixels`. +Map renderPassOutput( + FrameCapture capture, + Map parameters, +) => _answering(() { + final (pass, image) = _imageFor(capture, parameters); + final pixels = image.pixels; + if (pixels == null) { + throw _Refusal( + 'pass "${pass.name}" has no picture of "${image.resource}": ' + '${image.refused ?? 'the backend answered no pixels'}', + ); + } + return { + 'pass': pass.name, + ..._describeImage(image), + 'png': base64Encode( + encodePng( + pixels.buffer.asUint8List(pixels.offsetInBytes, pixels.lengthInBytes), + image.width, + image.height, + ), + ), + }; +}); + +/// `draws`: the frame's described draws, one row each. +/// +/// [parameters]: `pass` (optional; an index or a name to keep only that +/// pass's draws), `offset` and `limit` (a page; two hundred rows by default, +/// because a frame of a few thousand draws is a response nobody reads whole). +Map renderDraws( + FrameCapture capture, + Map parameters, +) => _answering(() { + final counted = capture.passes.fold(0, (sum, p) => sum + p.drawCalls); + if (capture.draws.isEmpty && capture.undetailedDraws.isEmpty && counted > 0) { + throw _Refusal( + 'this capture was taken without the draw journal, so its $counted ' + 'draws are counted and not described; capture again with ' + '`captureNextFrame(draws: true)`', + ); + } + final passName = switch (parameters['pass']) { + final key? => _passFor(capture, key).$2.name, + null => null, + }; + final offset = int.tryParse(parameters['offset'] ?? '') ?? 0; + final limit = int.tryParse(parameters['limit'] ?? '') ?? 200; + if (offset < 0 || limit < 1) { + throw _Refusal( + 'offset must be zero or more and limit one or more; ' + 'got offset $offset, limit $limit', + ); + } + final matching = [ + for (final draw in capture.draws) + if (passName == null || draw.pass == passName) draw, + ]; + return { + 'total': matching.length, + 'offset': offset, + 'draws': >[ + for (final draw in matching.skip(offset).take(limit)) + draw.toSummaryJson(), + ], + 'undetailed': { + for (final MapEntry(:key, :value) in capture.undetailedDraws.entries) + if (passName == null || key == passName) key: value, + }, + }; +}); + +/// `draw`: one draw, with its state and the values bound for it. +/// +/// [parameters]: `index`, as [renderDraws] numbered it. +Map renderDraw( + FrameCapture capture, + Map parameters, +) => _answering(() { + final index = int.tryParse(parameters['index'] ?? ''); + if (index == null) { + throw const _Refusal('draw takes an index, an integer from `draws`'); + } + if (index < 0 || index >= capture.draws.length) { + throw _Refusal( + capture.draws.isEmpty + ? 'there is no draw $index: this frame described no draws' + : 'there is no draw $index: this frame described ' + '${capture.draws.length}, numbered 0 to ' + '${capture.draws.length - 1}', + ); + } + final draw = capture.draws[index]; + return { + ...draw.toJson(), + // A NaN in a matrix is exactly what somebody opens one draw to find, and + // `jsonEncode` throws on one rather than writing it. + 'uniforms': { + for (final MapEntry(:key, :value) in draw.uniforms.entries) + key: [for (final v in value) _number(v)], + }, + }; +}); + +/// `readPixel`: one pixel of one output, as stored and as read back. +/// +/// [parameters]: `pass`, `x`, `y`, and `resource` (optional). `uint` is the +/// eight-bit readback every backend has; `float` is the texture's own value +/// where the backend keeps one, and null with `floatUnread` saying why +/// otherwise. A NaN or an infinity arrives as the string `"NaN"`, +/// `"Infinity"` or `"-Infinity"`, because JSON has no number for them. +Map renderReadPixel( + FrameCapture capture, + Map parameters, +) => _answering(() { + final x = int.tryParse(parameters['x'] ?? ''); + final y = int.tryParse(parameters['y'] ?? ''); + if (x == null || y == null) { + throw const _Refusal('readPixel takes x and y, integers from the top left'); + } + final (pass, image) = _imageFor(capture, parameters); + if (image.pixels == null && image.floats == null) { + throw _Refusal( + 'pass "${pass.name}" has no pixels of "${image.resource}": ' + '${image.refused ?? 'the backend answered none'}', + ); + } + if (x < 0 || x >= image.width || y < 0 || y >= image.height) { + throw _Refusal( + '($x, $y) lies outside "${image.resource}", which is ' + '${image.width}x${image.height}: x runs 0 to ${image.width - 1} and ' + 'y 0 to ${image.height - 1}', + ); + } + final uint = [for (var c = 0; c < 4; c++) image.channelAt(x, y, c)]; + final readable = !uint.contains(null); + final floats = _floatsAt(image, x, y); + return { + 'pass': pass.name, + 'resource': image.resource, + 'format': image.format.name, + 'x': x, + 'y': y, + 'uint': readable ? uint : null, + 'unorm': readable ? [for (final v in uint) v! / 255.0] : null, + 'float': floats == null + ? null + : [for (final v in floats) _number(v)], + if (floats == null) 'floatUnread': _floatsUnread(image), + }; +}); + +/// `scanNan`: every NaN and infinity in the frame's outputs, by pass. +/// +/// [parameters]: `pass` (optional; one pass rather than all), `first` (how +/// many coordinates to name per output; eight by default). +/// +/// Only a texture's floats can hold a NaN, and only a backend that keeps +/// floats can hand them back. An eight-bit output cannot hold one and is +/// counted as such; a float output this backend read back as eight bits is +/// listed under `unread` with the reason, rather than reported clean — a +/// NaN clamps to an ordinary byte on the way, and "no NaN found" would be a +/// claim nothing checked. +Map renderScanNan( + FrameCapture capture, + Map parameters, +) => _answering(() { + final first = int.tryParse(parameters['first'] ?? '') ?? 8; + final passes = switch (parameters['pass']) { + final key? => <(int, CapturedPass)>[_passFor(capture, key)], + null => capture.passes.indexed.toList(), + }; + final outputs = <(int, CapturedPass, CapturedImage)>[ + for (final (index, pass) in passes) + for (final image in pass.images) (index, pass, image), + ]; + final scanned = <(int, CapturedPass, CapturedImage, _Hits)>[ + for (final (index, pass, image) in outputs) + if (image.floats case final floats?) + (index, pass, image, _scan(image, floats, first)), + ]; + return { + 'clean': scanned.every((s) => s.$4.total == 0), + 'scanned': scanned.length, + 'eightBit': outputs + .where( + (o) => + o.$3.floats == null && + o.$3.pixels != null && + !_holdsFloats(o.$3.format), + ) + .length, + 'found': >[ + for (final (index, pass, image, hits) in scanned) + if (hits.total > 0) + { + 'passIndex': index, + 'pass': pass.name, + 'resource': image.resource, + 'nan': hits.nan, + 'infinite': hits.infinite, + 'first': hits.first, + }, + ], + 'unread': >[ + for (final (index, pass, image) in outputs) + if (image.floats == null && + (image.pixels == null || _holdsFloats(image.format))) + { + 'passIndex': index, + 'pass': pass.name, + 'resource': image.resource, + 'format': image.format.name, + 'why': image.refused ?? _floatsUnread(image), + }, + ], + }; +}); + +/// `stats`: what the frame cost and what it held. +/// +/// `targetBytes` sums each named resource once, at its captured size and +/// format; a pooled texture two names share is counted under both, and a +/// format whose size this does not know is listed in `unsizedFormats` rather +/// than guessed. +Map renderStats(FrameCapture capture) { + final targets = { + for (final pass in capture.passes) + for (final image in pass.images) + if (image.width > 0 && image.height > 0) image.resource: image, + }; + final sized = >[ + for (final image in targets.values) + { + 'resource': image.resource, + 'width': image.width, + 'height': image.height, + 'format': image.format.name, + 'bytes': switch (_bytesPerPixel(image.format)) { + final int b => b * image.width * image.height, + null => null, + }, + }, + ]; + return { + 'width': capture.width, + 'height': capture.height, + 'passes': capture.passes.length, + 'activePasses': capture.passes.where((p) => p.active).length, + 'drawCalls': capture.passes.fold(0, (s, p) => s + p.drawCalls), + 'describedDraws': capture.draws.length, + 'triangles': capture.passes.fold(0, (s, p) => s + p.triangles), + 'cpuMicros': capture.passes.fold(0, (s, p) => s + p.micros), + 'targetBytes': sized.fold( + 0, + (s, t) => s + ((t['bytes'] as int?) ?? 0), + ), + 'targets': sized, + 'unsizedFormats': { + for (final t in sized) + if (t['bytes'] == null) t['format']! as String, + }.toList(), + }; +} + +/// Raised inside a function here and answered at its edge by [_answering], +/// so a helper several calls down can refuse without every caller between +/// passing the refusal along by hand. Never escapes this file. +final class _Refusal implements Exception { + const _Refusal(this.why); + final String why; +} + +Map _answering(Map Function() body) { + try { + return body(); + } on _Refusal catch (refused) { + return refusal(refused.why); + } +} + +Map _describeImage(CapturedImage image) => { + 'resource': image.resource, + 'width': image.width, + 'height': image.height, + 'format': image.format.name, + 'readable': image.pixels != null, + 'floats': image.floats != null, + if (image.refused != null) 'refused': image.refused, +}; + +/// The pass [key] names — an index or a name — with its index. +(int, CapturedPass) _passFor(FrameCapture capture, String key) { + final names = capture.passes.map((p) => p.name).join(', '); + if (int.tryParse(key) case final index?) { + if (index >= 0 && index < capture.passes.length) { + return (index, capture.passes[index]); + } + throw _Refusal( + 'there is no pass $index: this frame ran ${capture.passes.length}, ' + 'numbered 0 to ${capture.passes.length - 1} ($names)', + ); + } + final index = capture.passes.indexWhere((p) => p.name == key); + if (index >= 0) return (index, capture.passes[index]); + throw _Refusal('no pass is called "$key"; this frame ran $names'); +} + +/// The output [parameters] name, as `pass` and `resource`. +(CapturedPass, CapturedImage) _imageFor( + FrameCapture capture, + Map parameters, +) { + final key = parameters['pass']; + if (key == null) { + throw const _Refusal('name a pass, by index or by name, as `pass`'); + } + final (_, pass) = _passFor(capture, key); + if (pass.images.isEmpty) { + throw _Refusal( + 'pass "${pass.name}" wrote and kept nothing this frame' + '${pass.active ? '' : ' — it was inactive'}', + ); + } + final resource = parameters['resource']; + if (resource == null) return (pass, pass.images.first); + final image = pass.imageOf(resource); + if (image == null) { + throw _Refusal( + 'pass "${pass.name}" has no output "$resource"; it wrote ' + '${pass.images.map((i) => i.resource).join(', ')}', + ); + } + return (pass, image); +} + +/// The floats at ([x], [y]), as many channels as the texture keeps a pixel. +List? _floatsAt(CapturedImage image, int x, int y) { + final floats = image.floats; + if (floats == null) return null; + final channels = _channelsOf(image, floats); + if (channels == 0) return null; + final at = (y * image.width + x) * channels; + return floats.sublist(at, at + channels); +} + +int _channelsOf(CapturedImage image, Float32List floats) { + final pixels = image.width * image.height; + return pixels == 0 ? 0 : floats.length ~/ pixels; +} + +typedef _Hits = ({ + int nan, + int infinite, + int total, + List> first, +}); + +_Hits _scan(CapturedImage image, Float32List floats, int first) { + final channels = _channelsOf(image, floats); + final bad = [ + for (var i = 0; i < image.width * image.height * channels; i++) + if (!floats[i].isFinite) i, + ]; + final nan = bad.where((i) => floats[i].isNaN).length; + return ( + nan: nan, + infinite: bad.length - nan, + total: bad.length, + first: >[ + for (final i in bad.take(first)) + { + 'x': (i ~/ channels) % image.width, + 'y': (i ~/ channels) ~/ image.width, + 'channel': i % channels, + 'value': _number(floats[i]), + }, + ], + ); +} + +String _floatsUnread(CapturedImage image) => _holdsFloats(image.format) + ? 'this backend reads ${image.format.name} back as eight bits a ' + 'channel, and a NaN clamps to an ordinary byte on the way; run ' + 'the frame on the software backend to read its floats' + : '${image.format.name} holds no floats to read'; + +bool _holdsFloats(TextureFormat format) => switch (format) { + TextureFormat.r32g32b32a32Float || + TextureFormat.r16g16b16a16Float || + TextureFormat.r32Float || + TextureFormat.d32FloatS8UInt => true, + _ => false, +}; + +int? _bytesPerPixel(TextureFormat format) => switch (format) { + TextureFormat.a8UNormInt || + TextureFormat.r8UNormInt || + TextureFormat.s8UInt => 1, + TextureFormat.r8g8UNormInt => 2, + TextureFormat.r8g8b8a8UNormInt || + TextureFormat.r8g8b8a8UNormIntSRGB || + TextureFormat.b8g8r8a8UNormInt || + TextureFormat.b8g8r8a8UNormIntSRGB || + TextureFormat.r32Float || + TextureFormat.d24UnormS8Uint => 4, + TextureFormat.r16g16b16a16Float || TextureFormat.d32FloatS8UInt => 8, + TextureFormat.r32g32b32a32Float => 16, + _ => null, +}; + +/// [v] as JSON can carry it: a number, or the name of one JSON has not got. +Object _number(double v) => v.isNaN + ? 'NaN' + : v == double.infinity + ? 'Infinity' + : v == double.negativeInfinity + ? '-Infinity' + : v; diff --git a/packages/flutter3d_app/lib/src/hot_swap/hot_swap.dart b/packages/flutter3d_app/lib/src/hot_swap/hot_swap.dart new file mode 100644 index 000000000..ecffd2829 --- /dev/null +++ b/packages/flutter3d_app/lib/src/hot_swap/hot_swap.dart @@ -0,0 +1,1337 @@ +/// What a hot reload does to a running 3D world, in debug builds. +library; + +import 'dart:async'; +import 'dart:convert'; +import 'dart:developer' as developer; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart' show rootBundle; +import 'package:flutter3d/flutter3d.dart'; + +/// Reads the current bytes of a shader bundle an application loaded itself, +/// or null when they cannot be read now. +typedef ShaderBundleSource = Future Function(); + +/// Reads the current bytes of a model file, or null when they cannot be read +/// now. +typedef ModelBytesSource = Future Function(); + +/// Builds a model from a file's bytes, on the device the game draws with. +typedef ModelBuild = Future Function(Uint8List bytes); + +/// Reads the current bytes of an image file, or null when they cannot be +/// read now. +typedef TextureBytesSource = Future Function(); + +/// Uploads an image file's bytes as a texture, or null when they do not +/// decode into one. +typedef TextureBuild = Future Function(Uint8List bytes); + +/// A prefiltered environment cube and how many levels it has below its base, +/// the two things `Scene.environment` and `Scene.environmentLevels` take. +typedef BuiltEnvironment = ({TextureHandle texture, int levels}); + +/// Builds an environment from a panorama file's bytes, or null when they do +/// not decode into one. +typedef EnvironmentBuild = Future Function(Uint8List bytes); + +/// The renderers and shader libraries a running application has, and what to +/// do with them when its code or its shaders change under it. +/// +/// **Flutter already swaps the bundle; nothing redrew with it.** The engine's +/// own bundle is loaded from an asset, and Flutter 3.47 reinitializes a +/// library loaded that way on every hot reload whose shaders changed. A +/// library keeps its handles through that, but a pipeline linked from the old +/// code keeps drawing it until it is linked again, so the new shaders reached +/// the library and never the picture. [swap] relinks every renderer this +/// knows of, which is the engine's half of the reload, and refreshes the +/// bundles an application loaded from bytes, which Flutter's half never +/// reaches. +/// +/// **Relinked every time, not only when a bundle changed.** Whether Flutter +/// reinitialized the engine's bundle is not observable from here, and a +/// relink costs one link per pipeline on the next frame — the cost of the +/// first frame — once per hot reload, in a debug build. +/// +/// **A bundle that does not load keeps the last one that did.** A refused +/// refresh leaves its library as it was (`LoadedShaderLibrary.refresh` +/// promises that), so a shader with a mistake in it costs a message in the +/// report, not the picture. +/// +/// Debug builds only: in a profile or release build every method returns at +/// once and holds nothing. What reaches it: `SceneSurface` and the Flame +/// bridge's widget register their renderer and call [swap] from +/// `reassemble`, which is what a hot reload runs; an application registers +/// the bundles it loads itself with [registerLibrary]; and a tool reaches the +/// same thing through the VM service as `ext.flutter3d.hotSwap`. +/// +/// A swap and not a reload in its names because `reload` is a weapon's here: +/// CONTRIBUTING.md keeps that word out of the packages, and the structure +/// check holds them to it. +final class HotSwap { + /// A coordinator of its own, for a test. Everything else uses [instance]. + @visibleForTesting + HotSwap({this.enabled = kDebugMode}); + + /// The one every widget and application registers with. + static final HotSwap instance = HotSwap(); + + /// False in profile and release builds, where nothing reloads. + final bool enabled; + + final List> _renderers = >[]; + final List<_Library> _libraries = <_Library>[]; + final List _models = []; + final List _textures = []; + final List _environments = []; + final List> _scenes = >[]; + final Map> _overrides = + >{}; + + /// Counts the reloads that finished, for a host that draws only on demand + /// and has to draw once more to show the new shaders. + final ValueNotifier swaps = ValueNotifier(0); + + Future? _running; + static bool _extensionRegistered = false; + + /// Remembers [renderer] until it is garbage, to relink it on [swap]. + void registerRenderer(Renderer renderer) { + if (!enabled) return; + _renderers.removeWhere((WeakReference r) => r.target == null); + if (_renderers.any((WeakReference r) => r.target == renderer)) { + return; + } + _renderers.add(WeakReference(renderer)); + _registerExtension(); + } + + /// Remembers [library], loaded from bytes by the application, to refresh + /// it on [swap] from whatever [read] returns then. + /// + /// A bundle loaded from an asset by `flutter_gpu` itself needs no + /// registration: Flutter reinitializes those. One loaded through + /// `GraphicsDevice.loadShaders` from bytes the application read does, + /// because nothing else knows where the bytes came from. + void registerLibrary( + LoadedShaderLibrary library, { + required ShaderBundleSource read, + ByteData? loadedFrom, + void Function(ByteData bytes)? onRefreshed, + }) { + if (!enabled) return; + _libraries.removeWhere((_Library l) => l.library.target == null); + _libraries.add( + _Library( + WeakReference(library), + read, + loadedFrom == null ? null : _fingerprint(loadedFrom), + onRefreshed, + ), + ); + _registerExtension(); + } + + /// Loads the material the build hook compiled [sourcePath] — a `.f3dmat` + /// under `assets_src/` — into, onto [device], and watches it — `P8`. + /// + /// **The one call a game makes for a material it writes in the language.** + /// Hand [HotMaterials.library] to the renderer — as its `materials`, or + /// through `Renderer.addMaterials`, once for every `.f3dmat` — and + /// bind each `Material` drawn with one of its stages through + /// [HotMaterials.bind]. A hot reload after the source was edited and the + /// hook compiled it again refreshes the library and relinks every + /// registered renderer, so the next frame draws the edit — on the software + /// backend too, which compiles the new source the bundle carries — and + /// gives every bound material the lighting model the new source describes, + /// so an edit that starts sampling a map or declares a new uniform binds + /// as the new program does. + /// + /// [read] stands in for the asset bundle in a test. Throws a [StateError] + /// naming the file when there is nothing compiled to read — the hook has + /// not run, or the path is not under `assets_src/`. + Future loadMaterial( + String sourcePath, { + required GraphicsDevice device, + ShaderBundleSource? read, + }) async { + final generated = generatedMaterialPathFor(sourcePath); + final source = + read ?? + () async { + try { + return await rootBundle.load(generated); + } on FlutterError { + return null; + } + }; + final bytes = + await source() ?? + (throw StateError( + 'no compiled material at $generated for $sourcePath: the build ' + 'hook compiles assets_src/**/*.f3dmat when the app is built', + )); + final library = await device.loadShaders(bytes); + final materials = HotMaterials(library, BundledMaterials.read(bytes)); + registerLibrary( + library, + read: source, + loadedFrom: bytes, + onRefreshed: materials._adopt, + ); + return materials; + } + + /// Loads the model the build hook converted [sourcePath] into, as + /// `loadModelAsset` does, and hands it back ready to be swapped when the + /// file changes. + /// + /// **The one call a game makes.** Instantiate through the result, and a + /// hot reload after the model was edited draws the edit in the nodes the + /// game already holds; see [ModelInstance.adopt] for what follows and what + /// needs the model instantiated again. Outside a debug build this is + /// `loadModelAsset` and nothing is watched. + Future loadModel( + String sourcePath, { + required GraphicsDevice device, + }) async { + final generated = generatedAssetPathFor(sourcePath); + + // The file `loadModelAsset` would read: this device class's own first, + // then the one every class shares. + Future read() async => switch (assetDeviceClass) { + final DeviceClass reading => + await _readAsset(deviceClassPath(generated, reading)) ?? + await _readAsset(generated), + null => await _readAsset(generated), + }; + + Future build(Uint8List bytes) async => ModelAsset.fromDocument( + await decodeModelInIsolate( + ModelLoadRequest(source: _MemorySource(generated, bytes)), + ), + device: device, + ); + + // Read once and built from what was read, so the bytes the swap compares + // against are the ones on screen. A project with no converted file yet + // goes through `loadModelAsset`, which decodes the source, and its first + // swap takes the converted file as it finds it as the starting point. + final bytes = enabled ? await read() : null; + final asset = bytes != null + ? await build(bytes) + : await ModelAsset.fromDocument( + await loadModelAsset(sourcePath), + device: device, + ); + return registerModel( + sourcePath, + asset, + read: read, + build: build, + device: device, + loadedFrom: bytes, + ); + } + + /// Wraps [asset], loaded from the file [read] reads, so that a [swap] after + /// the file changed rebuilds it with [build] and every instance made + /// through the result adopts it. + /// + /// [path] names it for `ext.flutter3d.assets.put` and in the report. + /// [loadedFrom] are the bytes [asset] was built from, when the caller has + /// them; without them the first swap reads the file and takes it as the + /// starting point rather than as a change. [device] is what an old asset is + /// released from once nothing draws it. + SwappableModel registerModel( + String path, + ModelAsset asset, { + required ModelBytesSource read, + required ModelBuild build, + GraphicsDevice? device, + Uint8List? loadedFrom, + }) { + final model = SwappableModel._( + path, + asset, + read, + build, + device, + loadedFrom == null + ? null + : _fingerprint(ByteData.sublistView(loadedFrom)), + ); + if (!enabled) return model; + _models + ..removeWhere((SwappableModel m) => m.path == path) + ..add(model); + _registerExtension(); + return model; + } + + /// Draws [bytes] as the model registered at [path] in every instance of it, + /// without the file on disk changing: what `ext.flutter3d.assets.put` + /// does for a tool on another machine, a phone that has no disk the + /// editor can write. + /// + /// The file's own bytes win again the next time they change. Null when no + /// model is registered at [path]. + Future put(String path, Uint8List bytes) async { + if (!enabled) return null; + for (final model in _models) { + if (model.path == path) { + final report = await model._adopt(bytes); + _applyOverrides(); + return report; + } + } + return null; + } + + /// Uploads the image asset at [path], as a level or a material file does, + /// and hands it back ready to be swapped when the file changes. Null when + /// the file is not there or does not decode. + /// + /// **A texture of any new size.** A swap uploads the new file as a texture + /// of its own and puts it in every slot of every material in the + /// registered scenes that held the old one, and in a scene's environment; + /// then the old one goes back to the device once no frame in flight can + /// still sample it. So a texture may change size, format or mip count + /// under a running game. What holds the texture anywhere else — a + /// post-process look-up table, a billboard atlas — reads + /// [SwappableTexture.texture] or listens to it. + /// + /// **For a game's own images**, the ones no loader reads for it: a decal, + /// a sign, a picture on a wall set up in code. A level's maps are + /// `LevelLoader`'s, which registers them itself through [registerTexture]. + Future loadTexture( + String path, { + required GraphicsDevice device, + TextureSampling sampling = const TextureSampling(), + }) async { + Future read() => _readAsset(path); + + Future build(Uint8List bytes) => uploadEncodedImage( + device, + bytes, + decodeImage: defaultImageDecoder, + sampling: sampling, + ); + + final bytes = await read(); + if (bytes == null) return null; + final texture = await build(bytes); + if (texture == null) return null; + return registerTexture( + path, + texture, + read: read, + build: build, + loadedFrom: bytes, + ); + } + + /// Wraps [texture], uploaded from the file [read] reads, so that a [swap] + /// after the file changed uploads it again with [build] and puts the new + /// texture where the old one was — see [loadTexture]. + /// + /// [loadedFrom] are the bytes [texture] was made from, when the caller has + /// them; without them the first swap takes the file as it finds it as the + /// starting point rather than as a change. + SwappableTexture registerTexture( + String path, + TextureHandle texture, { + required TextureBytesSource read, + required TextureBuild build, + Uint8List? loadedFrom, + }) { + final swappable = SwappableTexture._( + path, + texture, + read, + build, + loadedFrom == null + ? null + : _fingerprint(ByteData.sublistView(loadedFrom)), + ); + if (!enabled) return swappable; + _textures + ..removeWhere((SwappableTexture t) => t.path == path) + ..add(swappable); + _registerExtension(); + return swappable; + } + + /// Puts [bytes] in place of the texture registered at [path], as a changed + /// file would: what `ext.flutter3d.assets.put` does for an image. Why it + /// did not take when it did not, or null when it did or nothing is + /// registered there — [hasTexture] tells those two apart. + Future putTexture(String path, Uint8List bytes) async { + if (!enabled) return null; + for (final texture in _textures) { + if (texture.path == path) return _adoptTexture(texture, bytes); + } + return null; + } + + /// Stops watching [texture], for its owner letting it go: a swap after + /// that would upload a texture for nothing and release one already + /// released. + void forgetTexture(SwappableTexture texture) => + _textures.removeWhere((SwappableTexture t) => identical(t, texture)); + + /// Whether a texture is registered at [path]. + bool hasTexture(String path) => + _textures.any((SwappableTexture t) => t.path == path); + + /// Uploads [bytes] for [texture] and puts the result everywhere the old + /// one was. Why not, when it did not. + Future _adoptTexture( + SwappableTexture texture, + Uint8List bytes, + ) async { + final TextureHandle? next; + try { + next = await texture._build(bytes); + } on Object catch (error) { + return '$error'; + } + if (next == null) return 'the new bytes do not decode into an image'; + final previous = texture.texture; + texture._handle.value = next; + _replaceTexture(previous, next); + _releaseAfterFrame(previous); + return null; + } + + /// Hands [texture] back to the device once no frame in flight can still + /// sample it. + void _releaseAfterFrame(TextureHandle texture) { + _renderers.removeWhere((WeakReference r) => r.target == null); + // One renderer releases it: a handle released twice is a mistake a + // backend may refuse. With none alive nothing is drawing it either, and + // it goes with the device. + for (final renderer in _renderers) { + if (renderer.target case final Renderer live) { + live.releaseTextureAfterFrame(texture); + break; + } + } + } + + /// Builds the environment a panorama asset at [path] makes — a Radiance + /// `.hdr` or any image Flutter decodes — and hands it back ready to be + /// built again when the file changes. Null when the file is not there, + /// does not decode, or the device has no cube textures. + /// + /// **The cube and its irradiance come back together.** The roughest level + /// of the chain is the diffuse term (`EnvironmentMap.diffuseLevel`), so one + /// prefilter makes both, and a swap after the file changed runs it again + /// and puts the new cube, with its level count, on every registered scene + /// whose environment was the old one; then the old one goes back to the + /// device as a swapped texture does. [size] and [levels] stay as given, so + /// the new file may be a panorama of another size. + /// + /// **A swap costs what the prefilter costs.** It runs where the swap runs, + /// and `EnvironmentMap` states the cost: at the default thirty-two a + /// fraction of a millisecond, at 512 not something to wait for on every + /// save. + /// + /// **For a game that lights its world from a panorama of its own**, the + /// call it makes in place of reading the file and calling + /// `EnvironmentMap.fromPanorama`. Put the result on a scene with + /// [SwappableEnvironment.applyTo]; a scene a `SceneSurface` draws is + /// registered already, and any other has to be passed to [registerScene] + /// for a swap to reach it. + Future loadEnvironment( + String path, { + required GraphicsDevice device, + int size = 32, + int levels = 4, + }) async { + Future read() => _readAsset(path); + + Future build(Uint8List bytes) => + EnvironmentMap.fromEncoded( + device, + bytes, + decodeImage: defaultImageDecoder, + size: size, + levels: levels, + ); + + final bytes = await read(); + if (bytes == null) return null; + final built = await build(bytes); + if (built == null) return null; + return registerEnvironment( + path, + built, + read: read, + build: build, + loadedFrom: bytes, + ); + } + + /// Wraps [environment], built from the file [read] reads, so that a [swap] + /// after the file changed builds it again with [build] and puts the result + /// where the old one was — see [loadEnvironment]. + /// + /// For an environment made some other way than [loadEnvironment] makes it: + /// a size chosen per device, a decoder of the game's own. [loadedFrom] are + /// the bytes [environment] was built from, when the caller has them; + /// without them the first swap takes the file as it finds it as the + /// starting point rather than as a change. + SwappableEnvironment registerEnvironment( + String path, + BuiltEnvironment environment, { + required TextureBytesSource read, + required EnvironmentBuild build, + Uint8List? loadedFrom, + }) { + final swappable = SwappableEnvironment._( + path, + environment, + read, + build, + loadedFrom == null + ? null + : _fingerprint(ByteData.sublistView(loadedFrom)), + ); + if (!enabled) return swappable; + _environments + ..removeWhere((SwappableEnvironment e) => e.path == path) + ..add(swappable); + _registerExtension(); + return swappable; + } + + /// Builds [bytes] in place of the environment registered at [path], as a + /// changed file would: what `ext.flutter3d.assets.put` does for a + /// panorama. Why it did not take when it did not, or null when it did or + /// nothing is registered there — [hasEnvironment] tells those two apart. + Future putEnvironment(String path, Uint8List bytes) async { + if (!enabled) return null; + for (final environment in _environments) { + if (environment.path == path) { + return _adoptEnvironment(environment, bytes); + } + } + return null; + } + + /// Stops watching [environment], for a game letting it go with the level it + /// lit: a swap after that would prefilter a sky nobody draws and release a + /// cube already released. + void forgetEnvironment(SwappableEnvironment environment) => _environments + .removeWhere((SwappableEnvironment e) => identical(e, environment)); + + /// Whether an environment is registered at [path]. + bool hasEnvironment(String path) => + _environments.any((SwappableEnvironment e) => e.path == path); + + /// Builds [bytes] for [environment] and puts the result on every + /// registered scene that was lit by the old one. Why not, when it did not. + Future _adoptEnvironment( + SwappableEnvironment environment, + Uint8List bytes, + ) async { + final BuiltEnvironment? next; + try { + next = await environment._build(bytes); + } on Object catch (error) { + return '$error'; + } + if (next == null) { + return 'the new bytes do not decode into a panorama this device can ' + 'hold as a cube'; + } + final previous = environment.built; + environment._built.value = next; + _scenes.removeWhere((WeakReference s) => s.target == null); + for (final reference in _scenes) { + if (reference.target case final Scene scene + when identical(scene.environment, previous.texture)) { + // The level count with the cube: the shader reads roughness against + // it, and a chain that changed length under the old count would + // read the wrong lobe. + scene + ..environment = next.texture + ..environmentLevels = next.levels; + } + } + _releaseAfterFrame(previous.texture); + return null; + } + + /// Every slot of every material in the registered scenes, and every + /// scene's environment, that held [previous] now holds [next]. + int _replaceTexture(TextureHandle previous, TextureHandle next) { + _scenes.removeWhere((WeakReference s) => s.target == null); + final seen = Set.identity(); + TextureHandle? swapped(TextureHandle? slot) => + identical(slot, previous) ? next : slot; + for (final reference in _scenes) { + final scene = reference.target; + if (scene == null) continue; + scene.environment = swapped(scene.environment); + scene.root.traverse((SceneNode node) { + if (node is! MeshNode || !seen.add(node.material)) return; + node.material + ..albedo = swapped(node.material.albedo) + ..normal = swapped(node.material.normal) + ..metallicRoughness = swapped(node.material.metallicRoughness) + ..occlusion = swapped(node.material.occlusion) + ..emissiveTexture = swapped(node.material.emissiveTexture) + ..lightmap = swapped(node.material.lightmap) + ..coatMap = swapped(node.material.coatMap) + ..sheenMap = swapped(node.material.sheenMap); + }); + } + return seen.length; + } + + /// Remembers [scene] until it is garbage, for [setMaterial] to find + /// materials in. `SceneSurface` registers the scene it draws. + void registerScene(Scene scene) { + if (!enabled) return; + _scenes.removeWhere((WeakReference s) => s.target == null); + if (_scenes.any((WeakReference s) => identical(s.target, scene))) { + return; + } + _scenes.add(WeakReference(scene)); + _registerExtension(); + } + + /// The fields [setMaterial] understands, and how many numbers each takes. + /// A shader's own parameters are named with [parameterField] in front. + static const Map materialFields = { + 'baseColor': 4, + 'emissive': 3, + 'emissiveStrength': 1, + 'roughness': 1, + 'metallic': 1, + 'normalScale': 1, + 'alphaCutoff': 1, + }; + + /// What a field of [setMaterial] starts with when it sets one of + /// `Material.parameters` rather than a field every material has: + /// `parameters/windStrength` is the `windStrength` a `.fmat` lists under + /// `parameters` — the editor's own spelling of the same key. + /// + /// **Written into the list the material already has, and only that.** The + /// renderer binds `Material.parameters` afresh every frame, so a number + /// written in place is drawn on the next one with nothing rebuilt, the way + /// `Material.polylineViewport` is. A parameter the material was not loaded + /// with is refused rather than added: the map may be a constant, and a + /// member the compiled block does not have makes every frame of that + /// material throw in the encoder. A list of another length is refused for + /// the same reason — the block's layout is the shader's, not the panel's. + static const String parameterField = 'parameters/'; + + /// Sets [fields] on every material called [name] in the registered + /// scenes, from the next frame on, and keeps them as overrides. + /// + /// **For an inspector dragging a slider.** Nothing is saved and nothing is + /// reloaded; the material the frame is drawn with changes. What the + /// simulation reads is not a material's business, so nothing here goes + /// near the tape — a tunable is the door for that. + /// + /// **The overrides outlive the file.** A model swapped after the drag + /// brings its materials as the file has them, which is the value before + /// the drag; every swap puts the overrides back on, so the colour somebody + /// is still dragging does not snap back each time the model is saved. + /// [clearMaterial] drops them. + /// + /// [fields] maps a name from [materialFields] to a number or a list of + /// that many numbers, or a [parameterField] name to as many numbers as + /// that parameter of every such material has. Throws [ArgumentError] + /// naming the field otherwise, and keeps nothing of a call it refused. + /// Returns how many materials took the change. + int setMaterial(String name, Map fields) { + if (!enabled) return 0; + for (final MapEntry(key: field, :value) in fields.entries) { + _numbers(field, value); + } + for (final material in _materials(only: name)) { + for (final MapEntry(key: field, :value) in fields.entries) { + if (field.startsWith(parameterField)) { + _checkParameter(material, field, _numbers(field, value).length); + } + } + } + (_overrides[name] ??= {}).addAll(fields); + return _applyOverrides(only: name); + } + + static void _checkParameter(Material material, String field, int count) { + final parameter = field.substring(parameterField.length); + final held = material.parameters[parameter]; + if (held == null) { + final has = material.parameters.keys; + throw ArgumentError.value( + field, + 'field', + '${material.name} has no parameter "$parameter" ' + '(${has.isEmpty ? 'it has none' : 'it has ${has.join(', ')}'}); ' + 'one is added by writing it into the material file', + ); + } + if (held.length != count) { + throw ArgumentError.value( + count, + field, + 'takes ${held.length} number${held.length == 1 ? '' : 's'}, as ' + '${material.name} was loaded with', + ); + } + } + + /// Forgets the overrides for [name], or for every material when null. The + /// materials keep what they were set to until their file is loaded again. + void clearMaterial([String? name]) { + if (name == null) { + _overrides.clear(); + } else { + _overrides.remove(name); + } + } + + /// Every named material in the registered scenes, or every one called + /// [only], once however many nodes wear it. + Set _materials({String? only}) { + _scenes.removeWhere((WeakReference s) => s.target == null); + final seen = Set.identity(); + for (final scene in _scenes) { + scene.target?.root.traverse((SceneNode node) { + if (node is! MeshNode) return; + final name = node.material.name; + if (name == null || (only != null && name != only)) return; + seen.add(node.material); + }); + } + return seen; + } + + int _applyOverrides({String? only}) { + final touched = [ + for (final material in _materials(only: only)) + if (_overrides.containsKey(material.name)) material, + ]; + for (final material in touched) { + for (final MapEntry(key: field, :value) + in _overrides[material.name]!.entries) { + _write(material, field, _numbers(field, value)); + } + } + return touched.length; + } + + static List _numbers(String field, Object? value) { + final parameter = field.startsWith(parameterField); + final count = materialFields[field]; + if (count == null && !parameter) { + throw ArgumentError.value( + field, + 'field', + 'not a material field; there are ${materialFields.keys.join(', ')}, ' + 'and $parameterField for a parameter of its shader', + ); + } + final numbers = switch (value) { + final num one => [one.toDouble()], + final List many => [ + for (final item in many) + if (item is num) item.toDouble(), + ], + _ => const [], + }; + if (numbers.isEmpty || + (value is List && value.length != numbers.length) || + (count != null && numbers.length != count)) { + throw ArgumentError.value( + value, + field, + count == null + ? 'takes a number or a list of numbers' + : 'takes $count number${count == 1 ? '' : 's'}', + ); + } + return numbers; + } + + static void _write(Material material, String field, List v) { + switch (field) { + case 'baseColor': + material.baseColor.setValues(v[0], v[1], v[2], v[3]); + case 'emissive': + material.emissive.setValues(v[0], v[1], v[2]); + case 'emissiveStrength': + material.emissiveStrength = v[0]; + case 'roughness': + material.roughness = v[0]; + case 'metallic': + material.metallic = v[0]; + case 'normalScale': + material.normalScale = v[0]; + case 'alphaCutoff': + material.alphaCutoff = v[0]; + default: + // A parameter. A swapped model whose material no longer carries it, + // or carries it at another length, is left as the file has it; the + // override waits for one that does. + final held = + material.parameters[field.substring(parameterField.length)]; + if (held != null && held.length == v.length) held.setAll(0, v); + } + } + + /// Refreshes the registered bundles whose bytes changed, then relinks every + /// live renderer. + /// + /// Several surfaces reassembling in one hot reload share one run: a call + /// while one is in flight gets that one's report. + Future swap() { + if (!enabled) return Future.value(HotSwapReport.none); + return _running ??= _swap().whenComplete(() => _running = null); + } + + Future _swap() async { + final refreshed = []; + final refused = []; + for (final entry in List<_Library>.of(_libraries)) { + final library = entry.library.target; + if (library == null) continue; + final ByteData? bytes; + try { + bytes = await entry.read(); + } on Object catch (error) { + refused.add('${library.name}: could not be read ($error)'); + continue; + } + if (bytes == null) continue; + final fingerprint = _fingerprint(bytes); + if (fingerprint == entry.fingerprint) continue; + try { + library.refresh(bytes); + entry.fingerprint = fingerprint; + entry.onRefreshed?.call(bytes); + refreshed.add(library.name); + } on ShaderBundleRefused catch (refusal) { + // The library is as it was; the next reload tries these bytes again + // only if they change, since the same bytes would be refused again. + entry.fingerprint = fingerprint; + refused.add('${library.name}: ${refusal.reason}'); + } + } + final models = []; + for (final model in List.of(_models)) { + final bytes = await _changed(model, refused); + if (bytes == null) continue; + final report = await model._adopt(bytes); + if (report.refused case final String reason) { + refused.add('${model.path}: $reason'); + } else { + models.add(report); + } + } + if (models.isNotEmpty) _applyOverrides(); + final textures = []; + for (final texture in List.of(_textures)) { + final bytes = await _changed(texture, refused); + if (bytes == null) continue; + if (await _adoptTexture(texture, bytes) case final String reason) { + refused.add('${texture.path}: $reason'); + } else { + textures.add(texture.path); + } + } + final environments = []; + for (final environment in List.of(_environments)) { + final bytes = await _changed(environment, refused); + if (bytes == null) continue; + if (await _adoptEnvironment(environment, bytes) + case final String reason) { + refused.add('${environment.path}: $reason'); + } else { + environments.add(environment.path); + } + } + _renderers.removeWhere((WeakReference r) => r.target == null); + for (final renderer in _renderers) { + renderer.target?.relinkShaders(); + } + final report = HotSwapReport( + renderers: _renderers.length, + refreshed: List.unmodifiable(refreshed), + refused: List.unmodifiable(refused), + models: List.unmodifiable(models), + textures: List.unmodifiable(textures), + environments: List.unmodifiable(environments), + ); + for (final line in refused) { + debugPrint('flutter3d: kept the last version that loaded, $line'); + } + swaps.value++; + return report; + } + + /// [file]'s bytes when they differ from the last ones seen, or null when + /// they do not, cannot be read now, or are the first ones seen — which are + /// the starting point rather than a change. A read that throws goes into + /// [refused]. + Future _changed(_Watched file, List refused) async { + final Uint8List? bytes; + try { + bytes = await file._read(); + } on Object catch (error) { + refused.add('${file.path}: could not be read ($error)'); + return null; + } + if (bytes == null) return null; + final fingerprint = _fingerprint(ByteData.sublistView(bytes)); + final seen = file._fingerprint; + file._fingerprint = fingerprint; + return seen == null || seen == fingerprint ? null : bytes; + } + + /// An asset's bytes, or null when the bundle has no such file. + static Future _readAsset(String path) async { + try { + final data = await rootBundle.load(path); + return data.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes); + } on FlutterError { + return null; + } + } + + void _registerExtension() { + if (_extensionRegistered || !identical(this, instance)) return; + _extensionRegistered = true; + developer.registerExtension('ext.flutter3d.hotSwap', ( + String method, + Map parameters, + ) async { + final report = await instance.swap(); + return developer.ServiceExtensionResponse.result( + jsonEncode(report.toJson()), + ); + }); + developer.registerExtension('ext.flutter3d.material.set', ( + String method, + Map parameters, + ) async { + final name = parameters['name']; + final fields = parameters['fields']; + if (name == null || fields == null) { + return developer.ServiceExtensionResponse.error( + developer.ServiceExtensionResponse.invalidParams, + 'material.set takes a name and its fields as JSON', + ); + } + try { + final touched = instance.setMaterial( + name, + jsonDecode(fields) as Map, + ); + return developer.ServiceExtensionResponse.result( + jsonEncode({'materials': touched}), + ); + } on Object catch (error) { + return developer.ServiceExtensionResponse.error( + developer.ServiceExtensionResponse.invalidParams, + '$error', + ); + } + }); + developer.registerExtension('ext.flutter3d.assets.put', ( + String method, + Map parameters, + ) async { + final path = parameters['path']; + final bytes = parameters['bytes']; + if (path == null || bytes == null) { + return developer.ServiceExtensionResponse.error( + developer.ServiceExtensionResponse.invalidParams, + 'assets.put takes a path and its bytes in base64', + ); + } + final decoded = base64Decode(bytes); + final report = await instance.put(path, decoded); + final image = switch (report) { + null when instance.hasTexture(path) => ( + kind: 'texture', + put: instance.putTexture, + ), + null when instance.hasEnvironment(path) => ( + kind: 'environment', + put: instance.putEnvironment, + ), + _ => null, + }; + if (image != null) { + final refused = await image.put(path, decoded); + if (refused != null) { + return developer.ServiceExtensionResponse.error( + developer.ServiceExtensionResponse.invalidParams, + refused, + ); + } + return developer.ServiceExtensionResponse.result( + jsonEncode({'path': path, image.kind: true}), + ); + } + if (report == null) { + return developer.ServiceExtensionResponse.error( + developer.ServiceExtensionResponse.invalidParams, + 'no model, texture or environment is registered at $path', + ); + } + return developer.ServiceExtensionResponse.result( + jsonEncode(report.toJson()), + ); + }); + } + + /// FNV-1a over the bundle's bytes: enough to tell a changed file from an + /// unchanged one, which is all a reload asks. + static int _fingerprint(ByteData bytes) { + final data = bytes.buffer.asUint8List( + bytes.offsetInBytes, + bytes.lengthInBytes, + ); + var hash = 0x811c9dc5; + for (final byte in data) { + hash = ((hash ^ byte) * 0x01000193) & 0xffffffff; + } + return hash ^ data.length; + } +} + +/// What one [HotSwap.swap] did. +@immutable +final class HotSwapReport { + const HotSwapReport({ + required this.renderers, + required this.refreshed, + required this.refused, + this.models = const [], + this.textures = const [], + this.environments = const [], + }); + + /// What a profile or release build reports: nothing was touched. + static const HotSwapReport none = HotSwapReport( + renderers: 0, + refreshed: [], + refused: [], + ); + + /// How many renderers were relinked. + final int renderers; + + /// The bundles that changed and loaded. + final List refreshed; + + /// The bundles and models that changed and did not load, each with the + /// reason. They kept the last version that did. + final List refused; + + /// The models that changed and were adopted. + final List models; + + /// The textures whose file changed and whose new texture took the old + /// one's place. + final List textures; + + /// The environments whose file changed and whose new cube took the old + /// one's place. + final List environments; + + Map toJson() => { + 'renderers': renderers, + 'refreshed': refreshed, + 'refused': refused, + 'models': [for (final model in models) model.toJson()], + 'textures': textures, + 'environments': environments, + }; +} + +final class _Library { + _Library(this.library, this.read, this.fingerprint, this.onRefreshed); + + final WeakReference library; + final ShaderBundleSource read; + int? fingerprint; + + /// Told the bytes a refresh took, after it took them — how + /// [HotMaterials] rebinds what it bound. + final void Function(ByteData bytes)? onRefreshed; +} + +/// A model a game draws, and every instance of it, kept together so a +/// changed file reaches all of them. +/// +/// What [HotSwap.loadModel] and [HotSwap.registerModel] hand back. Make +/// instances through [instantiate], or hand ones made elsewhere to [track]; +/// an instance the game drops is forgotten with it. +final class SwappableModel extends _Watched { + SwappableModel._( + super.path, + this._asset, + super._read, + this._build, + this._device, + super._fingerprint, + ); + + /// The version drawn now; a new one after each swap. + ModelAsset get asset => _asset; + ModelAsset _asset; + + final ModelBuild _build; + final GraphicsDevice? _device; + final List> _instances = + >[]; + + /// [asset]'s `instantiate`, remembering the instance for the next swap. + ModelInstance instantiate( + Scene scene, { + SceneNode? parent, + String? name, + bool shareMaterials = true, + }) => track( + _asset.instantiate( + scene, + parent: parent, + name: name, + shareMaterials: shareMaterials, + ), + ); + + /// Remembers [instance], made from [asset] some other way — + /// `instantiateFitted`, say — for the next swap. + ModelInstance track(ModelInstance instance) { + _instances + ..removeWhere((WeakReference i) => i.target == null) + ..add(WeakReference(instance)); + return instance; + } + + Future _adopt(Uint8List bytes) async { + final ModelAsset next; + try { + next = await _build(bytes); + } on Object catch (error) { + return ModelSwapReport(path: path, instances: 0, refused: '$error'); + } + _instances.removeWhere( + (WeakReference i) => i.target == null, + ); + final swaps = [ + for (final instance in _instances) + if (instance.target case final ModelInstance live) live.adopt(next), + ]; + final previous = _asset; + _asset = next; + // Released only when no instance still draws a surface of it: a surface + // the new file dropped stays drawn from the old asset's meshes. + if (_device case final GraphicsDevice device + when swaps.every((ModelSwap s) => s.kept.isEmpty)) { + previous.release(device); + } + return ModelSwapReport( + path: path, + instances: swaps.length, + added: {for (final swap in swaps) ...swap.added}.toList(), + ); + } +} + +/// A texture a game draws, kept so a changed image file reaches every +/// material that samples it — see [HotSwap.loadTexture]. +/// +/// **The handle changes; [texture] follows it.** A swap puts a new texture +/// in place of the old one rather than writing into it, because the new one +/// may be another size and because writing a level over an existing one +/// would leave its smaller mips showing the old picture. +final class SwappableTexture extends _Watched { + SwappableTexture._( + super.path, + TextureHandle texture, + super._read, + this._build, + super._fingerprint, + ) : _handle = ValueNotifier(texture); + + /// The texture drawn now; a new one after each swap. + TextureHandle get texture => _handle.value; + + /// [texture], for something that holds it outside a material and has to + /// hear when it changes. + ValueListenable get changes => _handle; + + final ValueNotifier _handle; + final TextureBuild _build; +} + +/// An environment a game lights with, kept so a changed panorama file is +/// prefiltered again and reaches every scene lit by it — see +/// [HotSwap.loadEnvironment]. +final class SwappableEnvironment extends _Watched { + SwappableEnvironment._( + super.path, + BuiltEnvironment built, + super._read, + this._build, + super._fingerprint, + ) : _built = ValueNotifier(built); + + /// The cube drawn now and its level count; new ones after each swap. + BuiltEnvironment get built => _built.value; + + /// [built], for something that holds the cube outside a scene — a + /// weapon's own view, say — and has to hear when it changes. + ValueListenable get changes => _built; + + /// Lights [scene] with it: the cube and its level count, two fields of a + /// scene that are only right together. + void applyTo(Scene scene) => scene + ..environment = built.texture + ..environmentLevels = built.levels; + + final ValueNotifier _built; + final EnvironmentBuild _build; +} + +/// A file something was built from, and what its bytes were the last time a +/// swap looked. +abstract base class _Watched { + _Watched(this.path, this._read, this._fingerprint); + + /// The file it was loaded from, as the game named it. + final String path; + + final Future Function() _read; + int? _fingerprint; +} + +/// What a changed file did to one [SwappableModel]. +@immutable +final class ModelSwapReport { + const ModelSwapReport({ + required this.path, + required this.instances, + this.added = const [], + this.refused, + }); + + final String path; + + /// The instances that adopted the new version. + final int instances; + + /// Surfaces the new version has that its instances could not take on + /// (see `ModelSwap.added`); instantiate the model again to draw them. + final List added; + + /// Why the new bytes did not build, when they did not; the model is drawn + /// as it was. + final String? refused; + + Map toJson() => { + 'path': path, + 'instances': instances, + 'added': added, + if (refused != null) 'refused': refused, + }; +} + +/// A model file's bytes, already read, as the decoders read a file. +final class _MemorySource extends AssetSource { + const _MemorySource(this.path, this.bytes); + + final String path; + final Uint8List bytes; + + @override + String get key => 'memory:$path'; + + @override + Future read() async => bytes; + + @override + AssetUriResolver get resolveUri => + (AssetRequest request) async => throw StateError( + '$path was sent as bytes and has no files beside it', + ); +} + +/// What [HotSwap.loadMaterial] loaded: the library a renderer draws the +/// materials with, and how to bind each — `P8`. +final class HotMaterials { + HotMaterials(this.library, BundledMaterials materials) + : _materials = materials; + + /// The compiled stages, refreshed in place on a hot reload. + final LoadedShaderLibrary library; + + /// Each material's lighting model and default parameters, read off its + /// source — as it was loaded, and again after every hot reload. + BundledMaterials get materials => _materials; + BundledMaterials _materials; + + final List<(WeakReference, String)> _bound = + <(WeakReference, String)>[]; + + /// Draws [material] with the bundle's material [name], and keeps it drawn + /// that way across hot reloads — `P8`. Returns [material]. + /// + /// Gives it the lighting model and a default for every `uniform` it has + /// not got a value for. After a reload that changed the source, again: the + /// model the new program describes — a map it now samples, the light list + /// it now reads — and a default for a uniform it now declares, keeping the + /// values the game set for the ones it still does and dropping the ones it + /// no longer declares. Held weakly; a material the game drops is + /// forgotten. + Material bind(Material material, String name) { + _rebind(material, name); + _bound + ..removeWhere( + ((WeakReference, String) it) => + it.$1.target == null || identical(it.$1.target, material), + ) + ..add((WeakReference(material), name)); + return material; + } + + void _rebind(Material material, String name) { + material.lighting = _materials[name]; + final defaults = _materials.parameters(name); + material.parameters + ..removeWhere((String key, _) => !defaults.containsKey(key)) + ..addAll({ + for (final MapEntry(:key, :value) in defaults.entries) + if (!material.parameters.containsKey(key)) key: value, + }); + } + + /// A reload took [bytes]: read the sources again and rebind. A material + /// whose name the new bundle no longer has keeps what it had, since there + /// is nothing better to give it; the library refused the reload in that + /// case anyway if the stage was in use. + void _adopt(ByteData bytes) { + _materials = BundledMaterials.read(bytes); + for (final (reference, name) in List.of(_bound)) { + final material = reference.target; + if (material == null || !_materials.lighting.containsKey(name)) continue; + _rebind(material, name); + } + _bound.removeWhere( + ((WeakReference, String) it) => it.$1.target == null, + ); + } +} diff --git a/packages/flutter3d_app/lib/src/level/level_loader.dart b/packages/flutter3d_app/lib/src/level/level_loader.dart index 76f1ef26a..e320ecf8e 100644 --- a/packages/flutter3d_app/lib/src/level/level_loader.dart +++ b/packages/flutter3d_app/lib/src/level/level_loader.dart @@ -7,6 +7,7 @@ import 'package:flutter3d_editor_core/flutter3d_editor_core.dart' show LevelBatching, LevelScene; import 'package:flutter3d_sim/flutter3d_sim.dart'; +import '../hot_swap/hot_swap.dart'; import 'loaded_level.dart'; import 'visibility_culler.dart'; @@ -395,6 +396,12 @@ final class LevelLoader { // and this is the same kind of fact: the level plays, a wall is flat, and // the person who renamed the file is the one who wants to hear about it. final loadIssues = [...issues]; + // `HR2`: each map watched, so a picture repainted under a running game is + // drawn at whatever size it now is. The level's own map follows the swap, + // so what `rebuildBrushes` binds and what `dispose` releases is the + // texture on screen and never the one already given back. + final watched = []; + final read = readAsset ?? _bundleAsset; for (final source in level.materials.values) { // A material that defers to a `.fmat` names its maps in that file, and // `bindMaterial` uploads them below. Uploading these three as well would @@ -402,12 +409,23 @@ final class LevelLoader { if (source.fmat != null) continue; for (final path in [source.albedo, source.normal, source.orm]) { if (path == null || textures.containsKey(path)) continue; - textures[path] = await _upload( - device, + final (texture, bytes) = await _upload(device, path, read, loadIssues); + textures[path] = texture; + if (texture == null || bytes == null) continue; + final swappable = HotSwap.instance.registerTexture( path, - readAsset ?? _bundleAsset, - loadIssues, + texture, + read: () async => + _LevelMaterialSource._bytes(await read(AssetRequest(path))), + build: (Uint8List next) => uploadEncodedImage( + device, + next, + decodeImage: defaultImageDecoder, + ), + loadedFrom: bytes, ); + swappable.changes.addListener(() => textures[path] = swappable.texture); + watched.add(swappable); } } @@ -442,6 +460,7 @@ final class LevelLoader { if (entry.value.fmat case final String path) { if (await _fmatMaterial( device, + entry.key, path, readAsset ?? _bundleAsset, loadIssues, @@ -557,6 +576,7 @@ final class LevelLoader { ..brushNodes.addAll([ for (final batch in parts.batches) batch.node, ]) + ..watchedTextures.addAll(watched) // A `.fmat`'s maps are uploaded by `bindMaterial` and named nowhere but // on the material, so they are collected here or never released. ..boundTextures.addAll({ @@ -605,8 +625,14 @@ final class LevelLoader { /// The document's own findings come through too, because a `.fmat` with /// `roughnesss` in it is exactly the hand-edit that format exists to make /// survivable and the warning is the only place it shows. + /// + /// **Named [name], the level's word for it, not the file's.** The surfaces + /// that wear it are named that already, and it is the name the editor + /// sends a dragged parameter under (`HotSwap.setMaterial`); the file's own + /// name would leave a running level unreachable from the panel editing it. static Future _fmatMaterial( GraphicsDevice device, + String name, String path, AssetBytes read, List issues, @@ -621,6 +647,7 @@ final class LevelLoader { document, device: device, resolveUri: source.resolveUri, + name: name, warnings: warnings, ); } catch (error) { @@ -646,22 +673,24 @@ final class LevelLoader { return material; } - static Future _upload( + /// The texture [path] names, and the bytes it was made from for a swap to + /// compare against; both null when it could not be made. + static Future<(TextureHandle?, Uint8List?)> _upload( GraphicsDevice device, String path, AssetBytes read, List issues, ) async { try { - final bytes = await read(AssetRequest(path)); + // The view, not the whole buffer: a reader may hand back a window + // onto a larger one, and the bytes outside it are not this file. + final bytes = _LevelMaterialSource._bytes(await read(AssetRequest(path))); // Mipmapped: a level's walls and floors are the surfaces most often seen // small and at a glancing angle, which is exactly where a single level // crawls as the camera moves. - return await uploadEncodedImage( + final texture = await uploadEncodedImage( device, - // The view, not the whole buffer: a reader may hand back a window - // onto a larger one, and the bytes outside it are not this file. - _LevelMaterialSource._bytes(bytes), + bytes, decodeImage: defaultImageDecoder, sampling: const TextureSampling(), // A KTX2 the device does not sample, or a feature of one the reader @@ -671,6 +700,7 @@ final class LevelLoader { LevelIssue(LevelIssueSeverity.warning, message, where: path), ), ); + return (texture, bytes); } catch (error) { // A missing texture leaves the material flat rather than stopping the // level. Losing a wall texture should not cost the play-test — but it is @@ -683,7 +713,7 @@ final class LevelLoader { where: 'texture "$path"', ), ); - return null; + return (null, null); } } } diff --git a/packages/flutter3d_app/lib/src/level/loaded_level.dart b/packages/flutter3d_app/lib/src/level/loaded_level.dart index 36724a9d5..45b9b3cab 100644 --- a/packages/flutter3d_app/lib/src/level/loaded_level.dart +++ b/packages/flutter3d_app/lib/src/level/loaded_level.dart @@ -4,6 +4,7 @@ import 'package:flutter3d_editor_core/flutter3d_editor_core.dart' import 'package:flutter3d_sim/flutter3d_sim.dart'; import 'package:vector_math/vector_math.dart'; +import '../hot_swap/hot_swap.dart'; import 'visibility_culler.dart'; /// A loaded level, in the two forms the game needs it. @@ -111,6 +112,11 @@ final class LoadedLevel { /// all because [dispose] is the only place they can be given back. final List boundTextures = []; + /// [materialTextures] as `HotSwap` watches them, so an image repainted + /// under a running game is swapped in. Forgotten by [dispose]: a swap after + /// the level is gone would release what [dispose] already released. + final List watchedTextures = []; + /// The baked lightmap the brush batches sample, when the level came with /// one that matched it. Uploaded by the loader, released by [dispose]. TextureHandle? lightmap; @@ -133,6 +139,7 @@ final class LoadedLevel { /// `SharedMeshes.dispose`: a no-op release on flutter_gpu, the one real /// `gl.delete*` per resource on WebGL2. void dispose(GraphicsDevice device) { + watchedTextures.forEach(HotSwap.instance.forgetTexture); for (final mesh in brushMeshes) { device.releaseGeometry(mesh.vertices); device.releaseGeometry(mesh.indices); diff --git a/packages/flutter3d_app/lib/src/materials/material_language_stage.dart b/packages/flutter3d_app/lib/src/materials/material_language_stage.dart new file mode 100644 index 000000000..9db59432d --- /dev/null +++ b/packages/flutter3d_app/lib/src/materials/material_language_stage.dart @@ -0,0 +1,175 @@ +/// The software backend's half of the material language — `gfx-84n`. +/// +/// **Twenty lines, and here rather than in `flutter3d_cpu`.** That package has +/// `flutter3d_core` as a dev dependency with "nothing in `lib/` reaches it" +/// written beside it: a backend that imported the engine's formats library +/// would invert the direction an application assembles the two in. This +/// package already depends on both, which is what it is for. It lived in +/// `flutter3d_testing` until a game needed it — `P8`, a bundle built from a +/// `.f3dmat` drawn on the software backend — and a game cannot depend on a +/// package that carries `flutter_test`. +/// +/// Everything that decides what a material *means* is in +/// `material_eval.dart`, beside the emitter that writes the GLSL — so what is +/// left here is filling in the surface a fragment is being shaded at, and +/// handing back what `WriteSurface(rgb, a)` takes. +/// +/// final program = specialiseMaterial( +/// parseMaterial(source), +/// const MaterialVariant('RimLight'), +/// ); +/// final device = CpuDevice( +/// shaders: CpuShaderLibrary({ +/// ...builtinCpuShaders(), +/// 'RimLight': CpuStage.fragment(MaterialProgramStage(program)), +/// }), +/// ); +library; + +import 'dart:typed_data'; + +import 'package:flutter3d_core/formats.dart'; +import 'package:flutter3d_cpu/flutter3d_cpu.dart'; +import 'package:vector_math/vector_math.dart'; + +/// One compiled material, as a stage the software backend can be given. +/// +/// The program must already be through `specialiseMaterial`: a parameter has +/// no value until a variant gives it one, and a backend picking a default here +/// would be a backend disagreeing with the compiled shader. +final class MaterialProgramStage implements CpuFragmentShader { + const MaterialProgramStage(this.program); + + final MaterialProgram program; + + @override + Vector4? run(Float32List v, ShaderBindings bindings, FragmentContext c) { + final s = readSurface(v, bindings, c); + // Null is the GLSL's `discard`, which `ReadSurface` performs for a masked + // material under its cutoff — the emitted shader gets it from the same + // header, so both sides drop the same fragments. + if (s == null) return null; + + final footprint = uvFootprint(c); + // `P8`: a lighting hook makes this a lit model — the maps applied, the + // lights gathered through the block — as the emitted shader does. + final light = program.light; + if (light != null) applyCommonMaps(s, v, bindings, c); + final inputs = >{ + 'albedo': [s.albedo.x, s.albedo.y, s.albedo.z], + 'alpha': [s.alpha], + 'normal': [s.normal.x, s.normal.y, s.normal.z], + 'view': [s.view.x, s.view.y, s.view.z], + 'nDotV': [s.nDotV], + 'metallic': [s.metallic], + 'roughness': [s.roughness], + 'occlusion': [s.occlusion], + 'emissive': [s.emissive.x, s.emissive.y, s.emissive.z], + 'ambient': [s.ambient.x, s.ambient.y, s.ambient.z], + 'uv': [v[kVUv], v[kVUv + 1]], + 'world': [s.world.x, s.world.y, s.world.z], + 'instance': [ + v[kVInstance], + v[kVInstance + 1], + v[kVInstance + 2], + v[kVInstance + 3], + ], + }; + // `P8`: the draw's `MaterialParams`, which is `Material.parameters`, + // member by member as the GLSL reads its block. + final uniforms = >{ + for (final parameter in program.parameters) + if (parameter.uniform) + parameter.name: ?bindings.read('MaterialParams', parameter.name), + }; + List sample(MaterialTextureSlot slot, double u, double w) { + final texture = bindings.textures[slot.bindingName]; + // White is what `surface.glsl` falls back to for an unbound base + // colour, so a material sampling a slot this draw did not fill + // looks the same on both sides. + if (texture == null) return const [1, 1, 1, 1]; + final texel = texture.sample(u, w, du: footprint.du, dv: footprint.dv); + return [texel.x, texel.y, texel.z, texel.w]; + } + + if (light != null) { + // `lit` as the emitted `main` adds it up: the lights through the + // block, by radiance, n·l and shadow, then ambient and lightmap under + // the occlusion, then the emissive — `kMaterialLitInput`. + final gathered = accumulateLights( + s, + bindings, + c, + shade: (s, light) { + final half = (light.direction + s.view)..normalize(); + final answer = evaluateMaterialLight( + program, + MaterialSurfaceValues( + inputs: >{ + ...inputs, + 'lightDir': [ + light.direction.x, + light.direction.y, + light.direction.z, + ], + 'halfDir': [half.x, half.y, half.z], + 'nDotL': [light.nDotL], + 'nDotH': [light.nDotH], + 'vDotH': [light.vDotH], + }, + uniforms: uniforms, + sample: sample, + ), + ); + return Vector3(answer[0], answer[1], answer[2]); + }, + shadowed: true, + ).scaled(s.occlusion); + final ambient = + (s.albedo.clone() + ..multiply(s.ambient + sampleLightmap(v, bindings, c))) + .scaled(s.occlusion); + final lit = gathered + ambient + s.emissive; + inputs['lit'] = [lit.x, lit.y, lit.z]; + } + + final result = evaluateMaterial( + program, + MaterialSurfaceValues(inputs: inputs, uniforms: uniforms, sample: sample), + ); + + // `WriteSurface(rgb, a)` in `color.glsl`, which is the one-argument form: + // a surface that cannot say how polished it is is written fully rough, so + // nothing reflects off it. The same call `unlit.frag` makes and the same + // transcription `UnlitShader` makes of it. + return writeLit( + c, + v, + bindings, + colour: Vector3(result[0], result[1], result[2]), + alpha: result[3], + normal: s.normal, + roughness: 1, + ); + } +} + +/// A `CpuMaterialCompiler` for the engine's material language — `P8`: the +/// source parsed and specialised at its declared defaults, as the build does +/// for the GPU sections beside it, and run by [MaterialProgramStage]. +/// +/// Handed to `CpuDevice(materialCompiler: ...)`, a bundle the build made from +/// a `.f3dmat` loads on the software backend with nothing registered by hand. +/// A source that does not parse is refused with its line and column. +CpuFragmentShader materialLanguageCompiler(String stage, String source) { + final program = parseMaterial(source); + if (program.name != stage) { + throw FormatException( + 'the source is material "${program.name}" and the bundle calls it ' + '"$stage"', + ); + } + return MaterialProgramStage( + specialiseMaterial(program, MaterialVariant(program.name)), + ); +} diff --git a/packages/flutter3d_app/lib/src/photo/photo_shelf.dart b/packages/flutter3d_app/lib/src/photo/photo_shelf.dart new file mode 100644 index 000000000..82a14a3de --- /dev/null +++ b/packages/flutter3d_app/lib/src/photo/photo_shelf.dart @@ -0,0 +1,126 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:flutter3d/flutter3d.dart'; +import 'package:flutter3d_cpu/flutter3d_cpu.dart' show PngStripWriter; +import 'package:vector_math/vector_math.dart'; + +export 'photo_shelf_native.dart' + if (dart.library.js_interop) 'photo_shelf_web.dart'; + +/// Where photo mode puts a picture, and how it is passed on — `N8`. +/// +/// **Opened, written to, closed, rather than handed a file**, because the file +/// is never whole in memory: `capturePhoto` makes a strip at a time and +/// `PngStripWriter` encodes it at once, so a shelf receives the PNG in pieces. +/// On a desktop the pieces go straight to disk; in a browser they become the +/// parts of a `Blob`, which the browser holds outside the Dart heap. +/// +/// **Sharing is the shelf's call, not the game's.** In a phone's browser the +/// shelf offers the system share sheet with the file in it; on a desktop it +/// saves to the Pictures folder and says where. A game that wants the native +/// share sheet on Android or iOS implements this with the plugin it already +/// uses — this package does not pull one in for every game that has no use +/// for it. +abstract interface class PhotoShelf { + /// A place for a PNG called [name], which includes its extension. + Future open(String name); +} + +/// One picture on its way to a shelf. +abstract interface class PhotoSaving { + /// The next bytes of the file, in order. + void add(Uint8List bytes); + + /// Ends the file and passes it on. **Never throws**: a disk that is full + /// and a share sheet the player cancelled are both answers. + Future close(); + + /// Gives up on the file and leaves nothing half-written behind. + Future abandon(); +} + +/// What happened to a picture. +final class PhotoSaved { + const PhotoSaved({ + required this.kept, + required this.message, + this.where, + this.shared = false, + }); + + /// Whether the picture exists somewhere the player can find it. + final bool kept; + + /// One sentence for the screen: where it went, or why it did not. + final String message; + + /// A path or a file name, when there is one to show. + final String? where; + + /// Whether it went to a share sheet rather than only to a folder. + final bool shared; +} + +/// A picture taken: what the capture did, and where the file went. +final class PhotoTaken { + const PhotoTaken({required this.report, required this.saved}); + + /// Null when the capture itself failed; [saved] says how. + final PhotoCaptureReport? report; + final PhotoSaved saved; +} + +/// Draws [scene] through [camera] at [width] × [height] and puts it on +/// [shelf] as [name]. +/// +/// The whole of photo mode's last step in one call: `capturePhoto` for the +/// tiles, `PngStripWriter` for the file, [shelf] for where it goes. A capture +/// that fails part way abandons the file rather than leaving the first half +/// of a picture in the player's Pictures folder, and the failure comes back +/// as a [PhotoSaved] that says so. +Future takePhoto({ + required Renderer renderer, + required Scene scene, + required CameraNode camera, + required int width, + required int height, + required PhotoShelf shelf, + required String name, + RenderSettings settings = const RenderSettings(), + PhotoFilter filter = PhotoFilter.none, + Vector4? clearColor, + int tileWidth = 1024, + int tileHeight = 1024, + void Function(double progress)? onProgress, +}) async { + final saving = await shelf.open(name); + try { + final png = PngStripWriter(width: width, height: height, sink: saving.add); + final report = await capturePhoto( + renderer: renderer, + scene: scene, + camera: camera, + width: width, + height: height, + settings: settings, + filter: filter, + clearColor: clearColor, + tileWidth: tileWidth, + tileHeight: tileHeight, + onProgress: onProgress, + onRows: (rgba, top, rows) => png.addRows(rgba, rows), + ); + png.close(); + return PhotoTaken(report: report, saved: await saving.close()); + } catch (error) { + await saving.abandon(); + return PhotoTaken( + report: null, + saved: PhotoSaved( + kept: false, + message: 'The picture was not taken: $error', + ), + ); + } +} diff --git a/packages/flutter3d_app/lib/src/photo/photo_shelf_native.dart b/packages/flutter3d_app/lib/src/photo/photo_shelf_native.dart new file mode 100644 index 000000000..c453d7c11 --- /dev/null +++ b/packages/flutter3d_app/lib/src/photo/photo_shelf_native.dart @@ -0,0 +1,185 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; + +import '../storage/storage_native.dart' show applicationDirectory; +import 'photo_shelf.dart'; + +/// Where a game's photos go on this platform, or null where nothing can be +/// worked out. +/// +/// **The player's own Pictures folder on a desktop**, in a folder named after +/// the game, because that is where a person looks for a screenshot and the +/// game's settings folder is somewhere they never look. A sandboxed macOS +/// build's `HOME` is its container, so this lands in the container's own +/// `Pictures` unless the application has the pictures entitlement — still a +/// folder the message names, but not the one the player opens. +/// +/// **The game's own folder on a phone**, under `photos/`. Neither phone lets +/// an application write into the shared gallery without a plugin, and this +/// package takes none — see [PhotoShelf]. [FilePhotoShelf] says so in its +/// message rather than claiming the picture is in the gallery. +String? picturesDirectory({ + required String appName, + required TargetPlatform platform, + required Map environment, + required String temporary, +}) { + final home = environment['HOME']; + return switch (platform) { + TargetPlatform.macOS || + TargetPlatform.linux => home == null ? null : '$home/Pictures/$appName', + TargetPlatform.windows => switch (environment['USERPROFILE']) { + final profile? => '$profile\\Pictures\\$appName', + null => null, + }, + TargetPlatform.android || + TargetPlatform.iOS => switch (applicationDirectory( + appName: appName, + platform: platform, + environment: environment, + temporary: temporary, + )) { + final root? => '$root/photos', + null => null, + }, + TargetPlatform.fuchsia => null, + }; +} + +/// Photos written as files into [directory], or into [picturesDirectory] +/// for [appName]. +/// +/// **Through a `.part` file and a rename**, as every other write in this +/// package goes: a game closed half way through a large capture leaves a +/// `.part` behind rather than a PNG that opens as half a picture. +final class FilePhotoShelf implements PhotoShelf { + FilePhotoShelf({required this.appName, Directory? directory}) + : _given = directory; + + final String appName; + final Directory? _given; + + Directory? _resolve() { + if (_given != null) return _given; + try { + final path = picturesDirectory( + appName: appName, + platform: defaultTargetPlatform, + environment: Platform.environment, + temporary: Directory.systemTemp.path, + ); + return path == null ? null : Directory(path); + } catch (_) { + return null; + } + } + + @override + Future open(String name) async { + final directory = _resolve(); + if (directory == null) { + return _Nowhere('This platform has no folder to put "$name" in.'); + } + try { + await directory.create(recursive: true); + final file = File('${directory.path}${Platform.pathSeparator}$name'); + final part = File('${file.path}.part'); + return _FileSaving(file, part, await part.open(mode: FileMode.write)); + } catch (error) { + return _Nowhere( + '"$name" could not be started in ${directory.path}: ' + '$error', + ); + } + } +} + +final class _FileSaving implements PhotoSaving { + _FileSaving(this.file, this.part, this.handle); + + final File file; + final File part; + final RandomAccessFile handle; + Object? _failed; + + /// Writes as the bytes arrive, synchronously, so a strip is on disk before + /// the next is drawn and the memory it took is free. + @override + void add(Uint8List bytes) { + if (_failed != null) return; + try { + handle.writeFromSync(bytes); + } catch (error) { + _failed = error; + } + } + + @override + Future close() async { + final failed = _failed; + if (failed != null) { + await abandon(); + return PhotoSaved( + kept: false, + message: 'The picture could not be written to ${file.path}: $failed', + ); + } + try { + await handle.close(); + await part.rename(file.path); + final phone = + defaultTargetPlatform == TargetPlatform.android || + defaultTargetPlatform == TargetPlatform.iOS; + return PhotoSaved( + kept: true, + where: file.path, + message: phone + ? 'Saved in the game\'s own folder, ${file.path}. It is not in ' + 'the gallery: that needs the game to offer a share sheet.' + : 'Saved to ${file.path}.', + ); + } catch (error) { + await abandon(); + return PhotoSaved( + kept: false, + message: 'The picture could not be written to ${file.path}: $error', + ); + } + } + + @override + Future abandon() async { + try { + await handle.close(); + } catch (_) { + // Already closed by [close], which is how a failed close gets here. + } + try { + if (part.existsSync()) await part.delete(); + } catch (_) { + // A `.part` left behind is the documented failure, and harmless. + } + } +} + +/// A shelf that could not be opened: takes the bytes and drops them, and +/// says why at the end. +final class _Nowhere implements PhotoSaving { + _Nowhere(this.why); + + final String why; + + @override + void add(Uint8List bytes) {} + + @override + Future close() async => PhotoSaved(kept: false, message: why); + + @override + Future abandon() async {} +} + +/// The shelf a build outside the browser gets. +PhotoShelf defaultPhotoShelf(String appName) => + FilePhotoShelf(appName: appName); diff --git a/packages/flutter3d_app/lib/src/photo/photo_shelf_web.dart b/packages/flutter3d_app/lib/src/photo/photo_shelf_web.dart new file mode 100644 index 000000000..95cc8a33a --- /dev/null +++ b/packages/flutter3d_app/lib/src/photo/photo_shelf_web.dart @@ -0,0 +1,85 @@ +import 'dart:js_interop'; +import 'dart:typed_data'; + +import 'package:web/web.dart' as web; + +import 'photo_shelf.dart'; + +/// Photos handed to the browser: the system share sheet where the browser +/// offers one for files, a download everywhere else. +/// +/// **The pieces become `Blob` parts as they arrive**, so the PNG is assembled +/// by the browser outside the Dart heap and is never one buffer here. +/// +/// **The share sheet may refuse, and that is not a failure.** Browsers only +/// open it inside a user gesture, and a large capture can outlast the +/// gesture that started it; a refusal for that reason, like a player +/// cancelling the sheet, falls back to the download, so the picture is kept +/// either way. +final class BrowserPhotoShelf implements PhotoShelf { + BrowserPhotoShelf({this.share = true}); + + /// Whether to try the share sheet at all before downloading. + final bool share; + + @override + Future open(String name) async => _BlobSaving(name, share); +} + +final class _BlobSaving implements PhotoSaving { + _BlobSaving(this.name, this.share); + + final String name; + final bool share; + final List _parts = []; + + @override + void add(Uint8List bytes) => _parts.add(bytes.toJS); + + @override + Future close() async { + try { + final file = web.File( + _parts.toJS, + name, + web.FilePropertyBag(type: 'image/png'), + ); + _parts.clear(); + if (share) { + final data = web.ShareData(files: [file].toJS); + try { + if (web.window.navigator.canShare(data)) { + await web.window.navigator.share(data).toDart; + return PhotoSaved( + kept: true, + shared: true, + where: name, + message: 'Shared $name.', + ); + } + } catch (_) { + // Cancelled, or the gesture ran out while the picture was drawn: + // the download below keeps it all the same. + } + } + final url = web.URL.createObjectURL(file); + (web.document.createElement('a') as web.HTMLAnchorElement) + ..href = url + ..download = name + ..click(); + web.URL.revokeObjectURL(url); + return PhotoSaved(kept: true, where: name, message: 'Downloaded $name.'); + } catch (error) { + return PhotoSaved( + kept: false, + message: 'The browser would not take $name: $error', + ); + } + } + + @override + Future abandon() async => _parts.clear(); +} + +/// The shelf a browser build gets. +PhotoShelf defaultPhotoShelf(String appName) => BrowserPhotoShelf(); diff --git a/packages/flutter3d_app/lib/src/surface/scene_surface.dart b/packages/flutter3d_app/lib/src/surface/scene_surface.dart index 4feb16142..ae1fa8042 100644 --- a/packages/flutter3d_app/lib/src/surface/scene_surface.dart +++ b/packages/flutter3d_app/lib/src/surface/scene_surface.dart @@ -1,6 +1,10 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter3d/flutter3d.dart' hide Material; +import '../hot_swap/hot_swap.dart'; + /// The shape of `presentFrame`, and of every presenter a backend registers. /// /// A parameter of [SceneSurface] rather than a call it makes: `presentFrame` @@ -46,7 +50,12 @@ typedef FramePresenter = /// /// So the two games do the same thing in two different places, and only one of /// the two places works for both. A function called here is that place. -class SceneSurface extends StatelessWidget { +/// +/// **A hot reload relinks the renderer.** In a debug build the surface +/// registers its [renderer] with [HotSwap] and runs a reload from +/// `reassemble`, so an edited shader is drawn on the next frame with the world +/// as it was — see [HotSwap] for what that does and why. +class SceneSurface extends StatefulWidget { const SceneSurface({ super.key, required this.renderer, @@ -85,8 +94,47 @@ class SceneSurface extends StatelessWidget { /// parameter rather than an import. final FramePresenter presentFrame; + @override + State createState() => _SceneSurfaceState(); +} + +class _SceneSurfaceState extends State { + @override + void initState() { + super.initState(); + HotSwap.instance + ..registerRenderer(widget.renderer) + ..registerScene(widget.scene); + } + + @override + void didUpdateWidget(SceneSurface oldWidget) { + super.didUpdateWidget(oldWidget); + if (!identical(oldWidget.renderer, widget.renderer)) { + HotSwap.instance.registerRenderer(widget.renderer); + } + if (!identical(oldWidget.scene, widget.scene)) { + HotSwap.instance.registerScene(widget.scene); + } + } + + @override + void reassemble() { + super.reassemble(); + unawaited(HotSwap.instance.swap()); + } + @override Widget build(BuildContext context) { + final SceneSurface( + :renderer, + :scene, + :view, + :settings, + :onBeforeFrame, + :presentFrame, + :moreViews, + ) = widget; final dpr = MediaQuery.devicePixelRatioOf(context); return LayoutBuilder( builder: (BuildContext context, BoxConstraints constraints) { diff --git a/packages/flutter3d_app/pubspec.yaml b/packages/flutter3d_app/pubspec.yaml index 42ae17e0d..0f20215fb 100644 --- a/packages/flutter3d_app/pubspec.yaml +++ b/packages/flutter3d_app/pubspec.yaml @@ -26,6 +26,11 @@ dependencies: # The renderer: the `Scene` and `RenderSettings` a surface hands over, and # the mesh nodes, lights and probes a level becomes. flutter3d: ^0.8.0 + # The material language's parser and evaluator, for the software stage a + # bundle built from a `.f3dmat` is compiled into — `P8`. `flutter3d` + # carries it already; named here because this package imports its formats + # library directly. + flutter3d_core: ^0.8.3 # The vocabulary the backend choice is made in. Neither backend appears in # any signature here — `openDevice` returns a `GraphicsDevice`. @@ -48,6 +53,9 @@ dependencies: # Plain Dart, so an application that loads no level pays a resolve for it # and nothing else. flutter3d_sim: ^0.8.0 + # `P10`: `Particles3D` owns a ParticleSystem and draws it through the + # package's contributor. + flutter3d_particles: ^0.8.0 # `LevelScene`: the part of loading a level that needs no Flutter — brushes # into meshes, materials, lights and probes — which `LevelLoader` hands the diff --git a/packages/flutter3d_app/test/hot_swap_test.dart b/packages/flutter3d_app/test/hot_swap_test.dart new file mode 100644 index 000000000..05434ce35 --- /dev/null +++ b/packages/flutter3d_app/test/hot_swap_test.dart @@ -0,0 +1,958 @@ +/// What a hot reload does to a running world: relink every renderer, refresh +/// the bundles an application loaded itself, and keep the last bundle that +/// loaded when a new one does not. +/// +/// flutter test test/hot_swap_test.dart +library; + +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:flutter3d/flutter3d.dart' hide Material; +import 'package:flutter3d/flutter3d.dart' as engine show Material; +import 'package:flutter3d_app/flutter3d_app.dart'; +import 'package:flutter3d_cpu/flutter3d_cpu.dart'; +import 'package:flutter3d_hardware/trace.dart' + show RecordingDevice, TraceReleaseTexture; +import 'package:flutter_test/flutter_test.dart'; +import 'package:vector_math/vector_math.dart' show Vector3, Vector4; + +/// A renderer in software with one cube in front of it, so a frame links a +/// material pipeline a reload has to drop. +({Renderer renderer, Scene scene, RenderView view}) _stage() { + final device = CpuDevice( + width: 16, + height: 9, + shaders: CpuShaderLibrary(builtinCpuShaders()), + ); + final camera = CameraNode()..setPosition(0.0, 0.0, 4.0); + final scene = Scene() + ..add(camera) + ..add( + MeshNode( + DeviceMesh.upload(device, CuboidShape(size: Vector3.all(1.0)).build()), + engine.Material(name: 'cube'), + ), + ); + return ( + renderer: Renderer.create(device: device), + scene: scene, + view: RenderView(camera: camera), + ); +} + +void _draw(({Renderer renderer, Scene scene, RenderView view}) it) => + it.renderer.render( + width: 16, + height: 9, + scene: it.scene, + views: [it.view], + ); + +/// Counts the times a renderer asked it to drop what it linked. +final class _Contributor extends PassContributor { + int relinks = 0; + + @override + bool get isActive => false; + + @override + void encode(ContributorFrame frame) {} + + @override + void relinkShaders() => relinks++; +} + +/// A bundle an application loaded from bytes, refreshed from what [bytes] +/// says now; refuses whatever starts with a zero byte. +final class _Library implements LoadedShaderLibrary { + final List refreshes = []; + + @override + String get name => 'game'; + + @override + ShaderHandle? operator [](String name) => null; + + @override + void refresh(ByteData bytes) { + if (bytes.getUint8(0) == 0) { + throw const ShaderBundleRefused(name: 'game', reason: 'not a bundle'); + } + refreshes.add(bytes); + } +} + +ByteData _bytes(List values) => + ByteData.sublistView(Uint8List.fromList(values)); + +/// A one-quad model whose colour is its file's first byte: 1 red, 2 green, +/// 3 blue; 0 does not build. +Future _model(GraphicsDevice device, Uint8List bytes) { + if (bytes.first == 0) throw const FormatException('not a model'); + final colour = Vector4.zero() + ..[bytes.first - 1] = 1.0 + ..w = 1.0; + return ModelAsset.fromDocument( + PlainModelDocument( + surfaces: [ + ModelSurface( + mesh: CuboidShape(size: Vector3.all(1.0)).build(), + materialIndex: 0, + ), + ], + materials: [ + SurfaceMaterial(baseColor: colour, unlit: true, name: 'paint'), + ], + nodes: [ + ModelNode(name: 'hull', surfaces: [0]), + ], + ), + device: device, + ); +} + +void main() { + test('a reload drops every pipeline the renderer and its contributors ' + 'linked', () async { + final it = _stage(); + final contributor = it.renderer.addContributor(_Contributor()); + final swap = HotSwap(enabled: true)..registerRenderer(it.renderer); + _draw(it); + expect(it.renderer.pipelineCount, greaterThan(0)); + + final report = await swap.swap(); + + expect(report.renderers, 1); + expect(it.renderer.pipelineCount, 0); + expect(contributor.relinks, 1); + // And the next frame links them again: the reload costs one frame's + // links, not the picture. + _draw(it); + expect(it.renderer.pipelineCount, greaterThan(0)); + }); + + test('a material written in the language is drawn, edited and drawn again ' + 'from its compiled bundle', () async { + // `P8`: what `loadMaterial` adds over `registerLibrary` — the bundle read + // from where the hook wrote it, the lighting model read off the source, + // and on the software backend the edited source compiled again under the + // stage a draw already holds. + // + // Mutation: register the library without `loadedFrom`, and the first + // swap takes the unchanged bundle as a change; drop the registration, + // and the edit never reaches the frame. + ByteData bundle(String colour) => ShaderBundle( + name: 'paint', + sdk: '', + stages: const [ + ShaderBundleStage('Paint', fragment: true), + ], + sections: { + ShaderBundle.materialSection: encodeMaterialSection({ + 'Paint': 'material Paint { fragment { return vec4($colour, 1.0); } }', + }), + }, + ).encode(); + var current = bundle('vec3(1.0, 0.0, 0.0)'); + String? asked; + + const size = 16; + final device = CpuDevice( + width: size, + height: size, + shaders: CpuShaderLibrary(builtinCpuShaders()), + materialCompiler: materialLanguageCompiler, + ); + final swap = HotSwap(enabled: true); + final loaded = await swap.loadMaterial( + 'assets_src/fx/paint.f3dmat', + device: device, + read: () async { + asked = 'read'; + return current; + }, + ); + expect(asked, 'read'); + expect( + generatedMaterialPathFor('assets_src/fx/paint.f3dmat'), + 'flutter3d_generated/fx/paint.f3dshaders', + ); + + final renderer = Renderer.create(device: device, materials: loaded.library); + swap.registerRenderer(renderer); + final camera = CameraNode()..setPosition(0.0, 0.0, 3.0); + final scene = Scene() + ..add(camera) + ..add( + MeshNode( + DeviceMesh.upload(device, CuboidShape().build()), + engine.Material(lighting: loaded.materials['Paint']), + ), + ); + Future> centre() async { + final frame = renderer.render( + width: size, + height: size, + scene: scene, + views: [RenderView(camera: camera)], + settings: const RenderSettings( + bloom: BloomSettings(enabled: false), + tonemap: false, + ), + ); + final pixels = (await device.readPixels(frame.frame))!; + final at = ((size ~/ 2) * size + size ~/ 2) * 4; + return [pixels.getUint8(at), pixels.getUint8(at + 2)]; + } + + expect(await centre(), [255, 0]); + expect((await swap.swap()).refreshed, isEmpty); + + current = bundle('vec3(0.0, 0.0, 1.0)'); + expect((await swap.swap()).refreshed, ['paint']); + expect(await centre(), [0, 255]); + }); + + test('an edit that declares a uniform reaches a bound material', () async { + // `P8`: an edit that changes how a material binds. The first source + // paints red and declares nothing; the second declares `tint`, defaulting + // to green, and paints it. A material bound through `HotMaterials.bind` + // is given the new model and the new default, and draws green. Given the + // first model by hand, it would bind no `MaterialParams` and draw black. + // + // Mutation: drop `onRefreshed: materials._adopt` from `loadMaterial` — + // the material keeps the model that binds nothing. + ByteData bundle(String source) => ShaderBundle( + name: 'paint', + sdk: '', + stages: const [ + ShaderBundleStage('Paint', fragment: true), + ], + sections: { + ShaderBundle.materialSection: encodeMaterialSection({ + 'Paint': source, + }), + }, + ).encode(); + var current = bundle( + 'material Paint { fragment { return vec4(1.0, 0.0, 0.0, 1.0); } }', + ); + + const size = 16; + final device = CpuDevice( + width: size, + height: size, + shaders: CpuShaderLibrary(builtinCpuShaders()), + materialCompiler: materialLanguageCompiler, + ); + final swap = HotSwap(enabled: true); + final loaded = await swap.loadMaterial( + 'assets_src/fx/paint.f3dmat', + device: device, + read: () async => current, + ); + final renderer = Renderer.create(device: device, materials: loaded.library); + swap.registerRenderer(renderer); + final material = loaded.bind(engine.Material(), 'Paint'); + expect(material.lighting.usesMaterialParameters, isFalse); + final camera = CameraNode()..setPosition(0.0, 0.0, 3.0); + final scene = Scene() + ..add(camera) + ..add( + MeshNode(DeviceMesh.upload(device, CuboidShape().build()), material), + ); + Future> centre() async { + final frame = renderer.render( + width: size, + height: size, + scene: scene, + views: [RenderView(camera: camera)], + settings: const RenderSettings( + bloom: BloomSettings(enabled: false), + tonemap: false, + ), + ); + final pixels = (await device.readPixels(frame.frame))!; + final at = ((size ~/ 2) * size + size ~/ 2) * 4; + return [pixels.getUint8(at), pixels.getUint8(at + 1)]; + } + + expect(await centre(), [255, 0]); + current = bundle(''' +material Paint { + uniform vec3 tint = vec3(0.0, 1.0, 0.0); + fragment { return vec4(tint, 1.0); } +} +'''); + expect((await swap.swap()).refreshed, ['paint']); + expect(material.lighting.usesMaterialParameters, isTrue); + expect(material.parameters['tint'], [0.0, 1.0, 0.0]); + expect(await centre(), [0, 255]); + }); + + test('a bundle is refreshed when its bytes change, and only then', () async { + final library = _Library(); + var current = _bytes([1, 2, 3]); + final swap = HotSwap( + enabled: true, + )..registerLibrary(library, read: () async => current, loadedFrom: current); + + expect((await swap.swap()).refreshed, isEmpty); + expect(library.refreshes, isEmpty); + + current = _bytes([1, 2, 4]); + expect((await swap.swap()).refreshed, ['game']); + expect(library.refreshes, hasLength(1)); + + expect((await swap.swap()).refreshed, isEmpty); + expect(library.refreshes, hasLength(1)); + }); + + test('a bundle that does not load keeps the last one that did, and is ' + 'not tried again until it changes', () async { + final library = _Library(); + var current = _bytes([1, 2, 3]); + final swap = HotSwap( + enabled: true, + )..registerLibrary(library, read: () async => current, loadedFrom: current); + + current = _bytes([0, 9]); + final refused = await swap.swap(); + expect(refused.refused, hasLength(1)); + expect(refused.refused.single, contains('not a bundle')); + expect(library.refreshes, isEmpty); + + expect((await swap.swap()).refused, isEmpty); + + current = _bytes([1, 9]); + expect((await swap.swap()).refreshed, ['game']); + }); + + test('two surfaces reassembling in one hot reload share one run', () async { + final it = _stage(); + final contributor = it.renderer.addContributor(_Contributor()); + final swap = HotSwap(enabled: true) + ..registerRenderer(it.renderer) + ..registerRenderer(it.renderer); + + await Future.wait(>[swap.swap(), swap.swap()]); + + expect(contributor.relinks, 1); + }); + + test('outside a debug build nothing is held and nothing reloads', () async { + final it = _stage(); + final contributor = it.renderer.addContributor(_Contributor()); + final swap = HotSwap(enabled: false)..registerRenderer(it.renderer); + + final report = await swap.swap(); + + expect(report.renderers, 0); + expect(contributor.relinks, 0); + }); + + testWidgets('a hot reload of the app relinks the renderer a surface draws ' + 'with', (WidgetTester tester) async { + final it = _stage(); + final contributor = it.renderer.addContributor(_Contributor()); + await tester.pumpWidget( + MaterialApp( + home: SceneSurface( + renderer: it.renderer, + scene: it.scene, + view: it.view, + onBeforeFrame: () {}, + settings: () => const RenderSettings(), + presentFrame: + ( + GraphicsDevice device, + TextureHandle frame, { + BoxFit fit = BoxFit.fill, + FilterQuality quality = FilterQuality.none, + }) => const SizedBox.shrink(), + ), + ), + ); + final before = HotSwap.instance.swaps.value; + + // Not awaited: it completes at the end of a frame, and a widget test has + // frames only when it pumps them. + unawaited(tester.binding.reassembleApplication()); + await tester.pump(); + await tester.pump(); + + expect(contributor.relinks, 1); + expect(HotSwap.instance.swaps.value, before + 1); + }); + + group('a model', () { + late CpuDevice device; + late Uint8List file; + late HotSwap swap; + late SwappableModel model; + + setUp(() async { + device = CpuDevice( + width: 16, + height: 9, + shaders: CpuShaderLibrary(builtinCpuShaders()), + ); + file = Uint8List.fromList([1]); + swap = HotSwap(enabled: true); + model = swap.registerModel( + 'assets_src/ship.glb', + await _model(device, file), + read: () async => file, + build: (Uint8List bytes) => _model(device, bytes), + device: device, + loadedFrom: file, + ); + }); + + double blue(ModelInstance ship) => ship.meshes.single.material.baseColor.z; + + test('is drawn anew in every instance when its file changes, and ' + 'only then', () async { + final scene = Scene(); + final a = model.instantiate(scene); + final b = model.instantiate(scene); + + expect((await swap.swap()).models, isEmpty); + + file = Uint8List.fromList([3]); + final report = await swap.swap(); + + expect(report.models.single.instances, 2); + expect(blue(a), 1.0); + expect(blue(b), 1.0); + expect(model.instantiate(scene).meshes.single.material.baseColor.z, 1.0); + expect((await swap.swap()).models, isEmpty); + }); + + test( + 'put draws bytes the file never had, until the file changes', + () async { + final ship = model.instantiate(Scene()); + + final put = await swap.put( + 'assets_src/ship.glb', + Uint8List.fromList([3]), + ); + expect(put!.instances, 1); + expect(blue(ship), 1.0); + + expect((await swap.swap()).models, isEmpty, reason: 'file unchanged'); + expect(blue(ship), 1.0); + + file = Uint8List.fromList([2]); + await swap.swap(); + expect(ship.meshes.single.material.baseColor.y, 1.0); + expect(await swap.put('assets_src/boat.glb', file), isNull); + }, + ); + + test('that does not build keeps the version that did', () async { + final ship = model.instantiate(Scene()); + final before = model.asset; + + file = Uint8List.fromList([0]); + final report = await swap.swap(); + + expect(report.refused.single, contains('not a model')); + expect(model.asset, same(before)); + expect(ship.meshes.single.material.baseColor.x, 1.0); + }); + }); + + group('a material', () { + late CpuDevice device; + late HotSwap swap; + + setUp(() { + device = CpuDevice( + width: 16, + height: 9, + shaders: CpuShaderLibrary(builtinCpuShaders()), + ); + swap = HotSwap(enabled: true); + }); + + test('is set in every scene registered, once however many nodes wear ' + 'it', () async { + final asset = await _model(device, Uint8List.fromList([1])); + final scene = Scene(); + final a = asset.instantiate(scene); + asset.instantiate(scene); + swap.registerScene(scene); + + final touched = swap.setMaterial('paint', { + 'baseColor': [0.0, 0.5, 1.0, 1.0], + 'roughness': 0.25, + }); + + expect(touched, 1, reason: 'the two instances share it'); + final material = a.meshes.single.material; + expect(material.baseColor.z, 1.0); + expect(material.roughness, 0.25); + expect(swap.setMaterial('chrome', {'metallic': 1}), 0); + }); + + test('refuses a field it does not know or a value of the wrong size', () { + expect( + () => swap.setMaterial('paint', {'shine': 1}), + throwsArgumentError, + ); + expect( + () => swap.setMaterial('paint', { + 'baseColor': [1, 0, 0], + }), + throwsArgumentError, + ); + }); + + test('keeps what was dragged when the model is swapped under it', () async { + var file = Uint8List.fromList([1]); + final model = swap.registerModel( + 'assets_src/ship.glb', + await _model(device, file), + read: () async => file, + build: (Uint8List bytes) => _model(device, bytes), + loadedFrom: file, + ); + final scene = Scene(); + final ship = model.instantiate(scene); + swap + ..registerScene(scene) + ..setMaterial('paint', {'roughness': 0.1}); + + file = Uint8List.fromList([3]); + await swap.swap(); + + final material = ship.meshes.single.material; + expect(material.baseColor.z, 1.0, reason: 'the file\'s new colour'); + expect(material.roughness, 0.1, reason: 'and the drag, kept'); + + swap.clearMaterial('paint'); + file = Uint8List.fromList([2]); + await swap.swap(); + expect(ship.meshes.single.material.roughness, isNot(0.1)); + }); + + /// A scene with one quad in a material whose shader reads `wind` and + /// `tint`, as a `.fmat` with parameters is bound. + ({Scene scene, engine.Material material}) waving() { + final material = engine.Material( + name: 'grass', + parameters: { + 'wind': Float32List.fromList([0.3]), + 'tint': Float32List.fromList([0.1, 0.4, 0.5]), + }, + ); + final scene = Scene() + ..add( + MeshNode( + DeviceMesh.upload( + device, + CuboidShape(size: Vector3.all(1.0)).build(), + ), + material, + ), + ); + swap.registerScene(scene); + return (scene: scene, material: material); + } + + test('sets a parameter of its shader in the list the frame binds', () { + // `HR4`'s last half: a slider over a `.fmat` parameter reaches the + // game. Into the list the material already has, so the renderer, which + // binds `parameters` every frame, draws it next frame. + // + // Mutation: build a new list instead of writing into the old one. The + // values match but `held` is not the list the material was loaded with, + // and the second expectation fails — as a cached reference to it would. + final (scene: _, :material) = waving(); + final held = material.parameters['wind']!; + + final touched = swap.setMaterial('grass', { + 'parameters/wind': 1.5, + 'parameters/tint': [0.2, 0.2, 0.9], + }); + + expect(touched, 1); + expect(material.parameters['wind'], [1.5]); + expect(material.parameters['wind'], same(held)); + expect(material.parameters['tint']![2], closeTo(0.9, 1e-6)); + }); + + test('refuses a parameter the material was not loaded with, or one of ' + 'another length, and keeps nothing of the call', () { + // A member the compiled block does not have throws in the encoder on + // every frame of that material, so a typo in a panel must stop here. + // + // Mutation: drop `_checkParameter`. `gust` is stored as an override, the + // first expectation fails, and `tint` takes two numbers into three. + final (scene: _, :material) = waving(); + + expect( + () => swap.setMaterial('grass', { + 'parameters/gust': 1.0, + 'roughness': 0.2, + }), + throwsA( + isA().having( + (ArgumentError e) => e.message, + 'message', + contains('it has wind, tint'), + ), + ), + ); + expect( + () => swap.setMaterial('grass', { + 'parameters/tint': [1, 0], + }), + throwsArgumentError, + ); + expect( + () => swap.setMaterial('grass', { + 'parameters/wind': ['strong'], + }), + throwsArgumentError, + ); + expect(material.roughness, isNot(0.2), reason: 'nothing of it taken'); + expect(material.parameters['tint']![0], closeTo(0.1, 1e-6)); + + // And nothing kept to come back on a later call for another field. + swap.setMaterial('grass', {'metallic': 0.5}); + expect(material.roughness, isNot(0.2)); + }); + }); + + group('a texture', () { + late CpuDevice device; + late HotSwap swap; + + /// A "file" whose first byte is the side of the square it decodes to, + /// and zero a file that does not decode. + TextureHandle? decode(Uint8List bytes) => bytes.first == 0 + ? null + : device.createTextureFromPixels( + width: bytes.first, + height: bytes.first, + format: TextureFormat.r8g8b8a8UNormInt, + pixels: ByteData(bytes.first * bytes.first * 4), + ); + + setUp(() { + device = CpuDevice( + width: 16, + height: 9, + shaders: CpuShaderLibrary(builtinCpuShaders()), + ); + swap = HotSwap(enabled: true); + }); + + ({ + SwappableTexture texture, + engine.Material wall, + Scene scene, + void Function(int) write, + }) + stage() { + var file = Uint8List.fromList([4]); + final first = decode(file)!; + final texture = swap.registerTexture( + 'assets/wall.png', + first, + read: () async => file, + build: (Uint8List bytes) async => decode(bytes), + loadedFrom: file, + ); + final wall = engine.Material(name: 'wall') + ..albedo = first + ..normal = first; + final scene = Scene() + ..environment = first + ..add( + MeshNode( + DeviceMesh.upload( + device, + CuboidShape(size: Vector3.all(1.0)).build(), + ), + wall, + ), + ); + swap.registerScene(scene); + return ( + texture: texture, + wall: wall, + scene: scene, + write: (int side) => file = Uint8List.fromList([side]), + ); + } + + test('of a new size takes the old one\'s place in every slot', () async { + // Mutation: skip `_replaceTexture` and the material keeps sampling the + // four-texel texture the file no longer is. + final it = stage(); + final heard = []; + it.texture.changes.addListener(() => heard.add(it.texture.texture)); + + it.write(8); + final report = await swap.swap(); + + expect(report.textures, ['assets/wall.png']); + final now = it.texture.texture; + expect(now.width, 8); + expect(it.wall.albedo, same(now)); + expect(it.wall.normal, same(now), reason: 'every slot that held it'); + expect(it.scene.environment, same(now)); + expect(heard, [now]); + }); + + test('an unchanged file is not uploaded again', () async { + final it = stage(); + final before = it.texture.texture; + + final report = await swap.swap(); + + expect(report.textures, isEmpty); + expect(it.wall.albedo, same(before)); + }); + + test('a file that does not decode keeps the texture that did', () async { + final it = stage(); + final before = it.texture.texture; + + it.write(0); + final report = await swap.swap(); + + expect(report.refused.single, contains('assets/wall.png')); + expect(it.texture.texture, same(before)); + expect(it.wall.albedo, same(before)); + }); + + test( + 'the old texture goes back through a renderer, after its frames', + () async { + // Mutation: release through no renderer and nothing is ever given + // back; release at once and it is gone before the frames that still + // sample it are. + Future> releasesPerFrame({required bool change}) async { + final it = stage(); + final recording = RecordingDevice(device); + final renderer = Renderer.create(device: recording); + final camera = CameraNode()..setPosition(0.0, 0.0, 4.0); + it.scene.add(camera); + final view = RenderView(camera: camera); + swap.registerRenderer(renderer); + void frame() => renderer.render( + width: 16, + height: 9, + scene: it.scene, + views: [view], + ); + for (var i = 0; i < 4; i++) { + frame(); + } + if (change) it.write(8); + await swap.swap(); + final counts = []; + for (var i = 0; i < 4; i++) { + final before = recording.events.length; + frame(); + counts.add( + recording.events + .skip(before) + .whereType() + .length, + ); + } + renderer.dispose(); + return counts; + } + + final unchanged = await releasesPerFrame(change: false); + swap = HotSwap(enabled: true); + final swapped = await releasesPerFrame(change: true); + + expect(unchanged.every((int n) => n == 0), isTrue); + expect(swapped.fold(0, (int a, int b) => a + b), 1); + expect( + swapped.first, + 0, + reason: 'not while a frame may still sample it', + ); + }, + ); + }); + + group('an environment', () { + late CpuDevice device; + late HotSwap swap; + late int builds; + + /// A "panorama" whose first byte is the side of the cube it builds and + /// whose second is its level count; a zero side does not build. A flat + /// texture stands in for the cube: what a swap does with it is the same. + BuiltEnvironment? build(Uint8List bytes) { + builds++; + if (bytes.first == 0) return null; + final texture = device.createTextureFromPixels( + width: bytes.first, + height: bytes.first, + format: TextureFormat.r8g8b8a8UNormInt, + pixels: ByteData(bytes.first * bytes.first * 4), + ); + return texture == null ? null : (texture: texture, levels: bytes[1]); + } + + setUp(() { + device = CpuDevice( + width: 16, + height: 9, + shaders: CpuShaderLibrary(builtinCpuShaders()), + ); + swap = HotSwap(enabled: true); + builds = 0; + }); + + ({ + SwappableEnvironment environment, + Scene lit, + Scene other, + void Function(int side, int levels) write, + }) + stage() { + var file = Uint8List.fromList([4, 2]); + final environment = swap.registerEnvironment( + 'assets/sky.hdr', + build(file)!, + read: () async => file, + build: (Uint8List bytes) async => build(bytes), + loadedFrom: file, + ); + final lit = Scene(); + environment.applyTo(lit); + final elsewhere = build(Uint8List.fromList([2, 1]))!; + final other = Scene() + ..environment = elsewhere.texture + ..environmentLevels = elsewhere.levels; + swap + ..registerScene(lit) + ..registerScene(other); + return ( + environment: environment, + lit: lit, + other: other, + write: (int side, int levels) => + file = Uint8List.fromList([side, levels]), + ); + } + + test('is prefiltered again when its file changes, and the scenes it lit ' + 'take the new cube with its level count', () async { + // Mutation: set only `Scene.environment` and the scene reads the new + // chain's roughness against the old count, so every surface takes the + // wrong lobe. + final it = stage(); + final otherCube = it.other.environment; + final heard = []; + it.environment.changes.addListener(() => heard.add(it.environment.built)); + + it.write(8, 3); + final report = await swap.swap(); + + expect(report.environments, ['assets/sky.hdr']); + final now = it.environment.built; + expect(now.texture.width, 8); + expect(it.lit.environment, same(now.texture)); + expect(it.lit.environmentLevels, 3); + expect( + it.other.environment, + same(otherCube), + reason: 'a scene lit by another environment keeps it', + ); + expect(it.other.environmentLevels, 1); + expect(heard, hasLength(1)); + }); + + test('an unchanged file is not prefiltered again', () async { + // Mutation: drop the fingerprint and every hot reload pays for a + // convolution of a sky nobody touched. + final it = stage(); + final before = it.lit.environment; + final built = builds; + + final report = await swap.swap(); + + expect(report.environments, isEmpty); + expect(builds, built); + expect(it.lit.environment, same(before)); + }); + + test('a file that does not build keeps the cube that did', () async { + final it = stage(); + final before = it.environment.built; + + it.write(0, 2); + final report = await swap.swap(); + + expect(report.refused.single, contains('assets/sky.hdr')); + expect(it.environment.built, same(before)); + expect(it.lit.environment, same(before.texture)); + expect(it.lit.environmentLevels, 2); + }); + + test('bytes put over the VM service take the file\'s place', () async { + final it = stage(); + + final refused = await swap.putEnvironment( + 'assets/sky.hdr', + Uint8List.fromList([16, 4]), + ); + + expect(refused, isNull); + expect(swap.hasEnvironment('assets/sky.hdr'), isTrue); + expect(it.lit.environment!.width, 16); + expect(it.lit.environmentLevels, 4); + }); + + test( + 'the old cube goes back through a renderer, after its frames', + () async { + // Mutation: never release it and every saved sky leaks a cube. + final it = stage(); + final recording = RecordingDevice(device); + final renderer = Renderer.create(device: recording); + final camera = CameraNode()..setPosition(0.0, 0.0, 4.0); + it.lit.add(camera); + final view = RenderView(camera: camera); + swap.registerRenderer(renderer); + void frame() => renderer.render( + width: 16, + height: 9, + scene: it.lit, + views: [view], + ); + for (var i = 0; i < 4; i++) { + frame(); + } + it.write(8, 2); + await swap.swap(); + final before = recording.events.length; + for (var i = 0; i < 4; i++) { + frame(); + } + final released = recording.events + .skip(before) + .whereType() + .length; + renderer.dispose(); + + expect(released, 1); + }, + ); + }); +} diff --git a/packages/flutter3d_app/test/level_fmat_material_test.dart b/packages/flutter3d_app/test/level_fmat_material_test.dart index cd64507ed..73a766a59 100644 --- a/packages/flutter3d_app/test/level_fmat_material_test.dart +++ b/packages/flutter3d_app/test/level_fmat_material_test.dart @@ -113,7 +113,11 @@ void main() { expect(material.parameterBlock, 'WaterParams'); expect(material.parameters['speed'], [2.5]); expect(material.parameters['tint'], hasLength(3)); - expect(material.name, 'canal water'); + expect( + material.name, + 'wall', + reason: 'the level\'s name for it, which the editor addresses it by', + ); expect( material.roughness, closeTo(0.05, 1e-6), @@ -125,6 +129,33 @@ void main() { expect(material.albedo, isNotNull); }); + test( + 'and a parameter dragged in the editor reaches the running wall', + () async { + // `HR4`: the panel sends `parameters/speed` under the level material's + // name, and the game finds the wall by it. + // + // Mutation: bind the file under its own name (`canal water`) again. No + // material is called `wall`, nothing is touched, and the speed stays 2.5. + final loaded = await const LevelLoader().build( + Level.fromJson(_levelJson(fmat: 'materials/water.fmat')), + device: device, + registry: registry, + readAsset: readAsset, + ); + final swap = HotSwap(enabled: true)..registerScene(loaded.scene); + + final touched = swap.setMaterial('wall', { + 'parameters/speed': [3.5], + }); + + expect(touched, 1); + expect(loaded.brushNodes.single.material.parameters['speed'], [ + 3.5, + ]); + }, + ); + test('and its hints travel with it, describing without constraining', () async { // A hint is a description for an inspector, so it stops at the document — // a `Material` is what the renderer draws and has no use for a slider's diff --git a/packages/flutter3d_app/test/level_loader_test.dart b/packages/flutter3d_app/test/level_loader_test.dart index af445592a..c86eb8c72 100644 --- a/packages/flutter3d_app/test/level_loader_test.dart +++ b/packages/flutter3d_app/test/level_loader_test.dart @@ -114,6 +114,38 @@ void main() { expect(loaded.materialTextures['assets/textures/wall.png'], isNotNull); }); + test('a wall repainted at another size is drawn at it after a hot swap, ' + 'and is not watched once the level is gone', () async { + // `HR2`. Mutation: leave out the listener that keeps `materialTextures` + // in step, and the level's map names a texture already given back — the + // one `rebuildBrushes` would bind and `dispose` would release again. + var file = encodePng(Uint8List(4), 1, 1); + final loaded = await const LevelLoader().build( + _level(albedo: 'assets/textures/wall.png'), + device: device, + registry: registry, + readAsset: (AssetRequest request) async => ByteData.sublistView(file), + ); + HotSwap.instance.registerScene(loaded.scene); + final before = loaded.materialTextures['assets/textures/wall.png']!; + expect(before.width, 1); + + file = encodePng(Uint8List(4 * 4 * 4), 4, 4); + final report = await HotSwap.instance.swap(); + + expect(report.textures, contains('assets/textures/wall.png')); + final now = loaded.materialTextures['assets/textures/wall.png']!; + expect(now.width, 4); + final walls = []; + loaded.scene.root.traverse((SceneNode node) { + if (node is MeshNode) walls.add(node.material.albedo); + }); + expect(walls, everyElement(same(now))); + + loaded.dispose(device); + expect(HotSwap.instance.hasTexture('assets/textures/wall.png'), isFalse); + }); + test( 'load reads its document through readDocument, not just its textures', () async { diff --git a/packages/flutter3d_app/test/photo_shelf_test.dart b/packages/flutter3d_app/test/photo_shelf_test.dart new file mode 100644 index 000000000..05099fd4b --- /dev/null +++ b/packages/flutter3d_app/test/photo_shelf_test.dart @@ -0,0 +1,124 @@ +/// Photo mode's last step — `N8`: a picture drawn, encoded and on a shelf in +/// one call, and the folder each platform keeps it in. +/// +/// flutter test test/photo_shelf_test.dart +@TestOn('vm') +library; + +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter3d/flutter3d.dart'; +import 'package:flutter3d_app/flutter3d_app.dart'; +import 'package:flutter3d_cpu/flutter3d_cpu.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:vector_math/vector_math.dart'; + +({Renderer renderer, Scene scene, CameraNode camera}) _game() { + final device = CpuDevice( + width: 64, + height: 48, + shaders: CpuShaderLibrary(builtinCpuShaders()), + ); + final camera = CameraNode( + name: 'eye', + projection: const PerspectiveProjection(fovYRadians: 0.9), + )..setPosition(0.0, 0.0, 3.0); + final scene = Scene() + ..add( + MeshNode( + DeviceMesh.upload(device, CuboidShape(size: Vector3.all(1.0)).build()), + Material(lighting: LightingModel.unlit), + name: 'box', + ), + ) + ..add(camera); + return ( + renderer: Renderer.create(device: device), + scene: scene, + camera: camera, + ); +} + +void main() { + late Directory folder; + setUp(() => folder = Directory.systemTemp.createTempSync('photo_shelf')); + tearDown(() => folder.deleteSync(recursive: true)); + + test('a picture larger than the game\'s screen lands as one PNG', () async { + final game = _game(); + final taken = await takePhoto( + renderer: game.renderer, + scene: game.scene, + camera: game.camera, + width: 150, + height: 70, + tileWidth: 64, + tileHeight: 32, + shelf: FilePhotoShelf(appName: 'game', directory: folder), + name: 'shot.png', + ); + expect(taken.saved.kept, isTrue, reason: taken.saved.message); + expect(taken.report!.tilesX, 3); + // Mutation: skip the rename in `_FileSaving.close`. The picture stays a + // `.part` that no image viewer lists, and the message names a file that + // is not there. + final names = folder.listSync().map((e) => e.uri.pathSegments.last); + expect(names, ['shot.png']); + // The header's size, and the end chunk last: a whole file, of the picture + // asked for. `photo_capture_test.dart` decodes the pixels. + final bytes = File('${folder.path}/shot.png').readAsBytesSync(); + final header = ByteData.sublistView(bytes, 16, 24); + expect((header.getUint32(0), header.getUint32(4)), (150, 70)); + expect( + String.fromCharCodes(bytes.sublist(bytes.length - 8)), + startsWith('IEND'), + ); + }); + + test('a capture that fails leaves nothing behind and says why', () async { + final game = _game(); + final taken = await takePhoto( + renderer: game.renderer, + scene: game.scene, + camera: game.camera, + width: 150, + height: 70, + tileWidth: 0, + tileHeight: 32, + shelf: FilePhotoShelf(appName: 'game', directory: folder), + name: 'shot.png', + ); + // Mutation: drop the `abandon` in `takePhoto`'s catch. A `.part` holding + // the PNG's header is left in the player's Pictures folder for every + // capture that failed. + expect(taken.saved.kept, isFalse); + expect(taken.saved.message, contains('cannot cover a picture')); + expect(folder.listSync(), isEmpty); + }); + + test('a desktop keeps photos in Pictures, a phone in the game\'s folder', () { + String? where(TargetPlatform platform, Map environment) => + picturesDirectory( + appName: 'game', + platform: platform, + environment: environment, + temporary: '/data/user/0/dev.game/cache', + ); + expect( + where(TargetPlatform.linux, {'HOME': '/home/p'}), + '/home/p/Pictures/game', + ); + expect( + where(TargetPlatform.windows, {'USERPROFILE': r'C:\Users\p'}), + r'C:\Users\p\Pictures\game', + ); + // Mutation: put Android under `$HOME/Pictures` like Linux. `HOME` there is + // not the application's, the write fails, and no photo is ever kept. + expect( + where(TargetPlatform.android, {'HOME': '/'}), + '/data/user/0/dev.game/files/game/photos', + ); + expect(where(TargetPlatform.windows, const {}), isNull); + }); +} diff --git a/packages/flutter3d_app/test/render_inspection_test.dart b/packages/flutter3d_app/test/render_inspection_test.dart new file mode 100644 index 000000000..04299ab9e --- /dev/null +++ b/packages/flutter3d_app/test/render_inspection_test.dart @@ -0,0 +1,410 @@ +/// `P12`: what `ext.flutter3d.render.*` answers, asked of captures built by +/// hand — no VM, no window, no GPU. +/// +/// flutter test test/render_inspection_test.dart +/// +/// The extensions are a transport; the decisions are here. What an unknown +/// pass is told, what a pixel past the edge is told, how a NaN crosses JSON, +/// and — the one that matters most — that a float target this backend could +/// not read as floats is reported unread rather than clean. +library; + +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flutter3d/flutter3d.dart'; +import 'package:flutter3d_app/flutter3d_app.dart'; +import 'package:flutter3d_cpu/flutter3d_cpu.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:vector_math/vector_math.dart'; + +/// A 2×2 RGBA8 image whose pixel at (x, y) has red `10 * (y * 2 + x)`. +CapturedImage _bytes(String resource) => CapturedImage( + resource: resource, + width: 2, + height: 2, + format: TextureFormat.r8g8b8a8UNormInt, + pixels: ByteData.sublistView( + Uint8List.fromList([ + 0, 1, 2, 255, // + 10, 11, 12, 255, + 20, 21, 22, 255, + 30, 31, 32, 255, + ]), + ), +); + +/// A 2×2 half-float target with floats beside its bytes, one of them [bad]. +CapturedImage _hdr({double bad = 0.5, bool floats = true}) => CapturedImage( + resource: 'hdr colour', + width: 2, + height: 2, + format: TextureFormat.r16g16b16a16Float, + pixels: ByteData(16), + floats: floats + ? Float32List.fromList([ + 0.1, 0.2, 0.3, 1.0, // + 0.1, 0.2, 0.3, 1.0, + 0.1, bad, 0.3, 1.0, + 0.1, 0.2, 0.3, 1.0, + ]) + : null, +); + +FrameCapture _frame({CapturedImage? hdr}) => FrameCapture( + width: 2, + height: 2, + passes: [ + CapturedPass( + name: 'scene', + active: true, + reads: const [], + optionalReads: const [], + writes: const ['hdr colour'], + keeps: const [], + images: [hdr ?? _hdr()], + micros: 400, + drawCalls: 2, + triangles: 24, + ), + CapturedPass( + name: 'composite', + active: true, + reads: const ['hdr colour'], + optionalReads: const [], + writes: const ['frame'], + keeps: const [], + images: [_bytes('frame')], + micros: 100, + drawCalls: 1, + triangles: 1, + ), + ], + draws: [ + for (final (i, name) in ['crate', 'barrel'].indexed) + DrawRecord( + index: i, + passIndex: 0, + pass: 'scene', + kind: 'mesh', + mesh: name, + material: 'wood', + lighting: 'pbr', + vertices: 24, + indices: 36, + instances: 1, + triangles: 12, + state: const {'cull': 'back'}, + uniforms: >{ + 'tint': [1.0, double.nan, 1.0, 1.0], + }, + ), + ], + undetailedDraws: const {'composite': 1}, +); + +void main() { + group('passes', () { + test('lists each pass with its inputs, outputs, size and format', () { + final answer = renderPasses(_frame()); + final passes = answer['passes']! as List>; + + expect(passes.map((p) => p['name']), ['scene', 'composite']); + expect(passes[1]['index'], 1); + expect(passes[1]['reads'], ['hdr colour']); + final output = (passes[0]['outputs']! as List).single as Map; + expect(output['format'], 'r16g16b16a16Float'); + expect(output['width'], 2); + expect(output['floats'], isTrue); + }); + }); + + group('a pass is named by index or by name', () { + test('and an unknown name is refused with the names there are', () { + // Mutation: answering an empty map for a name nobody has hands the + // caller "nothing wrong" about a pass that does not exist. + final answer = renderPassOutput(_frame(), { + 'pass': 'bloom', + }); + expect(isRefusal(answer), isTrue); + expect(answer['refused'], contains('scene, composite')); + }); + + test('and an index past the end says how many passes ran', () { + final answer = renderReadPixel(_frame(), { + 'pass': '7', + 'x': '0', + 'y': '0', + }); + expect(answer['refused'], contains('numbered 0 to 1')); + }); + + test('and an output the pass did not write is refused by name', () { + final answer = renderPassOutput(_frame(), { + 'pass': 'composite', + 'resource': 'depth', + }); + expect(answer['refused'], contains('it wrote frame')); + }); + }); + + group('passOutput', () { + test('is a PNG of the pass output', () { + final answer = renderPassOutput(_frame(), {'pass': '1'}); + final png = base64Decode(answer['png']! as String); + expect(png.sublist(0, 8), [137, 80, 78, 71, 13, 10, 26, 10]); + expect(answer['resource'], 'frame'); + }); + + test('of an unreadable output passes on why', () { + final frame = _frame( + hdr: const CapturedImage( + resource: 'hdr colour', + width: 2, + height: 2, + format: TextureFormat.r16g16b16a16Float, + refused: 'tile memory: there is nothing to read', + ), + ); + final answer = renderPassOutput(frame, {'pass': '0'}); + expect(answer['refused'], contains('tile memory')); + }); + }); + + group('readPixel', () { + test('answers the byte at x, y and not its neighbour', () { + // Mutation: `(x * width + y)` reads (0, 1) as (1, 0) — 10 instead of 20. + final answer = renderReadPixel(_frame(), { + 'pass': 'composite', + 'x': '0', + 'y': '1', + }); + expect(answer['uint'], [20, 21, 22, 255]); + expect(answer['float'], isNull); + expect(answer['floatUnread'], contains('holds no floats')); + }); + + test('answers the float where the backend kept one', () { + final answer = renderReadPixel(_frame(), { + 'pass': 'scene', + 'x': '1', + 'y': '0', + }); + expect(answer['float'], [ + closeTo(0.1, 1e-6), + closeTo(0.2, 1e-6), + closeTo(0.3, 1e-6), + 1.0, + ]); + }); + + test('refuses a pixel past the edge, naming the size', () { + // Mutation: checking only `x + y * width < pixels` lets (2, 0) through + // as the first pixel of the next row — a real answer about the wrong + // place. + final answer = renderReadPixel(_frame(), { + 'pass': 'composite', + 'x': '2', + 'y': '0', + }); + expect(answer['refused'], contains('2x2')); + expect( + renderReadPixel(_frame(), { + 'pass': 'composite', + 'x': '0', + 'y': '-1', + }), + contains('refused'), + ); + }); + + test('carries a NaN as a string JSON can hold', () { + final answer = renderReadPixel( + _frame(hdr: _hdr(bad: double.nan)), + {'pass': 'scene', 'x': '0', 'y': '1'}, + ); + expect((answer['float']! as List)[1], 'NaN'); + expect(() => jsonEncode(answer), returnsNormally); + }); + }); + + group('scanNan', () { + test('finds a NaN and names the pass, the count and where', () { + final answer = renderScanNan( + _frame(hdr: _hdr(bad: double.nan)), + const {}, + ); + expect(answer['clean'], isFalse); + final found = (answer['found']! as List).single as Map; + expect(found['pass'], 'scene'); + expect(found['nan'], 1); + expect(found['infinite'], 0); + expect((found['first']! as List).single, { + 'x': 0, + 'y': 1, + 'channel': 1, + 'value': 'NaN', + }); + expect(() => jsonEncode(answer), returnsNormally); + }); + + test('counts an infinity apart from a NaN', () { + final answer = renderScanNan( + _frame(hdr: _hdr(bad: double.negativeInfinity)), + const {}, + ); + final found = (answer['found']! as List).single as Map; + expect(found['nan'], 0); + expect(found['infinite'], 1); + }); + + test('a clean frame is clean, and its eight-bit output is counted', () { + final answer = renderScanNan(_frame(), const {}); + expect(answer['clean'], isTrue); + expect(answer['scanned'], 1); + expect(answer['eightBit'], 1); + expect(answer['unread'], isEmpty); + }); + + test('a float target read back as bytes is unread, not clean', () { + // **The whole reason this is not a loop over `pixels`.** A NaN clamps + // to an ordinary byte on every hardware backend's readback, so a scan + // of the bytes finds nothing and says so. Mutation: treating an image + // with no floats as scanned reports this frame clean. + final answer = renderScanNan( + _frame(hdr: _hdr(floats: false)), + const {}, + ); + expect(answer['scanned'], 0); + final unread = (answer['unread']! as List).single as Map; + expect(unread['resource'], 'hdr colour'); + expect(unread['why'], contains('eight bits')); + }); + + test('an unknown pass is refused', () { + final answer = renderScanNan(_frame(), {'pass': 'sky'}); + expect(answer['refused'], contains('no pass is called "sky"')); + }); + }); + + group('draws', () { + test('lists every described draw and what each pass only counted', () { + final answer = renderDraws(_frame(), const {}); + expect(answer['total'], 2); + expect( + (answer['draws']! as List).map((d) => (d as Map)['mesh']), + ['crate', 'barrel'], + ); + expect(answer['undetailed'], {'composite': 1}); + }); + + test('pages, and filters by pass', () { + final page = renderDraws(_frame(), { + 'offset': '1', + 'limit': '1', + }); + expect((page['draws']! as List).single, containsPair('mesh', 'barrel')); + final composite = renderDraws(_frame(), { + 'pass': 'composite', + }); + expect(composite['total'], 0); + expect(composite['undetailed'], {'composite': 1}); + }); + + test('a capture taken without the journal says so', () { + final frame = FrameCapture(width: 2, height: 2, passes: _frame().passes); + expect( + renderDraws(frame, const {})['refused'], + contains('without the draw journal'), + ); + }); + }); + + group('draw', () { + test('answers one draw with its state and uniforms, JSON-safe', () { + final answer = renderDraw(_frame(), {'index': '1'}); + expect(answer['mesh'], 'barrel'); + expect(answer['state'], {'cull': 'back'}); + expect((answer['uniforms']! as Map)['tint'], [ + 1.0, + 'NaN', + 1.0, + 1.0, + ]); + expect(() => jsonEncode(answer), returnsNormally); + }); + + test('an index past the end says how many there are', () { + final answer = renderDraw(_frame(), {'index': '2'}); + expect(answer['refused'], contains('numbered 0 to 1')); + }); + }); + + group('stats', () { + test('sums the passes and sizes each target by its format', () { + final answer = renderStats(_frame()); + expect(answer['passes'], 2); + expect(answer['drawCalls'], 3); + expect(answer['triangles'], 25); + expect(answer['cpuMicros'], 500); + // 2×2 at eight bytes, and 2×2 at four. + expect(answer['targetBytes'], 2 * 2 * 8 + 2 * 2 * 4); + }); + }); + + test('a real software frame answers every question end to end', () async { + // The capture the extensions take, on the one backend that keeps floats: + // a lit cube scans clean and its scene pass reads back as floats. + const size = 16; + final device = CpuDevice( + width: size, + height: size, + shaders: CpuShaderLibrary(builtinCpuShaders()), + ); + final scene = Scene() + ..add( + MeshNode( + DeviceMesh.upload( + device, + CuboidShape(size: Vector3.all(1.4)).build(), + ), + Material(name: 'crate'), + name: 'crate', + ), + ) + ..add(LightNode(intensity: 6.0)..setPosition(2.0, 3.0, 4.0)) + ..add( + CameraNode() + ..setPosition(0.0, 0.0, 4.0) + ..lookAt(Vector3.zero()), + ); + final renderer = Renderer.create(device: device); + final pending = renderer.captureNextFrame( + draws: true, + readFloats: device.readHdrPixels, + ); + renderer.render( + width: size, + height: size, + scene: scene, + views: [RenderView(camera: scene.cameras.single)], + ); + final capture = await pending; + + final scan = renderScanNan(capture, const {}); + expect(scan['clean'], isTrue); + expect(scan['scanned'], greaterThan(0)); + expect( + renderDraws(capture, const {})['draws'], + contains(containsPair('mesh', 'crate')), + ); + final pixel = renderReadPixel(capture, { + 'pass': 'scene', + 'x': '8', + 'y': '8', + }); + expect(pixel['float'], hasLength(4)); + expect(() => jsonEncode(renderPasses(capture)), returnsNormally); + expect(() => jsonEncode(renderStats(capture)), returnsNormally); + }); +} diff --git a/packages/flutter3d_app/test/scene_widgets_test.dart b/packages/flutter3d_app/test/scene_widgets_test.dart new file mode 100644 index 000000000..69d626ed4 --- /dev/null +++ b/packages/flutter3d_app/test/scene_widgets_test.dart @@ -0,0 +1,581 @@ +/// A scene written as widgets — `P10`. +/// +/// flutter test test/scene_widgets_test.dart +/// +/// The claims are about the graph underneath: which nodes exist, whether a +/// rebuild keeps them, and whether a frame drawn from widgets is the frame +/// the same scene draws when it is built by hand. +library; + +import 'dart:async'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter/widgets.dart'; +import 'package:flutter3d/flutter3d.dart' hide Material; +import 'package:flutter3d/flutter3d.dart' as engine show Material; +import 'package:flutter3d_app/flutter3d_app.dart'; +import 'package:flutter3d_cpu/flutter3d_cpu.dart'; +import 'package:flutter3d_particles/flutter3d_particles.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:vector_math/vector_math.dart'; + +const int _width = 48; +const int _height = 36; + +CpuDevice _device() => CpuDevice( + width: _width, + height: _height, + shaders: CpuShaderLibrary(builtinCpuShaders()), +); + +/// Shows nothing: the frame is drawn by the surface whether or not it is +/// shown, and a test reads it off the controller's renderer. +Widget _noPresenter( + GraphicsDevice device, + TextureHandle frame, { + BoxFit fit = BoxFit.contain, + FilterQuality quality = FilterQuality.low, +}) => const SizedBox.expand(); + +/// [children] in a [Scene3D] of a fixed size, on [device]. +Widget _scene( + CpuDevice device, + List children, { + void Function(Scene3DController)? onCreated, + bool continuous = false, +}) => Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: SizedBox( + width: _width.toDouble(), + height: _height.toDouble(), + child: Scene3D( + device: device, + continuous: continuous, + presenter: _noPresenter, + onCreated: onCreated, + settings: const RenderSettings( + bloom: BloomSettings(enabled: false), + look: LookSettings(dither: 0.0), + ), + children: children, + ), + ), + ), +); + +/// Pumps until the device has opened and the children are built. +Future _pump( + WidgetTester tester, + CpuDevice device, + List children, +) async { + Scene3DController? controller; + await tester.pumpWidget( + _scene(device, children, onCreated: (c) => controller = c), + ); + await tester.pump(); + await tester.pump(); + return controller!; +} + +final engine.Material _grey = engine.Material( + baseColor: Vector4(0.6, 0.6, 0.6, 1.0), +); + +void main() { + setUp(() { + final view = + TestWidgetsFlutterBinding.instance.platformDispatcher.views.first; + view.devicePixelRatio = 1.0; + }); + + testWidgets('the widgets become the scene\'s nodes', (tester) async { + final controller = await _pump(tester, _device(), [ + Mesh3D(shape: CuboidShape(), material: _grey, name: 'a'), + Node3D( + name: 'group', + children: [ + Mesh3D(shape: CuboidShape(), material: _grey, name: 'b'), + ], + ), + ]); + final names = [for (final m in controller.scene.meshes) m.name]; + expect(names, containsAll(['a', 'b'])); + final b = controller.scene.meshes.firstWhere((m) => m.name == 'b'); + expect(b.parent?.name, 'group'); + }); + + testWidgets('a rebuild with the same keys keeps each node', (tester) async { + // Mutation: make the node in `build` rather than once in + // `didChangeDependencies`, and every rebuild hands the scene new nodes, + // losing whatever a game set on the old ones. + final device = _device(); + Widget mesh(String name) => Mesh3D( + key: ValueKey(name), + shape: CuboidShape(), + material: _grey, + name: name, + ); + final controller = await _pump(tester, device, [ + mesh('first'), + mesh('second'), + ]); + MeshNode named(String name) => + controller.scene.meshes.firstWhere((m) => m.name == name); + final first = named('first'); + final second = named('second'); + // Something a game did imperatively, which a new node would not have. + first.tint.setValues(1.0, 0.0, 0.0, 1.0); + + await tester.pumpWidget( + _scene(device, [mesh('second'), mesh('first')]), + ); + expect(identical(named('first'), first), isTrue); + expect(identical(named('second'), second), isTrue); + expect(named('first').tint.x, 1.0); + }); + + testWidgets('properties reach the node, and a widget gone takes its node', ( + tester, + ) async { + final device = _device(); + final controller = await _pump(tester, device, [ + Mesh3D( + key: const ValueKey('moving'), + shape: CuboidShape(), + material: _grey, + position: Vector3(1.0, 2.0, 3.0), + name: 'moving', + ), + ]); + final node = controller.scene.meshes.single; + expect(node.worldMatrix.getTranslation(), Vector3(1.0, 2.0, 3.0)); + + await tester.pumpWidget( + _scene(device, [ + Mesh3D( + key: const ValueKey('moving'), + shape: CuboidShape(), + material: _grey, + position: Vector3(-1.0, 0.0, 0.0), + name: 'moving', + ), + ]), + ); + expect(identical(controller.scene.meshes.single, node), isTrue); + expect(node.worldMatrix.getTranslation(), Vector3(-1.0, 0.0, 0.0)); + + await tester.pumpWidget(_scene(device, const [])); + expect(controller.scene.meshes, isEmpty); + expect(node.parent, isNull); + }); + + testWidgets('a Camera3D is the one drawn through, while it is there', ( + tester, + ) async { + final device = _device(); + final controller = await _pump(tester, device, [ + Camera3D(position: Vector3(0.0, 3.0, 4.0), target: Vector3.zero()), + ]); + expect( + controller.camera.worldMatrix.getTranslation(), + Vector3(0.0, 3.0, 4.0), + ); + + await tester.pumpWidget(_scene(device, const [])); + expect( + controller.camera.worldMatrix.getTranslation(), + Vector3(0.0, 0.0, 5.0), + ); + }); + + testWidgets('a model stands in with its placeholder until it has loaded', ( + tester, + ) async { + // Mutation: drop the placeholder from `_Model3DState.build` once the + // instance is set, and both stand in the scene together. + final device = _device(); + final bytes = File( + '../flutter3d_samples/assets/BoxAnimated.glb', + ).readAsBytesSync(); + final asset = await tester.runAsync( + () async => ModelAsset.fromDocument( + await GltfLoader().load(bytes), + device: device, + ), + ); + final arrived = Completer(); + ModelInstance? loaded; + final controller = await _pump(tester, device, [ + Model3D( + load: (GraphicsDevice device) => arrived.future, + placeholder: Mesh3D( + shape: CuboidShape(), + material: _grey, + name: 'placeholder', + ), + onLoaded: (instance) => loaded = instance, + ), + ]); + expect(controller.scene.meshes.map((m) => m.name), [ + 'placeholder', + ]); + + arrived.complete(asset); + await tester.pump(); + await tester.pump(); + expect(loaded, isNotNull); + expect( + controller.scene.meshes.map((m) => m.name), + isNot(contains('placeholder')), + ); + expect(controller.scene.meshes, isNotEmpty); + }); + + testWidgets('a model\'s animation plays while playing, and holds when not', ( + tester, + ) async { + // Mutation: advance the player whatever `playing` says, and the paused + // model keeps turning. + final device = _device(); + final bytes = File( + '../flutter3d_samples/assets/BoxAnimated.glb', + ).readAsBytesSync(); + final document = (await tester.runAsync(() => GltfLoader().load(bytes)))!; + final asset = await tester.runAsync( + () => ModelAsset.fromDocument(document, device: device), + ); + ModelInstance? loaded; + Widget model({required bool playing}) => _scene(device, [ + Model3D( + key: const ValueKey('box'), + load: (GraphicsDevice device) async => asset!, + animation: document.animations.first.name, + playing: playing, + onLoaded: (instance) => loaded = instance, + ), + ], continuous: true); + + await tester.pumpWidget(model(playing: true)); + await tester.pump(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 16)); + expect(loaded, isNotNull); + List pose() => [ + for (final mesh in loaded!.meshes) ...mesh.worldMatrix.storage, + ]; + + final before = pose(); + await tester.pump(const Duration(milliseconds: 400)); + await tester.pump(const Duration(milliseconds: 400)); + final moving = pose(); + expect(moving, isNot(before)); + + await tester.pumpWidget(model(playing: false)); + final held = pose(); + await tester.pump(const Duration(milliseconds: 400)); + await tester.pump(const Duration(milliseconds: 400)); + expect(pose(), held); + await tester.pumpWidget(const SizedBox()); + }); + + testWidgets('a frame drawn from widgets is the frame built by hand', ( + tester, + ) async { + // The golden this item asks for, held to the strictest reference there + // is: the same scene through the imperative API, on the same backend, + // to the byte. + final device = _device(); + final controller = await _pump(tester, device, [ + Camera3D(position: Vector3(2.0, 2.0, 4.0), target: Vector3.zero()), + Light3D.directional(direction: Vector3(-1.0, -2.0, -1.5), intensity: 3.0), + Mesh3D( + shape: CuboidShape(), + material: _grey, + rotation: Quaternion.axisAngle(Vector3(0.0, 1.0, 0.0), 0.5), + ), + ]); + const settings = RenderSettings( + bloom: BloomSettings(enabled: false), + look: LookSettings(dither: 0.0), + ); + Future frame( + Renderer renderer, + Scene scene, + CameraNode camera, + ) async { + final result = renderer.render( + width: _width, + height: _height, + scene: scene, + views: [RenderView(camera: camera)], + settings: settings, + ); + return (await device.readPixels(result.frame))!.buffer.asUint8List(); + } + + final fromWidgets = await tester.runAsync( + () => frame(controller.renderer, controller.scene, controller.camera), + ); + + final scene = Scene(); + final camera = CameraNode() + ..setPosition(2.0, 2.0, 4.0) + ..lookAt(Vector3.zero()); + scene + ..add(camera) + ..add( + LightNode(intensity: 3.0) + ..setLocalForward(Vector3(-1.0, -2.0, -1.5).normalized()), + ) + ..add( + MeshNode(DeviceMesh.upload(device, CuboidShape().build()), _grey) + ..setRotation(Quaternion.axisAngle(Vector3(0.0, 1.0, 0.0), 0.5)), + ); + final byHand = await tester.runAsync( + () => frame(Renderer.create(device: device), scene, camera), + ); + expect(fromWidgets, byHand); + }); + + group('materials, probes, decals, mirrors and particles as widgets', () { + testWidgets('the meshes under one Material3D share its one material, ' + 'changed in place', (tester) async { + // Mutation: make a new engine material in `_Material3DState._apply` — + // the meshes keep the old one and the rebuild's colour reaches neither. + final device = _device(); + Widget scene(Vector4 colour) => _scene(device, [ + Material3D( + key: const ValueKey('paint'), + baseColor: colour, + roughness: 0.3, + children: [ + Mesh3D(shape: CuboidShape(), name: 'a'), + Mesh3D(shape: CuboidShape(), name: 'b'), + ], + ), + ]); + Scene3DController? controller; + await tester.pumpWidget( + _scene(device, const [], onCreated: (c) => controller = c), + ); + await tester.pump(); + await tester.pumpWidget(scene(Vector4(1.0, 0.0, 0.0, 1.0))); + final meshes = controller!.scene.meshes.toList(); + expect(meshes, hasLength(2)); + final shared = meshes.first.material; + expect(identical(meshes.last.material, shared), isTrue); + expect(shared.roughness, 0.3); + + await tester.pumpWidget(scene(Vector4(0.0, 0.0, 1.0, 1.0))); + expect( + identical(controller!.scene.meshes.first.material, shared), + isTrue, + ); + expect(shared.baseColor.z, 1.0); + expect(shared.baseColor.x, 0.0); + }); + + testWidgets('a mesh with no material and none above is refused', ( + tester, + ) async { + await tester.pumpWidget(_scene(_device(), const [])); + await tester.pump(); + await tester.pump(); + await tester.pumpWidget( + _scene(_device(), [Mesh3D(shape: CuboidShape())]), + ); + expect(tester.takeException(), isA()); + }); + + testWidgets('a mesh directly below a Mirror3D is one of its surfaces, ' + 'while it is there', (tester) async { + // Mutation: drop `_joinMirror` from `_Mesh3DState.apply` — the mirror + // has no surface to be seen in. + final device = _device(); + final controller = await _pump(tester, device, [ + Mirror3D( + reflectance: 0.8, + children: [ + Mesh3D( + key: const ValueKey('floor'), + shape: CuboidShape(), + material: _grey, + name: 'floor', + ), + ], + ), + ]); + final mirror = controller.scene.root.children + .whereType() + .single; + expect(mirror.reflectance, 0.8); + expect(mirror.surfaces.map((m) => m.name), ['floor']); + + await tester.pumpWidget( + _scene(device, [Mirror3D(reflectance: 0.8)]), + ); + expect(mirror.surfaces, isEmpty); + }); + + testWidgets('a probe and a decal are nodes with what the widgets say', ( + tester, + ) async { + final controller = await _pump(tester, _device(), [ + ReflectionProbe3D(position: Vector3(0.0, 1.0, 0.0), radius: 6.0), + Decal3D( + scale: Vector3(2.0, 2.0, 0.5), + color: Vector4(1.0, 0.0, 0.0, 1.0), + order: 3, + ), + ]); + final probe = controller.scene.root.children + .whereType() + .single; + expect(probe.radius, 6.0); + final decal = controller.scene.root.children + .whereType() + .single; + expect(decal.order, 3); + expect(decal.color.x, 1.0); + }); + + testWidgets('particles are drawn while the widget is there and emitted ' + 'as the scene advances', (tester) async { + // Mutation: leave `host.animated.add(this)` out of + // `_Particles3DState.createNode` — nothing is ever emitted. + final device = _device(); + final effect = ParticleEffect( + count: 1, + emitter: const SphereEmitter(speed: Range.exact(0.0)), + lifetime: const Range.exact(5.0), + size: const Range.exact(0.2), + color: Vector4(1.0, 0.5, 0.2, 1.0), + ); + final system = ParticleSystem(capacity: 64); + Scene3DController? controller; + Widget torch() => _scene( + device, + [ + Particles3D( + key: const ValueKey('torch'), + effect: effect, + perSecond: 40.0, + system: system, + ), + ], + onCreated: (c) => controller = c, + continuous: true, + ); + await tester.pumpWidget(torch()); + await tester.pump(); + await tester.pump(); + expect( + controller!.renderer.contributors.all.whereType(), + hasLength(1), + ); + // A second of frames through the scene's own loop. + for (var i = 0; i < 10; i++) { + await tester.pump(const Duration(milliseconds: 100)); + } + expect(system.aliveCount, greaterThan(10)); + + await tester.pumpWidget(_scene(device, const [])); + expect( + controller!.renderer.contributors.all.whereType(), + isEmpty, + ); + }); + }); + + group('SceneWidgets.mount — widgets in a scene somebody else draws', () { + test('builds into the scene given, keeps a keyed node, and takes it all ' + 'out when disposed', () { + final device = _device(); + final scene = Scene(); + final renderer = Renderer.create(device: device); + Widget mesh(Vector3 at) => Mesh3D( + key: const ValueKey('crate'), + shape: CuboidShape(), + material: _grey, + position: at, + name: 'crate', + ); + final mount = SceneWidgets.mount( + scene: scene, + renderer: renderer, + device: device, + children: [ + Camera3D(position: Vector3(0.0, 2.0, 4.0), target: Vector3.zero()), + mesh(Vector3.zero()), + ], + ); + final node = scene.meshes.single; + expect(mount.camera, isNotNull); + + mount.update([mesh(Vector3(1.0, 0.0, 0.0))]); + expect(identical(scene.meshes.single, node), isTrue); + expect(node.worldMatrix.getTranslation().x, 1.0); + expect(mount.camera, isNull, reason: 'the camera widget went'); + + mount.dispose(); + expect(scene.meshes, isEmpty); + }); + + test('a frame drawn from a mount is the frame built by hand', () async { + // The golden runner's way in: the same scene as the imperative API's, + // to the byte, on the same backend. + final device = _device(); + const settings = RenderSettings( + bloom: BloomSettings(enabled: false), + look: LookSettings(dither: 0.0), + ); + Future frame(Scene scene, CameraNode camera) async { + final result = Renderer.create(device: device).render( + width: _width, + height: _height, + scene: scene, + views: [RenderView(camera: camera)], + settings: settings, + ); + return (await device.readPixels(result.frame))!.buffer.asUint8List(); + } + + final mounted = Scene(); + final mount = SceneWidgets.mount( + scene: mounted, + renderer: Renderer.create(device: device), + device: device, + children: [ + Camera3D(position: Vector3(2.0, 2.0, 4.0), target: Vector3.zero()), + Light3D.directional( + direction: Vector3(-1.0, -2.0, -1.5), + intensity: 3.0, + ), + Material3D( + baseColor: Vector4(0.6, 0.6, 0.6, 1.0), + children: [Mesh3D(shape: CuboidShape())], + ), + ], + ); + final fromWidgets = await frame(mounted, mount.camera!); + + final scene = Scene(); + final camera = CameraNode() + ..setPosition(2.0, 2.0, 4.0) + ..lookAt(Vector3.zero()); + scene + ..add(camera) + ..add( + LightNode(intensity: 3.0) + ..setLocalForward(Vector3(-1.0, -2.0, -1.5).normalized()), + ) + ..add( + MeshNode(DeviceMesh.upload(device, CuboidShape().build()), _grey), + ); + expect(fromWidgets, await frame(scene, camera)); + }); + }); +} diff --git a/packages/flutter3d_app/test/terrain_tiles_test.dart b/packages/flutter3d_app/test/terrain_tiles_test.dart index 24c48c5cb..36190cde7 100644 --- a/packages/flutter3d_app/test/terrain_tiles_test.dart +++ b/packages/flutter3d_app/test/terrain_tiles_test.dart @@ -18,7 +18,6 @@ import 'package:flutter3d/flutter3d.dart'; import 'package:flutter3d_app/flutter3d_app.dart'; import 'package:flutter3d_cpu/flutter3d_cpu.dart'; import 'package:flutter3d_sim/flutter3d_sim.dart'; -import 'package:flutter3d_testing/flutter3d_testing.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:vector_math/vector_math.dart'; diff --git a/packages/flutter3d_build/CHANGELOG.md b/packages/flutter3d_build/CHANGELOG.md index da3f2793e..146d8c162 100644 --- a/packages/flutter3d_build/CHANGELOG.md +++ b/packages/flutter3d_build/CHANGELOG.md @@ -1,3 +1,42 @@ +## Unreleased + +- **A material's bundle carries its source** in the material section, and + `kAssetPipelineVersion` is 3, so bundles built before it are built again. + +**A material written in the material language compiles at build time into +a shader bundle every GPU backend loads.** The hook finds +`assets_src/**/*.f3dmat` and writes `flutter3d_generated/**/*.f3dshaders` at +the same relative path. It parses each source, emits GLSL through +`flutter3d_core`'s emitter, resolves the engine's headers from +`flutter3d_shaders`, and packs one `ShaderBundle` with three sections: +`impellerc` output for Impeller, GLSL ES 3.00 for WebGL2, and WGSL with its +reflection for WebGPU. `GraphicsDevice.loadShaders` takes the file on any +backend; the software backend evaluates the same source on the CPU and needs +no section. Before this, the language emitted GLSL that nothing built, so a +material could be evaluated on the CPU and could not be drawn by a GPU. + +A mistake in a material fails the build with the file, line and column +(`fx/rim.f3dmat:3:13: ...`). A compiler refusing the GLSL generated from a +valid source names the source and keeps the generated `.frag` under +`.dart_tool/flutter3d_build/materials/`, so the line in the compiler's +message can be opened. Nothing compiles at run time. + +The WebGPU section needs glslangValidator and naga. On a machine without +them the bundle gets the other two sections and the log says so, and the +WebGPU backend refuses that bundle by name. Failing the whole build over one +backend's tooling would make it a requirement for everyone. A missing +`impellerc` is an error, because it ships in the Flutter SDK that runs the +hook. + +Every material source and every engine shader header is declared as a hook +dependency, so editing `surface.glsl` rebuilds the bundles that include it. +A bundle whose inputs did not change is not compiled again, and a bundle +whose source is gone is deleted. A project without materials does not look +for a compiler or for the engine's sources at all. + +A manifest rule's `glob` and `exclude` apply to materials as they do to +models. + ## 0.8.0 **Every cached model converts again once.** `kAssetPipelineVersion` is 2, diff --git a/packages/flutter3d_build/README.md b/packages/flutter3d_build/README.md index b1337f88c..44ac3558b 100644 --- a/packages/flutter3d_build/README.md +++ b/packages/flutter3d_build/README.md @@ -23,6 +23,18 @@ name in its `hooks: user_defines:` overrides either choice. The value `universal` there is the one family every device can load; it is turned into BC, ASTC, ETC2 or RGBA8 when the texture is uploaded. +## Materials + +A material written in the material language goes under `assets_src/` with the +extension `.f3dmat`, next to the models that wear it. The hook compiles +`assets_src/fx/rim.f3dmat` into `flutter3d_generated/fx/rim.f3dshaders`: one +shader bundle with a fragment stage named after the material, carrying an +Impeller section, a WebGL2 section and, when `glslangValidator` and `naga` are +on `PATH`, a WebGPU section. Load it with `GraphicsDevice.loadShaders`. +Compiling happens only here, at build time. A mistake fails the build with the +material's file, line and column. A manifest rule's `glob` and `exclude` apply +to materials as well. + The same converter can be run by hand: ```sh diff --git a/packages/flutter3d_build/lib/flutter3d_build.dart b/packages/flutter3d_build/lib/flutter3d_build.dart index 2b062eb72..3632a4640 100644 --- a/packages/flutter3d_build/lib/flutter3d_build.dart +++ b/packages/flutter3d_build/lib/flutter3d_build.dart @@ -13,6 +13,7 @@ export 'src/init.dart'; export 'src/layout.dart'; export 'src/lod_generate.dart'; export 'src/manifest.dart'; +export 'src/material_build.dart'; export 'src/pipeline_version.dart'; export 'src/six_way_bake.dart'; export 'src/texture_encode.dart'; diff --git a/packages/flutter3d_build/lib/src/build_assets.dart b/packages/flutter3d_build/lib/src/build_assets.dart index 441d15f44..24c9927d4 100644 --- a/packages/flutter3d_build/lib/src/build_assets.dart +++ b/packages/flutter3d_build/lib/src/build_assets.dart @@ -20,6 +20,7 @@ import 'package:hooks/hooks.dart'; import 'convert.dart'; import 'device_classes.dart'; import 'layout.dart'; +import 'material_build.dart'; import 'pipeline_version.dart'; /// What one call to [runAssetBuild] did, for a hook's own log line and for @@ -384,7 +385,22 @@ Future buildAssets(BuildInput input, BuildOutputBuilder output) async { textures: textures, deviceClasses: deviceClasses, ); - for (final source in report.dependencies) { + + // **Materials — P8.** After the models, and in the same hook, because they + // land in the same directory the project already bundles. A material that + // does not compile fails the build here, naming its file and line, rather + // than a draw at run time: there is no compiler to fall back to there. + final MaterialBuildReport materials; + try { + materials = runMaterialBuild(projectRoot); + } on MaterialBuildException catch (error) { + throw BuildError(message: error.toString()); + } + + for (final source in [ + ...report.dependencies, + ...materials.dependencies, + ]) { output.dependencies.add(Uri.file(source)); } } diff --git a/packages/flutter3d_build/lib/src/layout.dart b/packages/flutter3d_build/lib/src/layout.dart index 4e2e57a6e..fb791ac19 100644 --- a/packages/flutter3d_build/lib/src/layout.dart +++ b/packages/flutter3d_build/lib/src/layout.dart @@ -1,5 +1,6 @@ /// `ap-04`'s other half: where `assets_src/` and `flutter3d_generated/` sit -/// in a project, and what to convert given an optional [AssetManifest]. +/// in a project, and what to convert given an optional [AssetManifest] — +/// models, and since P8 materials written in the material language. library; import 'dart:io'; @@ -63,8 +64,53 @@ final class AssetLayout { } return result; } + + /// Every material source under [sourcesDir] — P8 — paired with the shader + /// bundle it compiles into: `assets_src/fx/rim.f3dmat` becomes + /// `flutter3d_generated/fx/rim.f3dshaders`. + /// + /// **The same tree as the models, not a `shaders/` of its own.** A material + /// belongs to the asset that wears it, and an author who keeps `crate.glb` + /// and `crate_glow.f3dmat` side by side should not have to split them + /// because one is compiled by a different program. The output lands where + /// `flutter: assets:` already looks, so a project that bundles + /// `flutter3d_generated/` ships its materials with no new line in its + /// pubspec. A manifest rule's `glob` and `exclude` hold here as they do for + /// a model; nothing else in a rule means anything to a material. + /// + /// **`.f3dmat`, a name nothing else claims** — and kept out of + /// [recognisedExtensions], so the model plan never tries to decode one. + List materialPlan() { + if (!sourcesDir.existsSync()) return const []; + return [ + for (final entity in sourcesDir.listSync(recursive: true)) + if (entity is File && _extensionOf(entity.path) == materialExtension) + if (_relativeTo(entity) case final relative) + if (manifest.ruleFor(relative) case final rule + when !(rule?.exclude ?? false)) + AssetPlan( + source: entity.path, + destination: + '${generatedDir.path}/' + '${relative.substring(0, relative.length - materialExtension.length)}' + '$shaderBundleExtension', + rule: rule, + ), + ]; + } + + String _relativeTo(File file) => + file.path.substring(sourcesDir.path.length + 1); } +/// What a material source is called — P8. Matched case-insensitively, like +/// every other extension here. +const String materialExtension = '.f3dmat'; + +/// What a compiled material is called: a `ShaderBundle`, the same container +/// `GraphicsDevice.loadShaders` takes from any other packer. +const String shaderBundleExtension = '.f3dshaders'; + String _extensionOf(String path) { final dot = path.lastIndexOf('.'); final slash = path.lastIndexOf('/'); diff --git a/packages/flutter3d_build/lib/src/manifest.dart b/packages/flutter3d_build/lib/src/manifest.dart index 113d649b7..76f79291a 100644 --- a/packages/flutter3d_build/lib/src/manifest.dart +++ b/packages/flutter3d_build/lib/src/manifest.dart @@ -17,6 +17,11 @@ /// `chair.desktop.f3d` — each cut to its class's `DeviceClassBudget`. A /// manifest without it builds what it always built. /// +/// **Materials (`*.f3dmat`, P8) are matched by the same rules**, and only +/// `glob` and `exclude` mean anything to them. A material has no textures to +/// compress and no levels of detail, so `exclude: true` is the one thing a +/// rule can say about one. `AssetLayout.materialPlan` reads it. +/// /// **`"**/*.obj"` matches a root-level `a.obj` too.** That is /// `package:glob`'s rule since 2.2.0, where `**` at the start of a pattern /// may match no directory at all; before it, the same pattern skipped every diff --git a/packages/flutter3d_build/lib/src/material_build.dart b/packages/flutter3d_build/lib/src/material_build.dart new file mode 100644 index 000000000..61f0b84b9 --- /dev/null +++ b/packages/flutter3d_build/lib/src/material_build.dart @@ -0,0 +1,512 @@ +/// P8: a project's materials, written in the material language, compiled +/// ahead of time into shader bundles every GPU backend loads. +/// +/// `assets_src/**/*.f3dmat` in, `flutter3d_generated/**/*.f3dshaders` out — +/// [AssetLayout.materialPlan] says which and where. Each source is parsed, +/// emitted as GLSL by `flutter3d_core`'s emitter, resolved against the +/// engine's own headers in `flutter3d_shaders`, and packed into one +/// [ShaderBundle] with three sections: `impellerc`'s output for Impeller, +/// GLSL ES 3.00 for WebGL2, and WGSL with its reflection for WebGPU. The +/// software backend needs no section; it evaluates the same source on the +/// CPU. `GraphicsDevice.loadShaders` takes the result on any of the four. +/// +/// **Ahead of time, and only ahead of time.** No backend gains a compiler at +/// run time (decision in §2 of the 0.9 roadmap): a material that does not +/// compile fails the build, on the author's machine, naming the file and the +/// line, rather than a draw on a player's. +/// +/// **The WebGPU section is the one allowed to be missing**, for the reason +/// `flutter3d_webgpu/tool/pack_wgsl_section.dart` gives: it needs +/// glslangValidator and naga, which a machine may not have, and a backend's +/// tooling must not become a requirement for everybody else's build. Without +/// them the bundle carries two sections, the log says so, and the WebGPU +/// backend refuses that bundle by name. `impellerc` is never missing in a +/// real build — it ships in the Flutter SDK that runs the hook — so its +/// absence is an error. +library; + +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:crypto/crypto.dart'; +import 'package:flutter3d_core/formats.dart'; +import 'package:flutter3d_hardware/shader_bundle.dart'; +import 'package:flutter3d_shaders/compile.dart'; + +import 'layout.dart'; +import 'pipeline_version.dart'; + +/// A material that did not build, and where. +/// +/// [line] and [column] are the material source's own when the failure is one +/// the parser can place — which is every mistake an author makes in the +/// language. A compiler refusing GLSL the language accepted names the source +/// too, and its own output names the line of the generated `.frag`, kept on +/// disk at the path the message gives. +final class MaterialBuildException implements Exception { + const MaterialBuildException( + this.message, { + required this.source, + this.line, + this.column, + }); + + final String message; + + /// The material source, as a path; empty for a failure no one source + /// caused, such as no `impellerc` to run. + final String source; + final int? line; + final int? column; + + @override + String toString() => switch ((source, line, column)) { + ('', _, _) => message, + (_, final int line, final int column) => '$source:$line:$column: $message', + (_, final int line, null) => '$source:$line: $message', + _ => '$source: $message', + }; +} + +/// Compiles one prepared stage to WGSL and reports the offsets glslang gave +/// each uniform block member — `compileStage`'s answer, behind a seam a test +/// can replace on a machine with neither compiler. +typedef WgslStageCompiler = + CompiledStage Function( + PreparedStage stage, { + required String name, + required bool fragment, + }); + +/// The programs a material build runs, and the SDK token it stamps. +/// +/// A value rather than a lookup inside the build, so a test hands it a stub +/// `impellerc` the way `flutter3d_impeller`'s own tests do, and so a build +/// that needs none of them — a project without materials — never looks. +final class MaterialCompilers { + const MaterialCompilers({ + required this.impellerc, + required this.sdk, + this.impellerIncludes = const [], + this.wgsl, + }); + + /// `impellerc`'s path. + final String impellerc; + + /// Extra `--include` roots for `impellerc`: the SDK's `shader_lib`. + final List impellerIncludes; + + /// What [ShaderBundle.sdk] says — the Dart that ran `impellerc`'s SDK, which + /// is the Dart running this build when the build runs as a hook. + final String sdk; + + /// Null when glslangValidator or naga is not on `PATH`: the bundle then + /// carries no WebGPU section. + final WgslStageCompiler? wgsl; + + /// The compilers of the Flutter SDK this process runs under, and glslang + /// and naga from `PATH` when both answer. + /// + /// Throws [MaterialBuildException] when there is no `impellerc` — this + /// process is not a Flutter SDK's Dart, or the SDK's artifacts were never + /// downloaded. + static MaterialCompilers locate() { + final sdkRoot = flutterSdkRootFrom(Platform.resolvedExecutable); + final name = Platform.isWindows ? 'impellerc.exe' : 'impellerc'; + final artifacts = '$sdkRoot/bin/cache/artifacts/engine'; + // macOS ships one universal `darwin-x64` directory even on Apple silicon, + // so the candidate that exists wins rather than a guess from the host. + final platform = switch (Platform.operatingSystem) { + 'macos' => const ['darwin-x64', 'darwin-arm64'], + 'linux' => const ['linux-x64', 'linux-arm64'], + 'windows' => const ['windows-x64', 'windows-arm64'], + _ => const ['darwin-x64', 'linux-x64', 'windows-x64'], + }.where((p) => File('$artifacts/$p/$name').existsSync()).firstOrNull; + if (platform == null) { + throw MaterialBuildException( + 'no $name under $artifacts — run "flutter precache" for this host', + source: '', + ); + } + return MaterialCompilers( + impellerc: '$artifacts/$platform/$name', + impellerIncludes: ['$artifacts/$platform/shader_lib'], + sdk: Platform.version.split(' ').first, + wgsl: _answers(kGlslang) && _answers(kNaga) ? _compileWgsl : null, + ); + } +} + +CompiledStage _compileWgsl( + PreparedStage stage, { + required String name, + required bool fragment, +}) => compileStage(stage.glsl, name: name, fragment: fragment); + +bool _answers(String program) { + try { + Process.runSync(program, const ['--version']); + return true; + } on ProcessException { + return false; + } +} + +/// The Flutter SDK root under [executable]: the SDK's own Dart, or +/// `flutter_tester` under its engine artifacts. +/// +/// The search `flutter3d_impeller`'s `shader_bundle_build.dart` makes, and +/// ported rather than shared: that package names Flutter, and this one is a +/// build hook that cannot resolve a package that does. Its doc comment holds +/// the measurements behind each shape — `$FLUTTER_ROOT` unset inside a hook, +/// `flutter_tester` under `flutter test`, backslashes and `.exe` on Windows. +String flutterSdkRootFrom(String executable) { + final normalized = executable.replaceAll(r'\', '/'); + const dart = '/bin/cache/dart-sdk/bin/dart'; + for (final suffix in [dart, '$dart.exe']) { + if (normalized.endsWith(suffix)) { + return executable.substring(0, executable.length - suffix.length); + } + } + const artifacts = '/bin/cache/artifacts/engine/'; + final at = normalized.indexOf(artifacts); + if (at >= 0 && + (normalized.endsWith('/flutter_tester') || + normalized.endsWith('/flutter_tester.exe'))) { + return executable.substring(0, at); + } + throw MaterialBuildException( + 'cannot find the Flutter SDK from $executable, so there is no impellerc ' + 'to compile materials with — build through `flutter`, whose Dart runs ' + 'this hook', + source: '', + ); +} + +/// What one call to [runMaterialBuild] did. +final class MaterialBuildReport { + const MaterialBuildReport({ + required this.built, + required this.skipped, + required this.dependencies, + }); + + /// Material sources this run compiled. + final List built; + + /// Material sources whose bundle was already current. + final List skipped; + + /// Every file the bundles were made from — each material source, and every + /// engine header they can include — for a hook to declare, so an edit to + /// any of them runs the hook again. + final List dependencies; +} + +/// Compiles every material [AssetLayout.materialPlan] finds under +/// [projectRoot]. +/// +/// [compilers] defaults to [MaterialCompilers.locate] and [engine] to the +/// `flutter3d_shaders` the project resolves; both are looked up only when +/// there is a material to build, so a project without one needs neither a +/// Flutter SDK nor the engine's sources. +/// +/// A bundle whose inputs have not changed — the source, every engine shader, +/// the SDK stamp and which sections the compilers can make — is left alone. +/// A bundle whose source is gone is deleted, because `flutter3d_generated/` +/// is bundled whole and would otherwise ship it. +/// +/// Throws [MaterialBuildException] on the first material that does not build, +/// having written nothing for it. +MaterialBuildReport runMaterialBuild( + Directory projectRoot, { + IOSink? log, + MaterialCompilers? compilers, + ShaderSet? engine, +}) { + final layout = AssetLayout(projectRoot: projectRoot); + final plan = layout.materialPlan(); + final cachePath = '${layout.generatedDir.path}/.flutter3d_materials.json'; + final previous = _readCache(cachePath); + final sink = log ?? stdout; + + // Bundles a previous build wrote for sources that are no longer planned. + for (final stale in previous.keys) { + if (plan.any((job) => job.destination == stale)) continue; + final file = File(stale); + if (file.existsSync()) file.deleteSync(); + } + if (plan.isEmpty) { + if (previous.isNotEmpty) File(cachePath).deleteSync(); + return const MaterialBuildReport( + built: [], + skipped: [], + dependencies: [], + ); + } + + final ShaderSet shaders; + try { + shaders = engine ?? loadShaders(from: projectRoot.path); + } on StateError catch (error) { + throw MaterialBuildException( + 'the engine\'s shaders are not reachable from ${projectRoot.path}: ' + '${error.message}', + source: plan.first.source, + ); + } + final tools = compilers ?? MaterialCompilers.locate(); + if (tools.wgsl == null) { + sink.writeln( + 'flutter3d_build: $kGlslang or $kNaga is not on PATH, so material ' + 'bundles get no "webgpu" section and the WebGPU backend will refuse ' + 'them by name. glslangValidator comes with the Vulkan SDK or `brew ' + 'install glslang`; naga from `cargo install naga-cli`.', + ); + } + + final engineHash = sha256 + .convert( + utf8.encode( + (shaders.sources.keys.toList()..sort()) + .map((key) => '$key\n${shaders.sources[key]}') + .join('\n'), + ), + ) + .toString(); + final stamp = + '$engineHash ${tools.sdk} $kAssetPipelineVersion ' + '${tools.wgsl == null ? 'no-webgpu' : 'webgpu'}'; + + final next = {}; + final built = []; + final skipped = []; + for (final job in plan) { + final text = File(job.source).readAsStringSync(); + final key = '${sha256.convert(utf8.encode(text))} $stamp'; + next[job.destination] = key; + if (previous[job.destination] == key && + File(job.destination).existsSync()) { + skipped.add(job.source); + continue; + } + + final bundle = compileMaterial( + text, + source: job.source, + engine: shaders, + compilers: tools, + scratch: Directory( + '${projectRoot.path}/.dart_tool/flutter3d_build/materials', + ), + ); + final bytes = bundle.encode(); + File(job.destination) + ..parent.createSync(recursive: true) + ..writeAsBytesSync( + bytes.buffer.asUint8List(bytes.offsetInBytes, bytes.lengthInBytes), + ); + sink.writeln( + 'flutter3d_build: ${job.destination} — "${bundle.name}", sections ' + '${bundle.sections.keys.join(', ')}', + ); + built.add(job.source); + } + + _writeCache(cachePath, next); + return MaterialBuildReport( + built: built, + skipped: skipped, + dependencies: [ + for (final job in plan) job.source, + for (final file in shaders.sources.keys) '${shaders.root}/$file', + ], + ); +} + +/// One material [text] as a bundle with a fragment stage named after the +/// material, compiled for every backend [compilers] can serve. +/// +/// [source] is the path every failure names. [scratch] is where the +/// generated `.frag` is written for `impellerc`, and where it stays when +/// a compiler refuses it, so the line in that compiler's message is a line +/// somebody can open. +ShaderBundle compileMaterial( + String text, { + required String source, + required ShaderSet engine, + required MaterialCompilers compilers, + required Directory scratch, +}) { + final MaterialProgram program; + try { + program = parseMaterial(text); + } on MaterialSyntaxError catch (error) { + throw MaterialBuildException( + error.message, + source: source, + line: error.line, + column: error.column, + ); + } + // One entry point per source, at its declared defaults. A variant is a + // second entry point with other constants folded in, and naming those is + // the manifest's to grow when somebody needs one. + final name = program.name; + final glsl = emitMaterialFragment( + specialiseMaterial(program, MaterialVariant(name)), + ); + + Never refused(String what, String message) => + throw MaterialBuildException('$what: $message', source: source); + + final generated = File('${scratch.path}/$name.frag') + ..parent.createSync(recursive: true) + ..writeAsStringSync(glsl); + + final impeller = _impeller(name, generated, engine, compilers, refused); + + final String webgl; + final String resolved; + try { + webgl = translateGlsl(glsl, engine.sources, from: source, fragment: true); + resolved = resolveIncludes(glsl, engine.sources, from: source); + } on GlslTranslateError catch (error) { + refused('the WebGL2 translation', error.message); + } + + final webgpu = switch (compilers.wgsl) { + null => null, + final compile => _webgpu( + name, + source, + resolved, + engine, + compile, + generated, + refused, + ), + }; + + // Nothing went wrong, so nothing needs opening. + generated.deleteSync(); + + return ShaderBundle( + name: name, + sdk: compilers.sdk, + stages: [ShaderBundleStage(name, fragment: true)], + sections: { + ShaderBundle.impellerSection: impeller, + ShaderBundle.webglSection: encodeWebGlSection( + vertex: const {}, + fragment: {name: webgl}, + ), + ShaderBundle.webgpuSection: ?webgpu, + // The source itself, for the backend that compiles nothing and for a + // runtime that builds the material's lighting model from it — `P8`. + ShaderBundle.materialSection: encodeMaterialSection({ + name: text, + }), + }, + ); +} + +ByteData _impeller( + String name, + File generated, + ShaderSet engine, + MaterialCompilers compilers, + Never Function(String, String) refused, +) { + final out = File('${generated.parent.path}/$name.shaderbundle'); + final ProcessResult result; + try { + result = Process.runSync(compilers.impellerc, [ + '--shader-bundle=${jsonEncode({ + name: {'type': 'fragment', 'file': generated.path}, + })}', + '--sl=${out.path}', + // The tree the other two sections are translated against, read off + // the set rather than found again, so the three cannot disagree. + '--include=${engine.root}', + for (final include in compilers.impellerIncludes) '--include=$include', + ], workingDirectory: generated.parent.path); + } on ProcessException catch (error) { + refused( + 'impellerc', + 'could not run ${compilers.impellerc}: ${error.message}', + ); + } + if (result.exitCode != 0 || !out.existsSync()) { + refused( + 'impellerc', + 'refused the GLSL generated from it, kept at ${generated.path} ' + '(exit ${result.exitCode}):\n${result.stdout}${result.stderr}', + ); + } + final bytes = out.readAsBytesSync(); + out.deleteSync(); + return bytes.buffer.asByteData(); +} + +ByteData _webgpu( + String name, + String source, + String resolved, + ShaderSet engine, + WgslStageCompiler compile, + File generated, + Never Function(String, String) refused, +) { + try { + final prepared = prepareStage( + resolved, + from: source, + fragment: true, + varyingLocations: bundleVaryingLocations(engine, [ + (file: source, fragment: true, resolved: resolved), + ]), + ); + final compiled = compile(prepared, name: name, fragment: true); + checkStd140Offsets(name, prepared, compiled.offsets); + final document = wgslSectionDocument( + vertex: const {}, + fragment: { + name: (wgsl: compiled.wgsl, prepared: prepared), + }, + ); + return Uint8List.fromList(utf8.encode(document)).buffer.asByteData(); + } on WgslPrepareError catch (error) { + refused('the WebGPU preparation', error.message); + } on WgslCompileError catch (error) { + refused( + 'the WebGPU compile', + '${error.message}\n(the GLSL it was generated from is kept at ' + '${generated.path})', + ); + } on WgslSectionError catch (error) { + refused('the WebGPU section', error.message); + } +} + +Map _readCache(String path) { + final file = File(path); + if (!file.existsSync()) return const {}; + try { + return (jsonDecode(file.readAsStringSync()) as Map).map( + (key, value) => MapEntry(key, value! as String), + ); + } on FormatException { + return const {}; + } on TypeError { + return const {}; + } +} + +void _writeCache(String path, Map cache) { + File(path) + ..parent.createSync(recursive: true) + ..writeAsStringSync(jsonEncode(cache)); +} diff --git a/packages/flutter3d_build/lib/src/pipeline_version.dart b/packages/flutter3d_build/lib/src/pipeline_version.dart index f8efffb18..47e52bcb4 100644 --- a/packages/flutter3d_build/lib/src/pipeline_version.dart +++ b/packages/flutter3d_build/lib/src/pipeline_version.dart @@ -11,4 +11,8 @@ /// 2: `C5` — a manifest rule's `lods:` reaches the converter, so a model /// cached before it may be missing the levels its rule now asks for, or the /// impostor (`C4`) its rule's `impostor:` does. -const int kAssetPipelineVersion = 2; +/// +/// 3: `P8` — a material's bundle carries its source in a section of its own, +/// which the software backend compiles and a runtime builds the material's +/// lighting model from; a bundle cached before it has neither. +const int kAssetPipelineVersion = 3; diff --git a/packages/flutter3d_build/pubspec.yaml b/packages/flutter3d_build/pubspec.yaml index 5fcd8ef9c..025cbee09 100644 --- a/packages/flutter3d_build/pubspec.yaml +++ b/packages/flutter3d_build/pubspec.yaml @@ -44,6 +44,14 @@ dependencies: flutter3d_editor_core: ^0.8.0 flutter3d_sim: ^0.8.0 + # P8's material step: `ShaderBundle`, the container it writes, and + # `flutter3d_shaders`' `compile.dart` — the engine's headers a material + # includes, the WebGL2 and WebGPU translators, and the WGSL compile. Both + # flat; the translators moved out of the two web backends for exactly this + # line, since a package declaring the Flutter SDK cannot be listed here. + flutter3d_hardware: ^0.8.0 + flutter3d_shaders: ^0.8.2 + # `hook/build.dart`'s own contract — `ap-00`'s spike proved this is the half # of the build-hook feature that is stable on Flutter 3.47/Dart 3.13; the # other half, `package:data_assets`, stays out (see `ap-00`'s own writeup @@ -79,9 +87,6 @@ dependencies: image: ^4.10.1 dev_dependencies: - # `RecordingDevice`, which a test wraps round the impostor bake's device to - # count what the bake uploads against what it releases. - flutter3d_hardware: ^0.8.0 test: ^1.25.0 flutter_lints: ^6.0.0 diff --git a/packages/flutter3d_build/test/material_build_test.dart b/packages/flutter3d_build/test/material_build_test.dart new file mode 100644 index 000000000..81347b1db --- /dev/null +++ b/packages/flutter3d_build/test/material_build_test.dart @@ -0,0 +1,452 @@ +/// P8: a material source compiles, at build time, into a bundle every GPU +/// backend loads. +/// +/// dart test test/material_build_test.dart +/// +/// **`impellerc` is a stub here**, a shell script that writes known bytes +/// where `--sl=` points — `flutter3d_impeller`'s `build_shaders_test.dart` +/// does the same, for the same reason: the real binary lives in a Flutter +/// SDK's artifact cache, and what this file checks is what the build does +/// with its answer. The WGSL compile has the same seam, and one test runs the +/// real glslang and naga where a machine has them. The WebGL2 translation has +/// no seam: it is pure Dart and always runs for real. +library; + +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter3d_build/flutter3d_build.dart'; +import 'package:flutter3d_hardware/shader_bundle.dart'; +import 'package:flutter3d_shaders/compile.dart'; +import 'package:test/test.dart'; + +const String _rim = ''' +material RimLight { + param float rimPower = 2.0; + texture base = base_color_texture; + + fragment { + let facing = clamp(nDotV, 0.0, 1.0); + let rim = pow(1.0 - facing, rimPower); + let texel = sample(base, uv); + return vec4(albedo * texel.rgb + vec3(rim), alpha * texel.a); + } +} +'''; + +/// What the stub writes, so a test can tell its bytes in the bundle. +const String _impellerBytes = 'stub impellerc output'; + +/// glslang's offsets read straight off the reflection, which is what a +/// compiler that agrees with the std140 arithmetic would report. +CompiledStage _fakeWgsl( + PreparedStage stage, { + required String name, + required bool fragment, +}) => ( + wgsl: '// WGSL for $name\n@fragment fn main() {}\n', + offsets: >{ + for (final block in stage.blocks) + block.name: { + for (final member in block.members) member.name: member.offsetInBytes, + }, + }, +); + +void main() { + // The engine's real tree, found from this package the way a hook finds it + // from a project. + final engine = loadShaders(); + + late Directory project; + late _Log log; + + setUp(() { + project = Directory.systemTemp.createTempSync('f3d_material_'); + log = _Log(); + }); + tearDown(() => project.deleteSync(recursive: true)); + + File source(String relative, String text) => + File('${project.path}/assets_src/$relative') + ..parent.createSync(recursive: true) + ..writeAsStringSync(text); + + /// A stub `impellerc` that writes [_impellerBytes] to `--sl=`, or exits + /// non-zero with [refusal] on stderr; it records its arguments beside it. + MaterialCompilers stub({String? refusal, WgslStageCompiler? wgsl}) { + final script = File('${project.path}/sdk/impellerc') + ..parent.createSync(recursive: true) + ..writeAsStringSync(''' +#!/usr/bin/env bash +printf '%s\\n' "\$@" > "\$(dirname "\$0")/arguments.txt" +${refusal == null ? '' : 'echo "$refusal" >&2; exit 1'} +for arg in "\$@"; do + case "\$arg" in + --sl=*) printf '%s' '$_impellerBytes' > "\${arg#--sl=}" ;; + esac +done +'''); + final chmod = Process.runSync('chmod', ['+x', script.path]); + expect(chmod.exitCode, 0, reason: '${chmod.stderr}'); + return MaterialCompilers( + impellerc: script.path, + sdk: '3.13.0', + wgsl: wgsl ?? _fakeWgsl, + ); + } + + ShaderBundle readBundle(String relative) { + final bytes = File( + '${project.path}/flutter3d_generated/$relative', + ).readAsBytesSync(); + return ShaderBundle.decode(bytes.buffer.asByteData()); + } + + String text(ByteData bytes) => utf8.decode( + bytes.buffer.asUint8List(bytes.offsetInBytes, bytes.lengthInBytes), + ); + + group('with a stub impellerc', () { + test('one source becomes one bundle carrying all three sections', () { + // Mutation: drop `ShaderBundle.webgpuSection` from the map + // `compileMaterial` returns — the bundle still decodes and still loads + // on Impeller and WebGL2, and this fails on the section list. + source('fx/rim.f3dmat', _rim); + + final report = runMaterialBuild( + project, + log: log, + compilers: stub(), + engine: engine, + ); + + expect(report.built, [ + '${project.path}/assets_src/fx/rim.f3dmat', + ]); + final bundle = readBundle('fx/rim.f3dshaders'); + expect(bundle.name, 'RimLight'); + expect(bundle.sdk, '3.13.0'); + expect(bundle.stages.single.name, 'RimLight'); + expect(bundle.stages.single.fragment, isTrue); + expect(bundle.sections.keys, [ + ShaderBundle.impellerSection, + ShaderBundle.webglSection, + ShaderBundle.webgpuSection, + ShaderBundle.materialSection, + ]); + + // `P8`: the source itself, which the software backend compiles and a + // runtime reads the lighting model out of. + expect( + decodeMaterialSection(bundle)['RimLight'], + contains('material RimLight'), + ); + + // Impeller's is impellerc's answer, as it is. + expect( + text(bundle.section(ShaderBundle.impellerSection)!), + _impellerBytes, + ); + + // WebGL2's is the translation, which is real here: GLSL ES 3.00 with + // the engine's headers inlined and the material's body in it. + final webgl = decodeWebGlSection( + bundle.section(ShaderBundle.webglSection)!, + ); + expect(webgl.vertex, isEmpty); + expect(webgl.fragment['RimLight'], startsWith('#version 300 es')); + expect(webgl.fragment['RimLight'], contains('struct Surface')); + expect( + webgl.fragment['RimLight'], + contains('texture(base_color_texture'), + ); + + // WebGPU's carries the WGSL and the reflection the browser cannot be + // asked for: the sampler the material reads has a group and binding. + final webgpu = + jsonDecode(text(bundle.section(ShaderBundle.webgpuSection)!)) + as Map; + expect(webgpu['version'], kSectionVersion); + final stage = + (webgpu['fragment']! as Map)['RimLight']! + as Map; + expect(stage['wgsl'], contains('@fragment')); + expect( + (stage['samplers']! as List).map( + (s) => (s! as Map)['name'], + ), + contains('base_color_texture'), + ); + }); + + test('impellerc is asked for one fragment stage, against the engine\'s ' + 'headers', () { + // Mutation: drop the `--include=${engine.root}` argument — the real + // impellerc then fails on `#include `, which the stub + // cannot, so the argument itself is what is held. + source('rim.f3dmat', _rim); + runMaterialBuild(project, log: log, compilers: stub(), engine: engine); + + final arguments = File( + '${project.path}/sdk/arguments.txt', + ).readAsLinesSync(); + final manifest = + jsonDecode( + arguments + .firstWhere((a) => a.startsWith('--shader-bundle=')) + .substring('--shader-bundle='.length), + ) + as Map; + expect(manifest.keys, ['RimLight']); + expect( + (manifest['RimLight']! as Map)['type'], + 'fragment', + ); + expect(arguments, contains('--include=${engine.root}')); + }); + + test('every source and every engine header is a dependency', () { + // Mutation: return only the material sources from + // `MaterialBuildReport.dependencies` — an edit to `surface.glsl`, which + // every material includes, then leaves every bundle stale until some + // other file changes. + source('rim.f3dmat', _rim); + final report = runMaterialBuild( + project, + log: log, + compilers: stub(), + engine: engine, + ); + expect( + report.dependencies, + containsAll([ + '${project.path}/assets_src/rim.f3dmat', + '${engine.root}/lib/surface.glsl', + ]), + ); + }); + + test('an unchanged source is not compiled again, and a removed one takes ' + 'its bundle with it', () { + // Mutation: skip the stale sweep at the top of `runMaterialBuild` — + // the deleted material's bundle stays in `flutter3d_generated/`, which + // is bundled whole, and ships. + final rim = source('rim.f3dmat', _rim); + runMaterialBuild(project, log: log, compilers: stub(), engine: engine); + + final again = runMaterialBuild( + project, + log: log, + compilers: stub(), + engine: engine, + ); + expect(again.built, isEmpty); + expect(again.skipped, [rim.path]); + + rim.deleteSync(); + runMaterialBuild(project, log: log, compilers: stub(), engine: engine); + expect( + File('${project.path}/flutter3d_generated/rim.f3dshaders').existsSync(), + isFalse, + ); + }); + + test('a source with a mistake in it names its file, line and column, ' + 'and writes nothing', () { + // Mutation: rethrow the parser's `MaterialSyntaxError` unwrapped — the + // message keeps its line but loses the file, and with two materials in + // a project the author is left to guess which. + final bad = source('fx/bad.f3dmat', ''' +material Bad { + fragment { + let x = nope(1.0); + return vec4(x); + } +} +'''); + + expect( + () => runMaterialBuild( + project, + log: log, + compilers: stub(), + engine: engine, + ), + throwsA( + isA() + .having((e) => e.source, 'source', bad.path) + .having((e) => e.line, 'line', 3) + .having( + (e) => e.toString(), + 'text', + startsWith('${bad.path}:3:'), + ), + ), + ); + expect( + File( + '${project.path}/flutter3d_generated/fx/bad.f3dshaders', + ).existsSync(), + isFalse, + ); + }); + + test('a compiler refusing the generated GLSL names the source and keeps ' + 'the GLSL where its line can be opened', () { + // Mutation: delete the generated `.frag` before calling impellerc's + // result an error — the message then points at a file that is gone, + // and the line impellerc named is a line nobody can read. + final rim = source('rim.f3dmat', _rim); + + expect( + () => runMaterialBuild( + project, + log: log, + compilers: stub(refusal: 'RimLight.frag:12: syntax error'), + engine: engine, + ), + throwsA( + isA() + .having((e) => e.source, 'source', rim.path) + .having( + (e) => e.message, + 'message', + allOf( + contains('impellerc'), + contains('RimLight.frag:12: syntax error'), + ), + ), + ), + ); + expect( + File( + '${project.path}/.dart_tool/flutter3d_build/materials/RimLight.frag', + ).existsSync(), + isTrue, + ); + }); + + test('without glslang and naga the bundle carries the other two sections, ' + 'and says so', () { + // Mutation: make a null `wgsl` an error — every machine without the + // Vulkan SDK, CI among them, then fails the whole build over one + // backend's tooling. + source('rim.f3dmat', _rim); + final compilers = stub(); + runMaterialBuild( + project, + log: log, + compilers: MaterialCompilers( + impellerc: compilers.impellerc, + sdk: compilers.sdk, + ), + engine: engine, + ); + expect(readBundle('rim.f3dshaders').sections.keys, [ + ShaderBundle.impellerSection, + ShaderBundle.webglSection, + ShaderBundle.materialSection, + ]); + expect(log.text, contains('no "webgpu" section')); + }); + }, skip: Platform.isWindows ? 'the stub impellerc is a bash script' : null); + + test('a project with no materials looks for no compiler and no engine', () { + // Mutation: call `MaterialCompilers.locate()` before checking the plan — + // this process is not always a Flutter SDK's Dart, and a project with + // only models in it would then fail to build for want of impellerc. + source('hero.obj', 'v 0 0 0\n'); + final report = runMaterialBuild(project, log: log); + expect(report.built, isEmpty); + expect(report.dependencies, isEmpty); + }); + + test('the real glslang and naga carry a material into WGSL', () { + // The fake above agrees with the reflection by construction; this is the + // one place the two compilers get to disagree with it. + source('rim.f3dmat', _rim); + final script = File('${project.path}/impellerc') + ..writeAsStringSync( + '#!/usr/bin/env bash\n' + 'for a in "\$@"; do case "\$a" in --sl=*) : > "\${a#--sl=}";; esac; ' + 'done\n', + ); + Process.runSync('chmod', ['+x', script.path]); + runMaterialBuild( + project, + log: log, + compilers: MaterialCompilers( + impellerc: script.path, + sdk: '3.13.0', + wgsl: (stage, {required name, required fragment}) => + compileStage(stage.glsl, name: name, fragment: fragment), + ), + engine: engine, + ); + final bundle = readBundle('rim.f3dshaders'); + final document = + jsonDecode(text(bundle.section(ShaderBundle.webgpuSection)!)) + as Map; + final wgsl = + ((document['fragment']! as Map)['RimLight']! + as Map)['wgsl']! + as String; + expect(wgsl, contains('@fragment')); + expect(wgsl, contains('base_color_texture')); + }, skip: _missing() ?? (Platform.isWindows ? 'bash stub' : null)); +} + +/// Why the real-compiler test cannot run here, or null when it can. +String? _missing() { + for (final program in [kGlslang, kNaga]) { + try { + Process.runSync(program, const ['--version']); + } on ProcessException { + return '$program is not on PATH'; + } + } + return null; +} + +/// An [IOSink] that keeps what it was told. +final class _Log implements IOSink { + final StringBuffer _buffer = StringBuffer(); + + String get text => _buffer.toString(); + + @override + Encoding encoding = utf8; + + @override + void write(Object? object) => _buffer.write(object); + + @override + void writeln([Object? object = '']) => _buffer.writeln(object); + + @override + void writeAll(Iterable objects, [String separator = '']) => + _buffer.writeAll(objects, separator); + + @override + void writeCharCode(int charCode) => _buffer.writeCharCode(charCode); + + @override + void add(List data) => _buffer.write(utf8.decode(data)); + + @override + void addError(Object error, [StackTrace? stackTrace]) {} + + @override + Future addStream(Stream> stream) async {} + + @override + Future flush() async {} + + @override + Future close() async {} + + @override + Future get done async {} +} diff --git a/packages/flutter3d_conformance/CHANGELOG.md b/packages/flutter3d_conformance/CHANGELOG.md index 7357f9088..a9bf934ca 100644 --- a/packages/flutter3d_conformance/CHANGELOG.md +++ b/packages/flutter3d_conformance/CHANGELOG.md @@ -1,3 +1,22 @@ +## Unreleased + +- Links the caustic stages: `MeshVertex` with `CausticSurface`, and + `CausticPhotonVertex` with `CausticPhoton`. +- Links every mesh vertex stage with `ShadowTransmittance`. +**The linking check names `Decal`**, so a backend whose bundle lacks it +fails the check rather than the first frame with a decal in it. +**The linking check names `PlanarReflection` and `RenderTextureEncode`**: +the first through the three mesh vertex stages it is drawn over a +reflector's surfaces with, the second through the full-screen one, so a +backend whose bundle lacks either fails the check rather than the first +frame with a reflector or a render texture in it. +**The linking check pairs `SkyPhysicalVertex` with `SkyPhysical`**, whose +seven varyings are new on both sides. +**A window of the index buffer draws that window.** A new shader check draws +a window over the second of two triangles in one index buffer, then over the +first, then with no count, and asks that a window past the end of the binding +is refused with a `RangeError`. Thirty-two shader checks, forty-two in all. + ## 0.8.1 **The linking check names `ContactShadowResolve`**, the stage diff --git a/packages/flutter3d_conformance/lib/flutter3d_conformance.dart b/packages/flutter3d_conformance/lib/flutter3d_conformance.dart index d595d5a37..073939e45 100644 --- a/packages/flutter3d_conformance/lib/flutter3d_conformance.dart +++ b/packages/flutter3d_conformance/lib/flutter3d_conformance.dart @@ -14,8 +14,8 @@ /// /// **Two tiers, and the split is a correction.** This file used to say it was /// shader-free as a whole, and that stopped being true the day a check needed a -/// pipeline: thirty-one of the forty-one link stages and draw. A new -/// backend following the old promise would have met thirty-one shader checks +/// pipeline: thirty-two of the forty-two link stages and draw. A new +/// backend following the old promise would have met thirty-two shader checks /// it could do nothing about, so the lists say which is which — [coreChecks] /// needs clears, uploads and readback alone, [shaderChecks] needs the bundle. /// The tiers answer "can this be asked yet", not "does this matter": the @@ -231,7 +231,7 @@ void runDeviceConformance({ /// **This list is why the two exist separately.** The library used to say it /// was shader-free as a whole, and it stopped being true the day the third /// check needed a pipeline — so a new backend, following the promise, would -/// have hit thirty-one shader checks it had no way to act on yet. Clears, +/// have hit thirty-two shader checks it had no way to act on yet. Clears, /// uploads and readback only: the answers here are the cheapest ones to get, /// and they are the ones worth having first. List get coreChecks => [ @@ -279,6 +279,10 @@ List get shaderChecks => [ run: checkReadbackOfRegion, ), (name: 'an instanced draw draws every instance', run: checkInstancedDraw), + ( + name: 'a window of the index buffer draws that window', + run: checkIndexWindowDraw, + ), ( name: 'a vertex stage can sample a texture', run: checkVertexTextureSampling, diff --git a/packages/flutter3d_conformance/lib/src/core_checks.dart b/packages/flutter3d_conformance/lib/src/core_checks.dart index c405f6ea3..5aa61ef5d 100644 --- a/packages/flutter3d_conformance/lib/src/core_checks.dart +++ b/packages/flutter3d_conformance/lib/src/core_checks.dart @@ -10,7 +10,7 @@ import '../flutter3d_conformance.dart'; /// **This list is why the two exist separately.** The library used to say it /// was shader-free as a whole, and it stopped being true the day the third /// check needed a pipeline — so a new backend, following the promise, would -/// have hit thirty-one shader checks it had no way to act on yet. Clears, +/// have hit thirty-two shader checks it had no way to act on yet. Clears, /// uploads and readback only: the answers here are the cheapest ones to get, /// and they are the ones worth having first. Future checkCapabilities(GraphicsDevice device) async { diff --git a/packages/flutter3d_conformance/lib/src/draw_checks.dart b/packages/flutter3d_conformance/lib/src/draw_checks.dart index 9199afe41..667c4d7a4 100644 --- a/packages/flutter3d_conformance/lib/src/draw_checks.dart +++ b/packages/flutter3d_conformance/lib/src/draw_checks.dart @@ -91,6 +91,158 @@ Future checkInstancedDraw(GraphicsDevice device) async { ); } +/// `draw(firstIndex:, indexCount:)` draws that window of the bound indices and +/// nothing else — `P7`. +/// +/// One index buffer holds two triangles, each covering the whole target, the +/// first red and the second green. A window over the second must read back +/// green and a window over the first red, and a window over both green, since +/// the second is drawn over the first. A backend that ignores the start reads +/// red where green was asked for; one that ignores the count reads green where +/// red was. +/// +/// **Four backends say where a window starts four different ways** — WebGPU +/// with a first index on the draw, WebGL2 with a byte offset, Impeller with a +/// narrower view of the buffer bound again, the software rasteriser by +/// counting — which is why it is asked of every device rather than of one. +/// +/// And a window past the end of the binding is refused with a [RangeError] +/// before anything reaches the driver, which left alone would answer four +/// different ways (`PassEncoder.draw` lists them). +Future checkIndexWindowDraw(GraphicsDevice device) async { + const size = 8; + final vertex = device.shaders['DebugLineVertex']; + final fragment = device.shaders['DebugLine']; + require( + vertex != null && fragment != null, + 'the debug-line stages are missing, so this cannot draw anything', + ); + final pipeline = device.createPipeline(vertex!, fragment!); + + final vertices = Float32List.fromList([ + -1, -1, 0.5, 1, 0, 0, 1, // + 3, -1, 0.5, 1, 0, 0, 1, + -1, 3, 0.5, 1, 0, 0, 1, + -1, -1, 0.5, 0, 1, 0, 1, + 3, -1, 0.5, 0, 1, 0, 1, + -1, 3, 0.5, 0, 1, 0, 1, + ]); + final indices = Uint16List.fromList([0, 1, 2, 3, 4, 5]); + + Future> colourOf({required int firstIndex, int? indexCount}) async { + final target = device.createTexture( + const RenderTargetSpec( + width: size, + height: size, + format: TextureFormat.r8g8b8a8UNormInt, + ), + ); + final pass = device.beginRenderPass( + RenderPassDescriptor( + colors: [ + ColorTarget( + texture: target, + loadAction: LoadAction.clear, + clearValue: Vector4.zero(), + ), + ], + ), + ); + pass + ..bindPipeline(pipeline) + ..setPrimitiveType(PrimitiveType.triangle) + ..setCullMode(CullMode.none) + ..bindUniformBlock(vertex, 'LineInfo', { + 'view_projection': Float32List.fromList(Matrix4.identity().storage), + }) + ..bindVertexData(ByteData.sublistView(vertices), 6) + ..bindIndexData(ByteData.sublistView(indices), IndexType.int16, 6) + ..draw(firstIndex: firstIndex, indexCount: indexCount) + ..submit(); + final pixels = await device.readPixels(target); + require(pixels != null, 'the target could not be read back'); + final bytes = pixels!.buffer.asUint8List(); + final at = ((size ~/ 2) * size + size ~/ 2) * 4; + return [bytes[at], bytes[at + 1], bytes[at + 2]]; + } + + bool near(List got, List want) => + got.indexed.every(((int, int) c) => (c.$2 - want[c.$1]).abs() <= 8); + + const red = [255, 0, 0]; + const green = [0, 255, 0]; + + // Mutation: drop `firstIndex +` from the `_indexAt` call in + // `CpuEncoder._drawOnce`. The second triangle is then read from the first + // three indices and this reads back red. + final second = await colourOf(firstIndex: 3, indexCount: 3); + require( + near(second, green), + 'a window of three indices from index 3 drew $second where the second ' + 'triangle\'s green was expected — the start of the window was ignored', + ); + + final first = await colourOf(firstIndex: 0, indexCount: 3); + require( + near(first, red), + 'a window of the first three indices drew $first where the first ' + 'triangle\'s red was expected — the count was ignored and both were drawn', + ); + + final both = await colourOf(firstIndex: 0); + require( + near(both, green), + 'a draw with no count drew $both where the second triangle, drawn last, ' + 'was expected — a missing count must read to the end of the binding', + ); + + // The refusal is asked in a pass of its own, which is submitted afterwards + // whatever happened, so a backend that does not refuse still leaves the + // device as the next check expects it. + final target = device.createTexture( + const RenderTargetSpec( + width: size, + height: size, + format: TextureFormat.r8g8b8a8UNormInt, + ), + ); + final pass = device.beginRenderPass( + RenderPassDescriptor( + colors: [ + ColorTarget( + texture: target, + loadAction: LoadAction.clear, + clearValue: Vector4.zero(), + ), + ], + ), + ); + pass + ..bindPipeline(pipeline) + ..setPrimitiveType(PrimitiveType.triangle) + ..setCullMode(CullMode.none) + ..bindUniformBlock(vertex, 'LineInfo', { + 'view_projection': Float32List.fromList(Matrix4.identity().storage), + }) + ..bindVertexData(ByteData.sublistView(vertices), 6) + ..bindIndexData(ByteData.sublistView(indices), IndexType.int16, 6); + final refused = () { + try { + pass.draw(firstIndex: 4, indexCount: 3); + return false; + } on RangeError { + return true; + } + }(); + pass.submit(); + require( + refused, + 'a window of three indices from index 4 of a binding of six was not ' + 'refused. It reads past the end, and every backend has to say so the same ' + 'way rather than leave it to a driver', + ); +} + /// A buffer uploaded for a use is bound for that use, and draws. /// /// **This check used to assert nothing.** It uploaded sixty-four zero bytes diff --git a/packages/flutter3d_conformance/lib/src/shader_link_checks.dart b/packages/flutter3d_conformance/lib/src/shader_link_checks.dart index f9ffd75b2..084fa6d61 100644 --- a/packages/flutter3d_conformance/lib/src/shader_link_checks.dart +++ b/packages/flutter3d_conformance/lib/src/shader_link_checks.dart @@ -81,6 +81,9 @@ Future checkLinking(GraphicsDevice device) async { // plain ones do not, so they are a different link. (vertex, 'ShadowDepthMasked'), (vertex, 'ShadowDistanceMasked'), + // `ShadowSettings.translucentCasters`: a see-through mesh into the + // sun's atlas, reading the normal the others ignore. + (vertex, 'ShadowTransmittance'), ], // The picking pass draws every mesh again through the stage its layout // needs — plain, skinned or instanced; a lightmapped mesh has the plain @@ -89,12 +92,23 @@ Future checkLinking(GraphicsDevice device) async { // // The x-ray stage reaches the same three, for the same reason. It declares // one output where the others declare two, which is the pairing least like - // the rest of this table and the one most worth linking here. + // the rest of this table and the one most worth linking here. `P4`'s + // planar reflection is drawn over its surfaces the same way, and declares + // one output for the same reason. for (final vertex in [ 'MeshVertex', 'MeshSkinnedVertex', 'MeshInstancedVertex', - ]) ...<(String, String)>[(vertex, 'ObjectId'), (vertex, 'Xray')], + ]) ...<(String, String)>[ + (vertex, 'ObjectId'), + (vertex, 'Xray'), + (vertex, 'PlanarReflection'), + ], + // `ShadowSettings.caustics`: a refracting caster's faces into its maps, + // through the static stage, and its photons, whose vertex stage reads + // three textures and draws one quad per instance. + ('MeshVertex', 'CausticSurface'), + ('CausticPhotonVertex', 'CausticPhoton'), // `R1`: the three stages a moved node is drawn through into the velocity // buffer, each with the one fragment stage that differences them. // `R4`: and with the stage that marks a blended surface reactive, which @@ -115,6 +129,8 @@ Future checkLinking(GraphicsDevice device) async { // `C3`: the surface buffer reduced for the occlusion readback. 'DepthPyramid', 'ProbePrefilter', + // `P4`: a render texture's light into the bytes a material reads. + 'RenderTextureEncode', 'BloomThreshold', 'BloomDownsample', 'BloomUpsample', @@ -145,6 +161,8 @@ Future checkLinking(GraphicsDevice device) async { 'VelocityNeighborMax', 'MotionBlur', 'ViewportShade', + // `P3`: the decals, over the surface and albedo buffers. + 'Decal', 'MrtProbe', // `H5`: the field kernel the conformance suite steps `FieldPass` with. 'FieldDecay', @@ -199,6 +217,8 @@ Future checkLinking(GraphicsDevice device) async { // and nothing ran them. ('SkyVertex', 'Sky'), ('SkyCubeVertex', 'SkyCube'), + // `P5`: the air's pair, whose seven varyings are new on both sides. + ('SkyPhysicalVertex', 'SkyPhysical'), ]; for (final (vertexName, fragmentName) in pairs) { diff --git a/packages/flutter3d_core/CHANGELOG.md b/packages/flutter3d_core/CHANGELOG.md index 5f48b852f..bf41743e5 100644 --- a/packages/flutter3d_core/CHANGELOG.md +++ b/packages/flutter3d_core/CHANGELOG.md @@ -1,3 +1,301 @@ +## Unreleased + +- **Nothing in the browser's build imports `dart:io` or `dart:isolate`.** + Decoding and encoding models and transcoding KTX2 off the main isolate go + through `src/engine/platform/background.dart`: `Isolate.run` natively, + in place in the browser. The model decoder's sibling-file ports are in + `model_isolate.dart`. `FileAssetSource` and `fileUriResolver` read + through `platform/files.dart`, which is `dart:io` natively and refuses + with a reason in the browser. Each is chosen by `if + (dart.library.js_interop)` at compile time, as pub.dev reads it. Native + behaviour is unchanged: the same `FileSystemException`, the same + isolates. `isMissingFile` tells a missing file on either. The pubspec + declares all six platforms, and a structure rule now holds every package + that says it runs on the web to imports the browser can have. + +- **Instance attributes.** `InstancedMeshNode.setInstanceData` (and `data` + on `addInstance` and `acquire`, `InstanceHandle.setData`) gives each copy + of a batch four numbers of the game's own, after its colour in a record + now twenty floats long. The instanced vertex stage hands them on as + `v_instance`, the other mesh stages as nought, and a material written in + the language reads them as `instance` — a colour, a team, a phase only the + game knows. The emitted stage declares the varying only when it reads it. + `material-instance-data` is in all four golden sets. + +- **Lighting hooks in the material language.** A `light { … }` block runs + once per light inside the engine's light loop and returns how the surface + answers it — a toon ramp, a BRDF of the author's — which the engine + multiplies by the light's radiance, `n·l` and shadow, as it does + `ShadeLight` of every lit model. It reads `lightDir`, `halfDir`, `nDotL`, + `nDotH` and `vDotH`; the fragment body reads `lit`, the surface lit + through the block with the ambient, the lightmap and the emissive added + as Lambert adds them. A material with a block binds as a lit model — the + maps, the shadows, the light list — and the parser refuses a block the + fragment body never reads, since the compiled shader would drop it and + everything bound for it. `evaluateMaterialLight` runs the block on the + software backend. `material-light-hook` is in all four golden sets. + +- **`Renderer.addMaterials` and `removeMaterials`**: more than one bundle of + materials, added after the renderer was made — `P8`. Each `.f3dmat` the + build hook compiles is a bundle of its own. Added later is consulted + earlier, so a bundle naming a stage another has replaces it; what the + renderer resolved by name is forgotten and linked again at the next draw. + +- **Caustics.** `ShadowSettings.caustics` (with `translucentCasters`, off by + default) follows the sun's light through every caster with a volume — a + transmission and a thickness. Each is drawn from the sun into two small + maps of its own, near faces and far faces; one photon per texel is bent in + and out by Snell's law, dimmed by Fresnel and by its volume's absorption + over the path, followed to the first opaque surface below, and added into + the atlas there, sized by where its neighbours land so that light is + neither made nor lost. The caster itself stops the light, so what lands + under it is what its photons bring: the bright focal line and dark rim a + glass of water throws, in the colour of what is in it. Thin-walled casters + keep the shading they had. Static meshes only; `causticPhotons` sets the + grid. +- **A see-through caster can paint what it lets through.** Its base colour + map multiplies the transmittance, and neither is held to one, so a card + marked `ShadowCastingMode.shadowsOnly` can carry a picture of where light + went: darker where it was turned away, brighter where a lens gathered it. + `MeshNode.receivesTranslucentShadows` (true by default) lets a surface that + stands in front of a translucent caster, such as a label on a glass, opt + out of being shaded by it. +- **See-through casters shade the sun by what they let through.** + `ShadowSettings.translucentCasters`, off by default. A blended or + transmissive material is drawn into the sun's atlas after the opaque + casters, into the three channels that held nothing, as how much of red, + green and blue it lets through: its opacity, its transmission tinted by its + colour and by what its volume leaves after its thickness + (`attenuationColor`, `attenuationDistance`), and the Fresnel loss of its + index at that angle. A transmitting material's alpha is read as its look, + not as holes in it. Clear glass casts + a faint shadow with darker edges, a coloured liquid a shadow of its colour, + and layers combine. A see-through surface that casts is not shaded by it, + and it does nothing under the `evsm` filter. Off, every frame is drawn byte for byte as + before. +- **A material in the language can declare a `uniform`**, a member of + `MaterialParams` that a game sets in `Material.parameters` on every draw, + where a `param` is still a constant folded into its variant. The emitter + writes the block, `describeMaterial` reports it and its defaults, and + `BundledMaterials.parameters` hands a material those defaults. + +- **`Material.parameters` is a map of the material's own**, empty and open + to additions. It was a shared constant, so a material made without + parameters could not be given any. + +- **`BundledMaterials`** reads the materials a bundle carries and builds + each one's `LightingModel` from its source — `P8`. The emitted stage keeps + no point-shadow block, and `describeMaterial` asks for the base colour map + always, since `ReadSurface` samples it: a material that named no texture + was drawn with the map unbound, black on Impeller and refused on WebGL2. + +- **`Material.alphaToCoverage`** antialiases a masked surface's edge by the + multisample resolve rather than cutting it at the threshold, on WebGL2 and + WebGPU. Elsewhere the hard cutoff is drawn and + `FrameResult.alphaToCoverageDeclined` says so. Off by default. + +- **A masked surface writes an opaque alpha once it has survived its cut.** + It wrote the texture's alpha into the frame, and anything reading the + frame as premultiplied brightened every leaf towards its rim. + +- **`MeshNode.drawOrder`** puts nodes that share a material in an order: + it adds to the material's `drawBucket`, outranks every other sort term in + both halves of the list, and is never merged across by the batching. Nought + by default. + +- **An orthographic camera no longer reads its eye as a point the light + travels to.** Highlights, Fresnel and reflections are measured against + the view axis rather than from the eye's position, so they stop sliding + across the frame as an orthographic camera pans; fog thickens with depth + from the eye's plane rather than in rings round it; and the sky is seen + through a sixty-degree lens turned as the camera is, rather than as one + colour. `isOrthographic` tells a view-projection matrix's kind. Light + shafts march from the eye's plane, as the volumetric fog already did. + `orthographic-metal` is in all four golden sets. + +- **Shadow cascades through an orthographic camera cover what it shows.** + The near cascades were spheres sized by distance from the eye, which + suits a perspective view that widens as it goes; an orthographic one is + as wide at every depth, so they covered the air in front of the camera + and every shadow fell to the cascade fitted to the whole level. They are + now slabs along the view axis, as wide as the frame and spanning the + depth the casters take up inside it, and the shader picks one by depth + along the axis, so stepping the camera back along it changes nothing. + `orthographic-shadows` is in all four golden sets. + +- **Splats through an orthographic camera fog by depth.** Both splat stages + measured the fog from the eye's position, so a splat off the axis came out + foggier than one on it; they now read the view axis and the lens from + `FogInfo`, as the lit stages do, through `contributor_eye.glsl`, which the + particle stages share. `orthographic-particles` — billboards, splats and + mesh particles, five columns alike — is in all four golden sets. + +- **An orthographic near plane can stand behind the camera.** + `CameraNode.readViewOrigin` is where the rays begin — the camera's + position, or its near plane where that is behind it — and the renderer + measures depths, the fog's air and the shadow cascades from it. What + stood between that plane and the camera used to have a depth of nought + or less, which the surface buffer reads as sky, and took no fog. + +- **Splats through an orthographic camera are sorted by depth.** Distance + from wherever the camera was put along its axis put a splat off to the + side behind one it covers; `SplatSorter.sort` takes an `axis`, and + `SplatQuads` sorts along the lens's own through an orthographic one, + re-sorting when it turns rather than when it moves. + +- **`MeshOverlay.lookThrough(camera, width, height)`** works the overlay's + pixel out from the camera's projection, which answers for every kind of + lens. The example worked it out from a field of view and sized every + handle through an orthographic camera as if seen in perspective. + +- **A material channel in place of the light, over all or part of the + frame.** `RenderSettings.debugView` takes a `DebugViewSettings`: a + `DebugView` — albedo, the shading normal, roughness, metalness, + occlusion, emission, the UV, or magenta where the light came out NaN or + infinite — and a `split`, the share of the width left lit, so one draw + wipes between the light and the channel. The lit models write the channel + themselves, so it shows what the maps did; the composite leaves that side + out of the exposure, the curve and the grade. Off by default and an exact + no-op; `debug-view-split` is in all four golden sets. + +- **`FrameResult.targetBytes`** says what the targets a frame drew into or + read from hold, each texture once, without a capture asked for. + `textureBytes` is how one is counted: its base level, every slice and + sample. + +- **The lens: distortion, flare, and tables from a grading tool.** + `LookSettings.distortion` bends the frame radially, barrel above nought + and pincushion below, held on its border, and everything laid over the + scene bends with it while the vignette and the grain stay put. + `BloomSettings.lensFlare` throws a bright light's ghosts and halo across + the middle of the frame, drawn from the glow so only what blooms flares. + `CubeLut` reads a `.cube` file, 3D or 1D with its domain, into the strip + `LookSettings.lut` grades through, and `upload` puts it on a device. +- **SMAA 1x beside FXAA.** `AntiAliasSettings(method: EdgeSmoothing.smaa)` + smooths the finished picture in three passes: the luma steps marked, the + line behind each staircase rebuilt from where its run ends and which side + of each end a crossing edge stands on, and each pixel blended by the area + that line covers of it. Against an 8×8 supersample a tilted edge comes out + at about a quarter of its hard error. FXAA stays the default; a bundle + without the three stages falls back to it. Orthogonal edges only: no + diagonal search and no corner rounding. The area table is the engine's + `smaaArea`, written by `tool/make_tables.dart` and shared with the + software backend byte for byte. +- **Projected box decals.** A `DecalNode` is a box, its node's unit cube, + that paints a picture onto whatever geometry stands inside it: a texture + (or none) times an sRGB tint with an opacity, an optional emission, a + region of an atlas, an `order` between overlapping decals, and an angle + limit past which a surface turned away from the box's up is left alone. + `RenderSettings.decals` switches them on and is off by default. The pass + reads the point under each pixel back out of the surface buffer, since a + depth attachment cannot be sampled, and lays the decal's colour under the + light the surface was lit by, read back through the albedo buffer, so a + decal in a shadow is in the shadow. It runs between the opaque half of the + scene and the transparent one, which it splits the frame for, so glass in + front of a decal is drawn over it. It needs three colour attachments and + turns multisampling off, as every reader of the surface buffer does. + +- **Planar reflections — `P4`.** A `PlanarReflectorNode` is a plane (its + node's origin, its local +Y the side it is seen from) and the meshes that + lie in it. Each frame, for each view that sees the plane's front, the + scene is drawn again through the view's camera mirrored in the plane, with + the projection's near plane moved onto the plane (`obliqueNearPlane`), so + nothing below it is reflected up through it. The picture, at `resolution` + of the view (half by default), is laid over the surfaces in the scene pass + straight after the opaque half, through the new `PlanarReflection` stage: + read by the pixel's place on screen, weighted by Schlick's Fresnel from + `reflectance` (one for a mirror, about 0.02 for water), tinted and fogged. + The surfaces keep their own materials and the surface buffer keeps + describing them. `RenderSettings.planarReflections` is off by default; a + frame without a visible reflector culls the pass either way. +- **A public camera into a texture — `P4`.** `RenderTexture.create` makes a + texture for a camera to draw into; `Scene.addRenderTexture` has it drawn + every frame (or once, and again after `invalidate`) before the scene, so + a material shows the picture in the frame it was taken. The pass is the + reflector's with an ordinary camera: meshes with the frame's lights, + shadows and sky, and no post chain. The texture holds sRGB bytes with the + top of the picture in its first row on every backend, as an uploaded image + does, so it goes into an albedo or emissive slot as one. +- `mirrorAcrossPlane`, `obliqueNearPlane` and `planeInEyeSpace` are public + in `mirror_view.dart`, and two passes join `RenderSettings.passOrder` + before `scene`: `render textures` and `planar reflections`. +- **A physical sky, with stars, and fog that lies on the ground.** + `SkySettings.physical` takes a `PhysicalSky`: the air's molecular and haze + scattering, their scale heights, the planet, the sunlight entering it, the + ground's albedo and the stars. With it set the sky is sunlight scattered + once along each view ray, so its colours come from where `directionToSun` + puts the sun — blue overhead at noon, red towards a setting sun, dark away + from it — and the disc is drawn white through the air in front of it, so it + reddens by itself. Stars come out as the sun goes down. `SkySettings.sample` + answers from the same model without the stars, so `EnvironmentMap.fromSky` + and the renderer's hemispheric ambient follow the sun too, and + `PhysicalSky.sunlight` is the colour a directional light standing for the + sun should have. A cube map still wins. Null, the default, draws the + gradient as before. +- **Height fog.** `FogSettings.heightFalloff` and `baseHeight` thin the fog + upwards by the law `VolumetricFogSettings` marches, integrated along the ray + in closed form. A falloff of nought, the default, is the flat fog to the + bit. `FogSettings.densityAt` and `copyWith` are new; `Atmosphere` carries + `fogHeightFalloff` and `fogBaseHeight` and blends them with the rest. + Particles and splats fog as a flat fog as thick as the air at the camera. + +- **A photo of any size.** `capturePhoto` draws a picture in tiles on the + game's own renderer and hands it out a row of tiles at a time, so memory + holds one row however large the picture. Each tile is drawn with a margin + of picture round it through the new `CropProjection`, which takes the + frame's aspect itself rather than the tile's, and the margin is cropped: + with 32 pixels a bloom's seams drop from 53 steps to a few. Exposure is held + at what the screen showed; temporal anti-aliasing, motion blur, render scale + and chromatic aberration are set aside, and the report says which. + `PhotoFilter` is eight looks composed onto the game's own `LookSettings`; + `PhotoFinish` puts the vignette and the grain back over the whole frame + rather than once per tile. + +- **A captured frame can say which draws it made.** + `Renderer.captureNextFrame(draws: true)` writes every mesh and shadow-caster + draw into a `DrawJournal` — pass, node, material, counts, pipeline state and + the values bound — and `FrameCapture.draws` hands them back, with + `undetailedDraws` counting the full-screen draws a pass made without a mesh + to name. Off in every other frame, where a draw site pays one null check. + `readFloats` puts a backend's own floats beside each output's bytes, which + the software backend's `readHdrPixels` can answer, and each `CapturedPass` + now carries its time, draws and triangles. + +- **An animation graph decides which clip plays, in the fixed step.** + `AnimationStateMachine` holds states over named clips and transitions + between them, each with conditions on typed parameters, a crossfade + duration, a priority and an optional exit time; `AnimationGraph` runs it, + and `evaluate(dt)` advances by the simulation's step and returns a `Pose`. + Parameters are float, integer, boolean or trigger, declared in an + `AnimationParameterSchema`, and a write of the wrong type or to a name the + schema lacks is refused with a `ParameterWrite` saying what to call + instead. A trigger stays set until a transition uses it, so an attack + pressed during a swing lands when the swing allows. A definition that + cannot run lists its problems through `problems(clips)` rather than + building a graph whose transitions are never taken. Until now the choice + of clip was left to each game, written against `AnimationPlayer` by hand. + +- **`Pose.blendFrom` crossfades whole poses the way the player does.** + The player's shortest-arc slerp is now one shared function, + `shortestArcSlerp`, used by both, so a graph and a player fading between + the same two clips turn each joint the same way. `Pose.restCopy` gives a + second pose over the same rest for the outgoing clip. + +- **A reload reaches the contributors too.** `Renderer.relinkShaders` asks + every `PassContributor` to drop what it linked, through the new + `PassContributor.relinkShaders`, which does nothing by default. + `SplatContributor` and `MeshOverlay` drop their pipelines; particles, whose + contributors live in `flutter3d_particles`, do the same there. Before this a + reloaded shader reached the scene's own pipelines and not the splats, the + debug lines or the particles. + +- **`EnvironmentMap.fromEncoded` builds an environment from a panorama + file's bytes.** A Radiance `.hdr` is told apart by its header and read + here; anything else goes through the `ImageDecoder` given. Then it is + `fromPanorama` as before. `EnvironmentMap.hdrToRgba8` is the clamp to + eight bits that `flutter3d_model_core`'s `panoramaPixels` used to keep to + itself, so both read a `.hdr` the same way. + ## 0.8.3+1 **Resolves on Flutter 3.44 and Dart 3.12.0.** The constraints asked for Dart diff --git a/packages/flutter3d_core/lib/flutter3d_core.dart b/packages/flutter3d_core/lib/flutter3d_core.dart index e9e29a026..3136164dd 100644 --- a/packages/flutter3d_core/lib/flutter3d_core.dart +++ b/packages/flutter3d_core/lib/flutter3d_core.dart @@ -48,6 +48,11 @@ export 'src/engine/animation/animation.dart'; export 'src/engine/animation/animation_player.dart'; export 'src/engine/animation/animation_target.dart'; export 'src/engine/animation/baked_poses.dart'; +// The animation graph, N1: typed parameters, a state machine over clips, and +// the fixed-step evaluation that turns them into a `Pose`. +export 'src/engine/animation/graph/animation_graph.dart'; +export 'src/engine/animation/graph/animation_parameters.dart'; +export 'src/engine/animation/graph/animation_state_machine.dart'; export 'src/engine/animation/inverse_kinematics.dart'; export 'src/engine/animation/pose.dart'; export 'src/engine/animation/skin_blend.dart'; @@ -74,11 +79,13 @@ export 'src/engine/assets/texture_upload.dart'; // graph is. export 'src/engine/geometry/device_mesh.dart'; // Rendering. +export 'src/engine/platform/files.dart' show isMissingFile; export 'src/engine/render/adaptive_quality.dart'; export 'src/engine/render/adaptive_scale.dart'; export 'src/engine/render/cluster_draws.dart'; export 'src/engine/render/debug_draw.dart'; export 'src/engine/render/debug_draw_gizmos.dart'; +export 'src/engine/render/draw_journal.dart'; export 'src/engine/render/empty_frame.dart'; export 'src/engine/render/environment_map.dart'; export 'src/engine/render/field_pass.dart'; @@ -92,13 +99,17 @@ export 'src/engine/render/fullscreen_effect.dart'; export 'src/engine/render/key_sort.dart'; export 'src/engine/render/material.dart'; export 'src/engine/render/mesh_overlay.dart'; +export 'src/engine/render/mirror_view.dart'; export 'src/engine/render/pass_contribution.dart'; export 'src/engine/render/pass_contributor.dart'; +export 'src/engine/render/photo_capture.dart'; +export 'src/engine/render/physical_sky.dart' show PhysicalSky; export 'src/engine/render/probe_faces.dart'; export 'src/engine/render/procedural_texture.dart'; export 'src/engine/render/quality_table.dart'; export 'src/engine/render/render_list.dart'; export 'src/engine/render/render_node.dart'; +export 'src/engine/render/render_texture.dart'; export 'src/engine/render/render_view.dart'; export 'src/engine/render/renderer.dart'; export 'src/engine/render/shadow_slots.dart'; @@ -111,6 +122,7 @@ export 'src/engine/render/view_model_node.dart'; export 'src/engine/scene/atmosphere.dart'; export 'src/engine/scene/bvh.dart'; export 'src/engine/scene/camera_node.dart'; +export 'src/engine/scene/decal_node.dart'; export 'src/engine/scene/free_look.dart'; export 'src/engine/scene/impostor_node.dart'; export 'src/engine/scene/instanced_mesh_node.dart'; @@ -127,6 +139,7 @@ export 'src/engine/scene/occlusion/occlusion_buffer.dart'; export 'src/engine/scene/occlusion/occlusion_test.dart'; export 'src/engine/scene/occlusion/software_occlusion.dart'; export 'src/engine/scene/orbit_controller.dart'; +export 'src/engine/scene/planar_reflector_node.dart'; export 'src/engine/scene/posed_mesh.dart'; export 'src/engine/scene/projection.dart'; export 'src/engine/scene/raycaster.dart'; diff --git a/packages/flutter3d_core/lib/formats.dart b/packages/flutter3d_core/lib/formats.dart index bf1da19fc..d05686eeb 100644 --- a/packages/flutter3d_core/lib/formats.dart +++ b/packages/flutter3d_core/lib/formats.dart @@ -28,6 +28,7 @@ export 'src/formats/animation/animation_mask.dart'; export 'src/formats/animation/animation_track.dart'; export 'src/formats/asset_resolver.dart'; export 'src/formats/asset_source.dart'; +export 'src/formats/cube_lut.dart'; export 'src/formats/document_compare.dart'; export 'src/formats/draco/draco.dart'; export 'src/formats/export_report.dart'; @@ -68,6 +69,7 @@ export 'src/formats/lighting_model.dart'; export 'src/formats/material_document.dart'; export 'src/formats/material_hint.dart'; export 'src/formats/material_language/material_ast.dart'; +export 'src/formats/material_language/material_bundle.dart'; export 'src/formats/material_language/material_eval.dart'; export 'src/formats/material_language/material_glsl.dart'; export 'src/formats/material_language/material_parser.dart'; diff --git a/packages/flutter3d_core/lib/geometry.dart b/packages/flutter3d_core/lib/geometry.dart index 7a985d68b..d06b8d5af 100644 --- a/packages/flutter3d_core/lib/geometry.dart +++ b/packages/flutter3d_core/lib/geometry.dart @@ -18,6 +18,7 @@ export 'src/geometry/box_shapes.dart'; export 'src/geometry/cell_grid.dart'; export 'src/geometry/intersections.dart'; export 'src/geometry/lathe_shape.dart'; +export 'src/geometry/liquid_shapes.dart'; export 'src/geometry/mesh_builder.dart'; export 'src/geometry/mesh_clusters.dart'; export 'src/geometry/mesh_data.dart'; diff --git a/packages/flutter3d_core/lib/src/engine/animation/animation_layer.dart b/packages/flutter3d_core/lib/src/engine/animation/animation_layer.dart index 35f7d25f4..1e583478b 100644 --- a/packages/flutter3d_core/lib/src/engine/animation/animation_layer.dart +++ b/packages/flutter3d_core/lib/src/engine/animation/animation_layer.dart @@ -1,4 +1,8 @@ +import 'dart:math' as math; + import 'package:flutter3d_core/formats.dart'; +import 'package:vector_math/vector_math.dart'; + import 'animation_target.dart'; /// Where a track sits in a pose: one number for a node and a path together. @@ -242,3 +246,41 @@ final class AnimationLayer { return (time: next, reversing: turned, stopped: false); } + +/// Shortest-arc interpolation between two rotations, [t] of the way from +/// [from] to [to]. +/// +/// **One copy, for the same reason [advanceTime] is one.** It was the +/// player's own, and the animation graph's crossfade needs exactly it: two +/// copies would be two chances for a graph and a player fading between the +/// same clips to turn a joint a different way. +/// +/// The sign flip is the part that matters: a quaternion and its negation are +/// the same rotation, so without choosing the closer of the two, half of all +/// blends spin the long way. +Quaternion shortestArcSlerp(Quaternion from, Quaternion to, double t) { + final raw = from.x * to.x + from.y * to.y + from.z * to.z + from.w * to.w; + final sign = raw < 0.0 ? -1.0 : 1.0; + final dot = raw * sign; + + final (scaleFrom, scaleTo) = dot > 0.9995 + // Nearly identical: the arc is so short that a straight line is closer + // than the trigonometry's own error. + ? (1.0 - t, t) + : _arcScales(math.acos(dot), t); + + return Quaternion( + scaleFrom * from.x + scaleTo * sign * to.x, + scaleFrom * from.y + scaleTo * sign * to.y, + scaleFrom * from.z + scaleTo * sign * to.z, + scaleFrom * from.w + scaleTo * sign * to.w, + )..normalize(); +} + +(double, double) _arcScales(double theta, double t) { + final sinTheta = math.sin(theta); + return ( + math.sin((1.0 - t) * theta) / sinTheta, + math.sin(t * theta) / sinTheta, + ); +} diff --git a/packages/flutter3d_core/lib/src/engine/animation/animation_player.dart b/packages/flutter3d_core/lib/src/engine/animation/animation_player.dart index d3af52fdb..3c35b2eaa 100644 --- a/packages/flutter3d_core/lib/src/engine/animation/animation_player.dart +++ b/packages/flutter3d_core/lib/src/engine/animation/animation_player.dart @@ -241,41 +241,6 @@ final class AnimationPlayer { static double _mix(double from, double to, double t) => from + (to - from) * t; - /// Shortest-arc interpolation between two rotations. - /// - /// The sign flip is the part that matters: a quaternion and its negation are - /// the same rotation, so without choosing the closer of the two, half of all - /// blends spin the long way. - static Quaternion _slerp(Quaternion from, Quaternion to, double t) { - var dot = from.x * to.x + from.y * to.y + from.z * to.z + from.w * to.w; - var sign = 1.0; - if (dot < 0.0) { - dot = -dot; - sign = -1.0; - } - - double scaleFrom; - double scaleTo; - if (dot > 0.9995) { - // Nearly identical: the arc is so short that a straight line is closer - // than the trigonometry's own error. - scaleFrom = 1.0 - t; - scaleTo = t; - } else { - final theta = math.acos(dot); - final sinTheta = math.sin(theta); - scaleFrom = math.sin((1.0 - t) * theta) / sinTheta; - scaleTo = math.sin(t * theta) / sinTheta; - } - - return Quaternion( - scaleFrom * from.x + scaleTo * sign * to.x, - scaleFrom * from.y + scaleTo * sign * to.y, - scaleFrom * from.z + scaleTo * sign * to.z, - scaleFrom * from.w + scaleTo * sign * to.w, - )..normalize(); - } - /// [q] scaled by [t]: a quarter of a turn, not a quarter of four numbers. /// /// The slerp from no rotation, written out — the identity end collapses most @@ -285,8 +250,8 @@ final class AnimationPlayer { /// nowhere else — which is why the fixture for it uses a quarter. static Quaternion _scaledRotation(Quaternion q, double t) { // A quaternion and its negation are the same rotation, and the one with a - // positive w is the short way round — the same choice [_slerp] makes, and - // for the same reason. + // positive w is the short way round — the same choice [shortestArcSlerp] + // makes, and for the same reason. final sign = q.w < 0.0 ? -1.0 : 1.0; final w = sign * q.w; if (w > 0.9995) { @@ -790,7 +755,7 @@ final class AnimationPlayer { // Slerp, not a component lerp: blending quaternions linearly and // renormalising takes the long way round whenever the two are more than // a quarter turn apart, which is exactly what a hurt reaction is. - final mixed = _slerp(_fadeQuaternion, _quaternion, weight); + final mixed = shortestArcSlerp(_fadeQuaternion, _quaternion, weight); into[0] = mixed.x; into[1] = mixed.y; into[2] = mixed.z; diff --git a/packages/flutter3d_core/lib/src/engine/animation/graph/animation_graph.dart b/packages/flutter3d_core/lib/src/engine/animation/graph/animation_graph.dart new file mode 100644 index 000000000..48476f7d9 --- /dev/null +++ b/packages/flutter3d_core/lib/src/engine/animation/graph/animation_graph.dart @@ -0,0 +1,265 @@ +import 'package:flutter3d_core/formats.dart'; + +import '../animation_layer.dart'; +import '../pose.dart'; +import 'animation_parameters.dart'; +import 'animation_state_machine.dart'; + +/// Runs an [AnimationStateMachine] over a set of clips, one fixed step at a +/// time, into a [Pose] — N1's core. +/// +/// **Advanced by the step, not by the frame.** [evaluate] is handed the +/// simulation's own `stepSeconds`, the way everything else a run steps is, so +/// the same parameters written on the same steps take the same transitions on +/// every machine and on every replay. Which state the graph is in, how far +/// into it and how far through a fade are sums and quotients of step lengths +/// and nothing else; the pose it hands back passes through the same slerp a +/// clip's own keys do, which is the frame's arithmetic and not part of what a +/// run has to agree on. +/// +/// **One transition a step, and none during a crossfade.** A fade that could +/// be interrupted would need the half-blended pose frozen as the new source, +/// which is a second kind of state for a snapshot to carry; until a graph +/// needs that, a trigger set during a fade waits for it to finish, which is +/// what [AnimationParameterType.trigger]'s buffering is for. +/// +/// **The crossfade is the player's.** The outgoing state keeps playing while +/// the incoming one comes in, and the two are mixed by [Pose.blendFrom], which +/// is `AnimationPlayer`'s own blend on whole poses. Whole poses is the one +/// difference: a joint the outgoing clip does not animate is faded from its +/// rest, where the player, which fades track by track, has nothing to fade +/// it from and cuts. +final class AnimationGraph { + /// Builds a graph over [machine], playing [clips] into [pose]. + /// + /// Throws an [ArgumentError] listing [AnimationStateMachine.problems] when + /// there are any: a definition that cannot run is a bug in whoever built + /// it, and a tool that wants to refuse politely asks `problems` first. + factory AnimationGraph({ + required AnimationStateMachine machine, + required List clips, + required Pose pose, + }) { + final problems = machine.problems(clips); + if (problems.isNotEmpty) { + throw ArgumentError( + 'This animation graph cannot run:\n- ${problems.join('\n- ')}', + ); + } + final byName = { + for (final clip in clips.reversed) ?clip.name: clip, + }; + final outgoing = >[ + for (final state in machine.states) _outgoingFrom(machine, state.name), + ]; + return AnimationGraph._( + machine, + AnimationParameters(machine.parameters), + [for (final state in machine.states) byName[state.clip]!], + outgoing, + pose, + ); + } + + AnimationGraph._( + this.machine, + this.parameters, + this._clips, + this._outgoing, + this.pose, + ) : _from = pose.restCopy(), + _current = machine.indexOfState(machine.entry) { + _sample(); + } + + final AnimationStateMachine machine; + + /// The values the transitions test; written by the game between steps. + final AnimationParameters parameters; + + /// What [evaluate] writes into and returns. + final Pose pose; + + /// The clip each state plays, index-aligned with the machine's states. + final List _clips; + + /// Each state's way out, highest priority first. + final List> _outgoing; + + /// Where the outgoing state is sampled during a crossfade. + final Pose _from; + + int _current; + _Playhead _head = _Playhead.start; + + /// The state being faded out of, or -1 when there is no crossfade. + int _previous = -1; + _Playhead _previousHead = _Playhead.start; + double _fadeElapsed = 0.0; + double _fadeDuration = 0.0; + + /// The state the graph is in — during a crossfade, the one it is entering. + String get state => machine.states[_current].name; + + /// The state being faded out of, or null. + String? get fadingFrom => + _previous < 0 ? null : machine.states[_previous].name; + + /// How much of [state] the pose holds: 1 unless a crossfade is under way. + double get fadeWeight => _previous < 0 ? 1.0 : _fadeElapsed / _fadeDuration; + + /// The playhead in [state]'s clip, in seconds. + double get stateTime => _head.time; + + /// How far into [state] the graph is, in lengths of its clip — what an exit + /// time is compared against. + double get normalizedTime => _normalized(_current, _head); + + /// Advances by [dt] seconds, takes at most one transition, and returns + /// [pose] holding the result. + /// + /// A [dt] that is not a finite positive number advances nothing, the way + /// `FixedStep.advance` treats a clock that went backwards; transitions are + /// still tested, so a parameter written between steps is seen on the next + /// one whatever the clock did. + Pose evaluate(double dt) { + final step = dt.isFinite && dt > 0.0 ? dt : 0.0; + _head = _advance(_current, _head, step); + if (_previous >= 0) { + _previousHead = _advance(_previous, _previousHead, step); + _fadeElapsed += step; + if (_fadeElapsed >= _fadeDuration) _previous = -1; + } + if (_previous < 0) _takeTransition(); + _sample(); + return pose; + } + + void _takeTransition() { + final normalized = _normalized(_current, _head); + for (final transition in _outgoing[_current]) { + if (transition.exitTime case final exit? when normalized < exit) { + continue; + } + if (!transition.checks.every(_holds)) continue; + for (final check in transition.checks) { + if (check.condition is TriggerCondition) { + parameters.consumeTriggerAt(check.parameter); + } + } + if (transition.duration > 0.0) { + _previous = _current; + _previousHead = _head; + _fadeElapsed = 0.0; + _fadeDuration = transition.duration; + } + _current = transition.to; + _head = _Playhead.start; + return; + } + } + + bool _holds(_Check check) { + final actual = parameters.valueAt(check.parameter); + return switch (check.condition) { + final CompareCondition c => c.holds(actual), + BoolCondition(:final value) => (actual != 0.0) == value, + TriggerCondition() => actual != 0.0, + }; + } + + _Playhead _advance(int state, _Playhead head, double step) { + final length = _clips[state].duration; + final delta = step * machine.states[state].speed; + if (length <= 0.0 || delta == 0.0) { + return _Playhead(head.time, head.elapsed + delta, head.reversing); + } + final moved = advanceTime( + time: head.time, + deltaSeconds: delta, + length: length, + wrap: machine.states[state].wrap, + reversing: head.reversing, + ); + return _Playhead(moved.time, head.elapsed + delta, moved.reversing); + } + + /// A clip with no length is a single pose, done as soon as it is entered. + double _normalized(int state, _Playhead head) { + final length = _clips[state].duration; + return length <= 0.0 ? 1.0 : head.elapsed / length; + } + + void _sample() { + pose.sampleClip(_clips[_current], _head.time); + if (_previous < 0) return; + _from.sampleClip(_clips[_previous], _previousHead.time); + pose.blendFrom(_from, fadeWeight); + } + + static List<_Transition> _outgoingFrom( + AnimationStateMachine machine, + String from, + ) { + final declared = <(int, AnimationTransition)>[ + for (final (i, t) in machine.transitions.indexed) + if (t.from == from) (i, t), + ]; + // `List.sort` is not stable, so declaration order is made part of the key + // rather than hoped for. + declared.sort( + (a, b) => switch (b.$2.priority.compareTo(a.$2.priority)) { + 0 => a.$1.compareTo(b.$1), + final order => order, + }, + ); + return <_Transition>[ + for (final (_, t) in declared) + _Transition( + to: machine.indexOfState(t.to), + duration: t.duration, + exitTime: t.exitTime, + checks: <_Check>[ + for (final c in t.conditions) + _Check(machine.parameters.indexOf(c.parameter), c), + ], + ), + ]; + } +} + +/// Where a state's clip is, and how long the graph has been in the state. +/// +/// [elapsed] is not wrapped: an exit time counts from entering the state, so +/// a loop that has gone round must not read as having just begun. +final class _Playhead { + const _Playhead(this.time, this.elapsed, this.reversing); + + static const _Playhead start = _Playhead(0.0, 0.0, false); + + final double time; + final double elapsed; + final bool reversing; +} + +/// A transition with its names resolved to indices. +final class _Transition { + const _Transition({ + required this.to, + required this.duration, + required this.exitTime, + required this.checks, + }); + + final int to; + final double duration; + final double? exitTime; + final List<_Check> checks; +} + +final class _Check { + const _Check(this.parameter, this.condition); + + final int parameter; + final AnimationCondition condition; +} diff --git a/packages/flutter3d_core/lib/src/engine/animation/graph/animation_parameters.dart b/packages/flutter3d_core/lib/src/engine/animation/graph/animation_parameters.dart new file mode 100644 index 000000000..5d353b8e4 --- /dev/null +++ b/packages/flutter3d_core/lib/src/engine/animation/graph/animation_parameters.dart @@ -0,0 +1,210 @@ +import 'dart:typed_data'; + +/// What kind of value an animation graph parameter holds — N1. +/// +/// **A class with four constants rather than an enum**, so that a fifth kind +/// — a vector for a 2D blend space is the likely one — is an addition rather +/// than a break in every `switch` somebody wrote against the four. +final class AnimationParameterType { + const AnimationParameterType._(this.name, this.writer); + + final String name; + + /// The [AnimationParameters] call that writes this kind, named in a + /// refusal so the caller is told what to call instead. + final String writer; + + /// A real number: a speed, a lean, an aim angle. + static const AnimationParameterType float = AnimationParameterType._( + 'float', + 'setFloat', + ); + + /// A whole number: a weapon slot, a combo stage. + static const AnimationParameterType integer = AnimationParameterType._( + 'integer', + 'setInteger', + ); + + /// On or off, and stays where it is put: grounded, crouching, armed. + static const AnimationParameterType boolean = AnimationParameterType._( + 'boolean', + 'setBool', + ); + + /// On until a transition uses it, then off: attack, jump, hit. + /// + /// **Kept until it is consumed, not cleared at the end of the step.** An + /// attack pressed in the middle of a swing whose transition waits for an + /// exit time is the buffered input a player expects to see land; a trigger + /// that lasted one step would be dropped on the floor. A caller who wants + /// the opposite calls [AnimationParameters.resetTrigger]. + static const AnimationParameterType trigger = AnimationParameterType._( + 'trigger', + 'fire', + ); + + @override + String toString() => name; +} + +/// One named parameter in a graph's schema, with the value it starts at. +final class AnimationParameter { + const AnimationParameter.float(this.name, {this.initial = 0.0}) + : type = AnimationParameterType.float; + + const AnimationParameter.integer(this.name, {int initial = 0}) + : type = AnimationParameterType.integer, + initial = initial + 0.0; + + const AnimationParameter.boolean(this.name, {bool initial = false}) + : type = AnimationParameterType.boolean, + initial = initial ? 1.0 : 0.0; + + const AnimationParameter.trigger(this.name) + : type = AnimationParameterType.trigger, + initial = 0.0; + + final String name; + final AnimationParameterType type; + + /// The starting value as stored: see [AnimationParameters.values]. + final double initial; +} + +/// The parameters a graph has, by name and type — the only ones a write may +/// name. +/// +/// **A schema rather than a map that grows on first write.** A misspelt +/// `"grounded"` written into a map that accepts anything is a parameter +/// nobody reads, and the character never lands; written against a schema it +/// is a refusal naming the parameters there are. +final class AnimationParameterSchema { + AnimationParameterSchema(List parameters) + : parameters = List.unmodifiable(parameters), + _index = { + // Reversed so the first of two same-named parameters wins; the + // duplicate itself is one of `AnimationStateMachine.problems`. + for (final (i, p) in parameters.indexed.toList().reversed) p.name: i, + }; + + final List parameters; + final Map _index; + + /// Where [name] sits in [parameters], or -1. + int indexOf(String name) => _index[name] ?? -1; + + /// The parameter called [name], or null. + AnimationParameter? operator [](String name) => switch (_index[name]) { + final i? => parameters[i], + null => null, + }; +} + +/// What a parameter write did: written, or refused and why. +/// +/// **A refusal is returned, not thrown.** A game writing a parameter every +/// step and an agent writing one over MCP both want to know what went wrong, +/// and neither wants its step unwound because a name was misspelt. +final class ParameterWrite { + const ParameterWrite._(this.refusal); + + static const ParameterWrite done = ParameterWrite._(null); + + /// What was asked, why it could not be, and what to do instead; null when + /// the value was written. + final String? refusal; + + bool get written => refusal == null; + + @override + String toString() => refusal ?? 'written'; +} + +/// The current values of a graph's parameters, held to its schema. +/// +/// **Every value is a double in one typed list**, booleans and triggers as +/// 0 and 1 and integers exactly. That keeps the state a graph carries to one +/// array a snapshot can copy and a digest can hash, with the type kept where +/// it is checked — at the write — rather than in the storage. +final class AnimationParameters { + AnimationParameters(this.schema) + : _values = Float64List.fromList([ + for (final p in schema.parameters) p.initial, + ]); + + final AnimationParameterSchema schema; + final Float64List _values; + + /// Every value, index-aligned with [AnimationParameterSchema.parameters]. + List get values => _values.asUnmodifiableView(); + + /// The value at [index], for a caller that resolved the name once. + double valueAt(int index) => _values[index]; + + ParameterWrite setFloat(String name, double value) => value.isFinite + ? _write(name, AnimationParameterType.float, 'setFloat', value) + : ParameterWrite._( + 'Cannot set `$name` to $value: a parameter holds a finite number, ' + 'and every comparison against NaN or infinity answers the same ' + 'thing whatever the threshold. Clamp it first.', + ); + + ParameterWrite setInteger(String name, int value) => _write( + name, + AnimationParameterType.integer, + 'setInteger', + value.toDouble(), + ); + + ParameterWrite setBool(String name, bool value) => _write( + name, + AnimationParameterType.boolean, + 'setBool', + value ? 1.0 : 0.0, + ); + + /// Sets the trigger [name]; it stays set until a transition consumes it. + ParameterWrite fire(String name) => + _write(name, AnimationParameterType.trigger, 'fire', 1.0); + + /// Clears the trigger [name] without anything having used it. + ParameterWrite resetTrigger(String name) => + _write(name, AnimationParameterType.trigger, 'resetTrigger', 0.0); + + /// Clears the trigger at [index] — what a transition does to the triggers + /// it fired on. + void consumeTriggerAt(int index) => _values[index] = 0.0; + + /// Every parameter back to its schema's initial value. + void reset() { + for (final (i, p) in schema.parameters.indexed) { + _values[i] = p.initial; + } + } + + ParameterWrite _write( + String name, + AnimationParameterType type, + String call, + double value, + ) { + final index = schema.indexOf(name); + if (index < 0) { + final known = schema.parameters.map((p) => '`${p.name}`').join(', '); + return ParameterWrite._( + 'No parameter `$name` in this graph. ' + '${known.isEmpty ? 'It has none.' : 'It has $known.'}', + ); + } + final actual = schema.parameters[index].type; + if (actual != type) { + return ParameterWrite._( + '`$name` is a ${actual.name} parameter, and $call writes a ' + '${type.name}. Call ${actual.writer} instead.', + ); + } + _values[index] = value; + return ParameterWrite.done; + } +} diff --git a/packages/flutter3d_core/lib/src/engine/animation/graph/animation_state_machine.dart b/packages/flutter3d_core/lib/src/engine/animation/graph/animation_state_machine.dart new file mode 100644 index 000000000..2af421196 --- /dev/null +++ b/packages/flutter3d_core/lib/src/engine/animation/graph/animation_state_machine.dart @@ -0,0 +1,281 @@ +import 'package:flutter3d_core/formats.dart'; + +import '../animation_target.dart'; +import 'animation_parameters.dart'; + +/// How a [CompareCondition] measures a number parameter against its value. +/// +/// **Constants of a class rather than an enum**, for the reason +/// [AnimationParameterType] is: "at least" and "at most" are the obvious +/// next two, and adding them should not break anybody's `switch`. +final class AnimationComparison { + const AnimationComparison._(this.name, this._test, {this.exact = false}); + + final String name; + final bool Function(double actual, num value) _test; + + /// Whether this asks for exact equality — which a float parameter reached + /// by arithmetic almost never meets, and so is refused on one. + final bool exact; + + static const AnimationComparison greater = AnimationComparison._( + 'greater', + _greater, + ); + static const AnimationComparison less = AnimationComparison._('less', _less); + static const AnimationComparison equals = AnimationComparison._( + 'equals', + _equals, + exact: true, + ); + static const AnimationComparison notEquals = AnimationComparison._( + 'notEquals', + _notEquals, + exact: true, + ); + + bool test(double actual, num value) => _test(actual, value); + + static bool _greater(double actual, num value) => actual > value; + static bool _less(double actual, num value) => actual < value; + static bool _equals(double actual, num value) => actual == value; + static bool _notEquals(double actual, num value) => actual != value; + + @override + String toString() => name; +} + +/// One test a transition makes against the graph's parameters. +/// +/// **Named by parameter, resolved once.** A condition is data — the shape a +/// `.f3d` will store and an MCP tool will build — so it names its parameter; +/// `AnimationGraph` resolves the name to an index when it is built, and a +/// name the schema lacks is one of [AnimationStateMachine.problems] rather +/// than a condition that is silently never true. +sealed class AnimationCondition { + const AnimationCondition(this.parameter); + + final String parameter; +} + +/// A float or integer parameter against a number. +final class CompareCondition extends AnimationCondition { + const CompareCondition(super.parameter, this.comparison, this.value); + + final AnimationComparison comparison; + final num value; + + bool holds(double actual) => comparison.test(actual, value); +} + +/// A boolean parameter being [value]. +final class BoolCondition extends AnimationCondition { + const BoolCondition(super.parameter, {this.value = true}); + + final bool value; +} + +/// A trigger parameter being set — and consumed when the transition fires. +final class TriggerCondition extends AnimationCondition { + const TriggerCondition(super.parameter); +} + +/// A state: which clip plays while the graph is in it, and how. +final class AnimationState { + const AnimationState({ + required this.name, + required this.clip, + this.speed = 1.0, + this.wrap = AnimationWrap.loop, + }); + + final String name; + + /// The [AnimationClip.name] this state plays. + /// + /// A name rather than an index into the clip list, because the list is + /// whatever order a file's exporter wrote; a re-export that sorts the clips + /// must not move a character from idling to dying. + final String clip; + + /// Playback rate, zero or more. Zero holds the first frame. + final double speed; + + final AnimationWrap wrap; +} + +/// A way out of one state into another. +/// +/// **Fires when every condition holds and the exit time, if any, has +/// passed.** The conditions are a conjunction; an "or" is two transitions, +/// which is also how it reads in a graph editor. +final class AnimationTransition { + AnimationTransition({ + required this.from, + required this.to, + List conditions = const [], + this.duration = 0.0, + this.priority = 0, + this.exitTime, + }) : conditions = List.unmodifiable(conditions); + + /// The state this transition leaves, by name. + final String from; + + /// The state it enters, by name. + final String to; + + final List conditions; + + /// The crossfade, in seconds; zero cuts. + final double duration; + + /// Which of several transitions that could fire on the same step does. + /// + /// Higher first, and declaration order between equals — so the answer to + /// "hit and jump on the same step" is written down rather than left to + /// whichever was added first by accident. + final int priority; + + /// How far through [from]'s clip the graph must be, as a fraction of the + /// clip's length, before this transition may fire; null for no such wait. + /// + /// **Counted from entering the state, not from the last loop**: 0.8 on a + /// looping clip is true from 80% of the first cycle on. 1.0 on a `once` + /// clip is "when it has finished". Combined with conditions as an "and", + /// so an attack that may be cancelled only late in its swing is an exit + /// time and a trigger on one transition. + final double? exitTime; +} + +/// The states, transitions and parameters of one animation graph — what a +/// file stores and an editor edits. `AnimationGraph` is what runs it. +/// +/// **The definition is checked as a whole, and asked rather than trusted.** +/// [problems] lists everything wrong with it at once, in words a person or +/// an agent can act on; `AnimationGraph` refuses to be built over a +/// definition that has any. +final class AnimationStateMachine { + AnimationStateMachine({ + required this.parameters, + required List states, + required List transitions, + String? entry, + }) : states = List.unmodifiable(states), + transitions = List.unmodifiable(transitions), + entry = entry ?? (states.isEmpty ? '' : states.first.name); + + final AnimationParameterSchema parameters; + final List states; + final List transitions; + + /// The state the graph starts in: the first one unless named. + final String entry; + + /// Where the state called [name] sits in [states], or -1. + int indexOfState(String name) => states.indexWhere((s) => s.name == name); + + /// Everything that would stop this definition running over [clips]; empty + /// when it can. + List problems(List clips) { + final clipNames = {for (final clip in clips) ?clip.name}; + final stateNames = {for (final s in states) s.name}; + String? duplicate(Iterable names, String what) { + final seen = {}; + final twice = names.where((n) => !seen.add(n)).toSet(); + return twice.isEmpty + ? null + : 'More than one $what is called ${_listed(twice, '')}; names are ' + 'how everything else refers to them, so each must be unique.'; + } + + return [ + ?duplicate(parameters.parameters.map((p) => p.name), 'parameter'), + ?duplicate(states.map((s) => s.name), 'state'), + if (states.isEmpty) + 'There are no states; a graph has to be in one. Add at least one.', + if (states.isNotEmpty && !stateNames.contains(entry)) + 'The entry state `$entry` is not one of the states.', + for (final s in states) ...[ + ?_if( + !clipNames.contains(s.clip), + 'State `${s.name}` plays clip `${s.clip}`, which is not among ' + '${_listed(clipNames, 'the clips (none is named)')}.', + ), + ?_if( + !s.speed.isFinite || s.speed < 0.0, + 'State `${s.name}` has speed ${s.speed}; it must be finite and not ' + 'negative, or its exit times could never be reached.', + ), + ], + for (final t in transitions) ..._transitionProblems(t, stateNames), + ]; + } + + /// [message] when [failed]; a collection `if` would do, but would hold a + /// message split over lines as adjacent strings in the list itself. + static String? _if(bool failed, String message) => failed ? message : null; + + static String _listed(Iterable names, String whenNone) => + names.isEmpty ? whenNone : names.map((n) => '`$n`').join(', '); + + List _transitionProblems( + AnimationTransition t, + Set stateNames, + ) { + final label = '`${t.from}` → `${t.to}`'; + return [ + if (!stateNames.contains(t.from)) + 'Transition $label leaves `${t.from}`, which is not a state.', + if (!stateNames.contains(t.to)) + 'Transition $label enters `${t.to}`, which is not a state.', + ?_if( + !t.duration.isFinite || t.duration < 0.0, + 'Transition $label fades over ${t.duration} s; use zero to cut or a ' + 'positive number of seconds.', + ), + if (t.exitTime case final exit? when !exit.isFinite || exit < 0.0) + 'Transition $label has exit time $exit; it must be finite, from 0 up.', + ?_if( + t.conditions.isEmpty && t.exitTime == null, + 'Transition $label has neither a condition nor an exit time, so it ' + 'would fire on the first step in `${t.from}` and the state would ' + 'never play. Give it one or the other.', + ), + for (final c in t.conditions) ?_conditionProblem(c, label), + ]; + } + + String? _conditionProblem(AnimationCondition condition, String label) { + final parameter = parameters[condition.parameter]; + if (parameter == null) { + return 'Transition $label tests `${condition.parameter}`, which is not ' + 'a parameter.'; + } + final type = parameter.type; + final wrong = 'Transition $label tests ${type.name} `${parameter.name}`'; + final instead = 'Use a ${_conditionFor(type)}.'; + final isNumber = + type == AnimationParameterType.float || + type == AnimationParameterType.integer; + return switch (condition) { + CompareCondition(:final comparison) + when comparison.exact && type == AnimationParameterType.float => + '$wrong for exact equality, which a float reached by arithmetic ' + 'almost never meets. Compare with greater or less.', + CompareCondition() when !isNumber => '$wrong against a number. $instead', + BoolCondition() when type != AnimationParameterType.boolean => + '$wrong as a boolean. $instead', + TriggerCondition() when type != AnimationParameterType.trigger => + '$wrong as a trigger. $instead', + _ => null, + }; + } + + static String _conditionFor(AnimationParameterType type) => + type == AnimationParameterType.boolean + ? 'BoolCondition' + : type == AnimationParameterType.trigger + ? 'TriggerCondition' + : 'CompareCondition'; +} diff --git a/packages/flutter3d_core/lib/src/engine/animation/pose.dart b/packages/flutter3d_core/lib/src/engine/animation/pose.dart index bb6d03b6e..12f2daa65 100644 --- a/packages/flutter3d_core/lib/src/engine/animation/pose.dart +++ b/packages/flutter3d_core/lib/src/engine/animation/pose.dart @@ -4,6 +4,7 @@ import 'package:flutter3d_core/formats.dart'; import 'package:vector_math/vector_math.dart'; import '../scene/skeleton.dart'; +import 'animation_layer.dart'; import 'animation_target.dart'; /// A flat, scene-graph-free pose: one hierarchy's local TRS, sampled from a @@ -124,6 +125,63 @@ final class Pose { scales.setAll(0, _restScales); } + /// A second pose over the same hierarchy and rest, at rest. + /// + /// What a crossfade needs: somewhere to sample the clip being left while + /// this pose holds the one being entered. The rest arrays are shared, not + /// copied — nothing writes to them after construction. + Pose restCopy() => Pose( + parents: parents, + restTranslations: _restTranslations, + restRotations: _restRotations, + restScales: _restScales, + ); + + /// Mixes [from] into this pose by [weight]: 0 leaves [from], 1 leaves this + /// pose as it is. + /// + /// **The player's crossfade, on whole poses.** Rotation goes through + /// [shortestArcSlerp] and translation and scale through a straight line — + /// `AnimationPlayer`'s own `_blendInto`, with the same argument order, so a + /// graph fading between two clips and a player fading between the same two + /// agree joint for joint. + void blendFrom(Pose from, double weight) { + if (from.nodeCount != nodeCount) { + throw ArgumentError( + 'Cannot blend a pose of ${from.nodeCount} nodes into one of ' + '$nodeCount.', + ); + } + for (var i = 0; i < translations.length; i++) { + final t = from.translations[i]; + final s = from.scales[i]; + translations[i] = t + (translations[i] - t) * weight; + scales[i] = s + (scales[i] - s) * weight; + } + for (var node = 0; node < nodeCount; node++) { + final at = node * 4; + final mixed = shortestArcSlerp( + Quaternion( + from.rotations[at], + from.rotations[at + 1], + from.rotations[at + 2], + from.rotations[at + 3], + ), + Quaternion( + rotations[at], + rotations[at + 1], + rotations[at + 2], + rotations[at + 3], + ), + weight, + ); + rotations[at] = mixed.x; + rotations[at + 1] = mixed.y; + rotations[at + 2] = mixed.z; + rotations[at + 3] = mixed.w; + } + } + final Float32List _sample3 = Float32List(3); final Float32List _sample4 = Float32List(4); diff --git a/packages/flutter3d_core/lib/src/engine/assets/asset_source.dart b/packages/flutter3d_core/lib/src/engine/assets/asset_source.dart index 2cd58549a..0e3aaa200 100644 --- a/packages/flutter3d_core/lib/src/engine/assets/asset_source.dart +++ b/packages/flutter3d_core/lib/src/engine/assets/asset_source.dart @@ -1,8 +1,9 @@ -import 'dart:io'; import 'dart:typed_data'; import 'package:flutter3d_core/formats.dart'; +import '../platform/files.dart'; + // `AssetSource` itself is `flutter3d_formats`, along with the decoders that // read through one. What is left here is the one place this package can read // from without the Flutter SDK — `dart:io` — and no more: a bundle-backed @@ -22,22 +23,21 @@ final class FileAssetSource extends AssetSource { String get key => 'file:$path'; @override - Future read() => File(path).readAsBytes(); + Future read() => readFileBytes(path); @override AssetUriResolver get resolveUri { - final directory = File(path).parent.path; + final directory = parentDirectoryOf(path); return (request) async { final uri = request.uri; if (uri.startsWith('data:')) return decodeDataUri(uri); final relative = safeRelativeAssetPath(uri); - final file = File('$directory/$relative'); - // Synchronous: see the note in `gltf_resolvers.dart`. This runs on an - // isolate of its own, so there is nothing here for it to block. - if (!file.existsSync()) { - throw FileSystemException('Referenced file not found', file.path); - } - return file.readAsBytes(); + // This runs on an isolate of its own, so the synchronous check + // `readReferencedFile` makes has nothing here to block. + return readReferencedFile( + '$directory/$relative', + 'Referenced file not found', + ); }; } } diff --git a/packages/flutter3d_core/lib/src/engine/assets/gltf_resolvers.dart b/packages/flutter3d_core/lib/src/engine/assets/gltf_resolvers.dart index c4e485172..85ca3e3df 100644 --- a/packages/flutter3d_core/lib/src/engine/assets/gltf_resolvers.dart +++ b/packages/flutter3d_core/lib/src/engine/assets/gltf_resolvers.dart @@ -1,7 +1,7 @@ -import 'dart:io'; - import 'package:flutter3d_core/formats.dart'; +import '../platform/files.dart'; + /// Resolves asset URIs relative to a directory on disk. /// /// Separated from the parser because `dart:io` is unavailable on web, and @@ -19,14 +19,10 @@ AssetUriResolver fileUriResolver(String baseDirectory) { ); } - final file = File('$baseDirectory/$relative'); - // Synchronous, because an async `exists` is a round trip through the - // event loop to answer a question the filesystem answers immediately — - // and this one is asked once per referenced buffer and image. - if (!file.existsSync()) { - throw FileSystemException('glTF resource not found', file.path); - } - return file.readAsBytes(); + return readReferencedFile( + '$baseDirectory/$relative', + 'glTF resource not found', + ); }; } diff --git a/packages/flutter3d_core/lib/src/engine/assets/model_isolate.dart b/packages/flutter3d_core/lib/src/engine/assets/model_isolate.dart new file mode 100644 index 000000000..871c6a44a --- /dev/null +++ b/packages/flutter3d_core/lib/src/engine/assets/model_isolate.dart @@ -0,0 +1,8 @@ +/// Decoding a model on an isolate, natively, with the ports that read its +/// sibling files back on the caller's — and in the browser, which has no +/// isolates, in place. Chosen at compile time so the browser's build imports +/// no `dart:isolate`: see `platform/background.dart`. +library; + +export 'model_isolate_io.dart' + if (dart.library.js_interop) 'model_isolate_web.dart'; diff --git a/packages/flutter3d_core/lib/src/engine/assets/model_isolate_io.dart b/packages/flutter3d_core/lib/src/engine/assets/model_isolate_io.dart new file mode 100644 index 000000000..efee58cd1 --- /dev/null +++ b/packages/flutter3d_core/lib/src/engine/assets/model_isolate_io.dart @@ -0,0 +1,63 @@ +import 'dart:developer' as developer; +import 'dart:isolate'; +import 'dart:typed_data'; + +import 'package:flutter3d_core/formats.dart'; + +/// [request] decoded on an isolate of its own, its sibling files read here +/// and sent over a port. +Future decodeOnIsolate( + ModelLoadRequest request, + developer.TimelineTask task, +) async { + task.instant('read'); + final primary = await request.source.read(); + final resolve = request.source.resolveUri; + + final requests = ReceivePort(); + final subscription = requests.listen((Object? message) async { + final envelope = message! as List; + final uri = envelope[0]! as String; + final reply = envelope[1]! as SendPort; + try { + reply.send(await resolve(AssetRequest(uri))); + } catch (error) { + // Errors cannot be thrown across a port, so they travel as a value and are + // rethrown on the far side. + reply.send(['error', error.toString()]); + } + }); + + final servicePort = requests.sendPort; + try { + task.instant('decode'); + return await Isolate.run( + () => decodeModelBytes(request, primary, _portResolver(servicePort)), + ); + } finally { + await subscription.cancel(); + requests.close(); + } +} + +/// A resolver that asks the spawning isolate to read a file. +AssetUriResolver _portResolver(SendPort servicePort) { + return (request) async { + // **The string, not the request.** What goes over the port is read by an + // isolate that expects a path, and `send` takes `Object?` — so handing it + // the whole object compiles, says nothing, and fails at the other end. + final uri = request.uri; + final reply = ReceivePort(); + try { + servicePort.send([uri, reply.sendPort]); + final response = await reply.first; + if (response is Uint8List) return response; + if (response is List && response.length == 2 && response[0] == 'error') { + throw StateError('Could not read "$uri": ${response[1]}'); + } + throw StateError('Unexpected reply reading "$uri": $response'); + } finally { + reply.close(); + } + }; +} diff --git a/packages/flutter3d_core/lib/src/engine/assets/model_isolate_web.dart b/packages/flutter3d_core/lib/src/engine/assets/model_isolate_web.dart new file mode 100644 index 000000000..a937dc8b2 --- /dev/null +++ b/packages/flutter3d_core/lib/src/engine/assets/model_isolate_web.dart @@ -0,0 +1,10 @@ +import 'dart:developer' as developer; + +import 'package:flutter3d_core/formats.dart'; + +/// [request] decoded in place: the browser has no isolates, and +/// `decodeModelInIsolate` does not ask for one there. +Future decodeOnIsolate( + ModelLoadRequest request, + developer.TimelineTask task, +) => decodeModel(request); diff --git a/packages/flutter3d_core/lib/src/engine/assets/model_loader.dart b/packages/flutter3d_core/lib/src/engine/assets/model_loader.dart index e8512e01f..3dff02f85 100644 --- a/packages/flutter3d_core/lib/src/engine/assets/model_loader.dart +++ b/packages/flutter3d_core/lib/src/engine/assets/model_loader.dart @@ -1,9 +1,9 @@ import 'dart:developer' as developer; -import 'dart:isolate'; -import 'dart:typed_data'; import 'package:flutter3d_core/formats.dart'; +import 'model_isolate.dart'; + export 'package:flutter3d_core/formats.dart' show ModelDecoder, @@ -68,64 +68,8 @@ Future decodeModelInIsolate(ModelLoadRequest request) async { arguments: {'source': request.source.key}, ); try { - return await _decodeModelInIsolate(request, task); + return await decodeOnIsolate(request, task); } finally { task.finish(); } } - -Future _decodeModelInIsolate( - ModelLoadRequest request, - developer.TimelineTask task, -) async { - task.instant('read'); - final primary = await request.source.read(); - final resolve = request.source.resolveUri; - - final requests = ReceivePort(); - final subscription = requests.listen((Object? message) async { - final envelope = message! as List; - final uri = envelope[0]! as String; - final reply = envelope[1]! as SendPort; - try { - reply.send(await resolve(AssetRequest(uri))); - } catch (error) { - // Errors cannot be thrown across a port, so they travel as a value and are - // rethrown on the far side. - reply.send(['error', error.toString()]); - } - }); - - final servicePort = requests.sendPort; - try { - task.instant('decode'); - return await Isolate.run( - () => decodeModelBytes(request, primary, _portResolver(servicePort)), - ); - } finally { - await subscription.cancel(); - requests.close(); - } -} - -/// A resolver that asks the spawning isolate to read a file. -AssetUriResolver _portResolver(SendPort servicePort) { - return (request) async { - // **The string, not the request.** What goes over the port is read by an - // isolate that expects a path, and `send` takes `Object?` — so handing it - // the whole object compiles, says nothing, and fails at the other end. - final uri = request.uri; - final reply = ReceivePort(); - try { - servicePort.send([uri, reply.sendPort]); - final response = await reply.first; - if (response is Uint8List) return response; - if (response is List && response.length == 2 && response[0] == 'error') { - throw StateError('Could not read "$uri": ${response[1]}'); - } - throw StateError('Unexpected reply reading "$uri": $response'); - } finally { - reply.close(); - } - }; -} diff --git a/packages/flutter3d_core/lib/src/engine/assets/model_writer.dart b/packages/flutter3d_core/lib/src/engine/assets/model_writer.dart index 7900b7d03..997b4253b 100644 --- a/packages/flutter3d_core/lib/src/engine/assets/model_writer.dart +++ b/packages/flutter3d_core/lib/src/engine/assets/model_writer.dart @@ -1,9 +1,10 @@ import 'dart:developer' as developer; -import 'dart:isolate'; import 'dart:typed_data'; import 'package:flutter3d_core/formats.dart'; +import '../platform/background.dart'; + /// Whether this build has no isolates in it — see `model_loader.dart`'s own /// copy of this constant for why it replaces `kIsWeb` here (mcp-03n). const bool _isWeb = bool.fromEnvironment('dart.library.js_interop'); @@ -95,7 +96,7 @@ Future encodeModelInIsolate(ModelWriteRequest request) async { arguments: {'format': request.format.name}, ); try { - return await Isolate.run(() => encodeModel(request)); + return await runInBackground(() => encodeModel(request)); } finally { task.finish(); } diff --git a/packages/flutter3d_core/lib/src/engine/assets/texture_upload.dart b/packages/flutter3d_core/lib/src/engine/assets/texture_upload.dart index 26b90141c..a4ae657b9 100644 --- a/packages/flutter3d_core/lib/src/engine/assets/texture_upload.dart +++ b/packages/flutter3d_core/lib/src/engine/assets/texture_upload.dart @@ -1,4 +1,3 @@ -import 'dart:isolate'; import 'dart:typed_data'; // `Ktx2Texture` hidden: this package's own thin wrapper of the same name, @@ -7,6 +6,7 @@ import 'dart:typed_data'; import 'package:flutter3d_core/formats.dart' hide Ktx2Texture; import 'package:flutter3d_hardware/flutter3d_hardware.dart'; +import '../platform/background.dart'; import 'image_decoder.dart'; import 'ktx2/ktx2.dart'; @@ -197,7 +197,7 @@ Future _uploadKtx2( texture = _isWeb || !isBasisUniversalKtx2(encoded) ? Ktx2Texture.parse(encoded, universalTarget: universalTarget) - : await Isolate.run( + : await runInBackground( () => Ktx2Texture.parse(encoded, universalTarget: universalTarget), ); } on Ktx2FormatException catch (error) { diff --git a/packages/flutter3d_core/lib/src/engine/platform/background.dart b/packages/flutter3d_core/lib/src/engine/platform/background.dart new file mode 100644 index 000000000..ae922cf29 --- /dev/null +++ b/packages/flutter3d_core/lib/src/engine/platform/background.dart @@ -0,0 +1,10 @@ +/// Work run off the calling isolate where there are isolates, and in place +/// in the browser, which has none. +/// +/// Chosen at compile time rather than by a constant beside an `Isolate.run`, +/// so that the browser's build imports no `dart:isolate` at all: pub.dev reads +/// an unconditional import of it, stub or not, as "not for the web", and +/// listed the whole engine without the web while it ran there. +library; + +export 'background_io.dart' if (dart.library.js_interop) 'background_web.dart'; diff --git a/packages/flutter3d_core/lib/src/engine/platform/background_io.dart b/packages/flutter3d_core/lib/src/engine/platform/background_io.dart new file mode 100644 index 000000000..3dfa2fe9f --- /dev/null +++ b/packages/flutter3d_core/lib/src/engine/platform/background_io.dart @@ -0,0 +1,5 @@ +import 'dart:async'; +import 'dart:isolate'; + +/// [work] on an isolate of its own, as `Isolate.run`. +Future runInBackground(FutureOr Function() work) => Isolate.run(work); diff --git a/packages/flutter3d_core/lib/src/engine/platform/background_web.dart b/packages/flutter3d_core/lib/src/engine/platform/background_web.dart new file mode 100644 index 000000000..b0a5608d5 --- /dev/null +++ b/packages/flutter3d_core/lib/src/engine/platform/background_web.dart @@ -0,0 +1,4 @@ +import 'dart:async'; + +/// [work] in place: the browser has no isolates. +Future runInBackground(FutureOr Function() work) async => work(); diff --git a/packages/flutter3d_core/lib/src/engine/platform/files.dart b/packages/flutter3d_core/lib/src/engine/platform/files.dart new file mode 100644 index 000000000..80083eca7 --- /dev/null +++ b/packages/flutter3d_core/lib/src/engine/platform/files.dart @@ -0,0 +1,6 @@ +/// The file system, where there is one: `dart:io` natively, and in the +/// browser a refusal that says why — chosen at compile time, so the browser's +/// build imports no `dart:io`, which pub.dev reads as "not for the web". +library; + +export 'files_io.dart' if (dart.library.js_interop) 'files_web.dart'; diff --git a/packages/flutter3d_core/lib/src/engine/platform/files_io.dart b/packages/flutter3d_core/lib/src/engine/platform/files_io.dart new file mode 100644 index 000000000..22909f439 --- /dev/null +++ b/packages/flutter3d_core/lib/src/engine/platform/files_io.dart @@ -0,0 +1,21 @@ +import 'dart:io'; +import 'dart:typed_data'; + +/// The bytes of the file at [path]. +Future readFileBytes(String path) => File(path).readAsBytes(); + +/// The directory [path] is in. +String parentDirectoryOf(String path) => File(path).parent.path; + +/// The bytes of the file at [path], or a [FileSystemException] saying +/// [message] when there is none. Asked synchronously: an async `exists` is a +/// round trip through the event loop for an answer the file system has at +/// once, and this is asked once per buffer and image a model references. +Future readReferencedFile(String path, String message) { + final file = File(path); + if (!file.existsSync()) throw FileSystemException(message, path); + return file.readAsBytes(); +} + +/// Whether [error] is a file that was not there. +bool isMissingFile(Object error) => error is FileSystemException; diff --git a/packages/flutter3d_core/lib/src/engine/platform/files_web.dart b/packages/flutter3d_core/lib/src/engine/platform/files_web.dart new file mode 100644 index 000000000..1f8f3bf61 --- /dev/null +++ b/packages/flutter3d_core/lib/src/engine/platform/files_web.dart @@ -0,0 +1,21 @@ +import 'dart:typed_data'; + +Never _noFiles(String path) => + throw UnsupportedError('there is no file system in the browser: $path'); + +/// The bytes of the file at [path]: never, in the browser. +Future readFileBytes(String path) => _noFiles(path); + +/// The directory [path] is in, by its last slash. +String parentDirectoryOf(String path) { + final slash = path.lastIndexOf('/'); + return slash < 0 ? '.' : path.substring(0, slash); +} + +/// The bytes of the file at [path]: never, in the browser. +Future readReferencedFile(String path, String message) => + _noFiles(path); + +/// Whether [error] is a file that was not there: never one in the browser, +/// where there are no files to miss. +bool isMissingFile(Object error) => false; diff --git a/packages/flutter3d_core/lib/src/engine/render/contributor_registry.dart b/packages/flutter3d_core/lib/src/engine/render/contributor_registry.dart index abb97ee56..f53e5ec28 100644 --- a/packages/flutter3d_core/lib/src/engine/render/contributor_registry.dart +++ b/packages/flutter3d_core/lib/src/engine/render/contributor_registry.dart @@ -49,6 +49,15 @@ abstract base class PassContributor { /// particles, splats, anything without a depth of its own — redraws it here /// through [ReactiveFrame.spriteStage] or a stage of its own. void encodeReactive(ReactiveFrame frame) {} + + /// Drops every pipeline this contributor has linked, so its next frame + /// links them again from whatever the stages are now. + /// + /// `Renderer.relinkShaders` calls it on each contributor after dropping its + /// own: a hot reload swaps the code behind a stage and keeps the handle, + /// and a pipeline built from the old code would go on drawing it. Nothing + /// by default, for a contributor that links nothing of its own. + void relinkShaders() {} } /// The contributors a renderer draws, and the order it draws them in. diff --git a/packages/flutter3d_core/lib/src/engine/render/draw_journal.dart b/packages/flutter3d_core/lib/src/engine/render/draw_journal.dart new file mode 100644 index 000000000..2e6a11a30 --- /dev/null +++ b/packages/flutter3d_core/lib/src/engine/render/draw_journal.dart @@ -0,0 +1,157 @@ +/// Every draw of one frame, written down as it was encoded — `P12`. +/// +/// **Why a journal beside the counters, and why it is off.** `gfx-01n` gave +/// each pass its draw count, and the count says a pass drew forty things +/// without saying which forty. A tool asking "is the crate drawn at all, and +/// with which material" needs the list, and the list costs an allocation per +/// draw — exactly the cost the counters were designed to avoid. So the +/// journal exists for the one frame somebody asked about +/// (`Renderer.captureNextFrame(draws: true)`) and is null in every other one; +/// each site writes through `state.journal?.add(...)`, whose arguments Dart +/// does not evaluate when the journal is null, so an ordinary frame pays one +/// null check per draw and builds nothing. +/// +/// **Not every draw is described.** The mesh encoder and the shadow casters +/// know a node and a material and record them; a full-screen triangle in a +/// post pass knows neither, and inventing a "mesh" for it would make the list +/// look more complete than it is. Those are counted per pass in +/// [DrawJournal.undetailed] instead — the pass's own draw count minus what it +/// described — so the total still adds up to what the pass reported. +library; + +import 'dart:typed_data'; + +/// One draw, as the encoder saw it. +final class DrawRecord { + const DrawRecord({ + required this.index, + required this.passIndex, + required this.pass, + required this.kind, + required this.mesh, + required this.material, + required this.lighting, + required this.vertices, + required this.indices, + required this.instances, + required this.triangles, + required this.state, + required this.uniforms, + }); + + /// Position in the frame, across every pass. + final int index; + + /// The graph node that encoded it, by position and by name. + final int passIndex; + final String pass; + + /// `mesh` for the scene's own encoder, `shadow` for a caster. + final String kind; + + /// The node's name — a mesh has none of its own — or null when unnamed. + final String? mesh; + final String? material; + + /// The lighting model's label: which pipeline drew it. + final String lighting; + + final int vertices; + final int indices; + final int instances; + final int triangles; + + /// Pipeline state the draw was encoded with: cull, blend, depth, winding, + /// and the variant flags that pick a pipeline. + final Map state; + + /// The values bound for this draw, by name — matrices column-major. + final Map> uniforms; + + /// The row a list shows. + Map toSummaryJson() => { + 'index': index, + 'passIndex': passIndex, + 'pass': pass, + 'kind': kind, + 'mesh': mesh, + 'material': material, + 'vertices': vertices, + 'indices': indices, + 'instances': instances, + }; + + /// Everything. + Map toJson() => { + ...toSummaryJson(), + 'lighting': lighting, + 'triangles': triangles, + 'state': state, + 'uniforms': uniforms, + }; +} + +/// Collects [DrawRecord]s while one frame runs. +final class DrawJournal { + final List _records = []; + final Map _undetailed = {}; + + int _passIndex = -1; + String _pass = ''; + int _passStart = 0; + + List get records => List.unmodifiable(_records); + + /// Draws a pass counted and did not describe, by pass name; passes that + /// described every draw are absent. + Map get undetailed => Map.unmodifiable(_undetailed); + + /// Called by the renderer before a graph node runs. + void beginPass(int index, String name) { + _passIndex = index; + _pass = name; + _passStart = _records.length; + } + + /// Called by the renderer after the node, with the draws it counted. + void endPass(int drawCalls) { + final missing = drawCalls - (_records.length - _passStart); + if (missing > 0) _undetailed[_pass] = missing; + } + + /// Writes one draw down. Call as `journal?.add(...)`, so nothing below is + /// evaluated in a frame that is not being journaled. + void add({ + required String kind, + required String? mesh, + required String? material, + required String lighting, + required int vertices, + required int indices, + required int instances, + required Map state, + Map uniforms = const {}, + }) { + _records.add( + DrawRecord( + index: _records.length, + passIndex: _passIndex, + pass: _pass, + kind: kind, + mesh: mesh, + material: material, + lighting: lighting, + vertices: vertices, + indices: indices, + instances: instances, + triangles: (indices ~/ 3) * instances, + state: Map.unmodifiable(state), + // Copied: the renderer refills its staging blocks for the next draw. + uniforms: >{ + for (final MapEntry(:key, :value) in uniforms.entries) + key: List.unmodifiable(value), + }, + ), + ); + } +} diff --git a/packages/flutter3d_core/lib/src/engine/render/engine_tables.dart b/packages/flutter3d_core/lib/src/engine/render/engine_tables.dart index f3d0cbd8c..f5aba0883 100644 --- a/packages/flutter3d_core/lib/src/engine/render/engine_tables.dart +++ b/packages/flutter3d_core/lib/src/engine/render/engine_tables.dart @@ -13,6 +13,7 @@ import 'engine_table.dart'; import 'tables/aces2_display.dart' as tables; import 'tables/blue_noise.dart' as tables; import 'tables/ltc.dart' as tables; +import 'tables/smaa_area.dart' as tables; export 'engine_table.dart'; @@ -32,6 +33,7 @@ final class EngineTables { tables.blueNoise, tables.aces2Display, tables.ltc, + tables.smaaArea, ]; /// Entries per axis of [aces2Display] — `L2`. @@ -53,6 +55,9 @@ final class EngineTables { /// See `tables/ltc.dart`. TextureHandle get ltc => this[tables.ltc]; + /// See `tables/smaa_area.dart`. + TextureHandle get smaaArea => this[tables.smaaArea]; + /// [table] on this device, uploaded now if it was not already. TextureHandle operator [](EngineTable table) => _uploaded[table] ??= _device.createTextureFromPixels( diff --git a/packages/flutter3d_core/lib/src/engine/render/environment_map.dart b/packages/flutter3d_core/lib/src/engine/render/environment_map.dart index 8ec86b130..80a1e3bca 100644 --- a/packages/flutter3d_core/lib/src/engine/render/environment_map.dart +++ b/packages/flutter3d_core/lib/src/engine/render/environment_map.dart @@ -1,9 +1,11 @@ import 'dart:math' as math; import 'dart:typed_data'; +import 'package:flutter3d_core/formats.dart'; import 'package:flutter3d_hardware/flutter3d_hardware.dart'; import 'package:vector_math/vector_math.dart'; +import '../assets/image_decoder.dart'; import 'sky_settings.dart'; /// Builds the cube a surface reflects: one environment, convolved by roughness. @@ -242,6 +244,82 @@ abstract final class EnvironmentMap { return texture == null ? null : (texture: texture, levels: levels); } + /// The same thing from a panorama file's bytes: a Radiance `.hdr`, or any + /// image [decodeImage] reads. + /// + /// **The file, not the pixels, because a file is what changes.** A game + /// that loads its sky from an asset and wants the edit drawn under a hot + /// swap has the bytes and nothing else; deciding which decoder they need + /// belongs beside the upload rather than at every caller that watches a + /// file. A `.hdr` is told apart by its header, so the name of the file + /// plays no part. + /// + /// Null for bytes neither reader takes, and for everything [fromPanorama] + /// answers with null. + static Future<({TextureHandle texture, int levels})?> fromEncoded( + GraphicsDevice device, + Uint8List bytes, { + required ImageDecoder decodeImage, + int size = 32, + int levels = 4, + }) async { + final ({ByteData pixels, int width, int height})? panorama = + hdrSizeOf(bytes) != null + ? _fromHdr(bytes) + : switch (await decodeImage(bytes)) { + final Rgba8Image image => ( + pixels: ByteData.sublistView(image.pixels), + width: image.width, + height: image.height, + ), + null => null, + }; + if (panorama == null) return null; + return fromPanorama( + device, + panorama.pixels, + width: panorama.width, + height: panorama.height, + size: size, + levels: levels, + ); + } + + static ({ByteData pixels, int width, int height})? _fromHdr(Uint8List bytes) { + try { + final HdrImage image = readHdr(bytes); + return ( + pixels: hdrToRgba8(image), + width: image.width, + height: image.height, + ); + } on HdrFormatException { + return null; + } + } + + /// A Radiance image as the RGBA8 [fromPanorama] reads. + /// + /// **Clamped rather than tone-mapped.** The cube is eight bits a channel — + /// [fromSky] says why, and that this is a real limitation — so a sun four + /// hundred times brighter than the sky around it becomes white either way. + /// Rolling the highlights off first would darken everything else to buy + /// detail in a region the cube cannot hold anyway, and would make the + /// indirect light a panorama gives differ from the indirect light the same + /// picture gives as a PNG. + static ByteData hdrToRgba8(HdrImage image) { + int byte(double value) => (value * 255.0).round().clamp(0, 255); + final out = ByteData(image.width * image.height * 4); + for (var i = 0; i < image.width * image.height; i++) { + out + ..setUint8(i * 4, byte(image.rgb[i * 3])) + ..setUint8(i * 4 + 1, byte(image.rgb[i * 3 + 1])) + ..setUint8(i * 4 + 2, byte(image.rgb[i * 3 + 2])) + ..setUint8(i * 4 + 3, 255); + } + return out; + } + /// Which level of the chain a shader should treat as the diffuse term. /// /// The roughest one. At roughness one the GGX lobe's reflected taps fall diff --git a/packages/flutter3d_core/lib/src/engine/render/frame_capture.dart b/packages/flutter3d_core/lib/src/engine/render/frame_capture.dart index db411a32e..9765ee1c2 100644 --- a/packages/flutter3d_core/lib/src/engine/render/frame_capture.dart +++ b/packages/flutter3d_core/lib/src/engine/render/frame_capture.dart @@ -28,6 +28,7 @@ import 'dart:typed_data'; import 'package:flutter3d_hardware/flutter3d_hardware.dart'; +import 'draw_journal.dart'; import 'frame_graph.dart'; /// One resource as one pass left it. @@ -38,6 +39,7 @@ final class CapturedImage { required this.height, required this.format, this.pixels, + this.floats, this.refused, }); @@ -52,6 +54,15 @@ final class CapturedImage { /// there are none. final ByteData? pixels; + /// The texture's own RGBA floats, unclamped, or null where the backend + /// cannot hand them back — `P12`. + /// + /// [pixels] is eight bits a channel on every backend, and a NaN a shader + /// wrote clamps to an ordinary byte on the way there. Only a backend that + /// keeps its textures as floats can answer this, and the capture asks it + /// through [FrameCaptureBuilder.readFloats]. + final Float32List? floats; + /// Why there is no image, in a sentence a reader can act on. /// /// A capture with a gap in it is worse than no capture: the reader assumes @@ -111,10 +122,19 @@ final class CapturedPass { required this.writes, required this.keeps, required this.images, + this.micros = 0, + this.drawCalls = 0, + this.triangles = 0, }); final String name; + /// What the pass cost, as `FrameResult.passes` reported it — `P12`, so a + /// capture answers "how big was this frame" on its own. + final int micros; + final int drawCalls; + final int triangles; + /// Whether the node said it had anything to do. /// /// A pass can be in the order and inactive — see `FrameGraphNode.isActive` — @@ -150,12 +170,22 @@ final class FrameCapture { required this.width, required this.height, required this.passes, + this.draws = const [], + this.undetailedDraws = const {}, }); final int width; final int height; final List passes; + /// Every draw the frame described, when the capture asked for them — `P12`. + /// Empty otherwise; see [DrawJournal]. + final List draws; + + /// Draws a pass counted without describing, by pass name — see + /// [DrawJournal.undetailed]. + final Map undetailedDraws; + /// The pass called [name], or null. CapturedPass? passNamed(String name) { for (final pass in passes) { @@ -191,11 +221,24 @@ final class FrameCapture { /// queued at the pass boundary and answered afterwards — and a caller should /// not be handed a half-filled capture. final class FrameCaptureBuilder { - FrameCaptureBuilder({required this.width, required this.height}); + FrameCaptureBuilder({ + required this.width, + required this.height, + this.journal, + this.readFloats, + }); final int width; final int height; + /// Where the frame's draws are written down, or null when nobody asked. + final DrawJournal? journal; + + /// Reads a texture's floats as they stand, for a backend that keeps them — + /// the software rasteriser's `readHdrPixels`. Null everywhere else, and + /// then [CapturedImage.floats] is null too. + final Float32List? Function(TextureHandle texture)? readFloats; + final List<_PendingPass> _passes = <_PendingPass>[]; /// Records [node] and queues a readback of everything it wrote or maintains. @@ -203,6 +246,9 @@ final class FrameCaptureBuilder { FrameGraphNode node, { required GraphicsDevice device, required TextureHandle? Function(ResourceId id) lookup, + int micros = 0, + int drawCalls = 0, + int triangles = 0, }) { final pending = >[]; // Written *and* maintained: a node that keeps an atlas between frames — the @@ -223,6 +269,9 @@ final class FrameCaptureBuilder { writes: [for (final id in node.writes) id.name], keeps: [for (final id in node.keeps) id.name], images: pending, + micros: micros, + drawCalls: drawCalls, + triangles: triangles, ), ); } @@ -280,9 +329,21 @@ final class FrameCaptureBuilder { height: texture.height, format: texture.format, pixels: bytes, + floats: _floatsOf(texture), ); } + /// [readFloats]' answer for [texture], or null when it has none or throws: + /// the eight-bit image is still evidence, and losing it to a float read + /// that failed would be worse than having only it. + Float32List? _floatsOf(TextureHandle texture) { + try { + return readFloats?.call(texture); + } on Object { + return null; + } + } + /// The finished capture, once every queued readback has answered. Future build() async => FrameCapture( width: width, @@ -297,8 +358,13 @@ final class FrameCaptureBuilder { writes: pass.writes, keeps: pass.keeps, images: await Future.wait(pass.images), + micros: pass.micros, + drawCalls: pass.drawCalls, + triangles: pass.triangles, ), ], + draws: journal?.records ?? const [], + undetailedDraws: journal?.undetailed ?? const {}, ); } @@ -311,6 +377,9 @@ final class _PendingPass { required this.writes, required this.keeps, required this.images, + required this.micros, + required this.drawCalls, + required this.triangles, }); final String name; @@ -320,4 +389,7 @@ final class _PendingPass { final List writes; final List keeps; final List> images; + final int micros; + final int drawCalls; + final int triangles; } diff --git a/packages/flutter3d_core/lib/src/engine/render/frame_plan.dart b/packages/flutter3d_core/lib/src/engine/render/frame_plan.dart index 469f52bea..2c6d8fda2 100644 --- a/packages/flutter3d_core/lib/src/engine/render/frame_plan.dart +++ b/packages/flutter3d_core/lib/src/engine/render/frame_plan.dart @@ -119,4 +119,16 @@ abstract final class FrameResourceIds { /// the world that reflects it. static ResourceId reflectionProbe(int index) => ResourceId('reflection_probe_$index'); + + /// What the scene's `RenderTexture`s were drawn into — `P4`. + /// + /// One name for all of them, and what it stands for is the order: the + /// scene reads it optionally, so every camera into a texture draws before + /// the materials that show its picture. The pictures themselves are each + /// texture's own and outlive the frame. + static const ResourceId renderTextures = ResourceId('render_textures'); + + /// The mirrored pictures of the scene's planar reflectors — `P4`, one + /// name for all of them and every view, for [renderTextures]' reason. + static const ResourceId planarReflections = ResourceId('planar_reflections'); } diff --git a/packages/flutter3d_core/lib/src/engine/render/frame_resources.dart b/packages/flutter3d_core/lib/src/engine/render/frame_resources.dart index 06753c993..23673f7cc 100644 --- a/packages/flutter3d_core/lib/src/engine/render/frame_resources.dart +++ b/packages/flutter3d_core/lib/src/engine/render/frame_resources.dart @@ -109,11 +109,24 @@ final class FrameResources { /// otherwise the source's. TextureHandle _acquire(RenderTargetSpec spec) { final free = _reusable[spec]; - return free != null && free.isNotEmpty + final texture = free != null && free.isNotEmpty ? free.removeLast() : source.acquire(spec); + _seen.add(texture); + return texture; } + /// Every texture this frame's nodes drew into or were handed, once each. + final Set _seen = Set.identity(); + + /// What the targets this frame touched hold, in bytes — `P6`, + /// `FrameResult.targetBytes`: the pooled scratch the nodes took and every + /// texture a node provided, the long-lived ones (the scene's colour, the + /// shadow atlas) among them, counted once however many versions stood on + /// one. See [textureBytes] for what one texture is counted as. + int get targetBytes => + _seen.fold(0, (int sum, TextureHandle t) => sum + textureBytes(t)); + /// Ends a texture's lifetime in this frame. /// /// The newest version of a frame output is never lent again: it is read @@ -224,6 +237,7 @@ final class FrameResources { /// in nobody else's hands, and once the key names the provided texture no /// retirement would ever find it again. void provide(ResourceId id, TextureHandle texture) { + _seen.add(texture); final key = ResourceVersion(id, _writeVersionFor(id)); final replaced = _live[key]; // **The node's own scratch, handed in as its output, is still pooled** — @@ -531,3 +545,32 @@ final class FrameResources { int _writeVersionFor(ResourceId id) => _node < 0 ? 0 : graph.writeVersionOf(_node, id) ?? 0; } + +/// What [texture] holds, in bytes: its base level, every slice, every +/// sample — `P6`. +/// +/// **An estimate a driver would round up, never down.** A device pads rows +/// and keeps tiles of its own, and none of the four backends says by how +/// much; what this counts is what the texels themselves need, the number a +/// budget is set against. A mip chain is not counted, because a handle does +/// not say it has one: the engine's own targets have none, and the pictures +/// it samples are not targets. +int textureBytes(TextureHandle texture) { + final perTexel = switch (texture.format) { + TextureFormat.a8UNormInt || + TextureFormat.r8UNormInt || + TextureFormat.s8UInt => 1, + TextureFormat.r8g8UNormInt => 2, + TextureFormat.r16g16b16a16Float || TextureFormat.d32FloatS8UInt => 8, + TextureFormat.r32g32b32a32Float => 16, + // Four bytes: the eight-bit colours, a float channel, depth with stencil. + // A compressed format is never a target and is counted as the colour it + // decodes to. + _ => 4, + }; + return texture.width * + texture.height * + texture.sliceCount * + texture.sampleCount * + perTexel; +} diff --git a/packages/flutter3d_core/lib/src/engine/render/frame_result.dart b/packages/flutter3d_core/lib/src/engine/render/frame_result.dart index 325dcce00..8dbe237c7 100644 --- a/packages/flutter3d_core/lib/src/engine/render/frame_result.dart +++ b/packages/flutter3d_core/lib/src/engine/render/frame_result.dart @@ -123,6 +123,8 @@ final class FrameResult { this.shadowsDenied = 0, this.batchedDraws = 0, this.wireframeDeclined = false, + this.alphaToCoverageDeclined = false, + this.targetBytes = 0, this.exposure = RenderSettings.defaultExposure, this.passes = const [], this.skipped = const [], @@ -259,6 +261,20 @@ final class FrameResult { /// [lightsDropped] is: a setting that did nothing should say so. final bool wireframeDeclined; + /// Whether a material asked for `Material.alphaToCoverage` and was drawn + /// with its hard cutoff instead — `P7`: the device has none (Impeller, the + /// software rasteriser), or the scene pass gave its multisampling up. + final bool alphaToCoverageDeclined; + + /// What the targets this frame drew into or read from hold, in bytes — + /// `P6`. Each texture once, its base level, every slice and sample: the + /// scene's colour and depth, the surface buffer, the glow's chain, the + /// shadow atlas, every scratch target a pass took from the pool. What a + /// frame costs in memory before a driver's padding, and what grows when a + /// render scale or an effect is turned up — `textureBytes` says how one is + /// counted. + final int targetBytes; + /// Pipelines the renderer has built so far. /// /// Reported per frame because it is the number that has to stay put: light diff --git a/packages/flutter3d_core/lib/src/engine/render/material.dart b/packages/flutter3d_core/lib/src/engine/render/material.dart index 4aeb828a4..610451d83 100644 --- a/packages/flutter3d_core/lib/src/engine/render/material.dart +++ b/packages/flutter3d_core/lib/src/engine/render/material.dart @@ -65,6 +65,7 @@ final class Material { this.emissiveStrength = 1.0, this.alphaMode = MaterialAlphaMode.opaque, this.alphaCutoff = 0.5, + this.alphaToCoverage = false, this.doubleSided = false, this.fogged = true, this.extensions, @@ -79,7 +80,7 @@ final class Material { this.parameterBlock = 'MaterialParams', Map? parameters, Map? extraTextures, - }) : parameters = parameters ?? const {}, + }) : parameters = parameters ?? {}, textureTransforms = textureTransforms ?? {}, extraTextures = extraTextures ?? const {}, @@ -176,6 +177,11 @@ final class Material { /// Every uniform in this engine is a float vector, a matrix or an array of /// either, so a `Float32List` is the only value there is. An integer or a /// boolean is encoded as a float, the same way the built-in shaders do it. + /// + /// **A map of the material's own, empty by default and open to additions** + /// — `P8`. It was a shared constant, so a material made without parameters + /// could not be given any: a model's surfaces, handed a material written + /// in the language after loading, had nowhere to put its uniforms. final Map parameters; /// Textures an application's own shader samples, by slot name. @@ -256,6 +262,19 @@ final class Material { MaterialAlphaMode alphaMode; double alphaCutoff; + + /// A masked surface's edge antialiased by the multisample resolve rather + /// than cut at [alphaCutoff] — `P7`. + /// + /// Read only under [MaterialAlphaMode.mask]. The alpha is sharpened round + /// the cutoff to a pixel's width and handed to the hardware as coverage, so + /// a leaf, a fence or a grille is as smooth at its edge as a triangle is. + /// **Two backends of four can**: WebGL2 and WebGPU, in a multisampled scene + /// pass. Elsewhere — Impeller, the software rasteriser, a scene pass that + /// gave its multisampling up — the material draws the hard cutoff it draws + /// without this, and `FrameResult.alphaToCoverageDeclined` says so. Off by + /// default. + bool alphaToCoverage; bool doubleSided; /// Whether the frame's fog reaches this material. True for almost @@ -316,6 +335,8 @@ final class Material { /// Signed, and negative is the useful half: ordinary materials sit at zero, /// so the only way to be drawn *before* the scene is to ask for less than it. /// The usable range is −128 to 127 and values outside it are clamped. + /// [MeshNode.drawOrder] adds to it, for an order between nodes that share + /// this material. int drawBucket; /// Overrides whether this surface writes depth. Null lets transparency @@ -395,6 +416,7 @@ final class Material { emissiveStrength: emissiveStrength, alphaMode: alphaMode, alphaCutoff: alphaCutoff, + alphaToCoverage: alphaToCoverage, doubleSided: doubleSided, fogged: fogged, extensions: extensions, diff --git a/packages/flutter3d_core/lib/src/engine/render/mesh_overlay.dart b/packages/flutter3d_core/lib/src/engine/render/mesh_overlay.dart index f60cd913c..a32f4c598 100644 --- a/packages/flutter3d_core/lib/src/engine/render/mesh_overlay.dart +++ b/packages/flutter3d_core/lib/src/engine/render/mesh_overlay.dart @@ -5,6 +5,7 @@ import 'package:flutter3d_hardware/flutter3d_hardware.dart'; import 'package:flutter3d_shaders/typed_blocks.dart'; import 'package:vector_math/vector_math.dart'; +import '../scene/camera_node.dart'; import 'identity_indices.dart'; import 'pass_contributor.dart'; @@ -164,6 +165,29 @@ final class MeshOverlay extends PassContributor { _perspective = perspective; } + /// [lookFrom] for [camera], drawn into a viewport [width] by [height] + /// logical pixels — `P7`. + /// + /// What a caller should use rather than working the pixel out itself: the + /// example's overlay did, from a field of view it took from the projection + /// when there was one and from a quarter turn when there was not, so through + /// an orthographic camera every handle and line was sized as if seen in + /// perspective. Read off the projection's own matrix instead, which answers + /// for every kind of lens: its vertical scale is `1 / tan(f / 2)` through a + /// perspective one and `2 / H` through an orthographic one, so `2 / (scale × + /// height)` is the pixel either way, and its bottom row says which. + void lookThrough(CameraNode camera, double width, double height) { + final world = camera.worldMatrix.storage; + final projection = camera.projection.toMatrix(width / height).storage; + lookFrom( + eye: camera.readWorldPosition(), + right: Vector3(world[0], world[1], world[2]), + up: Vector3(world[4], world[5], world[6]), + pixel: 2.0 / (projection[5].abs() * height), + perspective: projection[11] != 0.0, + ); + } + /// How far from the eye a point is, in the units [lookFrom]'s pixel scales by. double _depth(Vector3 at) => _perspective ? (at - _eye).length : 1.0; @@ -306,6 +330,9 @@ final class MeshOverlay extends PassContributor { PipelineHandle? _pipeline; + @override + void relinkShaders() => _pipeline = null; + /// The index sequence 0, 1, 2, … that every draw here is made through. /// /// **Not an optimisation and not a formality: `CommandEncoder.draw` in this diff --git a/packages/flutter3d_core/lib/src/engine/render/mirror_view.dart b/packages/flutter3d_core/lib/src/engine/render/mirror_view.dart new file mode 100644 index 000000000..5bee98c1c --- /dev/null +++ b/packages/flutter3d_core/lib/src/engine/render/mirror_view.dart @@ -0,0 +1,96 @@ +import 'package:vector_math/vector_math.dart'; + +/// The affine reflection of world space across the plane through [point] +/// with unit normal [normal] — `P4`. +/// +/// `x' = x - 2 (n·x - n·p) n`. Its own inverse, and it reverses handedness, +/// so whatever is drawn through it winds the other way round. +Matrix4 mirrorAcrossPlane(Vector3 normal, Vector3 point) { + final n = normal.normalized(); + final d = -n.dot(point); + return Matrix4( + 1.0 - 2.0 * n.x * n.x, + -2.0 * n.y * n.x, + -2.0 * n.z * n.x, + 0.0, + -2.0 * n.x * n.y, + 1.0 - 2.0 * n.y * n.y, + -2.0 * n.z * n.y, + 0.0, + -2.0 * n.x * n.z, + -2.0 * n.y * n.z, + 1.0 - 2.0 * n.z * n.z, + 0.0, + -2.0 * d * n.x, + -2.0 * d * n.y, + -2.0 * d * n.z, + 1.0, + ); +} + +/// [projection] with its near plane moved onto [plane], or null where that +/// cannot be done — `P4`'s clip plane. +/// +/// **The clip plane is the projection's own near plane, and not a plane the +/// shaders test.** No stage here has a clip distance to write, and adding +/// one would be an output on every vertex stage and a branch on every +/// backend for the one pass that wants it. Lengyel's oblique frustum +/// (*Oblique View Frustum Depth Projection and Clipping*, 2005) gets the same +/// cut out of the rasteriser's own near clip, for the price of the depth +/// precision the reflection does not need. +/// +/// [plane] is in the eye space [projection] maps from, as `(n, d)` with +/// `n·v + d >= 0` on the side that is kept; the eye has to be on the other +/// side, which is what a mirrored camera below a mirror is. The matrix is the +/// engine's: near at depth nought and far at one, the convention every +/// camera builds for before `toDepthRange` turns it into a device's. +/// +/// What it costs is the far plane, which tilts to pass through the frustum's +/// far corner on the plane's side: something far off to the other side can +/// be cut by it. A reflection is mostly of what is near the mirror. +/// +/// Null when the plane does not face away from the eye — a camera behind +/// the mirror, or level with it — since there is then nothing in front of +/// the mirror for the near plane to stand on. +Matrix4? obliqueNearPlane(Matrix4 projection, Vector4 plane) { + // The eye is the origin of its own space, so `d` is which side it is on. + if (plane.w >= 0.0) return null; + // The far corner of the frustum on the plane's side, in eye space: where + // the clip-space plane leans, at the far plane. + final inverse = Matrix4.copy(projection); + if (inverse.invert() == 0.0) return null; + final clipPlane = inverse.transposed().transform(plane.clone()); + final corner = inverse.transform( + Vector4(_sign(clipPlane.x), _sign(clipPlane.y), 1.0, 1.0), + ); + // The fourth row of the projection takes `corner` to w = 1 by + // construction; the third becomes the plane, scaled so the far plane + // `w - z = 0` still passes through it. + final along = plane.dot(corner); + if (along <= 0.0) return null; + final row = plane.scaled(1.0 / along); + return Matrix4.copy(projection)..setRow(2, row); +} + +/// The plane through [point] with [normal], in the eye space [view] maps +/// world space into, as `obliqueNearPlane` takes it — [offset] metres +/// further along the normal's opposite, so a surface standing in the plane +/// keeps its foot in the picture. +Vector4 planeInEyeSpace( + Matrix4 view, + Vector3 normal, + Vector3 point, { + double offset = 0.0, +}) { + final n = normal.normalized(); + final world = Vector4(n.x, n.y, n.z, -n.dot(point) + offset); + // A plane goes through the inverse transpose of what moves the points. + final inverse = Matrix4.copy(view)..invert(); + return inverse.transposed().transform(world); +} + +double _sign(double value) => value > 0.0 + ? 1.0 + : value < 0.0 + ? -1.0 + : 0.0; diff --git a/packages/flutter3d_core/lib/src/engine/render/pass_contributor.dart b/packages/flutter3d_core/lib/src/engine/render/pass_contributor.dart index caca5e00c..12bb1450a 100644 --- a/packages/flutter3d_core/lib/src/engine/render/pass_contributor.dart +++ b/packages/flutter3d_core/lib/src/engine/render/pass_contributor.dart @@ -6,6 +6,7 @@ import 'package:flutter3d_shaders/typed_blocks.dart'; import 'package:vector_math/vector_math.dart' as vm; import '../scene/scene.dart'; +import 'draw_journal.dart'; import 'frame_graph.dart'; import 'frame_plan.dart'; import 'frame_resources.dart'; @@ -43,6 +44,19 @@ final class FramePassState { /// Seeded with what the scene pass establishes for itself, and reseeded /// wherever that state is re-established. CompareFunction depthCompare = CompareFunction.less; + + /// Whether the pass now encoding may turn alpha into coverage — `P7`: it + /// multisamples and the device can. Set by the scene pass for its own + /// draws and cleared before its contributors. + bool coverageAvailable = false; + + /// What `setAlphaToCoverage` was last told, tracked as [depthCompare] is so + /// a scene without the setting emits no call at all. Every pass starts off. + bool alphaToCoverage = false; + + /// Whether a material asked for alpha to coverage this frame and was drawn + /// with its hard cutoff instead — `FrameResult.alphaToCoverageDeclined`. + bool coverageDeclined = false; int drawCalls = 0; int pipelineSwitches = 0; int skinnedDraws = 0; @@ -58,6 +72,11 @@ final class FramePassState { /// not one instance of itself, so it does not add to this count. int instances = 0; + /// Where each draw is written down, for the one frame somebody asked to + /// see them — `P12`. Null otherwise; see [DrawJournal] for why a draw site + /// writes `journal?.add(...)` and nothing more. + DrawJournal? journal; + /// Call after encoding anything that binds its own pipeline. /// /// The tracker describes the mesh pipelines only, so a pass that replaced diff --git a/packages/flutter3d_core/lib/src/engine/render/photo_capture.dart b/packages/flutter3d_core/lib/src/engine/render/photo_capture.dart new file mode 100644 index 000000000..ba4e48be4 --- /dev/null +++ b/packages/flutter3d_core/lib/src/engine/render/photo_capture.dart @@ -0,0 +1,438 @@ +/// Photo mode's picture: a frame of any size, drawn in tiles, finished and +/// handed out a strip at a time — `N8`. +/// +/// **Strips rather than a frame, because "any resolution" is a promise about +/// memory.** A 16384 × 16384 picture is a gigabyte of RGBA, which a phone does +/// not have and a browser tab will not be given. Drawn a row of tiles at a +/// time and handed to a sink that writes it out — `PngStripWriter` in +/// `flutter3d_cpu` is the one this was written for — the most this holds is +/// one row of tiles, whatever the size of the picture. +/// +/// **Everything that knows where the frame's edge is happens here, over the +/// whole frame, and not in the tiles.** A tile does not know it is a tile: its +/// composite would darken its own corners for a vignette and lay its own grain +/// from its own origin, and the stitched picture would have a vignette per +/// tile. So those two are taken out of the tiles' settings and put back by +/// [PhotoFinish], which mirrors the composite's arithmetic at the frame's own +/// pixel coordinates. +library; + +import 'dart:async'; +import 'dart:math' as math; +import 'dart:typed_data'; + +import 'package:vector_math/vector_math.dart' as vm; + +import '../../formats/srgb.dart'; +import '../scene/camera_node.dart'; +import '../scene/projection.dart'; +import '../scene/scene.dart'; +import 'render_view.dart'; +import 'renderer.dart'; + +/// A look a player picks in photo mode. +/// +/// **A change to the game's own [LookSettings] rather than a pass of its +/// own**, so every filter is drawn by the composite every backend already has +/// and the CPU backend already mirrors — four backends agreeing about a filter +/// costs nothing new. The colour parts work per pixel and so survive tiling as +/// they are; the vignette and the grain do not, and [PhotoFinish] puts them +/// back over the whole frame. +/// +/// **Composed onto the game's look, not put in its place**: a game graded +/// cold for a night level stays cold under [warm], only less so. Contrast and +/// saturation multiply, temperature and tint add, the vignette and the grain +/// take the larger of the two, a gain multiplies channel by channel, and a +/// lift replaces the game's because two lifts do not compose into anything a +/// person chose. +final class PhotoFilter { + const PhotoFilter({ + required this.name, + this.contrast = 1.0, + this.saturation = 1.0, + this.temperature = 0.0, + this.tint = 0.0, + this.vignette = 0.0, + this.grain = 0.0, + this.lift, + this.gain, + }); + + /// What a menu calls it; also how a game saves which one was last picked. + final String name; + + final double contrast; + final double saturation; + final double temperature; + final double tint; + final double vignette; + final double grain; + final vm.Vector3? lift; + final vm.Vector3? gain; + + static const PhotoFilter none = PhotoFilter(name: 'none'); + static const PhotoFilter vivid = PhotoFilter( + name: 'vivid', + contrast: 1.15, + saturation: 1.35, + ); + static const PhotoFilter warm = PhotoFilter( + name: 'warm', + temperature: 0.35, + saturation: 1.05, + ); + static const PhotoFilter cool = PhotoFilter( + name: 'cool', + temperature: -0.35, + tint: -0.05, + ); + static const PhotoFilter mono = PhotoFilter( + name: 'mono', + saturation: 0.0, + contrast: 1.1, + ); + + /// Mono with a brown gain — the print rather than the negative. + static final PhotoFilter sepia = PhotoFilter( + name: 'sepia', + saturation: 0.0, + contrast: 0.95, + gain: vm.Vector3(1.08, 0.96, 0.78), + lift: vm.Vector3(0.03, 0.02, 0.0), + vignette: 0.25, + ); + static const PhotoFilter noir = PhotoFilter( + name: 'noir', + saturation: 0.0, + contrast: 1.45, + vignette: 0.55, + grain: 0.04, + ); + static final PhotoFilter faded = PhotoFilter( + name: 'faded', + contrast: 0.85, + saturation: 0.75, + lift: vm.Vector3.all(0.06), + ); + + /// The order a menu shows them in. + static final List all = [ + none, + vivid, + warm, + cool, + mono, + sepia, + noir, + faded, + ]; + + /// The filter called [name], or [none] for a name no filter has — a saved + /// choice from a build that had a filter this one does not. + static PhotoFilter named(String name) => + all.firstWhere((filter) => filter.name == name, orElse: () => none); + + /// [look] with this filter on it, for the live view. + LookSettings applyTo(LookSettings look) => look.copyWith( + contrast: look.contrast * contrast, + saturation: look.saturation * saturation, + temperature: (look.temperature + temperature).clamp(-1.0, 1.0), + tint: (look.tint + tint).clamp(-1.0, 1.0), + vignette: math.max(look.vignette, vignette), + grain: math.max(look.grain, grain), + lift: lift ?? look.lift, + gain: switch ((look.gain, gain)) { + (final a?, final b?) => vm.Vector3(a.x * b.x, a.y * b.y, a.z * b.z), + (final a, final b) => b ?? a, + }, + ); +} + +/// The vignette and the grain, put over a finished frame at its own +/// coordinates. +/// +/// **The composite's arithmetic, moved after the encode.** The composite +/// darkens linear light by `1 − vignette · r` and then encodes; here the +/// encoded byte is decoded, darkened by the same factor and encoded again, +/// which is the same picture to within the rounding of one extra trip through +/// eight bits. The grain is the composite's own hash of the pixel's position, +/// added after the encode as the composite adds it — at the frame's position +/// rather than the tile's, which is the whole reason this exists. +final class PhotoFinish { + PhotoFinish({ + required this.width, + required this.height, + required this.vignette, + required this.roundness, + required this.grain, + }); + + /// What [look] asks of the frame's edges, for a frame [width] × [height]. + factory PhotoFinish.of(LookSettings look, int width, int height) => + PhotoFinish( + width: width, + height: height, + vignette: look.vignette.clamp(0.0, 1.0), + roundness: look.vignetteRoundness.clamp(0.0, 1.0), + grain: math.max(look.grain, 0.0), + ); + + final int width; + final int height; + final double vignette; + final double roundness; + final double grain; + + bool get isNeutral => vignette == 0.0 && grain == 0.0; + + static final Float64List _decode = Float64List.fromList( + List.generate(256, (i) => srgbToLinear(i / 255.0)), + ); + + /// Applies itself to [rows] rows of RGBA starting at frame row [top]. + void apply(Uint8List rgba, int top, int rows) { + if (isNeutral) return; + final aspect = width / height; + final stretch = 1.0 + (aspect - 1.0) * roundness; + for (var row = 0; row < rows; row++) { + final y = top + row; + final fy = (y + 0.5) / height - 0.5; + for (var x = 0; x < width; x++) { + final at = (row * width + x) * 4; + final fx = ((x + 0.5) / width - 0.5) * stretch; + final r = math.min(math.sqrt(fx * fx + fy * fy) * 1.41421356, 1.0); + final keep = 1.0 - vignette * r; + final noise = grain == 0.0 + ? 0.0 + : (_hash(x + 0.5, y + 0.5) - 0.5) * grain; + for (var c = 0; c < 3; c++) { + final encoded = vignette == 0.0 + ? rgba[at + c] / 255.0 + : linearToSrgb(_decode[rgba[at + c]] * keep); + rgba[at + c] = ((encoded + noise).clamp(0.0, 1.0) * 255.0).round(); + } + } + } + } + + /// `Hash` from `composite.frag`, as the software backend writes it. + static double _hash(double x, double y) { + final t = math.sin(x * 12.9898 + y * 78.233) * 43758.5453; + return t - t.floorToDouble(); + } +} + +/// What a capture did to the game's settings, and why. +/// +/// **Said rather than done quietly.** Each of these is a real difference +/// between the picture on screen and the picture saved, and a player who +/// notices one deserves a sentence rather than a bug report. +final class PhotoCaptureReport { + const PhotoCaptureReport({ + required this.width, + required this.height, + required this.tilesX, + required this.tilesY, + required this.margin, + required this.exposure, + required this.setAside, + }); + + final int width; + final int height; + final int tilesX; + final int tilesY; + + /// Pixels of picture drawn round each tile and cropped away. + final int margin; + + /// The exposure every tile was drawn at. + final double exposure; + + /// One sentence per setting the capture could not carry over. + final List setAside; +} + +const String _noTemporal = + 'temporal anti-aliasing is off: it resolves over frames a still tile ' + 'does not have'; +const String _noMotionBlur = + 'motion blur is off: the camera moved since the world stopped, and that ' + 'is not motion in the picture'; +const String _noAberration = + 'chromatic aberration is left out: it is centred on each tile, and no ' + 'whole-frame version is written yet'; +const String _noScale = + 'render scale and upscaling are off: the picture is drawn at its own size'; +const String _noExtended = + 'the extended-range output is written as standard range'; + +/// The settings every tile of a photo is drawn with, and what had to change. +/// +/// [exposure] is the one the game last drew with — `Renderer.exposure` — so +/// the picture is as bright as the screen was. A meter running per tile would +/// expose a tile of sky darker than a tile of shadow and the seams would show. +({RenderSettings tile, LookSettings look, List setAside}) photoSettings( + RenderSettings settings, + PhotoFilter filter, + double exposure, +) { + final look = filter.applyTo(settings.look); + final held = + 'auto exposure is held at ${exposure.toStringAsFixed(3)}, the value on ' + 'screen, so every tile is exposed alike'; + final setAside = [ + if (settings.autoExposure.enabled) held, + if (settings.antiAlias.temporal.enabled) _noTemporal, + if (settings.motionBlur.enabled) _noMotionBlur, + if (look.chromaticAberration > 0.0) _noAberration, + if (settings.renderScale != 1.0 || settings.spatialUpscale.enabled) + _noScale, + if (settings.outputTransform != OutputTransform.sdr) _noExtended, + ]; + final tile = settings.copyWith( + exposure: exposure, + autoExposure: const AutoExposureSettings(), + antiAlias: settings.antiAlias.copyWith(temporal: const TemporalSettings()), + motionBlur: settings.motionBlur.copyWith(enabled: false), + renderScale: 1.0, + spatialUpscale: const SpatialUpscaleSettings(), + outputTransform: OutputTransform.sdr, + look: look.copyWith(vignette: 0.0, grain: 0.0, chromaticAberration: 0.0), + ); + return (tile: tile, look: look, setAside: setAside); +} + +/// Draws [scene] through [camera] at [width] × [height] and hands it to +/// [onRows] a row of tiles at a time, top to bottom, finished and opaque. +/// +/// Each tile is [tileWidth] × [tileHeight] of the picture drawn with [margin] +/// pixels more on every side, through a [CropProjection] of [camera]'s own +/// projection; the margin is thrown away. **What the margin is for:** bloom, +/// ambient occlusion, depth of field and screen-space reflections read +/// neighbours, and at a tile's edge without one they read the clear colour — +/// a seam down the picture wherever a highlight sits near one. With a margin +/// wider than what they read, the neighbours are real. Bloom's widest levels +/// reach further than any margin, so a capture with bloom is close to the +/// screen rather than equal to it; `photo_capture_test.dart` measures how +/// close. +/// +/// The camera's projection is borrowed for the capture and put back after it, +/// failure included. The renderer is the game's own: a photo is drawn on the +/// device that is already up, at the size the game asks, so nothing about it +/// needs a second device. **Nothing else may draw on it until this returns.** +/// A hardware readback can resolve a frame or two after the ask, and a game +/// frame drawn in between takes its targets from the same pool as the tile +/// waiting to be read — so a game stops redrawing its surface while a photo +/// is taken, which it is free to do with the world paused. +/// +/// [onRows] gets `width × rows × 4` bytes for frame rows `top` to +/// `top + rows`, and is awaited before the next row of tiles is drawn, so a +/// sink writing to a disk holds this back rather than letting it run ahead. +Future capturePhoto({ + required Renderer renderer, + required Scene scene, + required CameraNode camera, + required int width, + required int height, + required FutureOr Function(Uint8List rgba, int top, int rows) onRows, + RenderSettings settings = const RenderSettings(), + PhotoFilter filter = PhotoFilter.none, + vm.Vector4? clearColor, + int layerMask = ~0, + int tileWidth = 1024, + int tileHeight = 1024, + int margin = 32, + void Function(double progress)? onProgress, +}) async { + if (width <= 0 || height <= 0) { + throw ArgumentError('A photo of ${width}x$height has no pixels to draw.'); + } + if (tileWidth <= 0 || tileHeight <= 0 || margin < 0) { + throw ArgumentError( + 'Tiles of ${tileWidth}x$tileHeight with a margin of $margin cannot ' + 'cover a picture; tiles need a size and a margin cannot be negative.', + ); + } + final tilesX = (width + tileWidth - 1) ~/ tileWidth; + final tilesY = (height + tileHeight - 1) ~/ tileHeight; + final chosen = photoSettings(settings, filter, renderer.exposure); + final finish = PhotoFinish.of(chosen.look, width, height); + final drawWidth = tileWidth + 2 * margin; + final drawHeight = tileHeight + 2 * margin; + + final whole = camera.projection; + try { + for (var tileY = 0; tileY < tilesY; tileY++) { + final top = tileY * tileHeight; + final rows = math.min(tileHeight, height - top); + final strip = Uint8List(width * rows * 4); + for (var tileX = 0; tileX < tilesX; tileX++) { + final left = tileX * tileWidth; + final columns = math.min(tileWidth, width - left); + camera.projection = CropProjection( + whole, + frameAspect: width / height, + left: (left - margin) / width, + right: (left + tileWidth + margin) / width, + top: (top - margin) / height, + bottom: (top + tileHeight + margin) / height, + ); + final result = renderer.render( + width: drawWidth, + height: drawHeight, + scene: scene, + views: [ + RenderView( + camera: camera, + clearColor: clearColor, + layerMask: layerMask, + cut: true, + ), + ], + settings: chosen.tile, + ); + final pixels = await renderer.device.readPixels(result.frame); + if (pixels == null) { + throw StateError( + 'Tile $tileX,$tileY of the photo could not be read back from the ' + 'device it was drawn on; nothing was written past row $top.', + ); + } + final tile = pixels.buffer.asUint8List( + pixels.offsetInBytes, + pixels.lengthInBytes, + ); + for (var row = 0; row < rows; row++) { + final from = ((row + margin) * drawWidth + margin) * 4; + strip.setRange( + (row * width + left) * 4, + (row * width + left + columns) * 4, + tile, + from, + ); + } + onProgress?.call((tileY * tilesX + tileX + 1) / (tilesX * tilesY)); + } + // Opaque: a saved photo with the sky see-through is a picture of the + // viewer's background, and a clear colour's alpha was never a choice + // about the picture. + for (var i = 3; i < strip.length; i += 4) { + strip[i] = 255; + } + finish.apply(strip, top, rows); + await onRows(strip, top, rows); + } + } finally { + camera.projection = whole; + } + return PhotoCaptureReport( + width: width, + height: height, + tilesX: tilesX, + tilesY: tilesY, + margin: margin, + exposure: renderer.exposure, + setAside: chosen.setAside, + ); +} diff --git a/packages/flutter3d_core/lib/src/engine/render/physical_sky.dart b/packages/flutter3d_core/lib/src/engine/render/physical_sky.dart new file mode 100644 index 000000000..87f8503fe --- /dev/null +++ b/packages/flutter3d_core/lib/src/engine/render/physical_sky.dart @@ -0,0 +1,350 @@ +/// The sky as air rather than as colours somebody chose — `P5`. +/// +/// `SkySettings` draws a gradient from three colours, which is right for a +/// level with a look and wrong for a day: dusk is a different three colours +/// that somebody has to pick, and the sun's disc stays the colour it was given +/// while the light it is meant to be goes orange. A [PhysicalSky] is the air +/// itself — how much it scatters, how thick it is and how fast it thins — and +/// the colours come out of where the sun is: blue overhead at noon, a white +/// band at the horizon, red at sunset towards the sun and dark away from it, +/// and stars once the sun is far enough down. +/// +/// Set on `SkySettings.physical`, beside the gradient and the cube map rather +/// than instead of them, and off unless it is set. +library; + +import 'dart:math' as math; + +import 'package:vector_math/vector_math.dart'; + +/// The air of a planet, for `SkySettings.physical`. +/// +/// **Single scattering by molecules and by haze, marched per pixel.** Light +/// from the sun is scattered once on its way to the eye: by molecules, after +/// Rayleigh, in proportion to the inverse fourth power of the wavelength, which +/// is why the sky is blue and the sun red through a long path; and by haze, +/// after Mie, white and mostly forwards, which is the glow round the sun. +/// Light scattered twice is not modelled, and that shows at the horizon, which +/// is a pale cyan at noon rather than white. +/// +/// The defaults are the Earth's, from Bruneton's 2017 reference model: the +/// molecular coefficients for 680, 550 and 440 nm, a scale height of eight +/// kilometres for molecules and 1.2 for haze, and 60 km of air. Change them for +/// a thicker or hazier sky, or another planet. +/// +/// **Lengths are in metres**, as everything else in the engine is; the +/// renderer hands the shader kilometres, for the precision its docstring gives. +/// The world the camera stands in is flat and small beside the planet, so the +/// camera's own position does not move the sky: the eye is always [altitude] +/// metres above the ground, wherever the level puts it. +/// +/// **What the sky costs.** Sixteen samples along every view ray and eight +/// towards the sun from each, per pixel the sky covers — the sky is drawn +/// after the opaque geometry and only where nothing stands. On a desktop GPU +/// that is nothing to speak of; on a phone a frame that is mostly sky pays for +/// it. A precomputed table would make it a texture read, and is not done yet. +final class PhysicalSky { + const PhysicalSky({ + this.rayleigh, + this.rayleighScaleHeight = 8000.0, + this.mie = 3.996e-6, + this.mieAbsorption = 0.444e-6, + this.mieScaleHeight = 1200.0, + this.mieAnisotropy = 0.8, + this.planetRadius = 6360000.0, + this.atmosphereHeight = 60000.0, + this.altitude = 200.0, + this.sunIlluminance = 20.0, + this.groundAlbedo = 0.3, + this.starBrightness = 1.0, + this.starDensity = 0.02, + this.starCells = 160, + }); + + /// Molecular scattering at the ground, per metre, for red, green and blue. + /// Null takes [resolvedRayleigh], the Earth's. + final Vector3? rayleigh; + + Vector3 get resolvedRayleigh => rayleigh ?? _earthRayleigh; + + // A getter rather than a `static final`: a `Vector3` is mutable, and a shared + // one is a global that looks like a constant — see `SkySettings`. + static Vector3 get _earthRayleigh => Vector3(5.802e-6, 13.558e-6, 33.1e-6); + + /// The height over which the molecules thin by a factor of e, in metres. + final double rayleighScaleHeight; + + /// Haze: how much it scatters at the ground and how much more it absorbs, + /// per metre, the same in every channel. Raise [mie] for a hazy summer + /// afternoon; its glow round the sun widens and the horizon whitens. + final double mie; + final double mieAbsorption; + + /// The height over which the haze thins by a factor of e, in metres. + final double mieScaleHeight; + + /// Which way haze scatters: Henyey–Greenstein's g, held inside ±0.99. 0.8 is + /// strongly forwards, the bright glow a sun has through haze. + final double mieAnisotropy; + + final double planetRadius; + + /// How far above the ground the air ends, in metres. + final double atmosphereHeight; + + /// How high the eye is above the ground, in metres; held inside the air. + final double altitude; + + /// The sunlight entering the air, in the units the scene's light has. Twenty + /// puts a noon zenith near 0.1, 0.2, 0.4 and the horizon near one, which + /// sits beside a directional light of intensity two or three under the + /// default exposure. + final double sunIlluminance; + + /// How much of the sunlight that reaches the ground below the horizon it + /// sends back. The ground is what fills the lower half of an environment + /// map built from the sky; it is not a terrain, and it is lit by the sun + /// alone. + final double groundAlbedo; + + /// How bright the stars are, in the sky's own units; nought for none. They + /// fade in as the sun goes from six degrees above the horizon to eleven + /// below, and are dimmed by the air in front of them, so they thin towards + /// the horizon. + final double starBrightness; + + /// The share of the grid's cells that hold a star, from nought to one. + final double starDensity; + + /// How many cells across each face of the cube the stars are scattered on. + /// 160 puts about half a degree between neighbours. + final int starCells; + + /// [mieAnisotropy], held where the phase function stays finite. + double get resolvedAnisotropy => mieAnisotropy.clamp(-0.99, 0.99); + + /// The eye's distance from the planet's centre, in metres, held inside the + /// air: an eye above it would have no air to march through towards the + /// ground. + double get eyeRadius => + planetRadius + altitude.clamp(0.0, atmosphereHeight * 0.999); + + /// The light the air sends towards the eye along [direction], with the sun + /// along [toSun]: the scattered light, plus the ground where the ray meets + /// it. No disc and no stars. + /// + /// The same arithmetic `sky_physical.frag` runs, in double precision; the + /// lengths are kilometres here too, so that the two agree to the extent + /// single precision lets them. [direction] and [toSun] need not be + /// normalised. + Vector3 radiance(Vector3 direction, Vector3 toSun) => + look(direction, toSun).radiance; + + /// [radiance], with what the air lets through along the same ray and + /// whether the ray ends at the ground — what the disc and the stars are + /// dimmed by, and what decides whether there is a disc at all. + ({Vector3 radiance, Vector3 transmittance, bool ground}) look( + Vector3 direction, + Vector3 toSun, + ) { + final d = _unit(direction, Vector3(0.0, 0.0, 1.0)); + final s = _unit(toSun, Vector3(0.0, 1.0, 0.0)); + final k = PhysicalSkyKilometres(this); + final eyeR = k.eye; + + final ground = _meet(eyeR, d.y, k.planet); + final grounded = ground > 0.0; + final span = grounded ? ground : _leave(eyeR, d.y, k.top); + + var seenR = 0.0, seenM = 0.0; + final molecules = Vector3.zero(); + final haze = Vector3.zero(); + for (var i = 0; i < _viewSteps; i++) { + final a0 = i / _viewSteps; + final a1 = (i + 1) / _viewSteps; + final t = span * 0.5 * (a0 * a0 + a1 * a1); + final stride = span * (a1 * a1 - a0 * a0); + final p = Vector3(d.x * t, eyeR + d.y * t, d.z * t); + final r = p.length; + final h = r - k.planet; + final airR = math.exp(-h / k.rayleighHeight) * stride; + final airM = math.exp(-h / k.mieHeight) * stride; + seenR += airR; + seenM += airM; + if (_meet(r, p.dot(s) / r, k.planet) > 0.0) continue; + final (sunR, sunM) = _sunwardAir(p, s, k); + final through = k.through( + seenR - 0.5 * airR + sunR, + seenM - 0.5 * airM + sunM, + ); + molecules.addScaled(through, airR); + haze.addScaled(through, airM); + } + + final mu = d.dot(s); + final g = resolvedAnisotropy; + final gg = g * g; + final rayleighPhase = 3.0 / (16.0 * math.pi) * (1.0 + mu * mu); + final miePhase = + 3.0 / + (8.0 * math.pi) * + ((1.0 - gg) * (1.0 + mu * mu)) / + ((2.0 + gg) * math.pow(1.0 + gg - 2.0 * g * mu, 1.5)); + + final colour = Vector3( + molecules.x * k.rayleigh.x * rayleighPhase + haze.x * k.mie * miePhase, + molecules.y * k.rayleigh.y * rayleighPhase + haze.y * k.mie * miePhase, + molecules.z * k.rayleigh.z * rayleighPhase + haze.z * k.mie * miePhase, + )..scale(sunIlluminance); + final seen = k.through(seenR, seenM); + + if (grounded) { + final p = Vector3(d.x * span, eyeR + d.y * span, d.z * span); + final lit = p.normalized().dot(s); + if (lit > 0.0) { + final (sunR, sunM) = _sunwardAir(p, s, k); + final sun = k.through(sunR, sunM); + final scale = groundAlbedo / math.pi * lit * sunIlluminance; + colour + ..x += seen.x * sun.x * scale + ..y += seen.y * sun.y * scale + ..z += seen.z * sun.z * scale; + } + } + + return (radiance: colour, transmittance: seen, ground: grounded); + } + + /// What the air leaves of white sunlight on its way down to the eye, from + /// a sun along [toSun]: the colour a directional light standing for the sun + /// should have, with [sunIlluminance] left out. Nought once the sun is + /// below the horizon. + Vector3 sunlight(Vector3 toSun) { + final s = _unit(toSun, Vector3(0.0, 1.0, 0.0)); + final k = PhysicalSkyKilometres(this); + final eye = Vector3(0.0, k.eye, 0.0); + if (_meet(k.eye, s.y, k.planet) > 0.0) return Vector3.zero(); + final (r, m) = _sunwardAir(eye, s, k); + return k.through(r, m); + } + + PhysicalSky copyWith({ + Vector3? rayleigh, + double? rayleighScaleHeight, + double? mie, + double? mieAbsorption, + double? mieScaleHeight, + double? mieAnisotropy, + double? planetRadius, + double? atmosphereHeight, + double? altitude, + double? sunIlluminance, + double? groundAlbedo, + double? starBrightness, + double? starDensity, + int? starCells, + }) => PhysicalSky( + rayleigh: rayleigh ?? this.rayleigh, + rayleighScaleHeight: rayleighScaleHeight ?? this.rayleighScaleHeight, + mie: mie ?? this.mie, + mieAbsorption: mieAbsorption ?? this.mieAbsorption, + mieScaleHeight: mieScaleHeight ?? this.mieScaleHeight, + mieAnisotropy: mieAnisotropy ?? this.mieAnisotropy, + planetRadius: planetRadius ?? this.planetRadius, + atmosphereHeight: atmosphereHeight ?? this.atmosphereHeight, + altitude: altitude ?? this.altitude, + sunIlluminance: sunIlluminance ?? this.sunIlluminance, + groundAlbedo: groundAlbedo ?? this.groundAlbedo, + starBrightness: starBrightness ?? this.starBrightness, + starDensity: starDensity ?? this.starDensity, + starCells: starCells ?? this.starCells, + ); + + // The march's lengths, which the shader has as constants too. + static const int _viewSteps = 16; + static const int _lightSteps = 8; + + /// `SunwardAir` from `sky_physical.frag`: the air between [p] and space + /// towards [s], as lengths at ground density, molecules then haze. + static (double, double) _sunwardAir( + Vector3 p, + Vector3 s, + PhysicalSkyKilometres k, + ) { + final r = p.length; + final span = _leave(r, p.dot(s) / r, k.top); + final stride = span / _lightSteps; + var airR = 0.0, airM = 0.0; + for (var j = 0; j < _lightSteps; j++) { + final u = (j + 0.5) * stride; + final qx = p.x + s.x * u; + final qy = p.y + s.y * u; + final qz = p.z + s.z * u; + final h = math.sqrt(qx * qx + qy * qy + qz * qz) - k.planet; + airR += math.exp(-h / k.rayleighHeight) * stride; + airM += math.exp(-h / k.mieHeight) * stride; + } + return (airR, airM); + } + + /// `Leave`: how far a ray from radius [r] at cosine [mu] runs before it + /// leaves a sphere of [radius], negative when it misses. + static double _leave(double r, double mu, double radius) { + final b = r * mu; + final d = b * b - (r - radius) * (r + radius); + return d < 0.0 ? -1.0 : -b + math.sqrt(d); + } + + /// `Meet`: how far the same ray runs before it meets the sphere from + /// outside, negative when it does not. + static double _meet(double r, double mu, double radius) { + final b = r * mu; + final d = b * b - (r - radius) * (r + radius); + return d < 0.0 ? -1.0 : -b - math.sqrt(d); + } + + static Vector3 _unit(Vector3 v, Vector3 otherwise) { + final length = v.length; + return length > 0.0 ? v / length : otherwise; + } +} + +/// A [PhysicalSky]'s numbers in the units the shader takes them in: +/// kilometres for lengths, per kilometre for coefficients. +/// +/// One conversion for both readers, so that the renderer, which writes these +/// onto the sky's vertices, and [PhysicalSky.look], which marches with them, +/// cannot convert differently. Not exported: it is the shader's view of the +/// settings, not a thing to set. +final class PhysicalSkyKilometres { + PhysicalSkyKilometres(PhysicalSky sky) + : rayleigh = sky.resolvedRayleigh * 1000.0, + rayleighHeight = sky.rayleighScaleHeight / 1000.0, + mie = sky.mie * 1000.0, + mieExtinction = (sky.mie + sky.mieAbsorption) * 1000.0, + mieHeight = sky.mieScaleHeight / 1000.0, + planet = sky.planetRadius / 1000.0, + top = (sky.planetRadius + sky.atmosphereHeight) / 1000.0, + eye = sky.eyeRadius / 1000.0; + + /// Molecular scattering per km, and its scale height in km. + final Vector3 rayleigh; + final double rayleighHeight; + + /// Haze scattering and extinction per km, and its scale height in km. + final double mie; + final double mieExtinction; + final double mieHeight; + + /// The radii of the ground, the top of the air and the eye, in km. + final double planet; + final double top; + final double eye; + + /// `Through`: what a length of air lets through, per channel. + Vector3 through(double molecules, double haze) => Vector3( + math.exp(-(rayleigh.x * molecules + mieExtinction * haze)), + math.exp(-(rayleigh.y * molecules + mieExtinction * haze)), + math.exp(-(rayleigh.z * molecules + mieExtinction * haze)), + ); +} diff --git a/packages/flutter3d_core/lib/src/engine/render/render_list.dart b/packages/flutter3d_core/lib/src/engine/render/render_list.dart index 5aa03c911..12c037f82 100644 --- a/packages/flutter3d_core/lib/src/engine/render/render_list.dart +++ b/packages/flutter3d_core/lib/src/engine/render/render_list.dart @@ -378,7 +378,9 @@ final class RenderList { // bucket. `& 0xFF` sent −1 to 255 and drew the thing dead last, silently: // a sky asked to go first went behind nothing and in front of everything. // Clamping rather than wrapping for the same reason at the other end. - final bucket = (material.drawBucket + 128).clamp(0, 255); + // `P7`: the node's own order on top of its material's. + final bucket = (material.drawBucket + item.requireNode.drawOrder + 128) + .clamp(0, 255); switch (mode) { case SortMode.stateThenDepth: diff --git a/packages/flutter3d_core/lib/src/engine/render/render_settings.dart b/packages/flutter3d_core/lib/src/engine/render/render_settings.dart index 9369099e3..b3a54682f 100644 --- a/packages/flutter3d_core/lib/src/engine/render/render_settings.dart +++ b/packages/flutter3d_core/lib/src/engine/render/render_settings.dart @@ -828,6 +828,42 @@ final class MotionBlurSettings { ); } +/// `P3`'s projected decals: every visible `DecalNode` in the scene painted +/// onto the geometry inside its box. +/// +/// **Off by default, and not because a scene without decals would pay.** A +/// frame with no visible decal registers the pass, finds it inactive and culls +/// it, whatever this says. It is off because a frame with one reads the +/// scene's surface and albedo buffers, and that turns multisampling off and +/// splits the scene pass around the decals so glass in front of one is drawn +/// over it rather than painted. Those are costs a caller turns on, the way +/// they turn on every other effect that reads the buffers. +final class DecalSettings { + const DecalSettings({this.enabled = false}); + + final bool enabled; + + DecalSettings copyWith({bool? enabled}) => + DecalSettings(enabled: enabled ?? this.enabled); +} + +/// `P4`'s planar reflections: every visible `PlanarReflectorNode` gets the +/// world drawn again through a mirrored camera, and its surfaces show it. +/// +/// **Off by default, and not because a frame without a reflector would +/// pay.** With none in the scene the pass is inactive and culled whatever +/// this says. It is off because one reflector is the scene drawn a second +/// time per view, which is a cost a caller turns on, the way they turn on +/// every other effect that doubles a pass. +final class PlanarReflectionSettings { + const PlanarReflectionSettings({this.enabled = false}); + + final bool enabled; + + PlanarReflectionSettings copyWith({bool? enabled}) => + PlanarReflectionSettings(enabled: enabled ?? this.enabled); +} + /// Which viewport shading a frame is drawn with — `gfx-43n`, `44n`, `45n`. /// /// **A final class with const instances rather than an enum**, the shape @@ -974,13 +1010,129 @@ final class ViewportShadingSettings { ); } -/// Distance fog. +/// What a debug view shows in place of the light — `P6`. +/// +/// **A final class with const instances rather than an enum**, for the reason +/// [ViewportShading] is one: [code] is what `FragInfo.debug_view.x` carries +/// to four backends, a number this type owns. +final class DebugView { + const DebugView._(this.name, this.code); + + /// The name it is written down as. + final String name; + + /// What goes into `FragInfo.debug_view.x`. Part of the shader contract. + final double code; + + /// The lit picture, untouched — the default, and an exact no-op. + static const DebugView off = DebugView._('off', 0.0); + + /// The base colour after its texture, tint and vertex colour, as the sRGB + /// colour a texture is painted in. + static const DebugView albedo = DebugView._('albedo', 1.0); + + /// The shading normal, after the normal map, as `n · 0.5 + 0.5` in world + /// space — the normal map's work, which the surface buffer's geometric + /// normal does not show. + static const DebugView normal = DebugView._('normal', 2.0); + + /// Perceptual roughness as a grey, after the metal-rough map. + static const DebugView roughness = DebugView._('roughness', 3.0); + + /// Metalness as a grey, after the metal-rough map. + static const DebugView metallic = DebugView._('metallic', 4.0); + + /// The occlusion map's share of light left, as a grey. + static const DebugView occlusion = DebugView._('occlusion', 5.0); + + /// Emission as its sRGB colour, clipped at one. + static const DebugView emissive = DebugView._('emissive', 6.0); + + /// The base colour map's coordinate, its fraction in red and green: a seam + /// is a step in colour, a stretched island a long gradient. + static const DebugView uv = DebugView._('uv', 7.0); + + /// Magenta wherever the light came out NaN or infinite, over the light's + /// own luminance in a dark grey — where a bad normal, a zero-length vector + /// or a division by nought turned shading into nothing. + static const DebugView nonFinite = DebugView._('nonFinite', 8.0); + + /// All of them, off first. + static const List values = [ + off, + albedo, + normal, + roughness, + metallic, + occlusion, + emissive, + uv, + nonFinite, + ]; + + @override + String toString() => 'DebugView.$name'; +} + +/// A material channel shown in place of the light, over all or part of the +/// frame — `P6`. +/// +/// **Written by the materials, not read back from a buffer**, which is what +/// sets this apart from [ViewportShadingSettings]: the surface buffer keeps a +/// geometric normal, a roughness and a depth, and the channels a look-dev +/// question is about — an albedo too bright, a metalness map read as sRGB, a +/// seam in the UVs — exist only inside the lit stage. Each lit model asks +/// `WriteDebugView` before it writes its light. A stage that is not a lit +/// material — the sky, particles, splats, glass's own pass — draws as it +/// always does, and right of the split it reaches the screen without the +/// tone curve, so it reads brighter there than it is. +/// +/// **The split is a wipe.** Left of [split] the frame is lit as ever, right +/// of it the channel, in one draw: a material shows where its map and its +/// light disagree without two captures to line up. Nought is the whole frame. +/// +/// **Passes between the materials and the composite still run.** Reflections, +/// depth of field, motion blur and a temporal resolve work on whatever the +/// scene holds, so turn them off for a clean read of a channel. The +/// composite's exposure, curve, bloom and grade do not touch the debug side. +final class DebugViewSettings { + const DebugViewSettings({this.view = DebugView.off, this.split = 0.0}); + + /// Which channel. [DebugView.off] is the default and an exact no-op. + final DebugView view; + + /// Where the debug view starts, as a share of the width from the left: + /// nought the whole frame, a half the right half. Held inside nought and + /// one. + final double split; + + /// Whether a frame drawn with these shows a channel anywhere. + bool get active => view != DebugView.off && split < 1.0; + + DebugViewSettings copyWith({DebugView? view, double? split}) => + DebugViewSettings(view: view ?? this.view, split: split ?? this.split); +} + +/// Distance fog, and fog that lies on the ground. /// /// Exponential per metre, which is what the level format already stores. A /// linear fog has a visible plane where it begins, and a dungeon corridor is /// exactly where that shows. +/// +/// **Height fog — `P5`.** With [heightFalloff] above nought the air thins +/// upwards, `density · e^(−heightFalloff · (y − baseHeight))`, the same law +/// `VolumetricFogSettings` marches; here it is integrated along the ray in +/// closed form instead, so it costs an exponential more than the flat fog and +/// no pass. A valley fills, a hilltop stands out of it, and a camera looking +/// up sees less fog than one looking along the ground, which a flat fog cannot +/// say. Nought, the default, is the flat fog and the same numbers to the bit. final class FogSettings { - const FogSettings({this.color, this.density = 0.0}); + const FogSettings({ + this.color, + this.density = 0.0, + this.heightFalloff = 0.0, + this.baseHeight = 0.0, + }); /// Linear, not sRGB: it is mixed with scene light before the display /// transform, and an sRGB value here reads as a fog too bright at the near @@ -1000,6 +1152,45 @@ final class FogSettings { bool get enabled => density > 0.0; + /// How fast the fog thins with height, per metre. Nought is the same fog at + /// every height; 0.1 halves it every seven metres, a mist in a valley; 0.01 + /// every seventy, the haze over a landscape. Negative is held at nought. + final double heightFalloff; + + /// The height at which the fog is [density] thick, in world metres. + final double baseHeight; + + /// How thick the fog is at height [y], per metre. + /// + /// What the shader is handed at the eye: the fog block has one spare lane, + /// which carries the falloff, so the base height is folded into the + /// density here rather than sent. Particles and splats, which fog by + /// distance alone, take the same number and so fog as a flat fog as thick + /// as the air at the camera — exact for the flat fog, and an approximation + /// once it has a falloff. + double densityAt(double y) { + final falloff = resolvedHeightFalloff; + if (falloff == 0.0) return density; + // Held where single precision keeps it: past e^±80 the shader's float + // is nought or infinite, and either is a frame of nothing but fog. + final exponent = (-falloff * (y - baseHeight)).clamp(-80.0, 80.0); + return density * math.exp(exponent); + } + + double get resolvedHeightFalloff => heightFalloff > 0.0 ? heightFalloff : 0.0; + + FogSettings copyWith({ + vm.Vector3? color, + double? density, + double? heightFalloff, + double? baseHeight, + }) => FogSettings( + color: color ?? this.color, + density: density ?? this.density, + heightFalloff: heightFalloff ?? this.heightFalloff, + baseHeight: baseHeight ?? this.baseHeight, + ); + static final vm.Vector3 _defaultColor = vm.Vector3(0.05, 0.05, 0.05); } @@ -1150,7 +1341,10 @@ final class RenderSettings { this.volumetricFog = const VolumetricFogSettings(), this.depthOfField = const DepthOfFieldSettings(), this.motionBlur = const MotionBlurSettings(), + this.planarReflections = const PlanarReflectionSettings(), + this.decals = const DecalSettings(), this.viewportShading = const ViewportShadingSettings(), + this.debugView = const DebugViewSettings(), this.transparency = TransparencyMode.sorted, }) : assert(anisotropy >= 1, 'anisotropy is a count of taps, one or more'), assert(lightFadeBand >= 0.0, 'a fade band is a width, not a direction'); @@ -1268,7 +1462,7 @@ final class RenderSettings { /// eight-bit answer — `auto_batch_test.dart` holds a hundred cubes, turned and /// scaled, to byte equality. Impeller, WebGL and WebGPU compute in 32-bit /// floats, where those expressions have far less room before they part, and - /// nothing headless can run them. So the seventy-eight goldens keep the frame + /// nothing headless can run them. So the ninety-five goldens keep the frame /// they have, and an application that wants the draw calls back asks. /// /// Shadows and picking are unaffected: both walk the scene themselves and @@ -1357,10 +1551,21 @@ final class RenderSettings { /// `R6`'s motion blur along the velocity buffer. final MotionBlurSettings motionBlur; + /// `P4`'s planar reflections, each a picture taken through a mirrored + /// camera before the scene. + final PlanarReflectionSettings planarReflections; + + /// `P3`'s projected decals, painted onto the scene before its glass. + final DecalSettings decals; + /// `gfx-43n`/`44n`/`45n`'s shading read out of the surface buffer rather /// than out of the materials. final ViewportShadingSettings viewportShading; + /// `P6`'s material channels in place of the light, over all or part of the + /// frame. + final DebugViewSettings debugView; + final FogSettings fog; /// What is behind everything, when there is anything. @@ -1658,7 +1863,10 @@ final class RenderSettings { VolumetricFogSettings? volumetricFog, DepthOfFieldSettings? depthOfField, MotionBlurSettings? motionBlur, + PlanarReflectionSettings? planarReflections, + DecalSettings? decals, ViewportShadingSettings? viewportShading, + DebugViewSettings? debugView, TransparencyMode? transparency, }) => RenderSettings( specular: specular ?? this.specular, @@ -1704,7 +1912,10 @@ final class RenderSettings { volumetricFog: volumetricFog ?? this.volumetricFog, depthOfField: depthOfField ?? this.depthOfField, motionBlur: motionBlur ?? this.motionBlur, + planarReflections: planarReflections ?? this.planarReflections, + decals: decals ?? this.decals, viewportShading: viewportShading ?? this.viewportShading, + debugView: debugView ?? this.debugView, transparency: transparency ?? this.transparency, ); @@ -1780,9 +1991,15 @@ final class RenderSettings { 'shadow moments', // `L4`: the irradiance field's probes, a few a frame. 'irradiance update', + // `P4`: what the scene's cameras into textures and its planar reflectors + // see, drawn before the scene that shows them. + 'render textures', + 'planar reflections', // Reflection probes are registered here, one per probe in the scene, and // are named by index rather than by a constant — see [probePassName]. 'scene', + // `P3`: painted onto the opaque half, before the glass is drawn over it. + 'decals', // `M3`: on a frame with glass, the scene as the opaque half left it, and // the glass and the transparent half drawn over it. Culled on any other. 'scene colour copy', @@ -1821,6 +2038,9 @@ final class RenderSettings { // `R7`: measured on the resolved picture, applied in the composite. 'local exposure', 'bloom', + // `P2`: drawn from the glow and added to it, before the composite reads + // it. + 'lens flare', 'composite', // `R5`: the finished picture brought up to the asked-for size, before // the sharpening that follows it. @@ -1862,6 +2082,7 @@ final class RenderSettings { /// composite is where `tonemap: false` is honoured. static const Set pixelAlteringPasses = { 'bloom', + 'lens flare', 'ssao', // Multiplied into the ambient term in the composite beside the occlusion, // so it moves a measured pixel exactly as `ssao` does. @@ -2025,7 +2246,7 @@ final class DisplayTransform { /// **Everything here defaults to doing nothing, exactly.** Not nearly nothing: /// a vignette of zero multiplies by one and grain of zero adds zero, so a scene /// that asks for none of it composites to the same bytes it did before this -/// existed. Seventy-eight goldens depend on that being exact, and the composite +/// existed. Ninety-five goldens depend on that being exact, and the composite /// pass already keeps the same promise for ambient occlusion. /// /// Applied in the composite rather than as passes of their own, which is the @@ -2045,6 +2266,7 @@ final class LookSettings { this.vignetteRoundness = 1.0, this.grain = 0.0, this.chromaticAberration = 0.0, + this.distortion = 0.0, this.dither = 1.0 / 255.0, this.lift, this.gamma, @@ -2091,6 +2313,19 @@ final class LookSettings { /// visible without reading as a fault. final double chromaticAberration; + /// Radial lens distortion — `P2`: barrel above nought, pincushion below, + /// nought off exactly. The frame is bent by `1 + k·r²` about its middle on + /// its own aspect and scaled so its border stays on the frame: a barrel + /// holds the corners, a pincushion the edge nearest the middle. 0.1 is a + /// wide lens's gentle bow; past 0.5 it reads as a fisheye. + /// + /// **Everything laid over the scene bends with it** — the glow, the + /// occlusion, the contact shadow, the local exposure — because the + /// composite bends the coordinate once, before anything is read. The + /// vignette, the grain and the dither stay put: they are the film, and the + /// film does not move with the glass. + final double distortion; + /// Where black is **lifted to**, per channel, so the shadows move and white /// stays — `gfx-27n`. Null is neutral, the same as zero. Applied as /// `c * (1 - lift) + lift`; until 0.7.4 it was a plain add, which moved @@ -2167,6 +2402,8 @@ final class LookSettings { /// and N tall, blue selecting the slice, red running across it and green /// down. [buildIdentityLut] makes the one that changes nothing, which is /// what a test compares against and what somebody starts from. + /// `CubeLut.parse(...).upload(device)` makes one from a `.cube` file, the + /// format the grading tools write — `P2`. /// /// Null is not "a neutral table": nothing is sampled at all, and that is /// the difference [lutStrength] of zero also makes. @@ -2203,6 +2440,7 @@ final class LookSettings { vignette == 0.0 && grain == 0.0 && chromaticAberration == 0.0 && + distortion == 0.0 && whiteBalance == 0.0 && tint == 0.0 && _isNeutralTriple(lift, 0.0) && @@ -2223,6 +2461,7 @@ final class LookSettings { double? vignetteRoundness, double? grain, double? chromaticAberration, + double? distortion, double? dither, vm.Vector3? lift, vm.Vector3? gamma, @@ -2240,6 +2479,7 @@ final class LookSettings { vignetteRoundness: vignetteRoundness ?? this.vignetteRoundness, grain: grain ?? this.grain, chromaticAberration: chromaticAberration ?? this.chromaticAberration, + distortion: distortion ?? this.distortion, dither: dither ?? this.dither, lift: lift ?? this.lift, gamma: gamma ?? this.gamma, @@ -2293,6 +2533,7 @@ int bloomLevelsFor(BloomSettings settings, {required int frameHeight}) { final class AntiAliasSettings { const AntiAliasSettings({ this.enabled = false, + this.method = EdgeSmoothing.fxaa, this.contrastThreshold = 0.125, this.blend = 0.75, this.sharpen = 0.0, @@ -2301,6 +2542,11 @@ final class AntiAliasSettings { final bool enabled; + /// How the edges are found and smoothed when [enabled] — `P1`. + /// [EdgeSmoothing.fxaa], the one pass this setting has always run, is the + /// default. + final EdgeSmoothing method; + /// Anti-aliasing across frames — `R1`, `R2`. Independent of [enabled]: the /// temporal resolve and the edge pass can run together, and the resolve is /// the one that replaces multisampling. @@ -2346,12 +2592,14 @@ final class AntiAliasSettings { AntiAliasSettings copyWith({ bool? enabled, + EdgeSmoothing? method, double? contrastThreshold, double? blend, double? sharpen, TemporalSettings? temporal, }) => AntiAliasSettings( enabled: enabled ?? this.enabled, + method: method ?? this.method, contrastThreshold: contrastThreshold ?? this.contrastThreshold, blend: blend ?? this.blend, sharpen: sharpen ?? this.sharpen, @@ -2359,6 +2607,35 @@ final class AntiAliasSettings { ); } +/// How [AntiAliasSettings] finds and smooths an edge — `P1`. +/// +/// **A class with constants rather than an enum**, because the set grows: +/// SMAA's 2x and 4x modes, or its diagonal search, would each be one more +/// value, and an enum would break every `switch` written against it the day +/// one arrived. +final class EdgeSmoothing { + const EdgeSmoothing._(this.name); + + /// FXAA 3.11 Quality: one pass, which walks along an edge from every pixel + /// with enough local contrast and moves it towards the side the edge leans + /// to. Uses [AntiAliasSettings.contrastThreshold] and + /// [AntiAliasSettings.blend]. + static const EdgeSmoothing fxaa = EdgeSmoothing._('fxaa'); + + /// SMAA 1x: three passes — the edges marked, the line behind each + /// staircase reconstructed from where its run ends and which sides those + /// ends are crossed on, and each pixel blended by the area that line + /// covers of it. Sharper than FXAA on text and fine texture, which it + /// leaves alone, and truer on long shallow edges; a little more work. + /// Orthogonal edges only: no diagonal search and no corner rounding. + static const EdgeSmoothing smaa = EdgeSmoothing._('smaa'); + + final String name; + + @override + String toString() => 'EdgeSmoothing.$name'; +} + /// Edges smoothed across frames — `R1`, `R2`. /// /// **The scene is drawn a fraction of a pixel off each frame**, along a @@ -2540,10 +2817,15 @@ final class BloomSettings { this.referenceHeight = 0, this.halation = 0.0, this.scatter = 1.0, + this.lensFlare = const LensFlareSettings(), }); final bool enabled; + /// Ghosts and a halo thrown from what blooms — `P2`. Off by default, and + /// off whenever the bloom is: it is drawn from the glow. + final LensFlareSettings lensFlare; + /// Luminance above which a pixel starts to bloom. One is display white, which /// is the only value with a physical meaning: below it nothing is clipping, /// above it the display cannot show the difference and a lens would scatter. @@ -2634,6 +2916,7 @@ final class BloomSettings { int? referenceHeight, double? halation, double? scatter, + LensFlareSettings? lensFlare, }) => BloomSettings( enabled: enabled ?? this.enabled, threshold: threshold ?? this.threshold, @@ -2644,5 +2927,82 @@ final class BloomSettings { referenceHeight: referenceHeight ?? this.referenceHeight, halation: halation ?? this.halation, scatter: scatter ?? this.scatter, + lensFlare: lensFlare ?? this.lensFlare, + ); +} + +/// The reflections a lens throws from a bright light — `P2`. +/// +/// **Drawn from the glow, and so only where something blooms.** The bloom +/// chain has already found what is bright and spread it; reading it again at +/// positions mirrored through the middle of the frame is what puts each ghost +/// where the light's reflection between two lens elements would land. A +/// frame with nothing over the bloom threshold has no flare, which is the +/// right answer, and a frame with the bloom off has none at all. +/// +/// The ghosts thin out towards the edges and none is drawn past the frame, +/// and each one's three channels are taken a little apart along its line, +/// the fringe a coated lens leaves on its reflections. The ring is every +/// light at [haloRadius] from the middle landing on the far side of it. +/// +/// Added into the glow, so the composite scales it by the bloom's own +/// intensity and tone maps it with the rest of the light. +/// +/// **A ghost is as soft as the glow it is drawn from.** Five levels at full +/// weight — the bloom's defaults — throw a small lamp's skirt half the frame +/// wide, and its reflections are as wide as that: a wash rather than a row +/// of discs. Fewer [BloomSettings.levels] or a [BloomSettings.scatter] below +/// one tighten the glow, and the ghosts with it. +final class LensFlareSettings { + const LensFlareSettings({ + this.enabled = false, + this.intensity = 1.0, + this.ghosts = 4, + this.ghostSpacing = 0.35, + this.haloRadius = 0.45, + this.halo = 0.5, + this.fringe = 0.004, + }); + + final bool enabled; + + /// How much flare, against the glow it is drawn from. + final double intensity; + + /// How many ghosts, up to eight. + final int ghosts; + + /// How far apart the ghosts fall, as a fraction of the way from the light + /// to the middle of the frame. + final double ghostSpacing; + + /// The halo's radius, as a fraction of the frame's height. + final double haloRadius; + + /// The halo's strength against the ghosts'. Nought draws no ring. + final double halo; + + /// How far apart a ghost's channels are taken, in screen widths. + final double fringe; + + /// Whether a flare is drawn at all. + bool get isActive => enabled && intensity > 0.0 && ghosts > 0; + + LensFlareSettings copyWith({ + bool? enabled, + double? intensity, + int? ghosts, + double? ghostSpacing, + double? haloRadius, + double? halo, + double? fringe, + }) => LensFlareSettings( + enabled: enabled ?? this.enabled, + intensity: intensity ?? this.intensity, + ghosts: ghosts ?? this.ghosts, + ghostSpacing: ghostSpacing ?? this.ghostSpacing, + haloRadius: haloRadius ?? this.haloRadius, + halo: halo ?? this.halo, + fringe: fringe ?? this.fringe, ); } diff --git a/packages/flutter3d_core/lib/src/engine/render/render_texture.dart b/packages/flutter3d_core/lib/src/engine/render/render_texture.dart new file mode 100644 index 000000000..5394375fb --- /dev/null +++ b/packages/flutter3d_core/lib/src/engine/render/render_texture.dart @@ -0,0 +1,120 @@ +import 'package:flutter3d_hardware/flutter3d_hardware.dart'; +import 'package:vector_math/vector_math.dart'; + +import '../scene/camera_node.dart'; +import '../scene/mesh_node.dart'; + +/// A camera that draws into a texture rather than onto the screen — `P4`: +/// a security monitor, a portal, a minimap, a mirror at an angle no plane +/// would give. +/// +/// **The same pass as a planar reflection's**, pointed through an ordinary +/// camera instead of a mirrored one: the scene's meshes with the frame's +/// lights and shadows, its sky behind them, drawn before the frame's own +/// scene so a material can show the picture in the same frame it was taken. +/// No post chain runs on it, and nothing a contributor draws — particles, +/// splats — is in it. +/// +/// [texture] holds sRGB bytes, the way a picture loaded from a file does, so +/// it goes into a material's albedo or emissive slot as one: an unlit quad +/// shows exactly what the camera saw, and a lit one shows it lit again, like +/// a printed photograph. The light is multiplied by [exposure] and clipped +/// at one; tone mapping is left to the frame that shows it, which would +/// otherwise apply it twice. Its first row is the top of the picture on +/// every backend, as an uploaded image's is, so a mesh shows it the right +/// way up where it would show a photograph the right way up: a +/// `PlaneShape` stood up, yes; the sides of a `CuboidShape`, whose v runs +/// up, no. +/// +/// Owned by whoever made it: add it to a scene with +/// `Scene.addRenderTexture` to have it drawn, and give [texture] back with +/// `Renderer.releaseTextureAfterFrame` when it is done with. +final class RenderTexture { + /// A texture of [width] × [height] for [camera] to draw into, made on + /// [device]. + factory RenderTexture.create( + GraphicsDevice device, { + required CameraNode camera, + required int width, + required int height, + int layerMask = ~0, + Vector4? clearColor, + double exposure = 1.0, + bool refreshEveryFrame = true, + }) { + if (width < 1 || height < 1) { + throw ArgumentError( + 'a RenderTexture of ${width}x$height has no pixels to draw into; ' + 'ask for at least one each way', + ); + } + return RenderTexture._( + device.createTexture( + RenderTargetSpec( + width: width, + height: height, + format: TextureFormat.r8g8b8a8UNormInt, + ), + ), + camera: camera, + layerMask: layerMask, + clearColor: clearColor ?? Vector4(0.055, 0.062, 0.078, 1.0), + exposure: exposure, + refreshEveryFrame: refreshEveryFrame, + ); + } + + RenderTexture._( + this.texture, { + required this.camera, + required this.layerMask, + required this.clearColor, + required this.exposure, + required this.refreshEveryFrame, + }); + + /// What the camera drew, as sRGB bytes. Whatever the allocation held until + /// the first frame draws it; see [isDrawn]. + final TextureHandle texture; + + int get width => texture.width; + int get height => texture.height; + + /// Where the picture is taken from, with its own projection; the aspect is + /// the texture's. + CameraNode camera; + + /// Which layers the camera draws. + int layerMask; + + /// What shows where nothing was drawn and the sky is off, sRGB-encoded as + /// `RenderView.clearColor` is. + Vector4 clearColor; + + /// What the light is multiplied by before it is clipped and encoded. + double exposure; + + /// Whether every frame draws it again. False draws it once and then only + /// after [invalidate]: a portrait of a room nobody changes is one picture, + /// not sixty a second. + bool refreshEveryFrame; + + /// Meshes the camera leaves out — the screen showing the picture, most + /// often, which would otherwise show itself. + final Set excluded = {}; + + /// Asks for the picture to be drawn again on the next frame. + void invalidate() => _generation++; + + int _generation = 0; + int _drawnGeneration = -1; + + /// Whether [texture] holds a picture rather than an allocation's contents. + bool get isDrawn => _drawnGeneration >= 0; + + /// Whether the next frame has to draw it. + bool get isDue => refreshEveryFrame || _drawnGeneration != _generation; + + /// Called by the renderer once the picture is in [texture]. + void markDrawn() => _drawnGeneration = _generation; +} diff --git a/packages/flutter3d_core/lib/src/engine/render/renderer.dart b/packages/flutter3d_core/lib/src/engine/render/renderer.dart index daedd0b6a..fbcf6cae6 100644 --- a/packages/flutter3d_core/lib/src/engine/render/renderer.dart +++ b/packages/flutter3d_core/lib/src/engine/render/renderer.dart @@ -11,6 +11,7 @@ import 'package:vector_math/vector_math.dart' as vm; import '../geometry/device_mesh.dart'; import '../scene/camera_node.dart'; +import '../scene/decal_node.dart'; import '../scene/instanced_mesh_node.dart'; import '../scene/irradiance_field.dart'; import '../scene/irradiance_gather.dart' show kIrradianceReach; @@ -21,6 +22,7 @@ import '../scene/morph_state.dart'; import '../scene/occlusion/hi_z_occlusion.dart'; import '../scene/occlusion/occlusion_test.dart'; import '../scene/occlusion/software_occlusion.dart'; +import '../scene/planar_reflector_node.dart'; import '../scene/projection.dart'; import '../scene/reflection_probe_node.dart'; import '../scene/scene.dart'; @@ -29,6 +31,7 @@ import 'cluster_draws.dart'; import 'composite_mix.dart'; import 'debug_draw.dart'; import 'debug_draw_gizmos.dart'; +import 'draw_journal.dart'; import 'empty_frame.dart'; import 'engine_tables.dart'; import 'field_pass.dart'; @@ -41,13 +44,16 @@ import 'frame_work_budget.dart'; import 'identity_indices.dart'; import 'light_clusters.dart'; import 'material.dart'; +import 'mirror_view.dart'; import 'object_id_frame.dart'; import 'pass_contributor.dart'; +import 'physical_sky.dart'; import 'probe_faces.dart'; import 'procedural_texture.dart'; import 'render_list.dart'; import 'render_node.dart'; import 'render_settings.dart'; +import 'render_texture.dart'; import 'render_view.dart'; import 'scene_colour_chain.dart'; import 'shadow_slots.dart'; @@ -65,12 +71,14 @@ export 'render_settings.dart'; part 'renderer_batch.dart'; part 'renderer_contributor_lights.dart'; +part 'renderer_decal_pass.dart'; part 'renderer_fog_pass.dart'; part 'renderer_frame_nodes.dart'; part 'renderer_irradiance_pass.dart'; part 'renderer_light_list.dart'; part 'renderer_mesh_encode.dart'; part 'renderer_pick_pass.dart'; +part 'renderer_planar_pass.dart'; part 'renderer_post_pass.dart'; part 'renderer_probe_pass.dart'; part 'renderer_resources.dart'; @@ -176,6 +184,7 @@ final class Renderer implements RenderServices { required this.velocityNeighborMaxShader, required this.motionBlurShader, required this.viewportShadeShader, + required this.decalShader, required this.wboitResolveShader, required this.sceneColourCopyShader, required TextureHandle fallbackAlbedo, @@ -213,7 +222,54 @@ final class Renderer implements RenderServices { /// application that compiles a stage of its own and hands the library in gets /// that stage found first, and needs no entry in any table of this package's. ShaderLibrary get shaders => _shaders; - late final ShaderLibrary _shaders; + late ShaderLibrary _shaders; + + /// What [shaders] is built over: `materials` as [Renderer.create] was + /// given it, over the backend's bundle. + late final ShaderLibrary _baseShaders; + + /// The libraries [addMaterials] added, the latest first. + final List _addedMaterials = []; + + /// Adds [library] to the stages this renderer finds by name, ahead of + /// everything it already had — `P8`. + /// + /// **More than one bundle of materials.** The build hook compiles each + /// `.f3dmat` into a bundle of its own, a level loaded after the renderer + /// was made brings its own, and `materials` at [Renderer.create] is one + /// library. Added later means consulted earlier, as a layer is: a bundle + /// that names a stage another already has is replacing it, which is what a + /// game loading a variant of a look means by it. Adding a library that is + /// already here moves it to the front. What the renderer resolved by name + /// is forgotten, since a name may now answer differently, and linked again + /// at the next draw that asks. + void addMaterials(ShaderLibrary library) { + _addedMaterials + ..remove(library) + ..insert(0, library); + _relayerShaders(); + } + + /// Takes [library] back out of what [addMaterials] added, and says whether + /// it was there. A material still naming one of its stages draws as a + /// material whose stage is missing does. + bool removeMaterials(ShaderLibrary library) { + final removed = _addedMaterials.remove(library); + if (removed) _relayerShaders(); + return removed; + } + + void _relayerShaders() { + _fragmentShaders.clear(); + _materialVertexShaders.clear(); + _pipelineCache.clear(); + _shaders = _addedMaterials.isEmpty + ? _baseShaders + : ShaderLibraryStack([ + ..._addedMaterials, + _baseShaders, + ]); + } /// What draws alongside the world. /// @@ -365,6 +421,9 @@ final class Renderer implements RenderServices { /// `gfx-43n`/`44n`/`45n`'s three branches over the surface buffer. final ShaderHandle viewportShadeShader; + /// `P3`'s projected decals, read out of the surface and albedo buffers. + final ShaderHandle decalShader; + /// `R8`'s resolve: the transparent layers' weighted average, laid over the /// scene. final ShaderHandle wboitResolveShader; @@ -571,10 +630,13 @@ final class Renderer implements RenderServices { probe.capture, probe.filtered, ], + for (final planar in _planarStates.values) ...planar.textures, ]) { if (texture != null) device.releaseTexture(texture); } _probeStates.clear(); + _planarStates.clear(); + _planarMaterial.extraTextures.clear(); _fallbackAlbedo = null; _fallbackNormal = null; _fallbackBlack = null; @@ -742,6 +804,8 @@ final class Renderer implements RenderServices { final FragCoordInfoBlock _fragCoordInfo = FragCoordInfoBlock(); final FragInfoBlock _fragInfo = FragInfoBlock(); final FxaaInfoBlock _fxaaInfo = FxaaInfoBlock(); + final SmaaInfoBlock _smaaInfo = SmaaInfoBlock(); + final LensFlareInfoBlock _lensFlareInfo = LensFlareInfoBlock(); final EasuInfoBlock _easuInfo = EasuInfoBlock(); final LocalExposureInfoBlock _localExposureInfo = LocalExposureInfoBlock(); final LocalExposureBlurInfoBlock _localExposureBlurInfo = @@ -756,6 +820,7 @@ final class Renderer implements RenderServices { final ProbeInfoBlock _probeInfo = ProbeInfoBlock(); final ReflectionInfoBlock _reflectionInfo = ReflectionInfoBlock(); final ShadeInfoBlock _shadeInfo = ShadeInfoBlock(); + final DecalInfoBlock _decalInfo = DecalInfoBlock(); final ShadowLightBlock _shadowLight = ShadowLightBlock(); final ShaftInfoBlock _shaftInfo = ShaftInfoBlock(); final VolumeFogInfoBlock _volumeFogInfo = VolumeFogInfoBlock(); @@ -807,6 +872,28 @@ final class Renderer implements RenderServices { final vm.Vector3 _probePosition = vm.Vector3.zero(); PipelineHandle? _probePrefilterPipeline; + /// The planar reflectors this renderer has drawn, by node: a picture per + /// view, kept across frames — see `renderer_planar_pass.dart`. + final Map _planarStates = + {}; + + /// What a reflector's surfaces are drawn again with: its picture, its view + /// and its reflectance, written per reflector, and the surface's own + /// sidedness written per draw. Held for the x-ray materials' reason. + final PlanarReflectionInfoBlock _planarInfo = PlanarReflectionInfoBlock(); + late final Material _planarMaterial = Material( + name: 'planar reflection', + lighting: LightingModel.planarReflection, + depthWrite: false, + depthCompare: CompareFunction.lessEqual, + parameterBlock: _planarInfo.name, + parameters: _planarInfo.members, + extraTextures: {}, + ); + + /// A render texture's exposure, for the encode into its bytes. + final RenderTextureInfoBlock _renderTextureInfo = RenderTextureInfoBlock(); + PipelineHandle? _debugLinePipeline; /// A 0, 1, 2, … index buffer for the debug overlay. @@ -873,9 +960,12 @@ final class Renderer implements RenderServices { /// renderer to reload. /// /// Contributors keep their own pipelines — `ParticleContributor` links once - /// and holds — and are not reached from here; a contributor that wants to - /// follow a reload exposes its own way to drop what it linked. + /// and holds — so each is asked to drop what it linked, through + /// [PassContributor.relinkShaders]. void relinkShaders() { + for (final contributor in contributors.all) { + contributor.relinkShaders(); + } _pipelineCache.clear(); _fragmentShaders.clear(); _fullscreenPipelines.clear(); @@ -896,6 +986,7 @@ final class Renderer implements RenderServices { _probePrefilterPipeline = null; _skyPipeline = null; _skyCubePipeline = null; + _skyPhysicalPipeline = null; _cubeShadowPipeline = null; _skinnedCubeShadowPipeline = null; _instancedCubeShadowPipeline = null; @@ -1126,6 +1217,36 @@ final class Renderer implements RenderServices { PipelineHandle? _skinnedMaskedCubeShadowPipeline; PipelineHandle? _instancedMaskedCubeShadowPipeline; + /// The same three once more, against `ShadowTransmittance`: see-through + /// casters writing what they let through into the sun's atlas — + /// `ShadowSettings.translucentCasters`. Never built while it is off. + PipelineHandle? _transmittanceShadowPipeline; + PipelineHandle? _skinnedTransmittanceShadowPipeline; + PipelineHandle? _instancedTransmittanceShadowPipeline; + + /// A see-through caster's colour, opacity, transmission and head-on + /// reflectance, and the way to the light, for `ShadowTransmittance`. + final TransmittanceInfoBlock _transmittanceInfo = TransmittanceInfoBlock(); + + /// `ShadowSettings.caustics`: a refracting caster's faces into its maps, + /// and its photons into the atlas; built the first frame they are needed. + PipelineHandle? _causticSurfacePipeline; + PipelineHandle? _causticPhotonPipeline; + + /// Six corners, two triangles: the quad each photon is drawn as. + GeometryBuffer? _causticQuad; + + /// A photon's matrices, grid and the caster's optics. + final CausticInfoBlock _causticInfo = CausticInfoBlock(); + + /// Whether the sun's atlas, as last drawn, had photons added to it. + bool _shadowCaustics = false; + + /// Whether the sun's atlas, as last drawn, carries what see-through + /// casters let through — read by the draws that light with it, which + /// otherwise would take an atlas's empty channels for a black filter. + bool _shadowTransmits = false; + /// `gfx-60n`: the cutoff and the base alpha, packed for the shadow stages. Float32List get _shadowMask => _maskInfo.mask; PipelineHandle? _instancedShadowPipeline; @@ -1149,6 +1270,9 @@ final class Renderer implements RenderServices { /// The textured half of the same pair, built only if a cube is ever set. PipelineHandle? _skyCubePipeline; + /// The air's, built only if `SkySettings.physical` is ever set — `P5`. + PipelineHandle? _skyPhysicalPipeline; + /// World space to the shadow camera's clip space, rebuilt each frame the /// light or the scene moves. final vm.Matrix4 _shadowMatrix = vm.Matrix4.identity(); @@ -1518,7 +1642,7 @@ final class Renderer implements RenderServices { /// `gfx-76n`'s strength, in x. Neutral is zero, which the composite reads as /// a multiplier of exactly one — the same arrangement the occlusion's - /// strength has, and for the same reason: seventy-eight goldens go through this + /// strength has, and for the same reason: ninety-five goldens go through this /// block and "off" has to be a number the shader cancels, not one it nearly /// cancels. Float32List get _compositeContact => _compositeInfo.contact; @@ -1603,6 +1727,7 @@ final class Renderer implements RenderServices { velocityNeighborMaxShader: require('VelocityNeighborMax'), motionBlurShader: require('MotionBlur'), viewportShadeShader: require('ViewportShade'), + decalShader: require('Decal'), wboitResolveShader: require('WboitResolve'), sceneColourCopyShader: require('SceneColourCopy'), fallbackAlbedo: @@ -1614,6 +1739,7 @@ final class Renderer implements RenderServices { ).upload(device), msaaEnabled: device.supportsOffscreenMsaa, ) + .._baseShaders = library .._shaders = library .._listenForGpuTimings(); } @@ -2107,7 +2233,20 @@ final class Renderer implements RenderServices { var upX = 1.0, upY = 1.0, upZ = 1.0; var downX = 1.0, downY = 1.0, downZ = 1.0; - if (sky.enabled) { + final air = sky.cubemap == null ? sky.physical : null; + if (sky.enabled && air != null) { + // `P5`: the same halves, out of the air. Taken off the scattered light + // with no disc, for the reason above, and a horizon averaged round the + // compass, because towards the sun and away from it differ by a factor + // of five at dusk and a surface faces neither. + final up = _physicalAmbient(air, sky.resolvedDirectionToSun); + upX = up.up.x; + upY = up.up.y; + upZ = up.up.z; + downX = up.down.x; + downY = up.down.y; + downZ = up.down.z; + } else if (sky.enabled) { final zenith = sky.resolvedZenith; final horizon = sky.resolvedHorizon; final nadir = sky.resolvedNadir; @@ -2134,6 +2273,55 @@ final class Renderer implements RenderServices { _ambientSky[3] = settings.diffuseModel == DiffuseModel.eon ? 1.0 : 0.0; } + /// The two ends of the hemispheric ambient under a physical sky: half the + /// zenith and half the horizon, and half the horizon and half the ground, + /// as [_updateAmbient] takes them from a gradient. + /// + /// Kept from frame to frame while the air and the sun do not change: it is + /// ten marches of the sky, which is nothing once and something every frame + /// on a phone. + ({vm.Vector3 up, vm.Vector3 down}) _physicalAmbient( + PhysicalSky air, + vm.Vector3 toSun, + ) { + final kept = _physicalAmbientKept; + if (kept != null && + identical(kept.air, air) && + kept.toSun.x == toSun.x && + kept.toSun.y == toSun.y && + kept.toSun.z == toSun.z) { + return (up: kept.up, down: kept.down); + } + final zenith = air.radiance(vm.Vector3(0.0, 1.0, 0.0), toSun); + final ground = air.radiance(vm.Vector3(0.0, -1.0, 0.0), toSun); + final horizon = vm.Vector3.zero(); + const around = 8; + for (var i = 0; i < around; i++) { + final angle = 2.0 * math.pi * i / around; + // A degree above the horizon rather than on it: exactly level, the ray + // grazes the ground's sphere and which side it lands is rounding. + horizon.addScaled( + air.radiance( + vm.Vector3(math.cos(angle), 0.0175, math.sin(angle)), + toSun, + ), + 1.0 / around, + ); + } + final up = (zenith + horizon)..scale(0.5); + final down = (horizon + ground)..scale(0.5); + _physicalAmbientKept = ( + air: air, + toSun: vm.Vector3.copy(toSun), + up: up, + down: down, + ); + return (up: up, down: down); + } + + ({PhysicalSky air, vm.Vector3 toSun, vm.Vector3 up, vm.Vector3 down})? + _physicalAmbientKept; + /// Per atlas row: xyz the direction a spot aims, w the tangent of half its /// frustum — or w negative when the row belongs to a point light. /// @@ -2244,7 +2432,7 @@ final class Renderer implements RenderServices { for (final view in views) { if (view.priority < primary.priority) primary = view; } - primary.camera.readWorldPosition(_shadowEye); + primary.camera.readViewOrigin(_shadowEye); for (final light in scene.lights) { final spot = light.type == LightType.spot; @@ -2474,6 +2662,8 @@ final class Renderer implements RenderServices { required _ShadowMapNode shadow, required List<_ReflectionProbeNode> probes, required _IrradianceUpdateNode irradiance, + required _RenderTextureNode renderTextures, + required _PlanarReflectionNode planarReflections, required _SceneNode scene, required _BloomNode bloom, required _CompositeNode composite, @@ -2525,8 +2715,17 @@ final class Renderer implements RenderServices { // `L4`: beside the probes, for their reason — it draws the lit scene and // the scene reads what it writes. graph.addNode(irradiance); + // `P4`: beside the probes, for their reason — each draws the lit scene + // through a camera of its own, and the scene optionally reads what each + // provides. Registered whatever the scene holds, so the names are known. + graph + ..addNode(renderTextures) + ..addNode(planarReflections); graph ..addNode(scene) + // `P3`: onto the opaque half, so the copy the glass reads holds the + // decals and the glass is drawn over them rather than painted. + ..addNode(scene.decals) // `M3`: the copy of the scene and the transparent half drawn over it, // straight after the scene they split, and culled on a frame without // glass. @@ -2652,6 +2851,9 @@ final class Renderer implements RenderServices { ..addNode(resolve) ..addNode(_LocalExposureNode(this, s)) ..addNode(bloom) + // `P2`: the flare, drawn from the glow and added to it, so the + // composite reads both as one. + ..addNode(_LensFlareNode(this, s.bloom)) ..addNode(composite) ..addNode(easu) // And the smoothing after the composite, which is what lets it read a @@ -2873,13 +3075,25 @@ final class Renderer implements RenderServices { /// /// Asking twice before a frame runs replaces the first request: there is one /// next frame. - Future captureNextFrame() { + /// + /// [draws] also writes every draw down — see [DrawJournal] — and + /// [readFloats] reads each output's floats beside its bytes, for a backend + /// that can: `P12`'s inspector needs both, and a capture for a person + /// looking at pictures needs neither. + Future captureNextFrame({ + bool draws = false, + Float32List? Function(TextureHandle texture)? readFloats, + }) { final completer = Completer(); _captureWanted = completer; + _captureDraws = draws; + _captureFloats = readFloats; return completer.future; } Completer? _captureWanted; + bool _captureDraws = false; + Float32List? Function(TextureHandle texture)? _captureFloats; /// Hands [wanted] the capture this frame built, and clears the builder. /// @@ -3029,13 +3243,21 @@ final class Renderer implements RenderServices { } /// Floats per vertex: two of clip position, three of ray, then six vec4s of - /// preset — or one vec4 of tint for the cube. + /// preset — the gradient's or the air's, the same size — or one vec4 of + /// tint for the cube. static const int _kSkyVertexFloats = 2 + 3 + 6 * 4; static const int _kSkyCubeVertexFloats = 2 + 3 + 4; final Float32List _skyVertexData = Float32List(3 * _kSkyVertexFloats); final vm.Vector3 _skyRay = vm.Vector3.zero(); + /// The lens the sky is seen through when the camera's is orthographic — + /// `P7`, set by `_encodeSky` for the one draw: the view axis, the world + /// directions of the frame's right and top edges, and the frame's aspect. + /// Null through a perspective lens, whose own rays the sky takes. + ({vm.Vector3 forward, vm.Vector3 right, vm.Vector3 up, double aspect})? + _skyOrthoLens; + /// Pipelines for full-screen stages this class does not have a field for. /// /// The engine's own effects each keep theirs in a named field, which is fine @@ -3192,6 +3414,45 @@ final class Renderer implements RenderServices { depthCompare: CompareFunction.less, ); + /// How a see-through caster is drawn into the sun's atlas: tested against + /// the opaque casters' depth and writing none, so a pane behind a wall adds + /// nothing and two panes both count; both faces, since the stage counts a + /// closed body as crossed by its two surfaces; and blended so that layers + /// combine — what is taken from red and green as `src + dst·(1 − src)`, + /// which keeps the depth in red because the stage writes nought there, + /// and what is left of blue, in alpha, multiplied. See + /// `shadow_transmittance.frag` for the layout. + static const PassState _kShadowTransmittanceState = PassState( + primitiveType: PrimitiveType.triangle, + cullMode: CullMode.none, + blend: BlendState( + sourceColorFactor: BlendFactor.one, + destinationColorFactor: BlendFactor.oneMinusSourceColor, + sourceAlphaFactor: BlendFactor.destinationAlpha, + destinationAlphaFactor: BlendFactor.zero, + ), + depthWrite: false, + depthCompare: CompareFunction.less, + ); + + /// Photons given back into the atlas: colour as destination minus source, + /// which takes from what green and blue say was taken from red and green, + /// and alpha as destination plus source, which adds to what is left of + /// blue. Every photon lands wherever it lands, so no depth. + static const PassState _kCausticPhotonState = PassState( + primitiveType: PrimitiveType.triangle, + cullMode: CullMode.none, + blend: BlendState( + colorOperation: BlendOperation.reverseSubtract, + sourceColorFactor: BlendFactor.one, + destinationColorFactor: BlendFactor.one, + sourceAlphaFactor: BlendFactor.one, + destinationAlphaFactor: BlendFactor.one, + ), + depthWrite: false, + depthCompare: CompareFunction.always, + ); + /// Blanking one tile of the atlas by drawing over it. /// /// The pass loads rather than clears — clearing is attachment-wide and would @@ -3378,6 +3639,9 @@ final class Renderer implements RenderServices { _forwardData[0] = _forward.x; _forwardData[1] = _forward.y; _forwardData[2] = _forward.z; + // `P7`: whether the eye is a point the rays meet at or only where an + // orthographic camera was put — see `Orthographic` in `color.glsl`. + _fogInfo.projection[0] = isOrthographic(viewProjection) ? 1.0 : 0.0; for (final node in scene.meshes) { if (!node.visibleInHierarchy) continue; @@ -3616,6 +3880,18 @@ final class Renderer implements RenderServices { clearColor: ordered.first.clearColor, ), ], + renderTextures: _RenderTextureNode( + this, + scene: scene, + shadowCaster: shadowCaster, + ), + planarReflections: _PlanarReflectionNode( + this, + scene: scene, + views: ordered, + settings: settings, + shadowCaster: shadowCaster, + ), scene: _SceneNode( this, scene: scene, @@ -3623,6 +3899,7 @@ final class Renderer implements RenderServices { contributors: contributors.active.toList(growable: false), shadowCaster: shadowCaster, lightOverflow: planLights.overflow, + settings: settings, ), bloom: _BloomNode(this, settings.bloom), composite: _CompositeNode(this, scene, ordered, settings), @@ -4059,6 +4336,7 @@ final class Renderer implements RenderServices { // probe that left the scene since last frame gives its cubes back here, // and this frame's one whole-cube capture is up for claiming again. _retireProbesNotIn(scene); + _retireReflectorsNotIn(scene); _wholeProbeCaptured = false; final probeNodes = <_ReflectionProbeNode>[ for (var i = 0; i < scene.probes.length; i++) @@ -4078,6 +4356,7 @@ final class Renderer implements RenderServices { contributors: contributors.active.toList(growable: false), shadowCaster: shadowCaster, lightOverflow: lightOverflowCount, + settings: settings, ); final bloomNode = _BloomNode(this, settings.bloom); compositeNode = _CompositeNode(this, scene, ordered, settings); @@ -4105,6 +4384,18 @@ final class Renderer implements RenderServices { shadowCaster: shadowCaster, clearColor: ordered.first.clearColor, ), + renderTextures: _RenderTextureNode( + this, + scene: scene, + shadowCaster: shadowCaster, + ), + planarReflections: _PlanarReflectionNode( + this, + scene: scene, + views: ordered, + settings: settings, + shadowCaster: shadowCaster, + ), scene: sceneNode, bloom: bloomNode, composite: compositeNode, @@ -4264,7 +4555,13 @@ final class Renderer implements RenderServices { _captureWanted = null; _capture = capturing == null ? null - : FrameCaptureBuilder(width: width, height: height); + : FrameCaptureBuilder( + width: width, + height: height, + journal: _captureDraws ? DrawJournal() : null, + readFloats: _captureFloats, + ); + passState.journal = _capture?.journal; try { for (var i = 0; i < frameGraph.order.length; i++) { resources.beginNode(i); @@ -4285,6 +4582,7 @@ final class Renderer implements RenderServices { // so a GPU debugger and `GraphicsDevice.onGpuTimings` see the graph's // own names rather than a pass nobody can place. _passLabel = node.name; + passState.journal?.beginPass(i, node.name); developer.Timeline.startSync(node.name); try { node.execute( @@ -4335,7 +4633,16 @@ final class Renderer implements RenderServices { // pool, and the next pass draws over it. A capture taken after the // frame would hold whatever the last pass to borrow that shape left // there, attributed to whichever pass wrote it first. - _capture?.record(node, device: device, lookup: resources.tryTexture); + passState.journal?.endPass(passState.drawCalls - drawsBefore); + final cost = passTimings.last; + _capture?.record( + node, + device: device, + lookup: resources.tryTexture, + micros: cost.micros, + drawCalls: cost.drawCalls, + triangles: cost.triangles, + ); resources.endNode(i); } } catch (error, stack) { @@ -4454,6 +4761,7 @@ final class Renderer implements RenderServices { // scene pass, because it is a fact about the settings and the device // rather than about anything that happened during the frame. wireframeDeclined: settings.wireframe && !device.supportsWireframe, + alphaToCoverageDeclined: passState.coverageDeclined, // `gfx-20n`. Half of it comes from the scene pass, which knows what it // attached, and half from the graph, which knows whether the node ran. // Neither half can answer alone, which is why the answer is assembled @@ -4488,6 +4796,7 @@ final class Renderer implements RenderServices { // are the compile's own answers, and a second derivation is a second // thing to disagree with the frame. skipped: frameGraph.skipped, + targetBytes: resources.targetBytes, ); } diff --git a/packages/flutter3d_core/lib/src/engine/render/renderer_batch.dart b/packages/flutter3d_core/lib/src/engine/render/renderer_batch.dart index 6175abfce..08a64dd49 100644 --- a/packages/flutter3d_core/lib/src/engine/render/renderer_batch.dart +++ b/packages/flutter3d_core/lib/src/engine/render/renderer_batch.dart @@ -45,6 +45,9 @@ extension _BatchedDraws on Renderer { if (!_isBatchable(next) || !identical(next.mesh, first.mesh) || !identical(next.material, first.material) || + // Adjacent across a boundary of two orders, and the merge would + // draw the later one at the earlier one's place — `P7`. + next.drawOrder != first.drawOrder || next.worldIsMirrored != first.worldIsMirrored || !identical(probes.nearest(next.worldBoundsCentre), probe)) { break; diff --git a/packages/flutter3d_core/lib/src/engine/render/renderer_decal_pass.dart b/packages/flutter3d_core/lib/src/engine/render/renderer_decal_pass.dart new file mode 100644 index 000000000..0d6ef88d8 --- /dev/null +++ b/packages/flutter3d_core/lib/src/engine/render/renderer_decal_pass.dart @@ -0,0 +1,356 @@ +/// Projected box decals — `P3`. +/// +/// A `part` of `renderer.dart` — see `renderer_shadow_pass.dart` for why. +/// +/// **One pass over the scene target, after the opaque half and before the +/// glass.** Each view's decals are cut into batches the stage can hold — +/// sixteen decals and four pictures — and each batch is two fullscreen draws, +/// scissored to the screen rectangle its boxes cover: the factor that swaps +/// the albedo under the light the surface was lit by, multiplied in, and the +/// term an unlit surface and an emissive decal add. `decal.frag` says why it +/// takes two. +/// +/// **Order is kept within a batch and approximated across two.** Inside one +/// draw the stage stacks its decals in order and swaps the albedo once, which +/// is exact. A second batch swaps from the surface's albedo again rather than +/// from the one the first left, so where a decal of the second lies over one +/// of the first, the light under the first is read through the surface's +/// colour rather than the first decal's. Seventeen overlapping decals, or a +/// fifth picture, are where that starts. +part of 'renderer.dart'; + +/// One draw's decals and the pictures they read. +final class _DecalBatch { + /// Decals per draw, as `kMaxDecals` in `decal.frag` holds them. + static const int maxDecals = 16; + + /// Pictures per draw, the stage's four `decal_texture_` slots. + static const int maxSlots = 4; + + final List decals = []; + final List slots = []; + + /// The screen rectangle the boxes cover, in pixels from the top left. + int left = 1 << 30; + int top = 1 << 30; + int right = -1; + int bottom = -1; + + /// Whether [decal] fits: a free place, and its picture already bound or a + /// free slot for it. + bool admits(DecalNode decal) { + if (decals.length >= maxDecals) return false; + final texture = decal.texture; + return texture == null || + slots.contains(texture) || + slots.length < maxSlots; + } + + /// The slot [texture] is read through, binding it if it is new; minus one + /// for none. + int slotOf(TextureHandle? texture) { + if (texture == null) return -1; + final at = slots.indexOf(texture); + if (at >= 0) return at; + slots.add(texture); + return slots.length - 1; + } + + void cover(ScreenRect rect) { + left = math.min(left, rect.x); + top = math.min(top, rect.y); + right = math.max(right, rect.x + rect.width); + bottom = math.max(bottom, rect.y + rect.height); + } + + ScreenRect get covered => + ScreenRect(x: left, y: top, width: right - left, height: bottom - top); +} + +extension _DecalPass on Renderer { + /// Multiplies the target by what the stage writes, leaving alpha alone. + static const BlendState _multiply = BlendState( + sourceColorFactor: BlendFactor.zero, + destinationColorFactor: BlendFactor.sourceColor, + sourceAlphaFactor: BlendFactor.zero, + destinationAlphaFactor: BlendFactor.one, + ); + + /// Adds what the stage writes, leaving alpha alone. + static const BlendState _add = BlendState( + sourceColorFactor: BlendFactor.one, + destinationColorFactor: BlendFactor.one, + sourceAlphaFactor: BlendFactor.zero, + destinationAlphaFactor: BlendFactor.one, + ); + + /// Trilinear and clamped: a decal's picture is minified as a material's + /// is, and its edge must not wrap the opposite edge in. + static const SamplerOptions _pictureSampler = SamplerOptions( + minFilter: MinMagFilter.linear, + magFilter: MinMagFilter.linear, + mipFilter: MipFilter.linear, + ); + + /// The decals [views] can see, painted into [target] in order. + void _encodeDecals({ + required TextureHandle target, + required TextureHandle surface, + required TextureHandle albedo, + required List decals, + required List views, + required FramePassState passState, + }) { + // Higher order over lower, attachment order between equals: a stable + // sort, which `List.sort` does not promise, so the index breaks the tie. + final visible = + <(int, DecalNode)>[ + for (var i = 0; i < decals.length; i++) + if (decals[i].visibleInHierarchy) (i, decals[i]), + ]..sort((a, b) { + final byOrder = a.$2.order.compareTo(b.$2.order); + return byOrder != 0 ? byOrder : a.$1.compareTo(b.$1); + }); + if (visible.isEmpty) return; + + final width = target.width; + final height = target.height; + final pass = device.beginRenderPass( + RenderPassDescriptor( + label: _passLabel, + colors: [ + ColorTarget(texture: target, loadAction: LoadAction.load), + ], + ), + ); + pass + ..bindPipeline( + _fullscreenPipelines[decalShader] ??= device.createPipeline( + fullscreenVertexShader, + decalShader, + ), + ) + ..bindVertexBuffer(_fullscreenTriangle, 3) + ..bindIndexBuffer(_identityIndices(3), IndexType.int32, 3) + ..bindTexture( + decalShader, + 'surface_texture', + surface, + sampler: SamplerOptions.nearestClamp, + ) + ..bindTexture( + decalShader, + 'albedo_texture', + albedo, + sampler: SamplerOptions.nearestClamp, + ); + passState.invalidatePipeline(); + + final info = _decalInfo; + info.params + ..[1] = 1.0 / width + ..[2] = 1.0 / height; + final full = ScreenRect(width: width, height: height); + for (final view in views) { + final rect = Renderer._viewportPixels( + view.viewportFraction, + width, + height, + ); + final aspect = rect.width / rect.height; + // Unadjusted for the depth range, for the reason `_encodeReflections` + // gives: the stage unprojects both ends of a pixel's ray at clip depths + // nought and one. Adjusted for the framebuffer origin, so the texture + // coordinates it derives land on the rows the buffer was written in. + final viewProjection = view.camera.viewProjection(aspect); + final inverse = vm.Matrix4.copy( + toFramebufferOrigin(viewProjection, device.framebufferOrigin), + )..invert(); + info.inverseViewProjection.setAll(0, inverse.storage); + final eye = view.camera.readViewOrigin(); + final forward = vm.Vector3.zero(); + view.camera.readForward(forward); + info.camera + ..[0] = eye.x + ..[1] = eye.y + ..[2] = eye.z; + info.forward + ..[0] = forward.x + ..[1] = forward.y + ..[2] = forward.z; + info.view + ..[0] = rect.x / width + ..[1] = rect.y / height + ..[2] = rect.width / width + ..[3] = rect.height / height; + + for (final batch in _decalBatches(visible, viewProjection, rect)) { + _fillDecalBatch(batch); + for (var i = 0; i < _DecalBatch.maxSlots; i++) { + pass.bindTexture( + decalShader, + 'decal_texture_$i', + i < batch.slots.length ? batch.slots[i] : fallbackAlbedo, + sampler: _pictureSampler, + ); + } + // **From the top left, and not adjusted for the framebuffer origin, + // unlike the matrix above.** A rectangle is the engine's own + // vocabulary and every backend takes it from the top: WebGL2's + // encoder turns it upside down itself in `setScissor`, as it does for + // the scene pass's view rectangles, which are handed over the same + // way. The matrix is different because the stage reads texture rows, + // which no encoder translates. Flipping here as well was tried: the + // WebGL2 `decal-floor` golden differs by 602 pixels, and + // `decal_test.dart` keeps a decal in the top rows for the same reason. + final scissor = batch.covered; + for (final term in const [0.0, 1.0]) { + info.params[3] = term; + pass + ..setState( + Renderer._kFullscreenState.copyWith( + viewport: full, + scissor: scissor, + blend: term == 0.0 ? _multiply : _add, + ), + ) + ..bindBlock(decalShader, info) + ..draw(); + _frameCounters?.drawCalls++; + } + } + } + pass.submit(); + } + + /// [visible]'s decals cut into draws, leaving out every one whose box the + /// view does not see. + List<_DecalBatch> _decalBatches( + List<(int, DecalNode)> visible, + vm.Matrix4 viewProjection, + ScreenRect view, + ) { + final batches = <_DecalBatch>[]; + for (final (_, decal) in visible) { + final covers = _decalCover(decal, viewProjection, view); + if (covers == null) continue; + if (batches.isEmpty || !batches.last.admits(decal)) { + batches.add(_DecalBatch()); + } + batches.last + ..decals.add(decal) + ..slotOf(decal.texture) + ..cover(covers); + } + return batches; + } + + /// The pixels of [view] that [decal]'s box can reach, or null for none. + /// + /// The eight corners projected and their bounds taken, a pixel wider on + /// each side for the rounding. A corner behind the eye has no place on the + /// screen, and then the box may reach anywhere in the view. + ScreenRect? _decalCover( + DecalNode decal, + vm.Matrix4 viewProjection, + ScreenRect view, + ) { + final toClip = vm.Matrix4.copy(viewProjection)..multiply(decal.worldMatrix); + final corners = [ + for (var corner = 0; corner < 8; corner++) + toClip.transform( + vm.Vector4( + (corner & 1) == 0 ? -0.5 : 0.5, + (corner & 2) == 0 ? -0.5 : 0.5, + (corner & 4) == 0 ? -0.5 : 0.5, + 1.0, + ), + ), + ]; + if (corners.any((clip) => clip.w <= 1e-6)) return view; + final xs = corners.map((clip) => clip.x / clip.w); + final ys = corners.map((clip) => clip.y / clip.w); + final minX = xs.reduce(math.min); + final maxX = xs.reduce(math.max); + final minY = ys.reduce(math.min); + final maxY = ys.reduce(math.max); + final left = math.max( + view.x, + (view.x + (minX * 0.5 + 0.5) * view.width).floor() - 1, + ); + final right = math.min( + view.x + view.width, + (view.x + (maxX * 0.5 + 0.5) * view.width).ceil() + 1, + ); + final top = math.max( + view.y, + (view.y + (0.5 - maxY * 0.5) * view.height).floor() - 1, + ); + final bottom = math.min( + view.y + view.height, + (view.y + (0.5 - minY * 0.5) * view.height).ceil() + 1, + ); + if (right <= left || bottom <= top) return null; + return ScreenRect( + x: left, + y: top, + width: right - left, + height: bottom - top, + ); + } + + /// [batch] written into the stage's block. + void _fillDecalBatch(_DecalBatch batch) { + final info = _decalInfo; + info.params[0] = batch.decals.length.toDouble(); + for (var i = 0; i < _DecalBatch.maxSlots; i++) { + final texture = i < batch.slots.length ? batch.slots[i] : null; + info.slots + ..[i * 4] = (texture?.width ?? 1).toDouble() + ..[i * 4 + 1] = (texture?.height ?? 1).toDouble(); + } + for (var i = 0; i < batch.decals.length; i++) { + final decal = batch.decals[i]; + // The rows of the world-to-box matrix, out of column-major storage. + final m = decal.inverseWorldMatrix.storage; + for (final (row, into) in <(int, Float32List)>[ + (0, info.axisX), + (1, info.axisY), + (2, info.axisZ), + ]) { + into + ..[i * 4] = m[row] + ..[i * 4 + 1] = m[4 + row] + ..[i * 4 + 2] = m[8 + row] + ..[i * 4 + 3] = m[12 + row]; + } + final region = decal.region; + info.region + ..[i * 4] = region.x + ..[i * 4 + 1] = region.y + ..[i * 4 + 2] = region.z + ..[i * 4 + 3] = region.w; + // Linear here rather than in the stage: one conversion, in doubles, + // that every backend then reads as the same float. + final tint = Renderer._srgbToLinear(decal.color); + info.color + ..[i * 4] = tint.x + ..[i * 4 + 1] = tint.y + ..[i * 4 + 2] = tint.z + ..[i * 4 + 3] = tint.w.clamp(0.0, 1.0); + final limit = decal.angleLimit.clamp(0.0, math.pi); + final gone = math.cos(limit); + final whole = math.cos(math.max(limit - decal.angleFade, 0.0)); + info.fade + ..[i * 4] = batch.slotOf(decal.texture).toDouble() + ..[i * 4 + 1] = gone + ..[i * 4 + 2] = math.max(whole - gone, 1e-4) + ..[i * 4 + 3] = decal.depthFade.clamp(0.0, 1.0); + final emissive = decal.emissive; + info.emissive + ..[i * 4] = emissive.x + ..[i * 4 + 1] = emissive.y + ..[i * 4 + 2] = emissive.z; + } + } +} diff --git a/packages/flutter3d_core/lib/src/engine/render/renderer_fog_pass.dart b/packages/flutter3d_core/lib/src/engine/render/renderer_fog_pass.dart index 8ed900591..4d689eb77 100644 --- a/packages/flutter3d_core/lib/src/engine/render/renderer_fog_pass.dart +++ b/packages/flutter3d_core/lib/src/engine/render/renderer_fog_pass.dart @@ -69,7 +69,7 @@ extension _FogPass on Renderer { info.inverseViewProjection.setAll(0, inverse.storage); final eye = vm.Vector3.zero(); - view.camera.readWorldPosition(eye); + view.camera.readViewOrigin(eye); info.camera ..[0] = eye.x ..[1] = eye.y diff --git a/packages/flutter3d_core/lib/src/engine/render/renderer_frame_nodes.dart b/packages/flutter3d_core/lib/src/engine/render/renderer_frame_nodes.dart index cd905fe41..039dd0177 100644 --- a/packages/flutter3d_core/lib/src/engine/render/renderer_frame_nodes.dart +++ b/packages/flutter3d_core/lib/src/engine/render/renderer_frame_nodes.dart @@ -542,6 +542,149 @@ final class _ReflectionProbeNode extends RenderNode { } } +/// Every [RenderTexture] the scene holds, drawn through its own camera — +/// `P4`. +/// +/// **One node for all of them**, with one name the scene optionally reads: +/// the name is what orders them before the materials that show them, and +/// the pictures are each texture's own, held by whoever made it rather than +/// by the frame. So the resource the node keeps stands for the order and is +/// a texture nothing samples through it. +/// +/// It optionally reads the shadow maps for a probe's reason: its pictures are +/// lit and shadowed the way the world is. +final class _RenderTextureNode extends RenderNode { + _RenderTextureNode( + this._renderer, { + required this.scene, + required this.shadowCaster, + }); + + final Renderer _renderer; + final Scene scene; + final int shadowCaster; + + @override + String get name => 'render textures'; + + @override + bool get isActive => scene.renderTextures.isNotEmpty; + + @override + List get optionalReads => const [ + FrameResourceIds.shadowMap, + FrameResourceIds.shadowMoments, + FrameResourceIds.cubeShadow, + FrameResourceIds.cubeShadowStatic, + ]; + + @override + List get keeps => const [ + FrameResourceIds.renderTextures, + ]; + + @override + void execute(NodeFrame frame) { + final shadows = SceneShadows.from(frame, casterIndex: shadowCaster); + for (final texture in scene.renderTextures) { + if (!texture.isDue) continue; + developer.Timeline.startSync('Renderer.renderTexture'); + _renderer._drawRenderTexture( + resources: frame.resources, + scene: scene, + texture: texture, + settings: frame.settings, + shadows: shadows, + passState: frame.state, + ); + developer.Timeline.finishSync(); + } + frame.resources.provide( + FrameResourceIds.renderTextures, + _renderer.fallbackBlack, + ); + } +} + +/// Every visible [PlanarReflectorNode]'s mirrored picture, a picture per view +/// — `P4`. +/// +/// One node and one name for all of them, for [_RenderTextureNode]'s reason: +/// a reflector has a picture per view, and the scene pass finds this view's +/// in the renderer's own state, where it is kept between frames. What the +/// name carries is that the pictures were drawn this frame, before the scene. +final class _PlanarReflectionNode extends RenderNode { + _PlanarReflectionNode( + this._renderer, { + required this.scene, + required this.views, + required this.settings, + required this.shadowCaster, + }); + + final Renderer _renderer; + final Scene scene; + + /// Every view, by priority — each gets its own mirrored camera. + final List views; + final RenderSettings settings; + final int shadowCaster; + + @override + String get name => 'planar reflections'; + + /// Asked of the scene when the graph is built, so a frame whose reflectors + /// are all hidden draws no picture for any of them. + @override + bool get isActive => + settings.planarReflections.enabled && + scene.reflectors.any( + (reflector) => + reflector.visibleInHierarchy && reflector.surfaces.isNotEmpty, + ); + + @override + List get optionalReads => const [ + FrameResourceIds.shadowMap, + FrameResourceIds.shadowMoments, + FrameResourceIds.cubeShadow, + FrameResourceIds.cubeShadowStatic, + ]; + + @override + List get keeps => const [ + FrameResourceIds.planarReflections, + ]; + + @override + void execute(NodeFrame frame) { + final shadows = SceneShadows.from(frame, casterIndex: shadowCaster); + for (final reflector in scene.reflectors) { + if (!reflector.visibleInHierarchy || reflector.surfaces.isEmpty) { + continue; + } + developer.Timeline.startSync('Renderer.planarReflection'); + _renderer._drawPlanarReflection( + resources: frame.resources, + scene: scene, + reflector: reflector, + state: _renderer._planarStates[reflector] ??= _PlanarState(), + views: views, + width: frame.width, + height: frame.height, + settings: frame.settings, + shadows: shadows, + passState: frame.state, + ); + developer.Timeline.finishSync(); + } + frame.resources.provide( + FrameResourceIds.planarReflections, + _renderer.fallbackBlack, + ); + } +} + /// The world, as a graph node — the pass everything else is ordered around. /// /// It writes `hdr_colour` and `surface_buffer`, which is what took those two @@ -582,18 +725,24 @@ final class _SceneNode extends RenderNode { required this.contributors, required this.shadowCaster, required this.lightOverflow, + required RenderSettings settings, }) { final transmits = _TransmissionPasses._holdsTransmission(scene, ordered); // A contributor that reads the scene's depth splits the frame too, and // for the same reason: what it reads is an attachment of this pass. It // needs no copy of the colour, so that node stays as the glass has it. final readsDepth = contributors.any((c) => c.readsSceneDepth); + // `P3`: decals split it for the glass's sake rather than their own. They + // read the buffers after the pass, which an unsplit frame would allow + // too, but glass drawn in the same pass would already be in the picture + // and the decal behind it would be painted over the glass. + decals = _DecalNode(_renderer, scene, ordered, settings); copy = _SceneColourCopyNode(_renderer, active: transmits); transparent = _TransparentNode( _renderer, scene: scene, contributors: contributors, - active: transmits || readsDepth, + active: transmits || readsDepth || decals.isActive, readsDepth: readsDepth, samples: optionalReads, ); @@ -615,6 +764,9 @@ final class _SceneNode extends RenderNode { /// What the pass counted, for the frame's own report. _ScenePass? result; + /// `P3`'s decals, painted between the two halves. + late final _DecalNode decals; + /// The copy of the scene the transmissive draws read, and the pass that /// draws them over it — `M3`. late final _SceneColourCopyNode copy; @@ -639,6 +791,10 @@ final class _SceneNode extends RenderNode { // `L4`: the irradiance atlas the GPU keeps, for the same reason — the // lit draws read it when it is there and the bake when it is not. FrameResourceIds.irradianceAtlas, + // `P4`: what the cameras into textures drew, which a material here may + // show, and the mirrored pictures the reflectors' surfaces lay on. + FrameResourceIds.renderTextures, + FrameResourceIds.planarReflections, ]; /// **Both names always, including on a device that cannot attach the @@ -782,6 +938,73 @@ final class _SceneNode extends RenderNode { } } +/// `P3`'s projected decals, painted into the scene target between the +/// opaque half and the transparent one. +/// +/// **In place, which no other link in the colour chain is.** The transparent +/// half draws into the renderer's own scene target and loads it, so a decal +/// pass that wrote a texture of its own would be drawn over by glass that +/// never saw it. It can write in place because it never reads the colour: +/// the two blends that make a decal are a factor times what is there and a +/// term added to it, and both are blend state rather than texture reads. +/// +/// Needs three attachments rather than the two [_NeedsSurfaceBuffer] asks +/// for: the light a decal is laid under is read back through the albedo. +final class _DecalNode extends RenderNode { + _DecalNode(this._renderer, this._scene, this._views, this._settings); + + final Renderer _renderer; + final Scene _scene; + + /// Every view, each painted with its own camera inside its own rectangle. + final List _views; + final RenderSettings _settings; + + @override + String get name => 'decals'; + + @override + bool get supported => _renderer.device.maxColorAttachments > 2; + + /// Asked of the scene when the graph is built, so a frame whose decals are + /// all hidden is a frame that neither splits nor gives up multisampling. + @override + bool get isActive => + _settings.decals.enabled && + _scene.decals.any((decal) => decal.visibleInHierarchy); + + @override + List get reads => const [ + FrameResourceIds.hdrColour, + FrameResourceIds.surfaceBuffer, + FrameResourceIds.albedoBuffer, + ]; + + @override + List get writes => const [FrameResourceIds.hdrColour]; + + @override + void execute(NodeFrame frame) { + final resources = frame.resources; + final scene = resources.texture(FrameResourceIds.hdrColour); + final surface = resources.tryTexture(FrameResourceIds.surfaceBuffer); + final albedo = resources.tryTexture(FrameResourceIds.albedoBuffer); + // Hard reads, culled on a device that cannot attach them; a frame that + // got neither has no surface to paint, and the picture stands. + if (surface != null && albedo != null) { + _renderer._encodeDecals( + target: scene, + surface: surface, + albedo: albedo, + decals: _scene.decals, + views: _views, + passState: frame.state, + ); + } + resources.provide(FrameResourceIds.hdrColour, scene); + } +} + /// The scene as the opaque half left it, in levels for the transmissive /// draws to read — `M3`. Active only on a frame that holds one; see /// `renderer_transmission_pass.dart`. @@ -1978,6 +2201,55 @@ final class _BloomNode extends RenderNode { } } +/// The flare thrown from the glow, added to it — `P2`. +/// +/// Registered after the bloom and before the composite, so it reads the glow +/// the chain finished and hands the composite the next version of it; with +/// the bloom off nothing produces the glow and this is culled with it. +final class _LensFlareNode extends RenderNode { + _LensFlareNode(this._renderer, this._settings); + + final Renderer _renderer; + final BloomSettings _settings; + + @override + String get name => 'lens flare'; + + @override + bool get isActive => + _settings.enabled && + _settings.intensity > 0.0 && + _settings.lensFlare.isActive && + _renderer.shaders['LensFlare'] != null; + + @override + List get reads => const [FrameResourceIds.bloom]; + + @override + List get writes => const [FrameResourceIds.bloom]; + + @override + void execute(NodeFrame frame) { + developer.Timeline.startSync('Renderer.lensFlare'); + final glow = frame.resources.texture(FrameResourceIds.bloom); + final target = frame.resources.transient( + RenderTargetSpec( + width: glow.width, + height: glow.height, + format: glow.format, + ), + ); + frame.resources.provide(FrameResourceIds.bloom, target); + _renderer._encodeLensFlare( + target: target, + glow: glow, + settings: _settings.lensFlare, + aspect: frame.width / math.max(frame.height, 1), + ); + developer.Timeline.finishSync(); + } +} + /// Tone map, sRGB and the debug overlay, as a graph node — the end of the post /// chain and the only pass that writes what is shown. /// @@ -2182,12 +2454,46 @@ final class _FxaaNode extends RenderNode { final source = frame.resources.texture(FrameResourceIds.frame); final target = _renderer._ldrColor!; frame.resources.provide(FrameResourceIds.frame, target); - _renderer._encodeFxaa( - target: target, - source: source, - settings: _settings, - upscaleSharpen: upscaleSharpen, - ); + // `P1`. SMAA smooths and nothing else; a sharpening asked for alongside + // it still rides in the FXAA pass, with the smoothing there switched off, + // over SMAA's output. + if (_settings.enabled && + _settings.method == EdgeSmoothing.smaa && + _renderer._hasSmaa) { + final sharpens = + _settings.sharpen > 0.0 || + (_settings.temporal.enabled && _settings.temporal.sharpen > 0.0) || + upscaleSharpen > 0.0; + final smoothed = sharpens + ? frame.resources.transient( + RenderTargetSpec( + width: source.width, + height: source.height, + format: _renderer._frameFormat, + ), + ) + : target; + _renderer._encodeSmaa( + target: smoothed, + source: source, + resources: frame.resources, + ); + if (sharpens) { + _renderer._encodeFxaa( + target: target, + source: smoothed, + settings: _settings.copyWith(enabled: false), + upscaleSharpen: upscaleSharpen, + ); + } + } else { + _renderer._encodeFxaa( + target: target, + source: source, + settings: _settings, + upscaleSharpen: upscaleSharpen, + ); + } developer.Timeline.finishSync(); } } diff --git a/packages/flutter3d_core/lib/src/engine/render/renderer_mesh_encode.dart b/packages/flutter3d_core/lib/src/engine/render/renderer_mesh_encode.dart index f4be28a5d..dbec9dd88 100644 --- a/packages/flutter3d_core/lib/src/engine/render/renderer_mesh_encode.dart +++ b/packages/flutter3d_core/lib/src/engine/render/renderer_mesh_encode.dart @@ -21,12 +21,21 @@ part of 'renderer.dart'; /// material already carries it — `depthWrite` and `depthCompare` are the two /// fields a backdrop asked for, and a silhouette wants exactly those two. final class _DrawOverride { - const _DrawOverride({required this.material, required this.blend}); + const _DrawOverride({ + required this.material, + required this.blend, + this.fogged = false, + }); final Material material; /// Null is blending off, as it is on `setBlend`. final BlendState? blend; + + /// Whether the frame's fog reaches the draw. Not for a silhouette, which + /// is a marker; yes for a planar reflection, which is light leaving a + /// surface and crosses the same air the surface's own light does. + final bool fogged; } /// Writes [transform] into [out] at [at] as the two rows `MapUv` reads — `C8`. @@ -488,10 +497,26 @@ extension _MeshEncode on Renderer { _materialData[2] = probe?.intensity ?? scene.ambientIntensity; _materialData[3] = settings.specular; + // `P7`: a masked material's edge as multisample coverage, where the + // pass and the device allow it, and its hard cutoff where they do not. + final wantsCoverage = + override == null && + material.alphaMode == MaterialAlphaMode.mask && + material.alphaToCoverage; + final coverage = wantsCoverage && state.coverageAvailable; + if (wantsCoverage && !coverage) state.coverageDeclined = true; + if (coverage != state.alphaToCoverage) { + encoder.setAlphaToCoverage(coverage); + state.alphaToCoverage = coverage; + } + // A negative cutoff means "not masked". The shader compares against // it directly, so encoding the mode in the value keeps a branch and // a separate flag out of the uniform block. _material2Data[0] = switch (material.alphaMode) { + // Above one is the cutoff plus one, with coverage — `P7`: the shader + // keeps the fragment and sharpens its alpha rather than cutting. + MaterialAlphaMode.mask when coverage => 1.0 + material.alphaCutoff, MaterialAlphaMode.mask => material.alphaCutoff, // `gfx-16n`'s sentinel. Below -1.5 is "hashed", which the shader // reads out of the same component: -1 already meant "not masked" and @@ -563,6 +588,27 @@ extension _MeshEncode on Renderer { ? settings.shadows.directionalLightRadius : ShadowSettings.sunAngularRadius; + // `ShadowSettings.translucentCasters`: whether this draw is shaded by + // what the see-through casters let through, in the one spare + // component of `shadow_bias`. Only when the atlas the shader reads is + // the one that carries it — not the moments — and never for a surface + // that is itself one of those casters, which would otherwise darken by + // its own colour wherever it stands in its own shadow. A see-through + // surface that casts nothing — a glazed floor, a water plane — is + // shaded like any other. + final castsThrough = + node.shadowCasting.casts && + (node.material.isTransparent || + (node.material.extensions?.transmission ?? 0.0) > 0.0); + _shadowCascadeBias[3] = + _shadowTransmits && + node.receivesTranslucentShadows && + shadows.directional != null && + shadows.directionalMoments == null && + !castsThrough + ? 1.0 + : 0.0; + // Gated on the model, like every other block and sampler here. Unlit // declares FragInfo but reaches no lighting loop, so the compiler drops // all three of these — and binding a block the compiled shader does not @@ -661,14 +707,18 @@ extension _MeshEncode on Renderer { _fogInfo.name, declared: material.lighting.usesFogInfo, )) { - final fog = override == null && material.fogged + final fog = (override?.fogged ?? material.fogged) ? settings.fog : const FogSettings(); _fogData[0] = fog.resolvedColor.x; _fogData[1] = fog.resolvedColor.y; _fogData[2] = fog.resolvedColor.z; - _fogData[3] = fog.density; _fogInfo.eye.setAll(0, _cameraData); + // `P5`: the density at the eye, and the falloff in the eye's spare lane, + // which is all `ApplyFog` needs to integrate a height fog along the ray. + // A flat fog writes its density and nought, as it always did. + _fogData[3] = fog.densityAt(_cameraData[1]); + _fogInfo.eye[3] = fog.resolvedHeightFalloff; encoder.bindBlock(fragmentShader, _fogInfo); } @@ -708,7 +758,9 @@ extension _MeshEncode on Renderer { _layerInfo.attenuation ..[0] = layers?.attenuationColor.x ?? 1.0 ..[1] = layers?.attenuationColor.y ?? 1.0 - ..[2] = layers?.attenuationColor.z ?? 1.0; + ..[2] = layers?.attenuationColor.z ?? 1.0 + // `MaterialExtensions.convexVolume`, in the spare lane. + ..[3] = (layers?.convexVolume ?? false) ? 1.0 : 0.0; _layerInfo.iridescence ..[0] = layers?.iridescence ?? 0.0 ..[1] = layers?.iridescenceIor ?? 1.3 @@ -910,6 +962,45 @@ extension _MeshEncode on Renderer { state.drawCalls++; state.triangles += (indexCount ~/ 3) * (instanced?.count ?? 1); if (instanced != null) state.instances += instanced.count; + // `P12`: nothing past the `?.` runs unless this frame is being journaled. + state.journal?.add( + kind: 'mesh', + mesh: node.name, + material: material.name, + lighting: material.lighting.label, + vertices: mesh.vertexCount, + indices: indexCount, + instances: instanced?.count ?? 1, + state: { + 'cull': cull ? 'back' : 'none', + 'blend': orderIndependent != null + ? 'weighted' + : override != null + ? 'override' + : (blend ? 'alpha' : 'opaque'), + 'depthWrite': + orderIndependent == null && (material.depthWrite ?? !blend), + 'depthCompare': depthCompare.name, + 'winding': node.worldIsMirrored != mirrored ? 'cw' : 'ccw', + 'skinned': skinned, + 'instanced': batched, + 'lightmapped': lightmapped, + 'clustered': clustered != null, + 'xray': override != null, + }, + uniforms: { + 'mvp': Float32List.fromList(mvp.storage), + 'model': Float32List.fromList(modelMatrix.storage), + 'tint': Float32List.fromList(node.tint.storage), + 'baseColor': Float32List.fromList(material.baseColor.storage), + 'emissive': Float32List.fromList(material.emissive.storage), + 'metallicRoughness': Float32List.fromList([ + material.metallic, + material.roughness, + ]), + ...material.parameters, + }, + ); } /// The index buffer that draws the clusters of [node]'s split mesh the diff --git a/packages/flutter3d_core/lib/src/engine/render/renderer_planar_pass.dart b/packages/flutter3d_core/lib/src/engine/render/renderer_planar_pass.dart new file mode 100644 index 000000000..afc0dedff --- /dev/null +++ b/packages/flutter3d_core/lib/src/engine/render/renderer_planar_pass.dart @@ -0,0 +1,411 @@ +/// Pictures of the scene taken before the scene: a [RenderTexture]'s camera +/// and a planar reflector's mirrored one — `P4`. +/// +/// A `part` of `renderer.dart` — see `renderer_shadow_pass.dart` for why. +/// Both draw every mesh through `_encodeNode`, as a probe's capture does, so +/// a picture of the world has the world's materials, lights and shadows. +/// +/// **One way of drawing a view into a texture, and two things pointed +/// through it.** [_captureView] is the probe face's pass with a 2D target, a +/// frustum to cull by and a layer mask: the opaque half, the sky, the +/// blended half. A render texture points it through its own camera and then +/// encodes the light into sRGB bytes for a material to read; a reflector +/// points it through the view's camera mirrored in its plane, with the near +/// plane moved onto the plane, and keeps the light as it is for its +/// surfaces to lay over themselves in the scene pass. +part of 'renderer.dart'; + +/// What the renderer holds for one planar reflector between frames: a +/// picture per view, kept so a frame does not allocate one. +final class _PlanarState { + /// Indexed by the view's place in the frame's priority order. + final List textures = []; + + /// Which of [textures] this frame drew. A view seeing the plane from + /// behind draws nothing, and the picture kept from an earlier frame must + /// not be laid on. + final List drawn = []; + + /// The frame [drawn] describes, so a frame whose pass was culled lays + /// nothing on either. + int frame = -1; +} + +extension _PlanarPasses on Renderer { + /// Lets go of the pictures of every reflector no longer in [scene]. + void _retireReflectorsNotIn(Scene scene) { + if (_planarStates.isEmpty) return; + final live = scene.reflectors.toSet(); + _planarStates.removeWhere((reflector, state) { + if (live.contains(reflector)) return false; + state.textures.forEach(_destroyAfterFrame); + return true; + }); + } + + /// Draws [scene] into [colour] through [viewProjection], as a camera at + /// [eye] sees it. + /// + /// [skyViewProjection] is the same view without anything done to its depth, + /// which is what the sky's rays are taken from: a reflector's near plane + /// tilts the depth and leaves every ray where it was, and the rays are all + /// the sky reads. [mirrored] says the matrix reverses the winding. + void _captureView({ + required FrameResources resources, + required Scene scene, + required TextureHandle colour, + required vm.Matrix4 viewProjection, + required vm.Matrix4 skyViewProjection, + required vm.Vector3 eye, + required int layerMask, + required Set excluded, + required bool mirrored, + required RenderSettings settings, + required SceneShadows shadows, + required FramePassState passState, + required vm.Vector4 clearColor, + }) { + final rect = ScreenRect.of(colour); + final depth = resources.transient( + RenderTargetSpec( + width: rect.width, + height: rect.height, + format: device.defaultDepthStencilFormat, + storageMode: StorageMode.deviceTransient, + ), + ); + final pass = device.beginRenderPass( + RenderPassDescriptor( + label: _passLabel, + colors: [ + ColorTarget( + texture: colour, + clearValue: Renderer._srgbToLinear(clearColor), + ), + ], + depth: DepthTarget(texture: depth), + ), + ); + // Every lane the scene pass sets, set here too: the capture runs before + // it, and a lane left as the last frame's scene pass wrote it is a + // picture that depends on what was drawn a frame ago. + _targetOrigin[0] = _rowsFromBottom(colour); + _targetOrigin[1] = 0.0; + _targetOrigin[2] = settings.energyCompensation ? 1.0 : 0.0; + _targetOrigin[3] = -1.0; + // A reflection shows the light, whatever the frame's debug view: the + // mirror is part of the picture being debugged, not a material in it. + _fragInfo.debugView.fillRange(0, 4, 0.0); + pass.setState( + Renderer._kSceneViewState.copyWith( + viewport: rect, + scissor: rect, + polygonMode: PolygonMode.fill, + ), + ); + passState + ..depthCompare = CompareFunction.less + ..invalidatePipeline(); + + _cameraData[0] = eye.x; + _cameraData[1] = eye.y; + _cameraData[2] = eye.z; + viewAxisOf(skyViewProjection, _forward); + _forwardData[0] = _forward.x; + _forwardData[1] = _forward.y; + _forwardData[2] = _forward.z; + // A mirror of an orthographic view is orthographic too. + _fogInfo.projection[0] = isOrthographic(skyViewProjection) ? 1.0 : 0.0; + + final frustum = vm.Frustum.matrix(viewProjection); + void encodeHalf({required bool blended}) { + for (final node in scene.meshes) { + if (!node.visibleInHierarchy || !node.shadowCasting.drawsColour) { + continue; + } + if (node.drawsTransparent != blended) continue; + if ((node.layerMask & layerMask) == 0) continue; + if (excluded.contains(node)) continue; + if (node.frustumCulled && + !frustum.intersectsWithAabb3(node.worldBounds)) { + continue; + } + final mesh = node.mesh; + if (mesh is! DrawableGeometry || mesh.indexCount == 0) continue; + _encodeNode( + encoder: pass, + node: node, + scene: scene, + settings: settings, + viewProjection: viewProjection, + shadows: shadows, + lights: lights, + shadowSlots: _shadowSlots, + state: passState, + mirrored: mirrored, + ); + } + } + + encodeHalf(blended: false); + _encodeSky( + pass: pass, + settings: settings, + viewProjection: skyViewProjection, + state: passState, + ); + encodeHalf(blended: true); + + pass.submit(); + passState.invalidatePipeline(); + } + + /// Draws [texture]'s camera into it: the light into a picture of the + /// frame's own, and that picture encoded into the texture's bytes. + void _drawRenderTexture({ + required FrameResources resources, + required Scene scene, + required RenderTexture texture, + required RenderSettings settings, + required SceneShadows shadows, + required FramePassState passState, + }) { + final light = resources.transient( + RenderTargetSpec( + width: texture.width, + height: texture.height, + format: hdrFormat, + ), + ); + final camera = texture.camera; + final eye = camera.readViewOrigin(); + final viewProjection = _viewProjection( + camera, + texture.width / texture.height, + ); + _captureView( + resources: resources, + scene: scene, + colour: light, + viewProjection: viewProjection, + skyViewProjection: viewProjection, + eye: eye, + layerMask: texture.layerMask, + excluded: texture.excluded, + mirrored: false, + settings: settings, + shadows: shadows, + passState: passState, + clearColor: texture.clearColor, + ); + final encode = shaders['RenderTextureEncode']; + if (encode == null) { + throw StateError( + 'the scene holds a RenderTexture but the bundle has no ' + '"RenderTextureEncode" entry. Rebuild the backend\'s shader bundle — ' + 'for the web backends that means re-running tool/generate_shaders.dart.', + ); + } + _renderTextureInfo.params + ..[0] = texture.exposure + ..[1] = device.framebufferOrigin == FramebufferOrigin.bottomLeft + ? 1.0 + : 0.0; + drawFullscreen( + FullscreenDraw( + target: texture.texture, + fragment: encode, + textures: {'source_texture': light}, + uniforms: >{ + _renderTextureInfo.name: _renderTextureInfo.members, + }, + ), + ); + texture.markDrawn(); + } + + /// Draws [reflector]'s mirrored picture for every view in [views] that + /// sees the front of its plane, into [state]'s textures. + /// + /// The view's own camera and projection, reflected in the plane: a world + /// point is mirrored first and then seen as the view sees it, so the + /// picture lines up texel for pixel with the view and a surface in the + /// plane reads it by where it is on screen. The near plane is moved onto + /// the plane (see `obliqueNearPlane`), so what is below it — the floor's + /// underside, the pool's bed — is cut away rather than reflected up + /// through it. + void _drawPlanarReflection({ + required FrameResources resources, + required Scene scene, + required PlanarReflectorNode reflector, + required _PlanarState state, + required List views, + required int width, + required int height, + required RenderSettings settings, + required SceneShadows shadows, + required FramePassState passState, + }) { + final normal = reflector.readPlaneNormal(); + final point = reflector.readWorldPosition(); + final mirror = mirrorAcrossPlane(normal, point); + final excluded = {...reflector.surfaces, ...reflector.excluded}; + while (state.textures.length < views.length) { + state.textures.add(null); + state.drawn.add(false); + } + state.frame = _frameIndex; + for (var i = 0; i < views.length; i++) { + state.drawn[i] = false; + final view = views[i]; + final camera = view.camera; + final eye = camera.readViewOrigin(); + // From behind, or level with it: no reflection to see. + if (normal.dot(eye - point) <= 0.0) continue; + + final rect = Renderer._viewportPixels( + view.viewportFraction, + width, + height, + ); + final pictureWidth = math.max( + 1, + (rect.width * reflector.resolution).round(), + ); + final pictureHeight = math.max( + 1, + (rect.height * reflector.resolution).round(), + ); + final kept = state.textures[i]; + final TextureHandle picture; + if (kept != null && + kept.width == pictureWidth && + kept.height == pictureHeight) { + picture = kept; + } else { + _destroyAfterFrame(kept); + picture = device.createTexture( + RenderTargetSpec( + width: pictureWidth, + height: pictureHeight, + format: hdrFormat, + ), + ); + state.textures[i] = picture; + } + + final vm.Matrix4 mirroredView = camera.viewMatrix * mirror; + final projection = camera.projection.toMatrix(rect.width / rect.height); + final clipped = + obliqueNearPlane( + projection, + planeInEyeSpace( + mirroredView, + normal, + point, + offset: reflector.clipOffset, + ), + ) ?? + projection; + _captureView( + resources: resources, + scene: scene, + colour: picture, + viewProjection: toDepthRange(clipped * mirroredView, device.depthRange), + skyViewProjection: toDepthRange( + projection * mirroredView, + device.depthRange, + ), + eye: mirror.transform3(eye), + layerMask: view.layerMask & reflector.reflectedLayers, + excluded: excluded, + mirrored: true, + settings: settings, + shadows: shadows, + passState: passState, + clearColor: view.clearColor, + ); + state.drawn[i] = true; + } + } + + /// Lays every reflector's picture for view [viewNumber] over the surfaces + /// that show it, inside the scene pass and straight after its opaque half. + /// + /// After the opaque half because the test is `lessEqual` against what the + /// surface itself wrote: a fragment of the second draw passes exactly + /// where the surface was not hidden. A blended surface writes no depth and + /// is drawn after this, so on water that blends its picture lies under the + /// water rather than on it. + void _encodePlanarReflections({ + required PassEncoder encoder, + required Scene scene, + required RenderView view, + required int viewNumber, + required ScreenRect rect, + required vm.Frustum frustum, + required RenderSettings settings, + required vm.Matrix4 viewProjection, + required SceneShadows shadows, + required FramePassState state, + }) { + if (!settings.planarReflections.enabled || _planarStates.isEmpty) return; + for (final reflector in scene.reflectors) { + final planar = _planarStates[reflector]; + if (planar == null || + planar.frame != _frameIndex || + viewNumber >= planar.drawn.length || + !planar.drawn[viewNumber]) { + continue; + } + final picture = planar.textures[viewNumber]!; + _planarInfo.view + ..[0] = rect.x.toDouble() + ..[1] = rect.y.toDouble() + ..[2] = rect.width.toDouble() + ..[3] = rect.height.toDouble(); + _planarInfo.params + ..[0] = reflector.reflectance + ..[1] = reflector.strength + // The rows the scene pass set for its own target, which is this one. + ..[2] = _targetOrigin[0]; + _planarInfo.tint + ..[0] = reflector.tint.x + ..[1] = reflector.tint.y + ..[2] = reflector.tint.z; + _planarMaterial.extraTextures['reflection_texture'] = picture; + for (final surface in reflector.surfaces) { + if (!surface.visibleInHierarchy || + !surface.shadowCasting.drawsColour || + (surface.layerMask & view.layerMask) == 0) { + continue; + } + if (surface.frustumCulled && + !frustum.intersectsWithAabb3(surface.worldBounds)) { + continue; + } + final mesh = surface.mesh; + if (mesh is! DrawableGeometry || mesh.indexCount == 0) continue; + // The surface's own answer to which faces exist, as the x-ray's + // marks take it. + _planarMaterial.doubleSided = surface.material.doubleSided; + _encodeNode( + encoder: encoder, + node: surface, + scene: scene, + settings: settings, + viewProjection: viewProjection, + shadows: shadows, + lights: lights, + shadowSlots: _shadowSlots, + state: state, + override: _DrawOverride( + material: _planarMaterial, + blend: BlendState.alphaBlend, + fogged: surface.material.fogged, + ), + ); + } + } + } +} diff --git a/packages/flutter3d_core/lib/src/engine/render/renderer_post_pass.dart b/packages/flutter3d_core/lib/src/engine/render/renderer_post_pass.dart index 57a6d5cf0..d4e5319dc 100644 --- a/packages/flutter3d_core/lib/src/engine/render/renderer_post_pass.dart +++ b/packages/flutter3d_core/lib/src/engine/render/renderer_post_pass.dart @@ -205,7 +205,7 @@ extension _PostPasses on Renderer { _fxaaParams[3] = settings.blend.clamp(0.0, 1.0); // `gfx-29n`. Zero exactly when nobody asked: the shader returns the // centre untouched at zero rather than running a kernel that rounds to - // nothing, and seventy-eight goldens depend on that being the same bytes. + // nothing, and ninety-five goldens depend on that being the same bytes. // // After a temporal resolve the robust kernel, at the resolve's own // strength — `R2`: what softens a resolved picture is the history, and @@ -234,6 +234,116 @@ extension _PostPasses on Renderer { ); } + /// Whether this device's bundle has SMAA's three stages — `P1`. A bundle + /// built before them falls back to FXAA rather than drawing nothing. + bool get _hasSmaa => + shaders['SmaaEdges'] != null && + shaders['SmaaWeights'] != null && + shaders['SmaaBlend'] != null; + + /// Smooths the edges of [source] into [target] with SMAA 1x — `P1`. + /// + /// Three full-screen draws: the edges into a scratch target, the shares + /// along them into another, and the blend into [target]. The two + /// intermediates are read texel for texel and bound unfiltered; the + /// picture and the area table are filtered, which is how the blend moves + /// a pixel a fraction of a texel and how the table interpolates between + /// the square roots of two distances. + /// + /// The thresholds are SMAA's own presets, not settings: a luma step of a + /// tenth, and a step half the largest beside it. + void _encodeSmaa({ + required TextureHandle target, + required TextureHandle source, + required FrameResources resources, + }) { + TextureHandle scratch() => resources.transient( + RenderTargetSpec( + width: source.width, + height: source.height, + format: TextureFormat.r8g8b8a8UNormInt, + ), + ); + _smaaInfo.params + ..[0] = 1.0 / math.max(source.width, 1) + ..[1] = 1.0 / math.max(source.height, 1) + ..[2] = 0.1 + ..[3] = 2.0; + final uniforms = >{ + _smaaInfo.name: _smaaInfo.members, + }; + final edges = scratch(); + final weights = scratch(); + drawFullscreen( + FullscreenDraw( + target: edges, + fragment: shaders['SmaaEdges']!, + textures: {_kPostSourceSlot: source}, + uniforms: uniforms, + sampler: SamplerOptions.nearestClamp, + ), + ); + drawFullscreen( + FullscreenDraw( + target: weights, + fragment: shaders['SmaaWeights']!, + textures: { + 'edges_texture': edges, + 'area_texture': EngineTables.of(device).smaaArea, + }, + uniforms: uniforms, + sampler: SamplerOptions.nearestClamp, + samplers: const { + 'area_texture': SamplerOptions.linearClamp, + }, + ), + ); + drawFullscreen( + FullscreenDraw( + target: target, + fragment: shaders['SmaaBlend']!, + textures: { + _kPostSourceSlot: source, + 'blend_texture': weights, + }, + uniforms: uniforms, + samplers: const { + 'blend_texture': SamplerOptions.nearestClamp, + }, + ), + ); + } + + /// The ghosts and the halo [settings] asks for, drawn from [glow] and + /// added to it in [target] — `P2`. One full-screen draw. + void _encodeLensFlare({ + required TextureHandle target, + required TextureHandle glow, + required LensFlareSettings settings, + required double aspect, + }) { + _lensFlareInfo.params + ..[0] = math.max(settings.intensity, 0.0) + ..[1] = settings.ghosts.clamp(0, 8).toDouble() + ..[2] = settings.ghostSpacing + ..[3] = settings.haloRadius; + _lensFlareInfo.more + ..[0] = settings.fringe + ..[1] = math.max(settings.halo, 0.0) + ..[2] = aspect + ..[3] = 0.0; + drawFullscreen( + FullscreenDraw( + target: target, + fragment: shaders['LensFlare']!, + textures: {'bloom_texture': glow}, + uniforms: >{ + _lensFlareInfo.name: _lensFlareInfo.members, + }, + ), + ); + } + /// `R7`: the three exposures' weights from [scene] at [target]'s size, /// blurred across and down, the second blur writing the exposure in stops /// into [target]. @@ -464,7 +574,7 @@ extension _PostPasses on Renderer { ); final inverse = vm.Matrix4.copy(viewProjection)..invert(); - view.camera.readWorldPosition(_ssaoCamera); + view.camera.readViewOrigin(_ssaoCamera); _contactCamera[0] = _ssaoCamera.x; _contactCamera[1] = _ssaoCamera.y; _contactCamera[2] = _ssaoCamera.z; @@ -536,7 +646,7 @@ extension _PostPasses on Renderer { final inverse = vm.Matrix4.copy(toFramebufferOrigin(current, origin)) ..invert(); - view.camera.readWorldPosition(_ssaoCamera); + view.camera.readViewOrigin(_ssaoCamera); view.camera.readForward(_ssaoForward); _cameraVelocityInfo.inverseViewProjection.setAll(0, inverse.storage); _cameraVelocityInfo.previousViewProjection.setAll( @@ -616,7 +726,7 @@ extension _PostPasses on Renderer { // The other half of the reconstruction: the buffer holds how far along the // view axis the surface is, in metres, so the pass needs to know where that // axis starts and which way it points. See `WorldAtDepth`. - view.camera.readWorldPosition(_ssaoCamera); + view.camera.readViewOrigin(_ssaoCamera); _ssaoCameraData[0] = _ssaoCamera.x; _ssaoCameraData[1] = _ssaoCamera.y; _ssaoCameraData[2] = _ssaoCamera.z; @@ -710,7 +820,7 @@ extension _PostPasses on Renderer { ); final inverse = vm.Matrix4.copy(viewProjection)..invert(); - view.camera.readWorldPosition(_shaftCameraVec); + view.camera.readViewOrigin(_shaftCameraVec); _shaftCamera[0] = _shaftCameraVec.x; _shaftCamera[1] = _shaftCameraVec.y; _shaftCamera[2] = _shaftCameraVec.z; @@ -1269,7 +1379,7 @@ extension _PostPasses on Renderer { device.framebufferOrigin, ); final inverse = vm.Matrix4.copy(viewProjection)..invert(); - view.camera.readWorldPosition(_reflectionCamera); + view.camera.readViewOrigin(_reflectionCamera); final options = settings.reflections; _reflectionParams[0] = options.steps.toDouble(); @@ -1490,7 +1600,7 @@ extension _PostPasses on Renderer { _pyramidInFlight = true; final epoch = _hiZEpoch; final viewProjection = camera.viewProjection(aspect); - final eye = camera.readWorldPosition(); + final eye = camera.readViewOrigin(); final forward = camera.readForward(); final far = camera.projection.far; Future.sync(() => device.readback(target)) @@ -1568,7 +1678,7 @@ extension _PostPasses on Renderer { // itself — then one draw per view, scissored to its own rectangle, so the // exposure in the uniform is the one that view metered. With per-view // metering off, or with a single view, this is the one full-frame draw it - // has always been and the bytes are the bytes seventy-eight goldens hold. + // has always been and the bytes are the bytes ninety-five goldens hold. final perView = settings.autoExposure.enabled && settings.autoExposure.perView && @@ -1681,6 +1791,13 @@ extension _PostPasses on Renderer { _compositeContact[3] = localExposure == null ? 0.0 : settings.localExposure.strength.clamp(0.0, 1.0); + // `P2`: the lens's bend, nought off exactly. + _compositeInfo.lens[0] = settings.look.distortion; + // `P6`: right of the split the scene holds display values, which the + // composite passes through its encode alone. + final debug = settings.debugView; + _compositeInfo.lens[1] = debug.active ? 1.0 : 0.0; + _compositeInfo.lens[2] = debug.split.clamp(0.0, 1.0); pass.bindTexture( compositeShader, 'local_exposure_texture', diff --git a/packages/flutter3d_core/lib/src/engine/render/renderer_probe_pass.dart b/packages/flutter3d_core/lib/src/engine/render/renderer_probe_pass.dart index 3172188b5..5798f3d36 100644 --- a/packages/flutter3d_core/lib/src/engine/render/renderer_probe_pass.dart +++ b/packages/flutter3d_core/lib/src/engine/render/renderer_probe_pass.dart @@ -284,6 +284,9 @@ extension _ProbePasses on Renderer { _targetOrigin[0] = device.framebufferOrigin == FramebufferOrigin.bottomLeft ? size.toDouble() : 0.0; + // A probe captures light for the materials to reflect; a debug view + // baked into it would outlive the frame that asked for one. + _fragInfo.debugView.fillRange(0, 4, 0.0); final rect = ScreenRect(width: size, height: size); pass.setState( @@ -321,6 +324,8 @@ extension _ProbePasses on Renderer { _forwardData[0] = _forward.x; _forwardData[1] = _forward.y; _forwardData[2] = _forward.z; + // A probe's faces are perspective, whatever the camera that asked is. + _fogInfo.projection[0] = 0.0; void encodeHalf({required bool blended}) { for (final node in scene.meshes) { diff --git a/packages/flutter3d_core/lib/src/engine/render/renderer_resources.dart b/packages/flutter3d_core/lib/src/engine/render/renderer_resources.dart index 22de9116e..2dfce8df4 100644 --- a/packages/flutter3d_core/lib/src/engine/render/renderer_resources.dart +++ b/packages/flutter3d_core/lib/src/engine/render/renderer_resources.dart @@ -187,6 +187,12 @@ final VertexLayoutSpec _kInstancedLayout = VertexLayoutSpec([ format: VertexFormat.float32x4, offsetInBytes: 48, ), + // `P8`: the instance's own four numbers, a material's `instance`. + InputAttribute( + name: 'i_data', + format: VertexFormat.float32x4, + offsetInBytes: 64, + ), ], ), ]); diff --git a/packages/flutter3d_core/lib/src/engine/render/renderer_scene_pass.dart b/packages/flutter3d_core/lib/src/engine/render/renderer_scene_pass.dart index 9d6732f9c..4333d43f2 100644 --- a/packages/flutter3d_core/lib/src/engine/render/renderer_scene_pass.dart +++ b/packages/flutter3d_core/lib/src/engine/render/renderer_scene_pass.dart @@ -175,6 +175,16 @@ extension _ScenePasses on Renderer { // shadow's taps anew each frame for the history to average; minus one // otherwise, and the turn stays put. _targetOrigin[3] = temporal ? (_frameIndex % 32).toDouble() : -1.0; + // `P6`: the channel a debug view shows in place of the light, and the + // column it starts at — the share of the width times the width of the + // target the materials draw into, which is the scene's and not the + // output's under a render scale. + final debug = settings.debugView; + _fragInfo.debugView + ..[0] = debug.active ? debug.view.code : 0.0 + ..[1] = debug.split.clamp(0.0, 1.0) + ..[2] = hdr.width.toDouble() + ..[3] = 0.0; final cameraPosition = vm.Vector3.zero(); for (var viewNumber = 0; viewNumber < ordered.length; viewNumber++) { @@ -209,6 +219,10 @@ extension _ScenePasses on Renderer { // debug overlay at the end of a view leaves the test on `always`, which // is exactly the case this catches. passState.depthCompare = CompareFunction.less; + // `P7`: the meshes of a multisampled pass on a device that can may + // turn alpha into coverage. + passState.coverageAvailable = + msaa != null && device.supportsAlphaToCoverage; final camera = view.camera; final viewMatrix = camera.viewMatrix; @@ -280,7 +294,7 @@ extension _ScenePasses on Renderer { occlusion: occlusion, ); - camera.readWorldPosition(cameraPosition); + camera.readViewOrigin(cameraPosition); lightOverflow = lightOverflowCount; @@ -296,6 +310,11 @@ extension _ScenePasses on Renderer { _forwardData[0] = _forward.x; _forwardData[1] = _forward.y; _forwardData[2] = _forward.z; + // `P7`: whether the eye is a point the rays meet at or only where an + // orthographic camera was put — see `Orthographic` in `color.glsl`. + _fogInfo.projection[0] = camera.projection is OrthographicProjection + ? 1.0 + : 0.0; developer.Timeline.startSync('Renderer.encodeDraws'); void encodeOne(MeshNode node) => _encodeNode( @@ -340,6 +359,21 @@ extension _ScenePasses on Renderer { } else { encodeHalf(opaque); } + // `P4`: each reflector's picture over its surfaces, while the depth + // buffer holds exactly what the opaque half left — see + // `renderer_planar_pass.dart`. Nothing at all without a reflector. + _encodePlanarReflections( + encoder: pass, + scene: scene, + view: view, + viewNumber: viewNumber, + rect: viewRect, + frustum: frustum, + settings: settings, + viewProjection: viewProjection, + shadows: shadows, + state: passState, + ); // Between the two halves, which is the one place it can go. After the // opaque half, so every pixel already covered by geometry fails the depth // test before the sky's fragment stage runs — the software rasteriser @@ -397,6 +431,14 @@ extension _ScenePasses on Renderer { // view's to cull. _clusterView = null; + // Off before the contributors and the next view: a particle drawn with + // a leaf's coverage still on would be speckled by its own alpha. + if (passState.alphaToCoverage) { + pass.setAlphaToCoverage(false); + passState.alphaToCoverage = false; + } + passState.coverageAvailable = false; + // `N6`: this view's lights, cells and all, for a contributor that // binds them. One that does not never asks, and nothing is built for it. _contributorLights.begin(lights, settings); @@ -537,7 +579,7 @@ extension _ScenePasses on Renderer { meshes: scene.meshes, viewProjection: camera.viewProjection(aspect), frustum: frustum, - eye: camera.readWorldPosition(), + eye: camera.readViewOrigin(), layerMask: view.layerMask, cullBackFaces: settings.backfaceCulling, ); @@ -548,7 +590,7 @@ extension _ScenePasses on Renderer { if (views != 1) return null; return hiZ.prepare( camera.viewProjection(aspect), - eye: camera.readWorldPosition(), + eye: camera.readViewOrigin(), forward: camera.readForward(), camera: camera, ); diff --git a/packages/flutter3d_core/lib/src/engine/render/renderer_shadow_pass.dart b/packages/flutter3d_core/lib/src/engine/render/renderer_shadow_pass.dart index 95fb4cd80..1311b171b 100644 --- a/packages/flutter3d_core/lib/src/engine/render/renderer_shadow_pass.dart +++ b/packages/flutter3d_core/lib/src/engine/render/renderer_shadow_pass.dart @@ -50,6 +50,38 @@ CullMode _casterCull(ShadowCasterFaces faces) => switch (faces) { /// The pass that draws what the sun cannot see, and the one that draws what a /// lamp cannot. extension _ShadowPasses on Renderer { + /// Writes one caster's draw into [journal] — `P12`. + /// + /// Called only behind `if (_frameCounters?.journal case final journal?)`, + /// so a frame nobody is journaling never reaches it. + void _journalCaster( + DrawJournal journal, + MeshNode node, + DrawableGeometry mesh, + vm.Matrix4 mvp, { + required int instances, + required String target, + }) => journal.add( + kind: 'shadow', + mesh: node.name, + material: node.material.name, + lighting: node.material.lighting.label, + vertices: mesh.vertexCount, + indices: mesh.indexCount, + instances: instances, + state: { + 'target': target, + 'masked': _castsMasked(node), + 'skinned': node.skeleton != null, + 'instanced': node is InstancedMeshNode, + 'winding': node.worldIsMirrored ? 'cw' : 'ccw', + }, + uniforms: { + 'mvp': Float32List.fromList(mvp.storage), + 'model': Float32List.fromList(node.worldMatrix.storage), + }, + ); + /// Whether [node] casts through the cut-out shadow stages — `gfx-60n`. /// /// glTF's MASK mode and nothing else. A blended material is a different @@ -470,6 +502,16 @@ extension _ShadowPasses on Renderer { pass.bindBlock(fragment, _shadowLight); pass.draw(instanceCount: instanced?.count ?? 1); _frameCounters?.drawCalls++; + if (_frameCounters?.journal case final journal?) { + _journalCaster( + journal, + node, + mesh, + mvp, + instances: instanced?.count ?? 1, + target: 'cube', + ); + } drawn++; } finishTile(tileIndex, tileStart); @@ -508,6 +550,7 @@ extension _ShadowPasses on Renderer { vm.Matrix4? shaderMatrix, vm.Frustum? frustum, { bool? only, + bool Function(MeshNode node)? seeThrough, }) { int mix(int key, int value) => 0x1fffffff & (key * 31 + value); var key = mix( @@ -518,9 +561,32 @@ extension _ShadowPasses on Renderer { key = mix(key, value.hashCode); } for (final node in scene.meshes) { - if (only != null && node.shadowIsStatic != only) continue; + // A see-through caster is never kept in the static atlas — its share + // of the map is blended into the frame's, after the walls — so it + // belongs to the frame's key whatever it is marked as. + final translucent = seeThrough?.call(node) ?? false; + if (only != null && (translucent ? only : node.shadowIsStatic != only)) { + continue; + } if (!_drawsIntoCascade(node, frustum)) continue; final material = node.material; + if (translucent) { + // What it lets through is read off the material, which no version + // follows: its colour, its transmission, its index and its volume. + key = mix(key, material.baseColor.x.hashCode); + key = mix(key, material.baseColor.y.hashCode); + key = mix(key, material.baseColor.z.hashCode); + key = mix(key, (material.extensions?.transmission ?? 0.0).hashCode); + key = mix(key, (material.extensions?.ior ?? 1.5).hashCode); + final volume = material.extensions; + if (volume != null) { + key = mix(key, volume.thickness.hashCode); + key = mix(key, volume.attenuationDistance.hashCode); + for (final value in volume.attenuationColor.storage) { + key = mix(key, value.hashCode); + } + } + } key = mix(key, identityHashCode(node)); key = mix(key, node.worldVersion); key = mix(key, identityHashCode(material)); @@ -599,6 +665,46 @@ extension _ShadowPasses on Renderer { // row, which is the shadow a cut-out caster used to get rather than no // shadow at all, and `masked` below then never fires. final maskedShadowShader = shaders['ShadowDepthMasked'] ?? shadowShader; + // `ShadowSettings.translucentCasters`: null when it is off, under the + // moments filter whose blur takes over the channels it writes, or in a + // bundle without the stage — and then every caster is drawn as before. + final transmittanceShader = + settings.translucentCasters && + settings.directionalFilter != ShadowFilter.evsm + ? shaders['ShadowTransmittance'] + : null; + final transmits = transmittanceShader != null; + // `ShadowSettings.caustics`: the three stages it adds and the copy it + // borrows, or nothing at all. + final causticSurface = shaders['CausticSurface']; + final photonVertex = shaders['CausticPhotonVertex']; + final photonFragment = shaders['CausticPhoton']; + final caustics = + transmits && + settings.caustics && + causticSurface != null && + photonVertex != null && + photonFragment != null && + shaders['ShadowCopy'] != null && + shaders['FullscreenVertex'] != null; + bool seeThrough(MeshNode node) { + if (!transmits) return false; + final material = node.material; + return material.isTransparent || + (material.extensions?.transmission ?? 0.0) > 0.0; + } + + // Whether [node]'s light is followed as photons: a static caster with a + // volume — transmission and a thickness. A thin-walled one refracts + // nothing worth following. + bool refracts(MeshNode node) { + if (!caustics || !seeThrough(node)) return false; + if (node is InstancedMeshNode || node.skeleton != null) return false; + final extensions = node.material.extensions; + return extensions != null && + extensions.transmission > 0.0 && + extensions.thickness > 0.0; + } // Casters only. The last cascade is fitted to this, so anything counted // here that cannot cast a shadow spends texels on nothing: a sky dome or a @@ -643,8 +749,80 @@ extension _ShadowPasses on Renderer { final radii = []; final splits = [0.0, 0.0]; - if (count > 1 && camera != null) { - final eyeAt = camera.readWorldPosition(); + final orthographic = switch (camera?.projection) { + final OrthographicProjection projection => projection, + _ => null, + }; + if (count > 1 && camera != null && orthographic != null) { + // `P7`: **slabs along the view axis, for a lens whose rays are + // parallel.** The spheres below grow with distance from the eye because + // a perspective view widens with it; an orthographic one is as wide at + // every depth, so a near cascade sized by distance covered the air in + // front of the camera and everything fell to the last, softest one. + // Here each cascade is a slab of the view's box, from where the casters + // begin: even in depth — a texel covers the same world at every depth, + // so there is nothing for a logarithm to follow — and as wide as the + // frame's half-diagonal, from the frame's own aspect. `shadow.glsl` picks one by depth along + // the axis to match. + final eyeAt = camera.readViewOrigin(); + final forward = camera.readForward(); + final aspect = resources.frameHeight > 0 + ? resources.frameWidth / resources.frameHeight + : 1.0; + final halfHeight = orthographic.height * 0.5; + final across = halfHeight * math.sqrt(1.0 + aspect * aspect); + // The depth the casters span *within the frame*, not the scene's: an + // orthographic camera is usually stood well back from a level far + // larger than its view, and slabs from its near plane, or across the + // whole level, spend the near cascades on air and on ground nobody can + // see. Read off five rays down the view's axis — through its middle + // and its four corners — each clipped by the casters' box. Something + // standing between the rays and outside their depths still falls + // through to the last cascade, so this costs sharpness, never coverage. + final m = camera.worldMatrix.storage; + final right = vm.Vector3(m[0], m[1], m[2])..normalize(); + final up = vm.Vector3(m[4], m[5], m[6])..normalize(); + final halfWidth = halfHeight * aspect; + final seen = <(double, double)>[ + for (final (x, y) in const <(double, double)>[ + (0.0, 0.0), + (-1.0, -1.0), + (1.0, -1.0), + (-1.0, 1.0), + (1.0, 1.0), + ]) + ?_rayThroughBox( + eyeAt + right.scaled(x * halfWidth) + up.scaled(y * halfHeight), + forward, + bounds.min, + bounds.max, + ), + ]; + final centreDepth = (sceneCentre - eyeAt).dot(forward); + final (nearest, furthest) = seen.isEmpty + ? (centreDepth - sceneRadius, centreDepth + sceneRadius) + : seen.reduce((a, b) => (math.min(a.$1, b.$1), math.max(a.$2, b.$2))); + final start = math.max(orthographic.near, nearest); + final end = math.max( + math.min( + math.min(orthographic.far, furthest), + start + math.max(settings.viewDistance, 1.0), + ), + start + 1.0, + ); + // All of it to the near slabs: the last cascade is the whole scene + // either way, and a fragment the frame shows should not need it. + final slabs = count - 1; + for (var i = 0; i < slabs; i++) { + final from = start + (end - start) * i / slabs; + final to = start + (end - start) * (i + 1) / slabs; + splits[i] = to; + final half = (to - from) * 0.5; + centres.add(eyeAt + forward.scaled(from + half)); + radii.add(math.sqrt(across * across + half * half)); + } + } else if (count > 1 && camera != null) { + final eyeAt = camera.readViewOrigin(); final forward = camera.readForward(); final near = 1.0; final far = math.min( @@ -865,16 +1043,27 @@ extension _ShadowPasses on Renderer { // atlas holds nothing, so every tile is drawn and the pass clears; // otherwise the pass keeps the atlas and redraws only the rest. A frame // tile's key takes in its static tile's, so a static change redraws both. + // A change of layout is a fresh atlas too: one drawn without the + // see-through channels holds ones where this frame reads "all taken". final fresh = _shadowMap == null || _shadowResolution != resolution || - _shadowCascadeCount != count; + _shadowCascadeCount != count || + _shadowTransmits != transmits || + _shadowCaustics != caustics; final staticFresh = fresh || _shadowMapStatic == null; // `S1`: the static casters keyed as a whole rather than per tile, since // a tile that scrolls gains the casters its new strip holds without any // of them having changed; and each tile's key is that and its matrix. final staticScene = split - ? _cascadeBakeKey(scene, settings, null, null, only: true) + ? _cascadeBakeKey( + scene, + settings, + null, + null, + only: true, + seeThrough: transmits ? seeThrough : null, + ) : 0; final staticKeys = [ for (var i = 0; i < count; i++) @@ -891,6 +1080,7 @@ extension _ShadowPasses on Renderer { shaderMatrices[i], cascadeFrusta[i], only: split ? false : null, + seeThrough: transmits ? seeThrough : null, ) * 31 + (split ? staticKeys[i] + 1 : 0)), @@ -904,7 +1094,14 @@ extension _ShadowPasses on Renderer { } // The tile reset is what lets a pass keep the others; a bundle without it // draws every tile from a clear, as the pass always did. - final keep = !fresh && resetShader != null && resetVertexShader != null; + // Not with see-through casters: their share is blended over what is + // there, so a redrawn tile has to start from the clear, and a clear takes + // the whole atlas. + final keep = + !fresh && + !transmits && + resetShader != null && + resetVertexShader != null; for (var i = 0; i < count; i++) { if (!keep) dirty[i] = true; if (dirty[i]) _directionalBaked[i] = keys[i]; @@ -961,6 +1158,8 @@ extension _ShadowPasses on Renderer { ); _shadowResolution = resolution; _shadowCascadeCount = count; + _shadowTransmits = transmits; + _shadowCaustics = caustics; } if (split && staticModes.any((mode) => mode != _StaticTile.keep)) { _shadowMapStaticSpare ??= device.createTexture(atlasSpec()); @@ -986,6 +1185,67 @@ extension _ShadowPasses on Renderer { ) : full; + /// Binds [node]'s geometry, matrices, morph, instances and joints for the + /// vertex stage [kind] picks (0 static, 1 skinned, 2 instanced, plus + /// three for a masked fragment stage), and draws it once. + void drawNode( + CommandEncoder pass, + MeshNode node, + int kind, + vm.Matrix4 drawMatrix, + ) { + final mesh = node.mesh as DrawableGeometry; + final instanced = node is InstancedMeshNode ? node : null; + final skeleton = node.skeleton; + pass.setWindingOrder( + node.worldIsMirrored + ? WindingOrder.clockwise + : WindingOrder.counterClockwise, + ); + pass.bindVertexBuffer(mesh.vertices, mesh.vertexCount); + pass.bindIndexBuffer(mesh.indices, mesh.indexType, mesh.indexCount); + + mvp + ..setFrom(drawMatrix) + ..multiply(node.worldMatrix); + final stage = switch (kind) { + 2 => instancedVertexShader, + 1 => skinnedVertexShader, + _ => vertexShader, + }; + _frameInfo.mvp.setAll(0, mvp.storage); + _frameInfo.model.setAll(0, node.worldMatrix.storage); + _frameInfo.normalMatrix.setAll(0, node.worldNormalMatrix.storage); + pass.bindBlock(stage, _frameInfo); + _bindMorph(pass, stage, node.morph); + if (instanced != null) { + _bindInstanceMorph(pass, stage, instanced); + } + if (instanced != null) { + pass.bindVertexData(instanced.instanceBytes, instanced.count, slot: 1); + } + if (skeleton != null) { + skeleton.update(node.worldMatrix); + _skinInfo.jointMatrices.setAll(0, skeleton.matrices); + pass.bindBlock(skinnedVertexShader, _skinInfo); + } + pass.draw(instanceCount: instanced?.count ?? 1); + // A caster in three cascades is three draws, and a frame that + // reported one caster and no draws at all was hiding the cost of the + // cascade count from every measurement made of it — `gfx-01n`. + _frameCounters?.drawCalls++; + if (_frameCounters?.journal case final journal?) { + _journalCaster( + journal, + node, + mesh, + mvp, + instances: instanced?.count ?? 1, + target: 'cascade', + ); + } + } + /// Draws the casters of [cascade] into [pass] — every caster when [only] /// is null, and the static or the dynamic ones otherwise. void drawCasters( @@ -1016,11 +1276,12 @@ extension _ShadowPasses on Renderer { // vertex layout, so it needs the skinned stage here too; drawing it with // the static one would read joints and weights as position and normal. int? boundKind; - final drawMatrix = drawMatrices[cascade]; final casterFrustum = cascadeFrusta[cascade]; for (var i = 0; i < meshes.length; i++) { final node = meshes[i]; + // Drawn after the walls, by [drawSeeThrough], in the frame's atlas. + if (seeThrough(node)) continue; if (only != null && node.shadowIsStatic != only) continue; // **A caster outside this cascade is not drawn into it — `gfx-63n`.** // Every cascade used to walk the whole scene, so a level larger than @@ -1039,7 +1300,6 @@ extension _ShadowPasses on Renderer { !within.any((f) => f.intersectsWithAabb3(node.worldBounds))) { continue; } - final mesh = node.mesh as DrawableGeometry; final instanced = node is InstancedMeshNode ? node : null; // Both sides recorded for a surface that has only one, so the sun does @@ -1063,7 +1323,7 @@ extension _ShadowPasses on Renderer { final skinned = skeleton != null; // `gfx-60n`. A cut-out caster goes through a stage with a sampler in // it; everything else keeps the stage it has always had, which is why - // the masked half costs the common path nothing and why seventy-eight + // the masked half costs the common path nothing and why ninety-five // goldens recorded against the plain stage cannot move. final masked = maskedShadowShader != shadowShader && _castsMasked(node); final kind = @@ -1106,48 +1366,116 @@ extension _ShadowPasses on Renderer { boundKind = kind; } if (masked) _bindShadowMask(pass, maskedShadowShader, node.material); + drawNode(pass, node, kind, drawMatrices[cascade]); + } + } - pass.setWindingOrder( - node.worldIsMirrored - ? WindingOrder.clockwise - : WindingOrder.counterClockwise, - ); - pass.bindVertexBuffer(mesh.vertices, mesh.vertexCount); - pass.bindIndexBuffer(mesh.indices, mesh.indexType, mesh.indexCount); - - mvp - ..setFrom(drawMatrix) - ..multiply(node.worldMatrix); - final stage = switch (kind) { - 2 => instancedVertexShader, - 1 => skinnedVertexShader, - _ => vertexShader, - }; - _frameInfo.mvp.setAll(0, mvp.storage); - _frameInfo.model.setAll(0, node.worldMatrix.storage); - _frameInfo.normalMatrix.setAll(0, node.worldNormalMatrix.storage); - pass.bindBlock(stage, _frameInfo); - _bindMorph(pass, stage, node.morph); - if (instanced != null) { - _bindInstanceMorph(pass, stage, instanced); + /// Draws [cascade]'s see-through casters into [pass], over the walls + /// [drawCasters] left there — `ShadowSettings.translucentCasters`. + void drawSeeThrough(CommandEncoder pass, int cascade) { + final shader = transmittanceShader; + if (shader == null) return; + final tile = tileOf(cascade); + final casterFrustum = cascadeFrusta[cascade]; + var stated = false; + int? boundKind; + for (final node in meshes) { + if (!seeThrough(node) || !_drawsIntoCascade(node, casterFrustum)) { + continue; } - if (instanced != null) { - pass.bindVertexData( - instanced.instanceBytes, - instanced.count, - slot: 1, + if (!stated) { + pass.setState( + Renderer._kShadowTransmittanceState.copyWith( + viewport: tile, + scissor: tile, + ), ); + stated = true; } - if (skeleton != null) { - skeleton.update(node.worldMatrix); - _skinInfo.jointMatrices.setAll(0, skeleton.matrices); - pass.bindBlock(skinnedVertexShader, _skinInfo); + final kind = node is InstancedMeshNode + ? 2 + : node.skeleton != null + ? 1 + : 0; + if (boundKind != kind) { + pass.bindPipeline(switch (kind) { + 2 => + _instancedTransmittanceShadowPipeline ??= device.createPipeline( + instancedVertexShader, + shader, + layout: _kInstancedLayout, + ), + 1 => _skinnedTransmittanceShadowPipeline ??= device.createPipeline( + skinnedVertexShader, + shader, + ), + _ => _transmittanceShadowPipeline ??= device.createPipeline( + vertexShader, + shader, + ), + }); + boundKind = kind; } - pass.draw(instanceCount: instanced?.count ?? 1); - // A caster in three cascades is three draws, and a frame that - // reported one caster and no draws at all was hiding the cost of the - // cascade count from every measurement made of it — `gfx-01n`. - _frameCounters?.drawCalls++; + final material = node.material; + final extensions = material.extensions; + final colour = material.baseColor; + final ior = extensions?.ior ?? 1.5; + final f0 = (ior - 1.0) / (ior + 1.0); + final transmits = (extensions?.transmission ?? 0.0) > 0.0; + // **What reaches the far side of a transmitting body is the colour of + // its depth, not of its surface** — glTF's transmission and volume: + // the base colour tints what is transmitted, and the attenuation + // colour is what is left after [MaterialExtensions.attenuationDistance] + // of it, raised to the thickness over that distance. A solution's + // shadow comes out the deep colour light through it really is. And + // a transmitting material's alpha says how it looks, not that light + // goes round it, so it is taken as wholly there: read as coverage it + // let a fifth of the sun through untinted, and washed the colour out. + var tint = vm.Vector3(colour.x, colour.y, colour.z); + if (transmits) { + final distance = extensions!.attenuationDistance; + final thickness = extensions.thickness; + if (distance.isFinite && distance > 0.0 && thickness > 0.0) { + final depth = thickness / distance; + final through = extensions.attenuationColor; + tint = vm.Vector3( + tint.x * math.pow(through.x.clamp(0.0, 1.0), depth), + tint.y * math.pow(through.y.clamp(0.0, 1.0), depth), + tint.z * math.pow(through.z.clamp(0.0, 1.0), depth), + ); + } + } + _transmittanceInfo.color + ..[0] = tint.x + ..[1] = tint.y + ..[2] = tint.z + ..[3] = transmits + ? 1.0 + : material.isTransparent + ? colour.w + : 1.0; + _transmittanceInfo.light + ..[0] = -aim.x + ..[1] = -aim.y + ..[2] = -aim.z + ..[3] = 0.0; + final transmission = material.extensions?.transmission ?? 0.0; + _transmittanceInfo.params + ..[0] = transmission + ..[1] = f0 * f0 + // Its light stopped here and given back by its photons. + ..[2] = refracts(node) ? 1.0 : 0.0 + ..[3] = 0.0; + pass + ..bindBlock(shader, _transmittanceInfo) + // What it lets through, painted: white for a material with no map. + ..bindTexture( + shader, + _kAlbedoTextureSlot, + material.albedo ?? fallbackAlbedo, + sampler: SamplerOptions.linearClamp, + ); + drawNode(pass, node, kind, drawMatrices[cascade]); } } @@ -1171,26 +1499,36 @@ extension _ShadowPasses on Renderer { _frameCounters?.drawCalls++; } - CommandEncoder open(TextureHandle target, {required bool load}) => - device.beginRenderPass( - RenderPassDescriptor( - label: _passLabel, - colors: [ - ColorTarget( - texture: target, - // Cleared to the far plane, so anything the pass does not draw - // reads as "nothing between here and the light" — or, since - // `S1`, kept, when some tiles still hold this frame's - // picture. The depth is cleared either way: it lives only as - // long as the pass, and a kept tile draws nothing that would - // test against it. - clearValue: vm.Vector4(1.0, 1.0, 1.0, 1.0), - loadAction: load ? LoadAction.load : LoadAction.clear, - ), - ], - depth: DepthTarget(texture: depth), + CommandEncoder open( + TextureHandle target, { + required bool load, + bool transmittance = false, + }) => device.beginRenderPass( + RenderPassDescriptor( + label: _passLabel, + colors: [ + ColorTarget( + texture: target, + // Cleared to the far plane, so anything the pass does not draw + // reads as "nothing between here and the light" — or, since + // `S1`, kept, when some tiles still hold this frame's + // picture. The depth is cleared either way: it lives only as + // long as the pass, and a kept tile draws nothing that would + // test against it. + // + // With see-through casters the frame's atlas clears to + // (1, 0, 0, 1) instead: the far plane, nothing taken from red + // or green, all of blue left — the layout + // `shadow_transmittance.frag` explains. + clearValue: transmittance + ? vm.Vector4(1.0, 0.0, 0.0, 1.0) + : vm.Vector4(1.0, 1.0, 1.0, 1.0), + loadAction: load ? LoadAction.load : LoadAction.clear, ), - ); + ], + depth: DepthTarget(texture: depth), + ), + ); developer.Timeline.startSync('Renderer.shadowPass'); @@ -1236,6 +1574,292 @@ extension _ShadowPasses on Renderer { _frameCounters?.drawCalls++; } + /// `ShadowSettings.caustics` for [cascade]: its opaque depth copied out, + /// each refracting caster's near and far faces drawn into maps of its + /// own, and one photon per texel of them followed through it and added + /// back into the tile where it lands. See `caustic_photon.vert`. + void followPhotons(int cascade) { + final frustum = cascadeFrusta[cascade]; + final casters = [ + for (final node in meshes) + if (refracts(node) && _drawsIntoCascade(node, frustum)) node, + ]; + if (casters.isEmpty) return; + + // The tile's opaque depth, where photons land, in a texture of its own: + // the atlas cannot be read by the pass that draws into it. + final depthCopy = resources.transient( + RenderTargetSpec( + width: resolution, + height: resolution, + format: hdrFormat, + ), + ); + final copyDepth = resources.transient( + RenderTargetSpec( + width: resolution, + height: resolution, + format: device.defaultDepthStencilFormat, + storageMode: StorageMode.deviceTransient, + ), + ); + final whole = ScreenRect(width: resolution, height: resolution); + final copyPass = device.beginRenderPass( + RenderPassDescriptor( + label: _passLabel, + colors: [ + ColorTarget( + texture: depthCopy, + clearValue: vm.Vector4(1.0, 0.0, 0.0, 1.0), + ), + ], + depth: DepthTarget(texture: copyDepth), + ), + ); + final copyShader = shaders['ShadowCopy']!; + _shadowCopyInfo.tile + ..[0] = count > 1 ? cascade / count : 0.0 + ..[1] = 0.0 + ..[2] = 1.0 / count + ..[3] = 1.0; + _shadowCopyInfo.shift + ..[0] = 0.0 + ..[1] = 0.0 + ..[2] = 0.0 + ..[3] = 0.0; + copyPass + ..setState( + Renderer._kShadowCopyState.copyWith(viewport: whole, scissor: whole), + ) + ..bindPipeline( + _shadowCopyPipeline ??= device.createPipeline( + shaders['FullscreenVertex']!, + copyShader, + ), + ) + ..bindBlock(copyShader, _shadowCopyInfo) + ..bindTexture( + copyShader, + 'static_shadow_texture', + _shadowMap!, + sampler: SamplerOptions.nearestClamp, + ) + ..bindVertexBuffer(_fullscreenTriangle, 3) + ..bindIndexBuffer(_identityIndices(3), IndexType.int32, 3) + ..draw(); + copyPass.submit(); + _frameCounters?.drawCalls++; + + final photons = settings.causticPhotons.clamp(8, 512); + final raw = rawMatrices[cascade]; + final surfacePipeline = _causticSurfacePipeline ??= device.createPipeline( + vertexShader, + causticSurface!, + ); + + final maps = + < + ({ + MeshNode node, + TextureHandle front, + TextureHandle back, + vm.Matrix4 toMap, + double width, + double height, + }) + >[]; + for (final node in casters) { + // The caster's footprint in the cascade's clip space, a little + // padded, and the cascade's matrix cropped to it. + final box = node.worldBounds; + var x0 = double.infinity, x1 = -double.infinity; + var y0 = double.infinity, y1 = -double.infinity; + for (var corner = 0; corner < 8; corner++) { + final p = raw.transform( + vm.Vector4( + corner & 1 == 0 ? box.min.x : box.max.x, + corner & 2 == 0 ? box.min.y : box.max.y, + corner & 4 == 0 ? box.min.z : box.max.z, + 1.0, + ), + ); + x0 = math.min(x0, p.x / p.w); + x1 = math.max(x1, p.x / p.w); + y0 = math.min(y0, p.y / p.w); + y1 = math.max(y1, p.y / p.w); + } + final padX = (x1 - x0) * 0.02, padY = (y1 - y0) * 0.02; + x0 = math.max(x0 - padX, -1.0); + x1 = math.min(x1 + padX, 1.0); + y0 = math.max(y0 - padY, -1.0); + y1 = math.min(y1 + padY, 1.0); + if (x1 - x0 < 1e-6 || y1 - y0 < 1e-6) continue; + final crop = vm.Matrix4.identity() + ..setEntry(0, 0, 2.0 / (x1 - x0)) + ..setEntry(1, 1, 2.0 / (y1 - y0)) + ..setEntry(0, 3, -(x1 + x0) / (x1 - x0)) + ..setEntry(1, 3, -(y1 + y0) / (y1 - y0)); + final cropped = crop.multiplied(raw); + final drawCropped = toDepthRange(cropped, device.depthRange); + final sampleCropped = toFramebufferOrigin( + cropped, + device.framebufferOrigin, + ); + + TextureHandle face({required bool near}) { + final target = resources.transient( + RenderTargetSpec( + width: photons, + height: photons, + format: hdrFormat, + ), + ); + final depth = resources.transient( + RenderTargetSpec( + width: photons, + height: photons, + format: device.defaultDepthStencilFormat, + storageMode: StorageMode.deviceTransient, + ), + ); + final facePass = device.beginRenderPass( + RenderPassDescriptor( + label: _passLabel, + colors: [ + ColorTarget( + texture: target, + // No surface: a depth past the far plane for the near + // faces, before the near one for the far faces. + clearValue: near + ? vm.Vector4(0.0, 0.0, 0.0, 1.0) + : vm.Vector4(0.0, 0.0, 0.0, 0.0), + ), + ], + depth: DepthTarget(texture: depth, clearValue: near ? 1.0 : 0.0), + ), + ); + final square = ScreenRect(width: photons, height: photons); + facePass + ..setState( + PassState( + primitiveType: PrimitiveType.triangle, + viewport: square, + scissor: square, + cullMode: near ? CullMode.backFace : CullMode.frontFace, + depthWrite: true, + depthCompare: near + ? CompareFunction.less + : CompareFunction.greater, + ), + ) + ..bindPipeline(surfacePipeline); + drawNode(facePass, node, 0, drawCropped); + facePass.submit(); + return target; + } + + maps.add(( + node: node, + front: face(near: true), + back: face(near: false), + toMap: sampleCropped, + width: x1 - x0, + height: y1 - y0, + )); + } + if (maps.isEmpty) return; + + // The photons, into the tile. + final tile = tileOf(cascade); + final photonPass = open(_shadowMap!, load: true); + final quad = _causticQuad ??= device.uploadGeometry( + Float32List.fromList([ + -1.0, -1.0, 1.0, -1.0, 1.0, 1.0, // + -1.0, -1.0, 1.0, 1.0, -1.0, 1.0, + ]).buffer.asByteData(), + GeometryUsage.vertices, + ); + photonPass + ..setState( + Renderer._kCausticPhotonState.copyWith(viewport: tile, scissor: tile), + ) + ..bindPipeline( + _causticPhotonPipeline ??= device.createPipeline( + photonVertex!, + photonFragment!, + ), + ) + ..bindVertexBuffer(quad, 6) + ..bindIndexBuffer(_identityIndices(6), IndexType.int32, 6); + final texel = 2.0 / resolution; + for (final map in maps) { + final material = map.node.material; + final extensions = material.extensions!; + final ior = math.max(extensions.ior, 1.0); + final f0 = (ior - 1.0) / (ior + 1.0); + // The least a photon's quad reaches either way: two texels, or a + // photon gathered to a point falls between texel centres. + final least = 2.0 * texel; + final distance = extensions.attenuationDistance; + _causticInfo.mapToWorld.setAll( + 0, + vm.Matrix4.inverted(map.toMap).storage, + ); + _causticInfo.worldToMap.setAll(0, map.toMap.storage); + _causticInfo.worldToTile.setAll(0, shaderMatrices[cascade].storage); + _causticInfo.worldToClip.setAll(0, drawMatrices[cascade].storage); + _causticInfo.grid + ..[0] = photons.toDouble() + ..[1] = least + ..[2] = map.width / photons + ..[3] = map.height / photons; + _causticInfo.light + ..[0] = aim.x + ..[1] = aim.y + ..[2] = aim.z + ..[3] = 0.0; + _causticInfo.optics + ..[0] = ior + ..[1] = f0 * f0 + ..[2] = distance.isFinite && distance > 0.0 ? distance : 0.0 + ..[3] = 0.0; + final transmission = extensions.transmission; + _causticInfo.tint + ..[0] = transmission * material.baseColor.x + ..[1] = transmission * material.baseColor.y + ..[2] = transmission * material.baseColor.z + ..[3] = 0.0; + _causticInfo.attenuation + ..[0] = extensions.attenuationColor.x + ..[1] = extensions.attenuationColor.y + ..[2] = extensions.attenuationColor.z + ..[3] = 0.0; + photonPass + ..bindBlock(photonVertex!, _causticInfo) + ..bindTexture( + photonVertex, + 'caustic_front', + map.front, + sampler: SamplerOptions.nearestClamp, + ) + ..bindTexture( + photonVertex, + 'caustic_back', + map.back, + sampler: SamplerOptions.nearestClamp, + ) + ..bindTexture( + photonVertex, + 'caustic_depth', + depthCopy, + sampler: SamplerOptions.nearestClamp, + ) + ..draw(instanceCount: photons * photons); + _frameCounters?.drawCalls++; + } + photonPass.submit(); + } + developer.Timeline.startSync('Renderer.shadowPass'); if (split && staticModes.any((mode) => mode != _StaticTile.keep)) { // Into the spare atlas, every tile: a kept one copied across, a @@ -1268,7 +1892,7 @@ extension _ShadowPasses on Renderer { _staticSceneKey = staticScene; } - final pass = open(_shadowMap!, load: keep); + final pass = open(_shadowMap!, load: keep, transmittance: transmits); for (var cascade = 0; cascade < count; cascade++) { if (!dirty[cascade]) continue; if (split) { @@ -1280,8 +1904,14 @@ extension _ShadowPasses on Renderer { if (keep) resetTile(pass, cascade); drawCasters(pass, cascade); } + drawSeeThrough(pass, cascade); } pass.submit(); + if (caustics) { + for (var cascade = 0; cascade < count; cascade++) { + if (dirty[cascade]) followPhotons(cascade); + } + } developer.Timeline.finishSync(); _shadowMapVersion++; @@ -1456,3 +2086,29 @@ final class _Scroll { final double dz; final List strips; } + +/// Where the ray from [origin] along [direction] (unit length) is inside the +/// box [min]–[max], as distances along it — or null when it misses. The slab +/// test, clamped to what lies ahead of [origin]. +(double, double)? _rayThroughBox( + vm.Vector3 origin, + vm.Vector3 direction, + vm.Vector3 min, + vm.Vector3 max, +) { + var enter = 0.0; + var leave = double.infinity; + for (var axis = 0; axis < 3; axis++) { + final o = origin[axis]; + final d = direction[axis]; + if (d.abs() < 1e-12) { + if (o < min[axis] || o > max[axis]) return null; + continue; + } + final a = (min[axis] - o) / d; + final b = (max[axis] - o) / d; + enter = math.max(enter, math.min(a, b)); + leave = math.min(leave, math.max(a, b)); + } + return enter <= leave ? (enter, leave) : null; +} diff --git a/packages/flutter3d_core/lib/src/engine/render/renderer_sky_pass.dart b/packages/flutter3d_core/lib/src/engine/render/renderer_sky_pass.dart index 5a83b6c79..5f47617b8 100644 --- a/packages/flutter3d_core/lib/src/engine/render/renderer_sky_pass.dart +++ b/packages/flutter3d_core/lib/src/engine/render/renderer_sky_pass.dart @@ -29,21 +29,28 @@ extension _SkyPass on Renderer { final sky = settings.sky; if (!sky.enabled) return; - // Two fragment stages behind one vertex stage: the ray is the same either - // way, and which one runs is decided by whether there is a cube to sample. + // Three fragment stages: the ray is the same in each, and which one runs + // is decided by whether there is a cube to sample, and failing that, + // whether there is air to scatter through. A cube wins, as + // `SkySettings.physical` says. final cubemap = sky.cubemap; final textured = cubemap != null; - final fragmentName = textured ? 'SkyCube' : 'Sky'; + final air = textured ? null : sky.physical; + final fragmentName = textured + ? 'SkyCube' + : (air != null ? 'SkyPhysical' : 'Sky'); - // Two vertex stages, one per fragment stage: the layout each draw carries - // is derived from the stage's own declarations, and the gradient and the - // cube want different things on their vertices. See `sky.vert`. + // A vertex stage per fragment stage: the layout each draw carries is + // derived from the stage's own declarations, and each sky wants + // different things on its vertices. See `sky.vert`. // // Through the renderer's own library rather than `device.shaders`, like // every other stage it resolves by name: a bundle handed in as // `materials:` wins any name it shares with the engine's, and a sky it // replaced — or reloaded, see `relinkShaders` — was otherwise never seen. - final vertexName = textured ? 'SkyCubeVertex' : 'SkyVertex'; + final vertexName = textured + ? 'SkyCubeVertex' + : (air != null ? 'SkyPhysicalVertex' : 'SkyVertex'); final vertex = shaders[vertexName]; final fragment = shaders[fragmentName]; if (vertex == null || fragment == null) { @@ -71,10 +78,15 @@ extension _SkyPass on Renderer { pass.setDepthWrite(false); final inverse = vm.Matrix4.copy(viewProjection)..invert(); + _skyOrthoLens = isOrthographic(viewProjection) + ? _orthoSkyLens(inverse) + : null; pass.bindPipeline( textured ? (_skyCubePipeline ??= device.createPipeline(vertex, fragment)) + : air != null + ? (_skyPhysicalPipeline ??= device.createPipeline(vertex, fragment)) : (_skyPipeline ??= device.createPipeline(vertex, fragment)), ); // The tracker described a pipeline this just replaced; the next mesh has to @@ -95,7 +107,12 @@ extension _SkyPass on Renderer { return; } - pass.bindVertexData(_skyVertexBytes(inverse, sky, textured: false), 3); + pass.bindVertexData( + air != null + ? _skyPhysicalVertexBytes(inverse, sky, air) + : _skyVertexBytes(inverse, sky, textured: false), + 3, + ); pass.bindIndexBuffer(_identityIndices(3), IndexType.int32, 3); pass.draw(); @@ -199,6 +216,72 @@ extension _SkyPass on Renderer { return ByteData.sublistView(data, 0, 3 * stride); } + /// The three corners of the physical sky's triangle — `P5`. + /// + /// The gradient's stride and the gradient's buffer: six vec4s after the ray + /// in both, and `sky_physical.vert` lists what each holds. Lengths go over + /// in kilometres, converted by `PhysicalSkyKilometres` so the Dart model + /// marches with the very numbers the shader does. + ByteData _skyPhysicalVertexBytes( + vm.Matrix4 inverse, + SkySettings sky, + PhysicalSky air, + ) { + final data = _skyVertexData; + const corners = [-1.0, -1.0, 3.0, -1.0, -1.0, 3.0]; + const stride = Renderer._kSkyVertexFloats; + final k = PhysicalSkyKilometres(air); + final toSun = sky.resolvedDirectionToSun.normalized(); + + for (var i = 0; i < 3; i++) { + final x = corners[i * 2]; + final y = corners[i * 2 + 1]; + var at = i * stride; + + data[at++] = x; + data[at++] = y; + + _skyCornerRay(inverse, x, y, _skyRay); + data[at++] = _skyRay.x; + data[at++] = _skyRay.y; + data[at++] = _skyRay.z; + + data[at++] = k.rayleigh.x; + data[at++] = k.rayleigh.y; + data[at++] = k.rayleigh.z; + data[at++] = k.rayleighHeight; + + data[at++] = k.mie; + data[at++] = k.mieExtinction; + data[at++] = k.mieHeight; + data[at++] = air.resolvedAnisotropy; + + data[at++] = toSun.x; + data[at++] = toSun.y; + data[at++] = toSun.z; + data[at++] = air.sunIlluminance; + + data[at++] = k.planet; + data[at++] = k.top; + data[at++] = k.eye; + data[at++] = air.groundAlbedo; + + data[at++] = air.starBrightness; + data[at++] = air.starDensity; + data[at++] = air.starCells.toDouble(); + data[at++] = 0.0; + + // The disc as the gradient sends it, inner cosine and the width of the + // edge — see `_skyVertexBytes` for why not the outer cosine. + data[at++] = sky.discInnerCosine; + data[at++] = sky.discInnerCosine - sky.discOuterCosine; + data[at++] = sky.sunIntensity; + data[at++] = 0.0; + } + + return ByteData.sublistView(data, 0, 3 * stride); + } + /// The world-space view ray at one clip-space corner. /// /// Two points on the same eye ray, subtracted. The depths are 0.5 and 1.0 @@ -206,12 +289,23 @@ extension _SkyPass on Renderer { /// zero-to-one on Impeller and on the software rasteriser and minus-one-to-one /// on WebGL, and the difference of two points on one ray is the same direction /// wherever the two points sit. - static void _skyCornerRay( - vm.Matrix4 inverse, - double x, - double y, - vm.Vector3 out, - ) { + /// + /// **Through an orthographic lens every corner's ray is the view axis**, so + /// the sky would be one colour, a cube map one texel, and the sun's disc the + /// whole frame or nothing — `P7`. The sky is then seen as a perspective + /// camera of [_kOrthoSkyFieldOfView] standing where the orthographic one + /// does would see it: an orthographic view is a way of drawing the near + /// world, not a claim that the sky is infinitely far in one direction only. + void _skyCornerRay(vm.Matrix4 inverse, double x, double y, vm.Vector3 out) { + final lens = _skyOrthoLens; + if (lens != null) { + final spread = math.tan(_kOrthoSkyFieldOfView / 2.0); + out + ..setFrom(lens.forward) + ..addScaled(lens.right, x * spread * lens.aspect) + ..addScaled(lens.up, y * spread); + return; + } final near = inverse.transform(vm.Vector4(x, y, 0.5, 1.0)); final far = inverse.transform(vm.Vector4(x, y, 1.0, 1.0)); out.setValues( @@ -220,4 +314,36 @@ extension _SkyPass on Renderer { far.z / far.w - near.z / near.w, ); } + + /// The view axis and the world directions of the frame's right and top + /// edges, out of an orthographic [inverse] view-projection, and the frame's + /// aspect — the lens [_skyCornerRay] sees the sky through. + /// + /// Read off the matrix at the frame's middle and edges rather than off the + /// camera, so whatever the backend did to its y — the top edge is the one + /// clip y of one points at on every backend — the sky turns with it. + static ({vm.Vector3 forward, vm.Vector3 right, vm.Vector3 up, double aspect}) + _orthoSkyLens(vm.Matrix4 inverse) { + vm.Vector3 at(double x, double y, double z) { + final p = inverse.transform(vm.Vector4(x, y, z, 1.0)); + return vm.Vector3(p.x / p.w, p.y / p.w, p.z / p.w); + } + + final centre = at(0.0, 0.0, 0.5); + final forward = (at(0.0, 0.0, 1.0) - centre)..normalize(); + final right = at(1.0, 0.0, 0.5) - centre; + final up = at(0.0, 1.0, 0.5) - centre; + final halfHeight = up.length; + final aspect = halfHeight > 0.0 ? right.length / halfHeight : 1.0; + return ( + forward: forward, + right: right..normalize(), + up: up..normalize(), + aspect: aspect, + ); + } } + +/// The vertical field of view the sky is seen with through an orthographic +/// lens, in radians: sixty degrees, a camera's ordinary width — `P7`. +const double _kOrthoSkyFieldOfView = math.pi / 3.0; diff --git a/packages/flutter3d_core/lib/src/engine/render/renderer_transparency_pass.dart b/packages/flutter3d_core/lib/src/engine/render/renderer_transparency_pass.dart index 749cf1c5f..06f0eae11 100644 --- a/packages/flutter3d_core/lib/src/engine/render/renderer_transparency_pass.dart +++ b/packages/flutter3d_core/lib/src/engine/render/renderer_transparency_pass.dart @@ -128,6 +128,8 @@ extension _TransparencyPasses on Renderer { _forwardData[0] = deferred.forward.x; _forwardData[1] = deferred.forward.y; _forwardData[2] = deferred.forward.z; + _fogInfo.projection[0] = + deferred.view.camera.projection is OrthographicProjection ? 1.0 : 0.0; _clustersActive = deferred.clustered; _aimSceneColour(deferred); if (deferred.clustered && rebuildClusters) { diff --git a/packages/flutter3d_core/lib/src/engine/render/renderer_velocity_pass.dart b/packages/flutter3d_core/lib/src/engine/render/renderer_velocity_pass.dart index 5f314c2b7..ee9f36402 100644 --- a/packages/flutter3d_core/lib/src/engine/render/renderer_velocity_pass.dart +++ b/packages/flutter3d_core/lib/src/engine/render/renderer_velocity_pass.dart @@ -106,7 +106,7 @@ extension _VelocityPass on Renderer { final eye = vm.Vector3.zero(); final forward = vm.Vector3.zero(); camera - ..readWorldPosition(eye) + ..readViewOrigin(eye) ..readForward(forward); _prevFrameInfo.camera ..[0] = eye.x diff --git a/packages/flutter3d_core/lib/src/engine/render/shadow_settings.dart b/packages/flutter3d_core/lib/src/engine/render/shadow_settings.dart index e792c00e3..9dc72ca40 100644 --- a/packages/flutter3d_core/lib/src/engine/render/shadow_settings.dart +++ b/packages/flutter3d_core/lib/src/engine/render/shadow_settings.dart @@ -105,6 +105,9 @@ final class ShadowSettings { this.filter, this.evsmBlurRadius = 2, this.evsmBleedReduction = 0.2, + this.translucentCasters = false, + this.caustics = false, + this.causticPhotons = 128, }); /// How the directional map is filtered — `S2`. Null picks from @@ -146,6 +149,59 @@ final class ShadowSettings { /// darkens the soft edge with it; a fifth is the usual compromise. final double evsmBleedReduction; + /// Whether glass, liquid and other see-through casters shade the sun's + /// light by what their material lets through, instead of blocking it like + /// a wall. + /// + /// **A shadow map holds one depth per texel**, and a half-transparent caster + /// has no single depth to give it, so until this every blended or + /// transmissive material cast a shadow as dark as stone. With this on, such + /// a caster (`MaterialAlphaMode.blend`, or a transmission above nought) is + /// drawn after the opaque ones into the three channels of the atlas that + /// held nothing: how much of each of red, green and blue it lets through, + /// from its opacity, its transmission, its colour and volume, and the + /// Fresnel loss its index of refraction gives at that angle — so a clear + /// glass casts a faint shadow with darker edges, and a coloured solution a + /// shadow of the deep colour light through it takes. Where refracted light + /// lands, the bright line down a tube's shadow, is not modelled. + /// Layers combine: light through two walls and a liquid is what all three + /// let through. + /// + /// What it does not do: a receiver standing between the light and the + /// translucent caster is shaded as if it were behind it, since the atlas + /// keeps the transmittance and not where along the light it happened; and + /// it does nothing with [ShadowFilter.evsm], whose moments take over the + /// channels this uses. A see-through surface that casts is not shaded by + /// it, so a glass does not darken itself; one that casts nothing, a glazed + /// floor, is shaded like any other. Off by default, which draws every frame + /// exactly as before. + final bool translucentCasters; + + /// Whether light through a refracting caster is followed to where it lands + /// — caustics. Needs [translucentCasters]. + /// + /// A caster with a volume (transmission above nought and a thickness, as + /// glTF's `KHR_materials_volume` says one has) is drawn from the sun twice + /// into small maps of its own, its near faces and its far faces, and one + /// photon per texel of them is bent in by Snell's law at the near face and + /// out at the far one, dimmed by Fresnel and by what its volume absorbs over + /// the path, followed to the first opaque surface below, and added into the + /// atlas there. The caster itself stops all of the sun's light, so what + /// lands under it is only what the photons bring: the dark rim and the + /// bright focal line a glass of water throws, in the colour of what is in + /// it. A thin-walled caster (no thickness) refracts nothing and keeps the + /// shading [translucentCasters] gives it, which is what a glass's own wall + /// wants. + /// + /// It follows two surfaces, the nearest and the farthest, so a liquid + /// inside a thin glass is followed as the liquid; a body with more + /// interfaces than two is followed as if it had two. Static meshes only. + final bool caustics; + + /// How many photons a refracting caster sends along each side of its + /// footprint in a cascade: [causticPhotons]² for each, per cascade. + final int causticPhotons; + /// How many cascades the directional map is split into, one to three. /// /// **Three by default, at a thousand-pixel tile.** It was one for a while, @@ -491,6 +547,9 @@ final class ShadowSettings { ShadowFilter? filter, int? evsmBlurRadius, double? evsmBleedReduction, + bool? translucentCasters, + bool? caustics, + int? causticPhotons, }) => // Every field, and that is not bookkeeping. This method already dropped // the point-shadow settings on the floor: `settingsFrom` calls it once a @@ -524,5 +583,8 @@ final class ShadowSettings { filter: filter ?? this.filter, evsmBlurRadius: evsmBlurRadius ?? this.evsmBlurRadius, evsmBleedReduction: evsmBleedReduction ?? this.evsmBleedReduction, + translucentCasters: translucentCasters ?? this.translucentCasters, + caustics: caustics ?? this.caustics, + causticPhotons: causticPhotons ?? this.causticPhotons, ); } diff --git a/packages/flutter3d_core/lib/src/engine/render/sky_settings.dart b/packages/flutter3d_core/lib/src/engine/render/sky_settings.dart index ccf193306..d6dc5c54e 100644 --- a/packages/flutter3d_core/lib/src/engine/render/sky_settings.dart +++ b/packages/flutter3d_core/lib/src/engine/render/sky_settings.dart @@ -34,6 +34,8 @@ import 'dart:math' as math; import 'package:flutter3d_hardware/flutter3d_hardware.dart'; import 'package:vector_math/vector_math.dart'; +import 'physical_sky.dart'; + /// What the sky looks like, and whether there is one. final class SkySettings { const SkySettings({ @@ -50,6 +52,7 @@ final class SkySettings { this.sunIntensity = 0.0, this.cubemap, this.tint, + this.physical, }); /// Whether to draw at all. False emits nothing — see the library docstring. @@ -137,6 +140,26 @@ final class SkySettings { static Vector3 get _defaultTint => Vector3(1.0, 1.0, 1.0); + /// The air to scatter the sun through instead of evaluating the gradient — + /// `P5`. Null, the default, draws the gradient. + /// + /// When this is set the colours of the sky come from the air and where the + /// sun is: [zenith], [horizon], [nadir], [sunColor], [glowExponent] and + /// [glowStrength] are not read. [directionToSun] places the sun, and the + /// disc keeps its size, softness and [sunIntensity], drawn white and dimmed + /// by the air in front of it — so it reddens towards the horizon by itself, + /// and is gone below it. Stars come out as the sun goes down; see + /// [PhysicalSky.starBrightness]. + /// + /// A [cubemap] still wins: a photographed sky is a sky somebody chose. + /// + /// [sample] answers from the same model, so an environment map built from + /// the sky, a fog colour picked off its horizon and the ambient the renderer + /// takes from it all follow the sun too. The stars are the one part [sample] + /// leaves out: an environment map thirty-two texels a side would turn each + /// into a bright texel somewhere it does not belong. + final PhysicalSky? physical; + /// The colour of the sky in [direction], on the CPU. /// /// The same arithmetic the shader runs, for the things that cannot ask the @@ -145,6 +168,8 @@ final class SkySettings { /// /// [direction] need not be normalised. Vector3 sample(Vector3 direction) { + final air = physical; + if (air != null) return _samplePhysical(air, direction); final length = direction.length; if (length <= 0.0) return Vector3.copy(resolvedHorizon); final x = direction.x / length; @@ -175,6 +200,21 @@ final class SkySettings { return colour; } + /// [sample] for a physical sky: the scattered light, and the disc where the + /// ray reaches space, white and dimmed by the air it crossed. + Vector3 _samplePhysical(PhysicalSky air, Vector3 direction) { + final seen = air.look(direction, resolvedDirectionToSun); + final colour = seen.radiance; + if (seen.ground || sunIntensity <= 0.0) return colour; + final length = direction.length; + if (length <= 0.0) return colour; + final towards = direction.dot(resolvedDirectionToSun.normalized()) / length; + final edge = + _smoothstep(discOuterCosine, discInnerCosine, towards) * sunIntensity; + if (edge > 0.0) colour.addScaled(seen.transmittance, edge); + return colour; + } + /// Cosine of the disc's angular radius, and of the radius plus its soft edge. /// /// Cosines rather than angles because that is what a dot product answers, and @@ -201,6 +241,7 @@ final class SkySettings { double? sunIntensity, TextureHandle? cubemap, Vector3? tint, + PhysicalSky? physical, }) => SkySettings( enabled: enabled ?? this.enabled, zenith: zenith ?? this.zenith, @@ -216,6 +257,7 @@ final class SkySettings { sunIntensity: sunIntensity ?? this.sunIntensity, cubemap: cubemap ?? this.cubemap, tint: tint ?? this.tint, + physical: physical ?? this.physical, ); } diff --git a/packages/flutter3d_core/lib/src/engine/render/splat_contributor.dart b/packages/flutter3d_core/lib/src/engine/render/splat_contributor.dart index bf08c4d88..beadae9b4 100644 --- a/packages/flutter3d_core/lib/src/engine/render/splat_contributor.dart +++ b/packages/flutter3d_core/lib/src/engine/render/splat_contributor.dart @@ -34,6 +34,7 @@ import 'package:flutter3d_shaders/typed_blocks.dart'; import 'package:vector_math/vector_math.dart'; import '../../formats/splat/splat_cloud.dart'; +import '../scene/projection.dart' show isOrthographic; import '../scene/scene_node.dart'; import 'engine_tables.dart'; import 'identity_indices.dart'; @@ -108,6 +109,9 @@ final class SplatLens { /// The camera's view axis, unit length, in world space. final Vector3 forward; + /// Whether this is an orthographic lens: clip `w` does not grow with depth. + bool get orthographic => depthWeight == 0.0; + /// Pixels per unit of `x / w` on the screen: half the viewport's height /// times the projection's vertical scale. final double focal; @@ -174,6 +178,10 @@ final class SplatQuads { /// Where the eye was, and where the cloud was placed, at the last sort. /// Null until the first, and after [invalidateSort]. Vector3? _sortedEye; + + /// The axis the last sort ordered along, or null when it ordered by + /// distance — `P7`. + Vector3? _sortedAxis; final Float64List _sortedModel = Float64List(16); bool _sortedWithModel = false; @@ -200,7 +208,9 @@ final class SplatQuads { /// /// Both axes are expected orthonormal, which is what a camera's own basis /// is. The camera's forward axis is not needed: the order is by distance - /// from [eye], which a turn does not change — see `splat_sort.dart`. + /// from [eye], which a turn does not change — see `splat_sort.dart` — + /// except through an orthographic [lens], whose order is depth along its + /// axis. /// /// The quads are rebuilt every call, since their axes follow the camera's /// own; the sort, which is most of the cost, runs only when the eye has @@ -227,7 +237,9 @@ final class SplatQuads { }) { // A tree's cut is chosen where the sort runs, so a hashed build still // sorts when the cut moves: the order and the cut have to agree. - if ((sorted || lod != null) && _needsSort(eye, model)) { + // `P7`: depth along the axis through an orthographic lens. + final axis = lens != null && lens.orthographic ? lens.forward : null; + if ((sorted || lod != null) && _needsSort(eye, model, axis)) { final lod = this.lod; if (lod != null) { // The cut is chosen by distance in the tree's own space, so the eye @@ -238,9 +250,10 @@ final class SplatQuads { _sortedPageVersion = lod.tree.pageVersion; _sortedBudget = lod.budget; } - _sorter.sort(cloud, eye, model: model); + _sorter.sort(cloud, eye, model: model, axis: axis); _sorts++; _sortedEye = eye.clone(); + _sortedAxis = axis?.clone(); _sortedWithModel = model != null; if (model != null) _sortedModel.setAll(0, model.storage); } @@ -410,7 +423,7 @@ final class SplatQuads { vertexCount = count * kSplatVerticesPerSplat; } - bool _needsSort(Vector3 eye, Matrix4? model) { + bool _needsSort(Vector3 eye, Matrix4? model, Vector3? axis) { final last = _sortedEye; if (last == null) return true; final lod = this.lod; @@ -426,6 +439,16 @@ final class SplatQuads { if (storage[k] != _sortedModel[k]) return true; } } + final sortedAxis = _sortedAxis; + if ((axis != null) != (sortedAxis != null)) return true; + if (axis != null && sortedAxis != null) { + // Through an orthographic lens a move changes no order and a turn + // does: turned by an angle θ, a splat's depth moves by up to the + // cloud's extent times θ, held to the same fraction a move is. A tree's + // cut still follows the eye, so a move re-sorts when there is one. + if (sortedAxis.distanceTo(axis) > resortFraction) return true; + if (lod == null) return false; + } return last.distanceTo(eye) > resortFraction * _sorter.lastRange; } } @@ -456,11 +479,12 @@ final class SplatContributor extends PassContributor { final ParticleInfoBlock _particleInfo = ParticleInfoBlock(); final SplatHashInfoBlock _hashInfo = SplatHashInfoBlock(); - /// `FogInfo`'s two members as both splat stages declare them: the fog's - /// colour and density, and the eye. Not `FogInfoBlock`, which is the lit - /// stages' three-member layout. + /// `FogInfo` as `contributor_eye.glsl` declares it for both splat stages: + /// the fog's colour and density, the eye, the view axis and the lens. final Float32List _fog = Float32List(4); final Float32List _eye = Float32List(4); + final Float32List _forward = Float32List(4); + final Float32List _projection = Float32List(4); SplatContributor( SplatCloud cloud, { @@ -496,6 +520,13 @@ final class SplatContributor extends PassContributor { PipelineHandle? _pipeline; PipelineHandle? _hashedPipeline; + @override + void relinkShaders() { + _pipeline = null; + _hashedPipeline = null; + _reactivePipeline = null; + } + /// The index sequence 0, 1, 2, … every draw in this engine needs — see /// `MeshOverlay._identityIndices`, which keeps the same sequence for the /// same reason: `CommandEncoder.draw` has no unindexed path, and a draw @@ -545,7 +576,7 @@ final class SplatContributor extends PassContributor { final m = camera.worldMatrix.storage; final right = Vector3(m[0], m[1], m[2])..normalize(); final up = Vector3(m[4], m[5], m[6])..normalize(); - final eye = camera.readWorldPosition(); + final eye = camera.readViewOrigin(); final forward = Vector3(-m[8], -m[9], -m[10])..normalize(); // The projection the frame draws with, recovered from the matrix it was @@ -611,15 +642,27 @@ final class SplatContributor extends PassContributor { ..[0] = colour.x ..[1] = colour.y ..[2] = colour.z - ..[3] = fog.density; + ..[3] = fog.densityAt(eye.y); _eye ..[0] = eye.x ..[1] = eye.y ..[2] = eye.z; + // Through an orthographic camera the fog is measured from the eye's + // plane, along the axis — `P7`. + _forward + ..[0] = forward.x + ..[1] = forward.y + ..[2] = forward.z; + _projection[0] = isOrthographic(viewProjection) ? 1.0 : 0.0; frame.encoder.bindUniformBlock( fragmentShader, 'FogInfo', - {'fog': _fog, 'eye': _eye}, + { + 'fog': _fog, + 'eye': _eye, + 'forward': _forward, + 'projection': _projection, + }, ); if (hashed) { // The frame's slice of the engine's blue noise, the same slice the diff --git a/packages/flutter3d_core/lib/src/engine/render/splat_sort.dart b/packages/flutter3d_core/lib/src/engine/render/splat_sort.dart index a961fd410..ae52215af 100644 --- a/packages/flutter3d_core/lib/src/engine/render/splat_sort.dart +++ b/packages/flutter3d_core/lib/src/engine/render/splat_sort.dart @@ -22,6 +22,13 @@ /// does not change when the camera turns, only when it moves. So a camera /// that looks around never re-sorts, and `SplatQuads` only has to ask /// whether the eye has travelled far enough to matter. +/// +/// **Through an orthographic lens, by depth along the axis instead** — `P7`. +/// There the rays are parallel and do not meet at the eye, so what is in +/// front of what is depth along the view axis, and distance from wherever +/// the camera was put along it puts a splat off to the side behind one it +/// covers. The consequence turns round with it: moving the camera adds the +/// same to every depth and changes no order, and turning it does. library; import 'dart:math' as math; @@ -112,12 +119,14 @@ final class SplatSorter { double _lastRange = 0.0; /// Orders [cloud] far to near by distance from [eye], with the cloud placed - /// in the world by [model] when it is given. + /// in the world by [model] when it is given — or, given an [axis] (unit + /// length), by depth from [eye] along it, which is an orthographic + /// camera's order. /// /// The distances are quantised across the range this cloud spans from /// this eye, not across a fixed one: sixteen bits over the cloud's own /// depth is what keeps a small cloud and a large one equally well ordered. - void sort(SplatCloud cloud, Vector3 eye, {Matrix4? model}) { + void sort(SplatCloud cloud, Vector3 eye, {Matrix4? model, Vector3? axis}) { final count = cloud.count; if (_keys.length < count) { _keys = Uint32List(count); @@ -150,7 +159,9 @@ final class SplatSorter { (m == null ? ly : m[1] * lx + m[5] * ly + m[9] * lz + m[13]) - ey; final dz = (m == null ? lz : m[2] * lx + m[6] * ly + m[10] * lz + m[14]) - ez; - final d = math.sqrt(dx * dx + dy * dy + dz * dz); + final d = axis == null + ? math.sqrt(dx * dx + dy * dy + dz * dz) + : dx * axis.x + dy * axis.y + dz * axis.z; distances[i] = d; if (d < near) near = d; if (d > far) far = d; diff --git a/packages/flutter3d_core/lib/src/engine/render/tables/smaa_area.dart b/packages/flutter3d_core/lib/src/engine/render/tables/smaa_area.dart new file mode 100644 index 000000000..33f949dfb --- /dev/null +++ b/packages/flutter3d_core/lib/src/engine/render/tables/smaa_area.dart @@ -0,0 +1,483 @@ +// GENERATED by tool/make_tables.dart. Do not edit by hand. +// +// ignore_for_file: lines_longer_than_80_chars + +import 'package:flutter3d_hardware/flutter3d_hardware.dart'; + +import '../engine_table.dart'; + +/// How much of each pixel along an edge the line behind it covers — `P1`, +/// SMAA 1x's orthogonal area table: 80×80 rgba8, red and green used. +/// +/// An edge is a run of pixels between two ends, and at each end a crossing +/// edge may stand on either side of it or on both. Those two ends pick one +/// of 5×5 cells 16 texels square (only the cells at 0, 1, 3 and 4 are ever +/// read, the codes `round(4·e)` a crossing's bilinear fetch gives), and +/// inside a cell the texel at (i, j) is the pixel `i²` from the run's first +/// end and `j²` from its last: the square root, so a short run gets a texel +/// per pixel and a long one shares them. Red is how much of the pixel on the +/// near side of the edge takes the far side's colour; green, the far +/// pixel's share of the near one. +/// +/// The lines are the ones Jimenez, Echevarria, Sousa and Gutierrez +/// reconstruct in "SMAA: Enhanced Subpixel Morphological Antialiasing", +/// Eurographics 2012, for the sixteen patterns of crossing edges, without +/// the subsample offsets of the 2x and 4x modes and without the diagonal +/// half of their table, which this engine does not search. +const EngineTable smaaArea = EngineTable( + name: 'smaaArea', + width: 80, + height: 80, + format: TextureFormat.r8g8b8a8UNormInt, + base64: + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAALAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABA' + 'AAAACwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAZgAAAEAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGYAAABAAAAABAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABz' + 'AAAAXQAAAC4AAAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAcwAAAF0AAAAuAAAAAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHgAAABqAAAASQAAACIAAAABAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB4AAAAagAAAEkA' + 'AAAiAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAewAAAHEAAABZAAAAOgAAABsAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHsAAABxAAAAWQAAADoAAAAbAAAAAQAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB8AAAAdQAAAGQA' + 'AABLAAAAMAAAABcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAfAAAAHUAAABkAAAASwAAADAAAAAXAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAH0AAAB4AAAAagAAAFYAAABAAAAAKQAAABMAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB9AAAAeAAAAGoAAABWAAAAQAAA' + 'ACkAAAATAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAfgAAAHoAAABvAAAAXwAAAEwAAAA3AAAAIwAAABEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAH4AAAB6AAAAbwAAAF8AAABMAAAANwAAACMAAAARAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB+AAAAewAAAHIAAABlAAAAVQAA' + 'AEMAAAAxAAAAHwAAAA8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAfgAAAHsA' + 'AAByAAAAZQAAAFUAAABDAAAAMQAAAB8AAAAPAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAH4AAAB8AAAAdQAAAGkAAABcAAAATAAAADwAAAArAAAAHAAAAA0AAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB+AAAAfAAAAHUAAABpAAAAXAAAAEwAAAA8AAAA' + 'KwAAABwAAAANAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAfgAAAHwA' + 'AAB2AAAAbQAAAGEAAABTAAAARQAAADYAAAAnAAAAGQAAAAwAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAH4AAAB8AAAAdgAAAG0AAABhAAAAUwAAAEUAAAA2AAAAJwAAABkAAAAMAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB/AAAAfQAAAHgAAABwAAAAZQAAAFkAAABMAAAA' + 'PgAAADEAAAAkAAAAFwAAAAsAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAfwAAAH0AAAB4AAAAcAAA' + 'AGUAAABZAAAATAAAAD4AAAAxAAAAJAAAABcAAAALAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAH8AAAB9AAAAeQAAAHIAAABpAAAAXgAAAFIAAABGAAAAOQAAAC0AAAAhAAAAFQAAAAoA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAB/AAAAfQAAAHkAAAByAAAAaQAAAF4AAABSAAAARgAAADkAAAAt' + 'AAAAIQAAABUAAAAKAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAfwAAAH4AAAB6AAAAdAAA' + 'AGwAAABiAAAAWAAAAEwAAABAAAAANQAAACkAAAAeAAAAEwAAAAkAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAH8A' + 'AAB+AAAAegAAAHQAAABsAAAAYgAAAFgAAABMAAAAQAAAADUAAAApAAAAHgAAABMAAAAJAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAB/AAAAfgAAAHsAAAB1AAAAbgAAAGYAAABcAAAAUgAAAEcAAAA8' + 'AAAAMQAAACYAAAAcAAAAEgAAAAkAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAfwAAAH4AAAB7AAAAdQAAAG4AAABmAAAA' + 'XAAAAFIAAABHAAAAPAAAADEAAAAmAAAAHAAAABIAAAAJAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAEAAAABm' + 'AAAAcwAAAHgAAAB7AAAAfAAAAH0AAAB+AAAAfgAAAH4AAAB+AAAAfwAAAH8AAAB/AAAAfwAAAH4A' + 'AABZAAAAcAAAAHcAAAB6AAAAewAAAHwAAAB9AAAAfgAAAH4AAAB+AAAAfgAAAH8AAAB/AAAAfwAA' + 'AH8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAgIAAAAEAAAABmAAAAcwAAAHgAAAB7AAAAfAAAAH0AAAB+AAAAfgAAAH4AAAB+' + 'AAAAfwAAAH8AAAB/AAAAfwAAICAAAABAAAAAZgAAAHMAAAB4AAAAewAAAHwAAAB9AAAAfgAAAH4A' + 'AAB+AAAAfgAAAH8AAAB/AAAAfwAAAH8AAAAAAAAACwAAAEAAAABdAAAAagAAAHEAAAB1AAAAeAAA' + 'AHoAAAB7AAAAfAAAAHwAAAB9AAAAfQAAAH4AAAB+AAAAWQAAAEUAAABVAAAAZwAAAG8AAAByAAAA' + 'dQAAAHgAAAB6AAAAewAAAHwAAAB8AAAAfQAAAH0AAAB+AAAAfgAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAALCwAAAEAA' + 'AABdAAAAagAAAHEAAAB1AAAAeAAAAHoAAAB7AAAAfAAAAHwAAAB9AAAAfQAAAH4AAAB+AABAAAAA' + 'CwsAAABAAAAAXQAAAGoAAABxAAAAdQAAAHgAAAB6AAAAewAAAHwAAAB8AAAAfQAAAH0AAAB+AAAA' + 'fgAAAAAAAAAAAAAABAAAAC4AAABJAAAAWQAAAGQAAABqAAAAbwAAAHIAAAB1AAAAdgAAAHgAAAB5' + 'AAAAegAAAHsAAABwAAAAVQAAACEAAAA/AAAAUQAAAFoAAABkAAAAagAAAG8AAAByAAAAdQAAAHYA' + 'AAB4AAAAeQAAAHoAAAB7AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAZgAAAEAAAAAEBAAAAC4AAABJAAAAWQAAAGQAAABqAAAA' + 'bwAAAHIAAAB1AAAAdgAAAHgAAAB5AAAAegAAAHsAAGYAAABAAAAABAQAAAAuAAAASQAAAFkAAABk' + 'AAAAagAAAG8AAAByAAAAdQAAAHYAAAB4AAAAeQAAAHoAAAB7AAAAAAAAAAAAAAAAAAAAAgAAACIA' + 'AAA6AAAASwAAAFYAAABfAAAAZQAAAGkAAABtAAAAcAAAAHIAAAB0AAAAdQAAAHcAAABnAAAAPwAA' + 'AA4AAAAoAAAAOgAAAEsAAABWAAAAXwAAAGUAAABpAAAAbQAAAHAAAAByAAAAdAAAAHUAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AABzAAAAXQAAAC4AAAACAgAAACIAAAA6AAAASwAAAFYAAABfAAAAZQAAAGkAAABtAAAAcAAAAHIA' + 'AAB0AAAAdQAAcwAAAF0AAAAuAAAAAgIAAAAiAAAAOgAAAEsAAABWAAAAXwAAAGUAAABpAAAAbQAA' + 'AHAAAAByAAAAdAAAAHUAAAAAAAAAAAAAAAAAAAAAAAAAAQAAABsAAAAwAAAAQAAAAEwAAABVAAAA' + 'XAAAAGEAAABlAAAAaQAAAGwAAABuAAAAegAAAG8AAABRAAAAKAAAAAIAAAAbAAAAMAAAAEAAAABM' + 'AAAAVQAAAFwAAABhAAAAZQAAAGkAAABsAAAAbgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHgAAABqAAAASQAAACIAAAABAQAA' + 'ABsAAAAwAAAAQAAAAEwAAABVAAAAXAAAAGEAAABlAAAAaQAAAGwAAABuAAB4AAAAagAAAEkAAAAi' + 'AAAAAQEAAAAbAAAAMAAAAEAAAABMAAAAVQAAAFwAAABhAAAAZQAAAGkAAABsAAAAbgAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAQAAABcAAAApAAAANwAAAEMAAABMAAAAUwAAAFkAAABeAAAAYgAAAGYA' + 'AAB7AAAAcgAAAFoAAAA6AAAAGwAAAAEAAAAXAAAAKQAAADcAAABDAAAATAAAAFMAAABZAAAAXgAA' + 'AGIAAABmAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAewAAAHEAAABZAAAAOgAAABsAAAABAQAAABcAAAApAAAANwAAAEMAAABM' + 'AAAAUwAAAFkAAABeAAAAYgAAAGYAAHsAAABxAAAAWQAAADoAAAAbAAAAAQEAAAAXAAAAKQAAADcA' + 'AABDAAAATAAAAFMAAABZAAAAXgAAAGIAAABmAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'ABMAAAAjAAAAMQAAADwAAABFAAAATAAAAFIAAABYAAAAXAAAAHwAAAB1AAAAZAAAAEsAAAAwAAAA' + 'FwAAAAEAAAATAAAAIwAAADEAAAA8AAAARQAAAEwAAABSAAAAWAAAAFwAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB8AAAAdQAA' + 'AGQAAABLAAAAMAAAABcAAAAAAAAAABMAAAAjAAAAMQAAADwAAABFAAAATAAAAFIAAABYAAAAXAAA' + 'fAAAAHUAAABkAAAASwAAADAAAAAXAAAAAAAAAAATAAAAIwAAADEAAAA8AAAARQAAAEwAAABSAAAA' + 'WAAAAFwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABEAAAAfAAAAKwAAADYAAAA+' + 'AAAARgAAAEwAAABSAAAAfQAAAHgAAABqAAAAVgAAAEAAAAApAAAAEwAAAAEAAAARAAAAHwAAACsA' + 'AAA2AAAAPgAAAEYAAABMAAAAUgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAH0AAAB4AAAAagAAAFYAAABAAAAAKQAAABMAAAAA' + 'AAAAABEAAAAfAAAAKwAAADYAAAA+AAAARgAAAEwAAABSAAB9AAAAeAAAAGoAAABWAAAAQAAAACkA' + 'AAATAAAAAAAAAAARAAAAHwAAACsAAAA2AAAAPgAAAEYAAABMAAAAUgAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA8AAAAcAAAAJwAAADEAAAA5AAAAQAAAAEcAAAB+AAAAegAA' + 'AG8AAABfAAAATAAAADcAAAAjAAAAEQAAAAAAAAAPAAAAHAAAACcAAAAxAAAAOQAAAEAAAABHAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAfgAAAHoAAABvAAAAXwAAAEwAAAA3AAAAIwAAABEAAAAAAAAAAA8AAAAcAAAAJwAAADEA' + 'AAA5AAAAQAAAAEcAAH4AAAB6AAAAbwAAAF8AAABMAAAANwAAACMAAAARAAAAAAAAAAAPAAAAHAAA' + 'ACcAAAAxAAAAOQAAAEAAAABHAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAA0AAAAZAAAAJAAAAC0AAAA1AAAAPAAAAH4AAAB7AAAAcgAAAGUAAABVAAAAQwAAADEAAAAf' + 'AAAADwAAAAAAAAANAAAAGQAAACQAAAAtAAAANQAAADwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB+AAAAewAAAHIAAABlAAAA' + 'VQAAAEMAAAAxAAAAHwAAAA8AAAAAAAAAAA0AAAAZAAAAJAAAAC0AAAA1AAAAPAAAfgAAAHsAAABy' + 'AAAAZQAAAFUAAABDAAAAMQAAAB8AAAAPAAAAAAAAAAANAAAAGQAAACQAAAAtAAAANQAAADwAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAwAAAAXAAAAIQAAACkA' + 'AAAxAAAAfgAAAHwAAAB1AAAAaQAAAFwAAABMAAAAPAAAACsAAAAcAAAADQAAAAAAAAAMAAAAFwAA' + 'ACEAAAApAAAAMQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAH4AAAB8AAAAdQAAAGkAAABcAAAATAAAADwAAAArAAAAHAAAAA0A' + 'AAAAAAAAAAwAAAAXAAAAIQAAACkAAAAxAAB+AAAAfAAAAHUAAABpAAAAXAAAAEwAAAA8AAAAKwAA' + 'ABwAAAANAAAAAAAAAAAMAAAAFwAAACEAAAApAAAAMQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAsAAAAVAAAAHgAAACYAAAB+AAAAfAAAAHYAAABtAAAA' + 'YQAAAFMAAABFAAAANgAAACcAAAAZAAAADAAAAAAAAAALAAAAFQAAAB4AAAAmAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAfgAA' + 'AHwAAAB2AAAAbQAAAGEAAABTAAAARQAAADYAAAAnAAAAGQAAAAwAAAAAAAAAAAsAAAAVAAAAHgAA' + 'ACYAAH4AAAB8AAAAdgAAAG0AAABhAAAAUwAAAEUAAAA2AAAAJwAAABkAAAAMAAAAAAAAAAALAAAA' + 'FQAAAB4AAAAmAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAoAAAATAAAAHAAAAH8AAAB9AAAAeAAAAHAAAABlAAAAWQAAAEwAAAA+AAAAMQAAACQA' + 'AAAXAAAACwAAAAAAAAAKAAAAEwAAABwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB/AAAAfQAAAHgAAABwAAAAZQAAAFkAAABM' + 'AAAAPgAAADEAAAAkAAAAFwAAAAsAAAAAAAAAAAoAAAATAAAAHAAAfwAAAH0AAAB4AAAAcAAAAGUA' + 'AABZAAAATAAAAD4AAAAxAAAAJAAAABcAAAALAAAAAAAAAAAKAAAAEwAAABwAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAkAAAASAAAAfwAA' + 'AH0AAAB5AAAAcgAAAGkAAABeAAAAUgAAAEYAAAA5AAAALQAAACEAAAAVAAAACgAAAAAAAAAJAAAA' + 'EgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAH8AAAB9AAAAeQAAAHIAAABpAAAAXgAAAFIAAABGAAAAOQAAAC0AAAAhAAAAFQAA' + 'AAoAAAAAAAAAAAkAAAASAAB/AAAAfQAAAHkAAAByAAAAaQAAAF4AAABSAAAARgAAADkAAAAtAAAA' + 'IQAAABUAAAAKAAAAAAAAAAAJAAAAEgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAkAAAB/AAAAfgAAAHoAAAB0AAAAbAAAAGIAAABY' + 'AAAATAAAAEAAAAA1AAAAKQAAAB4AAAATAAAACQAAAAAAAAAJAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAfwAAAH4AAAB6AAAA' + 'dAAAAGwAAABiAAAAWAAAAEwAAABAAAAANQAAACkAAAAeAAAAEwAAAAkAAAAAAAAAAAkAAH8AAAB+' + 'AAAAegAAAHQAAABsAAAAYgAAAFgAAABMAAAAQAAAADUAAAApAAAAHgAAABMAAAAJAAAAAAAAAAAJ' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAH8AAAB+AAAAewAAAHUAAABuAAAAZgAAAFwAAABSAAAARwAAADwAAAAxAAAAJgAA' + 'ABwAAAASAAAACQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB/AAAAfgAAAHsAAAB1AAAAbgAAAGYAAABcAAAAUgAAAEcA' + 'AAA8AAAAMQAAACYAAAAcAAAAEgAAAAkAAAAAAAAAfwAAAH4AAAB7AAAAdQAAAG4AAABmAAAAXAAA' + 'AFIAAABHAAAAPAAAADEAAAAmAAAAHAAAABIAAAAJAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAAEAAAABmAAAAcwAAAHgAAAB7AAAAfAAAAH0A' + 'AAB+AAAAfgAAAH4AAAB+AAAAfwAAAH8AAAB/AAAAfwAAACAgAABAAAAAZgAAAHMAAAB4AAAAewAA' + 'AHwAAAB9AAAAfgAAAH4AAAB+AAAAfgAAAH8AAAB/AAAAfwAAAH8AAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAfgAAAFkAAABw' + 'AAAAdwAAAHoAAAB7AAAAfAAAAH0AAAB+AAAAfgAAAH4AAAB+AAAAfwAAAH8AAAB/AAAAfwAAACAg' + 'AABAAAAAZgAAAHMAAAB4AAAAewAAAHwAAAB9AAAAfgAAAH4AAAB+AAAAfgAAAH8AAAB/AAAAfwAA' + 'AH8AAAAAAAAACwAAAEAAAABdAAAAagAAAHEAAAB1AAAAeAAAAHoAAAB7AAAAfAAAAHwAAAB9AAAA' + 'fQAAAH4AAAB+AAAAAEAAAAsLAABAAAAAXQAAAGoAAABxAAAAdQAAAHgAAAB6AAAAewAAAHwAAAB8' + 'AAAAfQAAAH0AAAB+AAAAfgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABZAAAARQAAAFUAAABnAAAAbwAAAHIAAAB1AAAAeAAA' + 'AHoAAAB7AAAAfAAAAHwAAAB9AAAAfQAAAH4AAAB+AAAAAEAAAAsLAABAAAAAXQAAAGoAAABxAAAA' + 'dQAAAHgAAAB6AAAAewAAAHwAAAB8AAAAfQAAAH0AAAB+AAAAfgAAAAAAAAAAAAAABAAAAC4AAABJ' + 'AAAAWQAAAGQAAABqAAAAbwAAAHIAAAB1AAAAdgAAAHgAAAB5AAAAegAAAHsAAAAAZgAAAEAAAAQE' + 'AAAuAAAASQAAAFkAAABkAAAAagAAAG8AAAByAAAAdQAAAHYAAAB4AAAAeQAAAHoAAAB7AAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAHAAAABVAAAAIQAAAD8AAABRAAAAWgAAAGQAAABqAAAAbwAAAHIAAAB1AAAAdgAAAHgAAAB5' + 'AAAAegAAAHsAAAAAZgAAAEAAAAQEAAAuAAAASQAAAFkAAABkAAAAagAAAG8AAAByAAAAdQAAAHYA' + 'AAB4AAAAeQAAAHoAAAB7AAAAAAAAAAAAAAAAAAAAAgAAACIAAAA6AAAASwAAAFYAAABfAAAAZQAA' + 'AGkAAABtAAAAcAAAAHIAAAB0AAAAdQAAAABzAAAAXQAAAC4AAAICAAAiAAAAOgAAAEsAAABWAAAA' + 'XwAAAGUAAABpAAAAbQAAAHAAAAByAAAAdAAAAHUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAdwAAAGcAAAA/AAAADgAAACgA' + 'AAA6AAAASwAAAFYAAABfAAAAZQAAAGkAAABtAAAAcAAAAHIAAAB0AAAAdQAAAABzAAAAXQAAAC4A' + 'AAICAAAiAAAAOgAAAEsAAABWAAAAXwAAAGUAAABpAAAAbQAAAHAAAAByAAAAdAAAAHUAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAQAAABsAAAAwAAAAQAAAAEwAAABVAAAAXAAAAGEAAABlAAAAaQAAAGwAAABu' + 'AAAAAHgAAABqAAAASQAAACIAAAEBAAAbAAAAMAAAAEAAAABMAAAAVQAAAFwAAABhAAAAZQAAAGkA' + 'AABsAAAAbgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAB6AAAAbwAAAFEAAAAoAAAAAgAAABsAAAAwAAAAQAAAAEwAAABVAAAA' + 'XAAAAGEAAABlAAAAaQAAAGwAAABuAAAAAHgAAABqAAAASQAAACIAAAEBAAAbAAAAMAAAAEAAAABM' + 'AAAAVQAAAFwAAABhAAAAZQAAAGkAAABsAAAAbgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAAABcA' + 'AAApAAAANwAAAEMAAABMAAAAUwAAAFkAAABeAAAAYgAAAGYAAAAAewAAAHEAAABZAAAAOgAAABsA' + 'AAEBAAAXAAAAKQAAADcAAABDAAAATAAAAFMAAABZAAAAXgAAAGIAAABmAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHsAAABy' + 'AAAAWgAAADoAAAAbAAAAAQAAABcAAAApAAAANwAAAEMAAABMAAAAUwAAAFkAAABeAAAAYgAAAGYA' + 'AAAAewAAAHEAAABZAAAAOgAAABsAAAEBAAAXAAAAKQAAADcAAABDAAAATAAAAFMAAABZAAAAXgAA' + 'AGIAAABmAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABMAAAAjAAAAMQAAADwAAABFAAAA' + 'TAAAAFIAAABYAAAAXAAAAAB8AAAAdQAAAGQAAABLAAAAMAAAABcAAAAAAAATAAAAIwAAADEAAAA8' + 'AAAARQAAAEwAAABSAAAAWAAAAFwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAfAAAAHUAAABkAAAASwAAADAAAAAXAAAAAQAA' + 'ABMAAAAjAAAAMQAAADwAAABFAAAATAAAAFIAAABYAAAAXAAAAAB8AAAAdQAAAGQAAABLAAAAMAAA' + 'ABcAAAAAAAATAAAAIwAAADEAAAA8AAAARQAAAEwAAABSAAAAWAAAAFwAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAABEAAAAfAAAAKwAAADYAAAA+AAAARgAAAEwAAABSAAAAAH0AAAB4' + 'AAAAagAAAFYAAABAAAAAKQAAABMAAAAAAAARAAAAHwAAACsAAAA2AAAAPgAAAEYAAABMAAAAUgAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAB9AAAAeAAAAGoAAABWAAAAQAAAACkAAAATAAAAAQAAABEAAAAfAAAAKwAAADYAAAA+' + 'AAAARgAAAEwAAABSAAAAAH0AAAB4AAAAagAAAFYAAABAAAAAKQAAABMAAAAAAAARAAAAHwAAACsA' + 'AAA2AAAAPgAAAEYAAABMAAAAUgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AA8AAAAcAAAAJwAAADEAAAA5AAAAQAAAAEcAAAAAfgAAAHoAAABvAAAAXwAAAEwAAAA3AAAAIwAA' + 'ABEAAAAAAAAPAAAAHAAAACcAAAAxAAAAOQAAAEAAAABHAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAH4AAAB6AAAAbwAAAF8A' + 'AABMAAAANwAAACMAAAARAAAAAAAAAA8AAAAcAAAAJwAAADEAAAA5AAAAQAAAAEcAAAAAfgAAAHoA' + 'AABvAAAAXwAAAEwAAAA3AAAAIwAAABEAAAAAAAAPAAAAHAAAACcAAAAxAAAAOQAAAEAAAABHAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA0AAAAZAAAAJAAAAC0AAAA1' + 'AAAAPAAAAAB+AAAAewAAAHIAAABlAAAAVQAAAEMAAAAxAAAAHwAAAA8AAAAAAAANAAAAGQAAACQA' + 'AAAtAAAANQAAADwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAfgAAAHsAAAByAAAAZQAAAFUAAABDAAAAMQAAAB8AAAAPAAAA' + 'AAAAAA0AAAAZAAAAJAAAAC0AAAA1AAAAPAAAAAB+AAAAewAAAHIAAABlAAAAVQAAAEMAAAAxAAAA' + 'HwAAAA8AAAAAAAANAAAAGQAAACQAAAAtAAAANQAAADwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAwAAAAXAAAAIQAAACkAAAAxAAAAAH4AAAB8AAAAdQAAAGkA' + 'AABcAAAATAAAADwAAAArAAAAHAAAAA0AAAAAAAAMAAAAFwAAACEAAAApAAAAMQAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB+' + 'AAAAfAAAAHUAAABpAAAAXAAAAEwAAAA8AAAAKwAAABwAAAANAAAAAAAAAAwAAAAXAAAAIQAAACkA' + 'AAAxAAAAAH4AAAB8AAAAdQAAAGkAAABcAAAATAAAADwAAAArAAAAHAAAAA0AAAAAAAAMAAAAFwAA' + 'ACEAAAApAAAAMQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAsAAAAVAAAAHgAAACYAAAAAfgAAAHwAAAB2AAAAbQAAAGEAAABTAAAARQAAADYAAAAnAAAA' + 'GQAAAAwAAAAAAAALAAAAFQAAAB4AAAAmAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAH4AAAB8AAAAdgAAAG0AAABhAAAAUwAA' + 'AEUAAAA2AAAAJwAAABkAAAAMAAAAAAAAAAsAAAAVAAAAHgAAACYAAAAAfgAAAHwAAAB2AAAAbQAA' + 'AGEAAABTAAAARQAAADYAAAAnAAAAGQAAAAwAAAAAAAALAAAAFQAAAB4AAAAmAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAoAAAATAAAAHAAAAAB/' + 'AAAAfQAAAHgAAABwAAAAZQAAAFkAAABMAAAAPgAAADEAAAAkAAAAFwAAAAsAAAAAAAAKAAAAEwAA' + 'ABwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAfwAAAH0AAAB4AAAAcAAAAGUAAABZAAAATAAAAD4AAAAxAAAAJAAAABcAAAAL' + 'AAAAAAAAAAoAAAATAAAAHAAAAAB/AAAAfQAAAHgAAABwAAAAZQAAAFkAAABMAAAAPgAAADEAAAAk' + 'AAAAFwAAAAsAAAAAAAAKAAAAEwAAABwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAkAAAASAAAAAH8AAAB9AAAAeQAAAHIAAABpAAAAXgAA' + 'AFIAAABGAAAAOQAAAC0AAAAhAAAAFQAAAAoAAAAAAAAJAAAAEgAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB/AAAAfQAAAHkA' + 'AAByAAAAaQAAAF4AAABSAAAARgAAADkAAAAtAAAAIQAAABUAAAAKAAAAAAAAAAkAAAASAAAAAH8A' + 'AAB9AAAAeQAAAHIAAABpAAAAXgAAAFIAAABGAAAAOQAAAC0AAAAhAAAAFQAAAAoAAAAAAAAJAAAA' + 'EgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAkAAAAAfwAAAH4AAAB6AAAAdAAAAGwAAABiAAAAWAAAAEwAAABAAAAANQAAACkAAAAe' + 'AAAAEwAAAAkAAAAAAAAJAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAH8AAAB+AAAAegAAAHQAAABsAAAAYgAAAFgAAABMAAAA' + 'QAAAADUAAAApAAAAHgAAABMAAAAJAAAAAAAAAAkAAAAAfwAAAH4AAAB6AAAAdAAAAGwAAABiAAAA' + 'WAAAAEwAAABAAAAANQAAACkAAAAeAAAAEwAAAAkAAAAAAAAJAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB/AAAAfgAAAHsA' + 'AAB1AAAAbgAAAGYAAABcAAAAUgAAAEcAAAA8AAAAMQAAACYAAAAcAAAAEgAAAAkAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAfwAAAH4AAAB7AAAAdQAAAG4AAABmAAAAXAAAAFIAAABHAAAAPAAAADEAAAAmAAAAHAAAABIA' + 'AAAJAAAAAAAAAAB/AAAAfgAAAHsAAAB1AAAAbgAAAGYAAABcAAAAUgAAAEcAAAA8AAAAMQAAACYA' + 'AAAcAAAAEgAAAAkAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAICAAAEAAAABmAAAAcwAAAHgAAAB7AAAAfAAAAH0AAAB+' + 'AAAAfgAAAH4AAAB+AAAAfwAAAH8AAAB/AAAAfwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgIAAAAEAAAABmAAAAcwAAAHgA' + 'AAB7AAAAfAAAAH0AAAB+AAAAfgAAAH4AAAB+AAAAfwAAAH8AAAB/AAAAfwAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAQAAACwsAAEAAAABdAAAAagAAAHEAAAB1AAAAeAAAAHoAAAB7AAAAfAAAAHwAAAB9AAAAfQAA' + 'AH4AAAB+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAEAAAAALCwAAAEAAAABdAAAAagAAAHEAAAB1AAAAeAAAAHoAAAB7AAAA' + 'fAAAAHwAAAB9AAAAfQAAAH4AAAB+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABmAAAAQAAABAQAAC4AAABJAAAA' + 'WQAAAGQAAABqAAAAbwAAAHIAAAB1AAAAdgAAAHgAAAB5AAAAegAAAHsAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAZgAAAEAA' + 'AAAEBAAAAC4AAABJAAAAWQAAAGQAAABqAAAAbwAAAHIAAAB1AAAAdgAAAHgAAAB5AAAAegAAAHsA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAHMAAABdAAAALgAAAgIAACIAAAA6AAAASwAAAFYAAABfAAAAZQAAAGkA' + 'AABtAAAAcAAAAHIAAAB0AAAAdQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABzAAAAXQAAAC4AAAACAgAAACIAAAA6AAAASwAA' + 'AFYAAABfAAAAZQAAAGkAAABtAAAAcAAAAHIAAAB0AAAAdQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAeAAAAGoA' + 'AABJAAAAIgAAAQEAABsAAAAwAAAAQAAAAEwAAABVAAAAXAAAAGEAAABlAAAAaQAAAGwAAABuAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAHgAAABqAAAASQAAACIAAAABAQAAABsAAAAwAAAAQAAAAEwAAABVAAAAXAAAAGEAAABl' + 'AAAAaQAAAGwAAABuAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB7AAAAcQAAAFkAAAA6AAAAGwAAAQEAABcAAAAp' + 'AAAANwAAAEMAAABMAAAAUwAAAFkAAABeAAAAYgAAAGYAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAewAAAHEAAABZAAAAOgAA' + 'ABsAAAABAQAAABcAAAApAAAANwAAAEMAAABMAAAAUwAAAFkAAABeAAAAYgAAAGYAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAHwAAAB1AAAAZAAAAEsAAAAwAAAAFwAAAAAAABMAAAAjAAAAMQAAADwAAABFAAAATAAA' + 'AFIAAABYAAAAXAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAB8AAAAdQAAAGQAAABLAAAAMAAAABcAAAAAAAAAABMAAAAjAAAA' + 'MQAAADwAAABFAAAATAAAAFIAAABYAAAAXAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAfQAAAHgAAABqAAAAVgAA' + 'AEAAAAApAAAAEwAAAAAAABEAAAAfAAAAKwAAADYAAAA+AAAARgAAAEwAAABSAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAH0A' + 'AAB4AAAAagAAAFYAAABAAAAAKQAAABMAAAAAAAAAABEAAAAfAAAAKwAAADYAAAA+AAAARgAAAEwA' + 'AABSAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAB+AAAAegAAAG8AAABfAAAATAAAADcAAAAjAAAAEQAAAAAAAA8A' + 'AAAcAAAAJwAAADEAAAA5AAAAQAAAAEcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAfgAAAHoAAABvAAAAXwAAAEwAAAA3AAAA' + 'IwAAABEAAAAAAAAAAA8AAAAcAAAAJwAAADEAAAA5AAAAQAAAAEcAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAH4A' + 'AAB7AAAAcgAAAGUAAABVAAAAQwAAADEAAAAfAAAADwAAAAAAAA0AAAAZAAAAJAAAAC0AAAA1AAAA' + 'PAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAB+AAAAewAAAHIAAABlAAAAVQAAAEMAAAAxAAAAHwAAAA8AAAAAAAAAAA0AAAAZ' + 'AAAAJAAAAC0AAAA1AAAAPAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAfgAAAHwAAAB1AAAAaQAAAFwAAABMAAAA' + 'PAAAACsAAAAcAAAADQAAAAAAAAwAAAAXAAAAIQAAACkAAAAxAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAH4AAAB8AAAAdQAA' + 'AGkAAABcAAAATAAAADwAAAArAAAAHAAAAA0AAAAAAAAAAAwAAAAXAAAAIQAAACkAAAAxAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAB+AAAAfAAAAHYAAABtAAAAYQAAAFMAAABFAAAANgAAACcAAAAZAAAADAAAAAAA' + 'AAsAAAAVAAAAHgAAACYAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAfgAAAHwAAAB2AAAAbQAAAGEAAABTAAAARQAAADYAAAAn' + 'AAAAGQAAAAwAAAAAAAAAAAsAAAAVAAAAHgAAACYAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAH8AAAB9AAAAeAAA' + 'AHAAAABlAAAAWQAAAEwAAAA+AAAAMQAAACQAAAAXAAAACwAAAAAAAAoAAAATAAAAHAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAB/AAAAfQAAAHgAAABwAAAAZQAAAFkAAABMAAAAPgAAADEAAAAkAAAAFwAAAAsAAAAAAAAAAAoA' + 'AAATAAAAHAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAfwAAAH0AAAB5AAAAcgAAAGkAAABeAAAAUgAAAEYAAAA5' + 'AAAALQAAACEAAAAVAAAACgAAAAAAAAkAAAASAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAH8AAAB9AAAAeQAAAHIAAABpAAAA' + 'XgAAAFIAAABGAAAAOQAAAC0AAAAhAAAAFQAAAAoAAAAAAAAAAAkAAAASAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAB/AAAAfgAAAHoAAAB0AAAAbAAAAGIAAABYAAAATAAAAEAAAAA1AAAAKQAAAB4AAAATAAAACQAA' + 'AAAAAAkAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAfwAAAH4AAAB6AAAAdAAAAGwAAABiAAAAWAAAAEwAAABAAAAANQAAACkA' + 'AAAeAAAAEwAAAAkAAAAAAAAAAAkAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAH8AAAB+AAAAewAAAHUAAABuAAAA' + 'ZgAAAFwAAABSAAAARwAAADwAAAAxAAAAJgAAABwAAAASAAAACQAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB/AAAAfgAA' + 'AHsAAAB1AAAAbgAAAGYAAABcAAAAUgAAAEcAAAA8AAAAMQAAACYAAAAcAAAAEgAAAAkAAAAAAAAA' + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + 'AAAAAAAAAA==', +); diff --git a/packages/flutter3d_core/lib/src/engine/render/view_model_node.dart b/packages/flutter3d_core/lib/src/engine/render/view_model_node.dart index c9e6fd9b2..231c16771 100644 --- a/packages/flutter3d_core/lib/src/engine/render/view_model_node.dart +++ b/packages/flutter3d_core/lib/src/engine/render/view_model_node.dart @@ -110,7 +110,7 @@ final class ViewModelNode extends RenderNode { frame.state.invalidatePipeline(); final aspect = height == 0 ? 1.0 : width / height; - camera.readWorldPosition(_cameraPosition); + camera.readViewOrigin(_cameraPosition); frame.services.encodeScene( frame: frame, diff --git a/packages/flutter3d_core/lib/src/engine/scene/atmosphere.dart b/packages/flutter3d_core/lib/src/engine/scene/atmosphere.dart index 32b431ffb..ed7a8d005 100644 --- a/packages/flutter3d_core/lib/src/engine/scene/atmosphere.dart +++ b/packages/flutter3d_core/lib/src/engine/scene/atmosphere.dart @@ -22,6 +22,8 @@ final class Atmosphere { required Vector3 sky, Vector3? fogColor, this.fogDensity = 0.0, + this.fogHeightFalloff = 0.0, + this.fogBaseHeight = 0.0, required Vector3 sunColor, this.sunIntensity = 2.0, Vector3? ambientColor, @@ -38,9 +40,16 @@ final class Atmosphere { /// end of the world meets the sky without a seam. final Vector3 fogColor; - /// How thick the fog is, per metre. + /// How thick the fog is, per metre, at [fogBaseHeight]. final double fogDensity; + /// How fast the fog thins upwards, per metre, and the height at which it is + /// [fogDensity] thick — `FogSettings.heightFalloff` and `baseHeight`. Nought + /// is fog at every height alike, which every atmosphere was before `P5`; a + /// morning that burns off is a falloff blended up through the day. + final double fogHeightFalloff; + final double fogBaseHeight; + final Vector3 sunColor; final double sunIntensity; final Vector3 ambientColor; @@ -55,6 +64,8 @@ final class Atmosphere { sky: mix(a.sky, b.sky), fogColor: mix(a.fogColor, b.fogColor), fogDensity: blend(a.fogDensity, b.fogDensity), + fogHeightFalloff: blend(a.fogHeightFalloff, b.fogHeightFalloff), + fogBaseHeight: blend(a.fogBaseHeight, b.fogBaseHeight), sunColor: mix(a.sunColor, b.sunColor), sunIntensity: blend(a.sunIntensity, b.sunIntensity), ambientColor: mix(a.ambientColor, b.ambientColor), @@ -63,7 +74,12 @@ final class Atmosphere { } /// The fog this atmosphere draws with, for `RenderSettings.fog`. - FogSettings get fog => FogSettings(color: fogColor, density: fogDensity); + FogSettings get fog => FogSettings( + color: fogColor, + density: fogDensity, + heightFalloff: fogHeightFalloff, + baseHeight: fogBaseHeight, + ); /// Puts this atmosphere on [scene]'s ambient light and on [sun], and /// writes the sky into [clearColor] if given (a view's clear colour is diff --git a/packages/flutter3d_core/lib/src/engine/scene/camera_node.dart b/packages/flutter3d_core/lib/src/engine/scene/camera_node.dart index f3c7dc3f0..8dd67d08a 100644 --- a/packages/flutter3d_core/lib/src/engine/scene/camera_node.dart +++ b/packages/flutter3d_core/lib/src/engine/scene/camera_node.dart @@ -41,6 +41,28 @@ final class CameraNode extends SceneNode { return result; } + /// Where the rays this camera draws begin, in world space, written into + /// [out] and returned — what the renderer measures every eye-relative + /// quantity from: depths in the surface buffer, the fog's air, the shadow + /// cascades' splits. + /// + /// The camera's own position, except through an orthographic lens whose + /// near plane is behind it — `P7`. There the rays start on that plane, and + /// what stands between it and the camera is drawn; measured from the + /// camera it had a depth of nought or less, which the surface buffer reads + /// as sky, and no fog in front of it. Through an orthographic lens the + /// camera's place along its axis moves nothing in the picture, so moving + /// the origin back to the plane changes nothing but the measurements. + Vector3 readViewOrigin([Vector3? out]) { + final result = readWorldPosition(out); + final lens = projection; + if (lens is OrthographicProjection && lens.near < 0.0) { + final forward = readForward(); + result.addScaled(forward, lens.near); + } + return result; + } + @override void onAttachedToScene(Scene scene) => scene.registerCamera(this); diff --git a/packages/flutter3d_core/lib/src/engine/scene/decal_node.dart b/packages/flutter3d_core/lib/src/engine/scene/decal_node.dart new file mode 100644 index 000000000..1e1936884 --- /dev/null +++ b/packages/flutter3d_core/lib/src/engine/scene/decal_node.dart @@ -0,0 +1,87 @@ +import 'package:flutter3d_hardware/flutter3d_hardware.dart' show TextureHandle; +import 'package:vector_math/vector_math.dart'; + +import 'scene.dart'; +import 'scene_node.dart'; + +/// A picture projected onto whatever geometry stands inside a box — `P3`. +/// +/// **The box is this node's unit cube**, from minus a half to a half on every +/// axis, so its position, rotation and scale are the decal's: scale x and z +/// for the size of the picture and y for how deep it reaches. The picture is +/// stamped down the box's y axis and faces up it, so a decal with no rotation +/// lies on a floor; to put one on a wall, rotate the box until its up points +/// out of the wall. +/// +/// **It paints the surface's colour, not the light on it.** The renderer +/// reads back the light each pixel was lit by and lays [color] (times +/// [texture]) under that same light, so a decal in a shadow is in the shadow. +/// It does not change a normal or a roughness; see `decal.frag` for what the +/// light that is read back cannot say. +/// +/// Drawn only while `RenderSettings.decals` is on, which it is not by +/// default, and only on a device that opens three colour attachments: the +/// renderer needs the scene's surface and albedo buffers. Reading those turns +/// multisampling off for the frame. +final class DecalNode extends SceneNode { + DecalNode({ + this.texture, + Vector4? color, + Vector3? emissive, + Vector4? region, + this.order = 0, + this.angleLimit = 1.3, + this.angleFade = 0.2, + this.depthFade = 0.1, + super.name, + }) : color = color ?? Vector4(1.0, 1.0, 1.0, 1.0), + emissive = emissive ?? Vector3.zero(), + region = region ?? Vector4(0.0, 0.0, 1.0, 1.0), + assert(angleLimit >= 0.0, 'an angle limit is an angle'), + assert(angleFade >= 0.0, 'a fade is a width'), + assert(depthFade >= 0.0 && depthFade <= 1.0, 'a share of the box'); + + /// The picture, sRGB-encoded as a base colour map is. Null paints [color] + /// alone, which is what a stain or a scorch with no detail needs. + /// + /// Mipmapped if it was uploaded with a chain: the level is chosen from how + /// much of the picture one pixel covers, as a material's would be. + TextureHandle? texture; + + /// The tint, sRGB like `Material.baseColor`, and in [Vector4.w] the opacity + /// the picture's own alpha is multiplied by. + Vector4 color; + + /// Light the painted colour gives off, as a multiple of it. Zero, the + /// default, emits nothing; a glowing sign wants a few. + Vector3 emissive; + + /// The part of [texture] this decal shows: x and y where it starts in + /// texture coordinates, z and w how far it reaches. The whole picture by + /// default; a sheet of decals in one atlas names its cell. + Vector4 region; + + /// Which decal is painted over which where two overlap: higher over lower, + /// and attachment order between equals. + int order; + + /// The angle, in radians from the box's up, past which a surface takes + /// none of the decal. Seventy-five degrees by default, so a decal on a + /// floor stays off the walls around it rather than smearing down them. + double angleLimit; + + /// How many radians inside [angleLimit] the decal fades in over, so the + /// edge where a surface turns away is soft rather than cut. + double angleFade; + + /// The share of the box's half height, from its top and bottom faces, the + /// decal fades out over, so geometry that only just enters the box is not + /// painted with a hard edge. + double depthFade; + + @override + void onAttachedToScene(Scene scene) => scene.registerDecal(this); + + @override + void onDetachedFromScene(Scene scene) => scene.unregisterDecal(this); +} diff --git a/packages/flutter3d_core/lib/src/engine/scene/instanced_mesh_node.dart b/packages/flutter3d_core/lib/src/engine/scene/instanced_mesh_node.dart index afe3d0b26..adb2038c9 100644 --- a/packages/flutter3d_core/lib/src/engine/scene/instanced_mesh_node.dart +++ b/packages/flutter3d_core/lib/src/engine/scene/instanced_mesh_node.dart @@ -72,8 +72,12 @@ final class InstancedMeshNode extends MeshNode { } } - /// Floats one instance occupies: three rows of the transform, then RGBA. - static const int floatsPerInstance = 16; + /// Floats one instance occupies: three rows of the transform, then RGBA, + /// then four of the game's own — `P8`, see [setInstanceData]. + static const int floatsPerInstance = 20; + + /// Where an instance's own four floats start within its record. + static const int _dataOffset = 16; /// Bytes one instance occupies. static const int strideInBytes = floatsPerInstance * 4; @@ -231,13 +235,37 @@ final class InstancedMeshNode extends MeshNode { _touched(); } + /// Gives instance [index] four numbers of the game's own — `P8`. + /// + /// **What a material reads as `instance`.** The instanced vertex stage + /// hands them to the fragment as they are, so a material written in the + /// language can colour, fade or animate each copy of a batch by a value + /// only the game knows: a health, a team, a phase. Nought until set, and + /// nought for every draw that is not instanced. + void setInstanceData(int index, Vector4 data) { + _check(index); + final at = index * floatsPerInstance + _dataOffset; + _data[at] = data.x; + _data[at + 1] = data.y; + _data[at + 2] = data.z; + _data[at + 3] = data.w; + _touched(); + } + + /// Reads instance [index]'s own four numbers back into [out]. + void readInstanceData(int index, Vector4 out) { + _check(index); + final at = index * floatsPerInstance + _dataOffset; + out.setValues(_data[at], _data[at + 1], _data[at + 2], _data[at + 3]); + } + /// Appends an instance and returns its index. /// /// Throws when the batch is full: a caller that sized its field and then /// overran it has a bug, and growing quietly underneath it would hide the /// bug and the reallocation both. [ensureCapacity] is how a caller that /// means to grow says so. - int addInstance(Matrix4 transform, {Vector4? color}) { + int addInstance(Matrix4 transform, {Vector4? color, Vector4? data}) { if (_count >= _capacity) { throw StateError( 'InstancedMeshNode "$name" is full at $capacity instances.', @@ -246,6 +274,7 @@ final class InstancedMeshNode extends MeshNode { final index = _count++; setTransform(index, transform); if (color != null) setColor(index, color); + if (data != null) setInstanceData(index, data); return index; } @@ -265,7 +294,7 @@ final class InstancedMeshNode extends MeshNode { /// /// Grows the buffer when it is full, the way [ensureCapacity] does: a /// batch of things that come and go has no size to name in advance. - InstanceHandle acquire({Matrix4? transform, Vector4? color}) { + InstanceHandle acquire({Matrix4? transform, Vector4? color, Vector4? data}) { ensureCapacity(_count + 1); final index = _count; final handle = InstanceHandle._(this, index); @@ -276,6 +305,9 @@ final class InstancedMeshNode extends MeshNode { count = index + 1; setTransform(index, transform ?? Matrix4.identity()); setColor(index, color ?? Vector4.all(1.0)); + // A slot a released member left holds its numbers; the new one starts + // from nought, as a fresh batch does. + setInstanceData(index, data ?? Vector4.zero()); final weights = _weights; if (weights != null) { weights.fillRange( @@ -523,4 +555,8 @@ final class InstanceHandle { /// Tints the instance. void setColor(Vector4 color) => _batch.setColor(index, color); + + /// Gives the instance four numbers of the game's own — see + /// [InstancedMeshNode.setInstanceData]. + void setData(Vector4 data) => _batch.setInstanceData(index, data); } diff --git a/packages/flutter3d_core/lib/src/engine/scene/mesh_node.dart b/packages/flutter3d_core/lib/src/engine/scene/mesh_node.dart index 7f21a3a48..10573812b 100644 --- a/packages/flutter3d_core/lib/src/engine/scene/mesh_node.dart +++ b/packages/flutter3d_core/lib/src/engine/scene/mesh_node.dart @@ -205,6 +205,17 @@ base class MeshNode extends SceneNode { bool get castsShadowFromEveryFace => shadowCasting.castsFromEveryFace || material.doubleSided; + /// Whether this node is shaded by what see-through casters let through to + /// the sun — `ShadowSettings.translucentCasters`. True by default. + /// + /// The atlas keeps the colour a pane or a liquid gives the light and not + /// where along the ray it gave it, so a surface standing *in front of* a + /// translucent caster, between it and the sun, is shaded as if it stood + /// behind. Most scenes never put anything there; one that does — a label + /// wrapped round a glass, a card painted with the light under a lens — + /// turns it off for that surface. + bool receivesTranslucentShadows = true; + /// Whether this caster never moves. /// /// A dungeon's walls do not, and their contribution to a point light's cube @@ -218,6 +229,22 @@ base class MeshNode extends SceneNode { /// looks for. A static thing wrongly left dynamic merely costs a redraw. bool shadowIsStatic = false; + /// Where this node is drawn among the others, on top of its material's + /// [Material.drawBucket] — `P7`. + /// + /// **Per node, because the bucket is per material.** Two nodes sharing a + /// material — a decal card and the wall it marks, a ghost and the player it + /// replays — could not be put in an order without giving one a copy of the + /// material, and a copy breaks the batching, the material's sort id and + /// every edit made to the original afterwards. The two are added, and the + /// sum outranks every other sort term in both halves of the list: lower + /// first, the same within a half in the order the view's sort mode gives. + /// The usable sum is −128 to 127 and is clamped beyond it. + /// + /// Nought by default, which leaves every scene drawn as it was. Nodes of + /// different orders are never merged into one instanced draw. + int drawOrder = 0; + /// Skips frustum culling for this node. /// /// Worth setting on anything that follows the camera, such as a skybox, whose diff --git a/packages/flutter3d_core/lib/src/engine/scene/planar_reflector_node.dart b/packages/flutter3d_core/lib/src/engine/scene/planar_reflector_node.dart new file mode 100644 index 000000000..e2b7a569e --- /dev/null +++ b/packages/flutter3d_core/lib/src/engine/scene/planar_reflector_node.dart @@ -0,0 +1,95 @@ +import 'package:vector_math/vector_math.dart'; + +import 'mesh_node.dart'; +import 'scene.dart'; +import 'scene_node.dart'; + +/// A flat mirror or a still pool — `P4`: the world drawn again by a camera +/// mirrored in this node's plane, and laid over the [surfaces] that lie in +/// it. +/// +/// **The plane is the node's own, not the surfaces'.** It passes through the +/// node's origin with its local +Y as the normal, the side the reflection is +/// seen from; a camera behind it sees no reflection, as one below a pool +/// sees none. The surfaces are whatever stands in that plane — a quad, a +/// water mesh, the floor of a hall — and keep their own materials: the +/// reflection is drawn over each of them where it won the depth test, so a +/// surface is lit, textured and shadowed as it was, with the mirrored world +/// on top in proportion to [reflectance] and the angle. +/// +/// **What it costs is a second picture of the scene per view**, at +/// [resolution] of the view's pixels, drawn before the scene with the frame's +/// lights and shadows and its sky. Nothing in it is reflected again, and +/// neither the particles nor anything else a contributor draws reaches it. +/// +/// Drawn only while `RenderSettings.planarReflections` is on. +final class PlanarReflectorNode extends SceneNode { + PlanarReflectorNode({ + Iterable surfaces = const [], + this.reflectance = 1.0, + this.strength = 1.0, + Vector3? tint, + this.resolution = 0.5, + this.clipOffset = 0.01, + this.reflectedLayers = ~0, + super.name, + }) : surfaces = {...surfaces}, + tint = tint ?? Vector3(1.0, 1.0, 1.0), + assert(reflectance >= 0.0 && reflectance <= 1.0, 'F0 is a fraction'), + assert(strength >= 0.0, 'a reflection cannot take light away'), + assert( + resolution > 0.0 && resolution <= 1.0, + 'a fraction of the view, and not nothing', + ); + + /// The meshes the reflection is laid over. Left out of the reflection + /// themselves: they stand in the plane, where the clip would cut them in + /// half. + final Set surfaces; + + /// Meshes the mirrored camera leaves out, beyond [surfaces]. + final Set excluded = {}; + + /// How much is reflected looking straight at the plane, Schlick's F0: one + /// for a mirror, which then reflects everything at every angle, and about + /// 0.02 for water, which reflects little looking down and nearly all at a + /// grazing angle. + double reflectance; + + /// What the Fresnel term is multiplied by before the reflection is laid + /// on; one lays it on as the angle says. + double strength; + + /// Linear light the reflection is multiplied by: a tinted glass, a dark + /// pool. + final Vector3 tint; + + /// The reflection's size as a fraction of its view's, each way. Half by + /// default, which is a quarter of the pixels; a still mirror that fills the + /// screen wants one. + double resolution; + + /// How far below the plane the mirrored camera's near plane sits, in + /// metres. A little, so a wall standing on the floor keeps its foot in the + /// reflection rather than a seam of whatever the clip left. + double clipOffset; + + /// Which layers the mirrored camera draws, intersected with each view's. + /// Not [layerMask], which is this node's own layer like any node's. + int reflectedLayers; + + /// The plane's normal in world space: the node's local +Y, normalised. + Vector3 readPlaneNormal([Vector3? out]) { + final result = out ?? Vector3.zero(); + final m = worldMatrix.storage; + result.setValues(m[4], m[5], m[6]); + if (result.length2 > 0.0) result.normalize(); + return result; + } + + @override + void onAttachedToScene(Scene scene) => scene.registerReflector(this); + + @override + void onDetachedFromScene(Scene scene) => scene.unregisterReflector(this); +} diff --git a/packages/flutter3d_core/lib/src/engine/scene/projection.dart b/packages/flutter3d_core/lib/src/engine/scene/projection.dart index 0e19a12f4..5747df580 100644 --- a/packages/flutter3d_core/lib/src/engine/scene/projection.dart +++ b/packages/flutter3d_core/lib/src/engine/scene/projection.dart @@ -420,6 +420,73 @@ final class TiledProjection extends Projection { } } +/// Any rectangle of a larger virtual frame — `N8`'s photo capture. +/// +/// [TiledProjection]'s crop of NDC space, with the rectangle given directly +/// rather than as a square of a grid: [left], [top], [right] and [bottom] are +/// fractions of the whole frame's width and height, rows counting down from +/// the top as an image's do. They may run outside `[0, 1]`, and that is the +/// point of the class: a photo tile is drawn with a margin of the picture +/// around it on every side — past the frame's own edge, for the outer tiles — +/// so a blur reaching across a tile's edge has something real to read there, +/// and the margin is cropped away afterwards. +/// +/// **The whole frame's aspect is held here, and the one [toMatrix] is handed +/// is ignored.** The renderer passes the viewport's own, which for a tile is +/// the tile's — and a tile with a margin round it is never the shape of the +/// frame. [TiledProjection] asks its caller to pass the frame's aspect +/// instead, which the renderer has no way to do. +final class CropProjection extends Projection { + const CropProjection( + this.base, { + required this.frameAspect, + required this.left, + required this.top, + required this.right, + required this.bottom, + }); + + final Projection base; + + /// Width over height of the whole frame the rectangle is a piece of. + final double frameAspect; + + final double left; + final double top; + final double right; + final double bottom; + + @override + double get near => base.near; + + @override + double get far => base.far; + + @override + double? get verticalFieldOfView => base.verticalFieldOfView; + + @override + Matrix4 toMatrix(double aspect) { + if (right <= left || bottom <= top) { + throw ArgumentError( + 'Degenerate crop: left/right are $left/$right and top/bottom are ' + '$top/$bottom; a crop needs some width and some height.', + ); + } + // The rectangle in NDC: x runs as the fractions do, y the other way. + final x0 = -1.0 + 2.0 * left; + final x1 = -1.0 + 2.0 * right; + final y0 = 1.0 - 2.0 * bottom; + final y1 = 1.0 - 2.0 * top; + final crop = Matrix4.identity() + ..setEntry(0, 0, 2.0 / (x1 - x0)) + ..setEntry(0, 3, -(x1 + x0) / (x1 - x0)) + ..setEntry(1, 1, 2.0 / (y1 - y0)) + ..setEntry(1, 3, -(y1 + y0) / (y1 - y0)); + return crop * base.toMatrix(frameAspect); + } +} + /// [base] moved a fraction of a pixel across the screen — `R1`. /// /// The same crop of NDC space [TiledProjection] makes, with a scale of one: @@ -559,3 +626,21 @@ Vector3 viewAxisOf(Matrix4 viewProjection, [Vector3? out]) { if (result.length2 > 0.0) result.normalize(); return result; } + +/// Whether [viewProjection] projects orthographically — `P7`. +/// +/// Read out of the matrix for the reason [viewAxisOf] is: a probe face and a +/// mirrored view have no camera to ask. An orthographic projection leaves w +/// alone, so the matrix's bottom row is the view's own, nought nought nought +/// and a number; a perspective one puts the depth there. What +/// [toDepthRange] and [toFramebufferOrigin] do touches the depth and the +/// height rows and leaves that one. +bool isOrthographic(Matrix4 viewProjection) { + final m = viewProjection.storage; + final w = m[15].abs(); + const tiny = 1e-9; + return w > tiny && + m[3].abs() <= tiny * w && + m[7].abs() <= tiny * w && + m[11].abs() <= tiny * w; +} diff --git a/packages/flutter3d_core/lib/src/engine/scene/scene.dart b/packages/flutter3d_core/lib/src/engine/scene/scene.dart index 8df97bf5e..44b1bc257 100644 --- a/packages/flutter3d_core/lib/src/engine/scene/scene.dart +++ b/packages/flutter3d_core/lib/src/engine/scene/scene.dart @@ -3,11 +3,14 @@ import 'dart:collection'; import 'package:flutter3d_hardware/flutter3d_hardware.dart'; import 'package:vector_math/vector_math.dart'; +import '../render/render_texture.dart'; import 'camera_node.dart'; +import 'decal_node.dart'; import 'irradiance_field.dart'; import 'light_node.dart'; import 'lod_group.dart'; import 'mesh_node.dart'; +import 'planar_reflector_node.dart'; import 'reflection_probe_node.dart'; import 'scene_node.dart'; @@ -68,6 +71,22 @@ final class Scene { /// per draw, so both want a list rather than a tree walk. final List _probes = []; + /// Projected decals, in attachment order — `P3`. + /// + /// A registry for the probes' reason: the decal pass reads every one each + /// frame, and the order they were attached in breaks ties between equal + /// [DecalNode.order]s. + final List _decals = []; + + /// Planar reflectors, in attachment order — `P4`. A registry for the + /// probes' reason: the renderer draws each of them a picture every frame. + final List _reflectors = []; + + /// Cameras drawing into textures — `P4`. Not nodes, so not registered by + /// attaching anything: a [RenderTexture] is added here, and is drawn for as + /// long as it stays. + final List _renderTextures = []; + /// Everything drawable currently in the scene, in attachment order. /// /// **A view, where these four used to be the lists themselves.** Handing out @@ -103,6 +122,30 @@ final class Scene { late final List _probesView = UnmodifiableListView(_probes); + List get decals => _decalsView; + late final List _decalsView = UnmodifiableListView( + _decals, + ); + List get reflectors => _reflectorsView; + late final List _reflectorsView = + UnmodifiableListView(_reflectors); + + List get renderTextures => _renderTexturesView; + late final List _renderTexturesView = + UnmodifiableListView(_renderTextures); + + /// Has [texture]'s camera draw into it every frame, before the scene — or + /// once, if it does not refresh every frame. Adding it twice draws it once. + void addRenderTexture(RenderTexture texture) { + if (!_renderTextures.contains(texture)) _renderTextures.add(texture); + } + + /// Stops drawing [texture]: for a game switching a monitor off, or taking + /// a portal down, before it gives the texture back. Nothing here calls it; + /// the texture itself is its owner's to give back. + void removeRenderTexture(RenderTexture texture) => + _renderTextures.remove(texture); + /// Ambient light applied where no direct light reaches. /// /// The flat stand-in for [environment], used when a scene has none. It also @@ -198,6 +241,9 @@ final class Scene { _cameras.clear(); _lodGroups.clear(); _probes.clear(); + _decals.clear(); + _reflectors.clear(); + _renderTextures.clear(); // Back to front, because `SceneNode.remove` takes the last child without // searching for it or shifting the rest; front to back shifted the whole @@ -228,6 +274,14 @@ final class Scene { void unregisterProbe(ReflectionProbeNode node) => _probes.remove(node); + void registerDecal(DecalNode node) => _decals.add(node); + + void unregisterDecal(DecalNode node) => _decals.remove(node); + void registerReflector(PlanarReflectorNode node) => _reflectors.add(node); + + void unregisterReflector(PlanarReflectorNode node) => + _reflectors.remove(node); + /// First light of the given type, or null. /// /// A convenience for scene code — "where is the key light" — not something the diff --git a/packages/flutter3d_core/lib/src/engine/scene/scene_node.dart b/packages/flutter3d_core/lib/src/engine/scene/scene_node.dart index 83de3ee66..86b322a11 100644 --- a/packages/flutter3d_core/lib/src/engine/scene/scene_node.dart +++ b/packages/flutter3d_core/lib/src/engine/scene/scene_node.dart @@ -311,6 +311,13 @@ base class SceneNode implements AnimationTarget { void _recomputeLocal() { _localMatrix.setFromTranslationRotationScale(_position, _rotation, _scale); _localDirty = false; + // **The world matrix is built from this one, so it is stale now too.** + // [worldMatrix] recomputes when the local matrix is dirty or the parent + // has moved, and a read of [localMatrix] between a move and the next + // read of [worldMatrix] cleared the first without the second: the node + // moved for whoever asked its local matrix and stayed where it was on + // screen. [worldMatrix] sets the stamp again after it calls this. + _seenParentVersion = -1; } void _bumpWorld() { diff --git a/packages/flutter3d_core/lib/src/formats/cube_lut.dart b/packages/flutter3d_core/lib/src/formats/cube_lut.dart new file mode 100644 index 000000000..f068721eb --- /dev/null +++ b/packages/flutter3d_core/lib/src/formats/cube_lut.dart @@ -0,0 +1,236 @@ +/// A colour table in the `.cube` format — `P2`. +/// +/// **The format every grading tool writes**: Resolve, Premiere, Photoshop and +/// OCIO all export it. Plain text: a few keywords, then one line of three +/// numbers per entry, red running fastest, then green, then blue. A table is +/// either 3D (`LUT_3D_SIZE N`, N³ lines) or 1D (`LUT_1D_SIZE N`, one curve +/// per channel), and `DOMAIN_MIN`/`DOMAIN_MAX` say what input the first and +/// last entries stand for. +/// +/// The engine grades through a strip — N slices of N×N, `LookSettings.lut` — +/// so [CubeLut.toStrip] resamples whatever the file holds into one, the same +/// layout `buildIdentityLut` writes. +library; + +import 'dart:math' as math; +import 'dart:typed_data'; + +import 'package:flutter3d_hardware/flutter3d_hardware.dart'; + +/// A parsed `.cube` file. +final class CubeLut { + CubeLut._({ + required this.title, + required this.size, + required this.isOneD, + required this.domainMin, + required this.domainMax, + required this._values, + }); + + /// Reads [text], a `.cube` file's contents. Throws a [FormatException] + /// naming the line when it is not one: a missing size, a line that is not + /// three numbers, too few or too many entries, a domain that is empty. + factory CubeLut.parse(String text) { + String? title; + int? size3; + int? size1; + var domainMin = (0.0, 0.0, 0.0); + var domainMax = (1.0, 1.0, 1.0); + final values = []; + final lines = text.split(RegExp(r'\r\n|\r|\n')); + + (double, double, double) triple(List words, int line) { + if (words.length != 3) { + throw FormatException('line $line: expected three numbers'); + } + final parsed = words.map(double.tryParse).toList(); + if (parsed.contains(null)) { + throw FormatException( + 'line $line: "${words.join(' ')}" is not three ' + 'numbers', + ); + } + return (parsed[0]!, parsed[1]!, parsed[2]!); + } + + for (var i = 0; i < lines.length; i++) { + final line = i + 1; + final trimmed = lines[i].trim(); + if (trimmed.isEmpty || trimmed.startsWith('#')) continue; + final words = trimmed.split(RegExp(r'\s+')); + switch (words.first) { + case 'TITLE': + final quoted = RegExp(r'"(.*)"').firstMatch(trimmed); + title = quoted?.group(1) ?? words.skip(1).join(' '); + case 'LUT_3D_SIZE': + size3 = int.tryParse(words.length == 2 ? words[1] : ''); + if (size3 == null || size3 < 2 || size3 > 256) { + throw FormatException('line $line: LUT_3D_SIZE must be 2 to 256'); + } + case 'LUT_1D_SIZE': + size1 = int.tryParse(words.length == 2 ? words[1] : ''); + if (size1 == null || size1 < 2 || size1 > 65536) { + throw FormatException('line $line: LUT_1D_SIZE must be 2 to 65536'); + } + case 'DOMAIN_MIN': + domainMin = triple(words.sublist(1), line); + case 'DOMAIN_MAX': + domainMax = triple(words.sublist(1), line); + // Written by some tools and meaningless to a table read here. + case 'LUT_3D_INPUT_RANGE' || 'LUT_1D_INPUT_RANGE': + final range = words.skip(1).map(double.tryParse).toList(); + if (range.length != 2 || range.contains(null)) { + throw FormatException('line $line: an input range is two numbers'); + } + domainMin = (range[0]!, range[0]!, range[0]!); + domainMax = (range[1]!, range[1]!, range[1]!); + default: + final (r, g, b) = triple(words, line); + values + ..add(r) + ..add(g) + ..add(b); + } + } + + if (size3 != null && size1 != null) { + throw const FormatException('a table is 3D or 1D, not both'); + } + final size = size3 ?? size1; + if (size == null) { + throw const FormatException('no LUT_3D_SIZE or LUT_1D_SIZE'); + } + final expected = size3 != null ? size * size * size : size; + if (values.length != expected * 3) { + throw FormatException( + 'expected $expected entries for a ' + '${size3 != null ? '3D' : '1D'} table of $size, found ' + '${values.length ~/ 3}', + ); + } + if (domainMax.$1 <= domainMin.$1 || + domainMax.$2 <= domainMin.$2 || + domainMax.$3 <= domainMin.$3) { + throw const FormatException('DOMAIN_MAX must be above DOMAIN_MIN'); + } + return CubeLut._( + title: title, + size: size, + isOneD: size1 != null, + domainMin: domainMin, + domainMax: domainMax, + values: Float32List.fromList(values), + ); + } + + /// The file's `TITLE`, when it has one. + final String? title; + + /// Entries per axis: N of N³ for a 3D table, the curve's length for 1D. + final int size; + + /// Whether this is three curves rather than a cube. + final bool isOneD; + + /// The input the first and last entries stand for, per channel. + final (double, double, double) domainMin; + final (double, double, double) domainMax; + + final Float32List _values; + + /// What the table makes of ([r], [g], [b]): trilinear between the eight + /// nearest entries of a 3D table, linear along each curve of a 1D one. + /// Inputs outside the domain are held to its edge. + (double, double, double) lookup(double r, double g, double b) { + double at(double value, double min, double max) => + ((value - min) / (max - min)).clamp(0.0, 1.0) * (size - 1); + final x = at(r, domainMin.$1, domainMax.$1); + final y = at(g, domainMin.$2, domainMax.$2); + final z = at(b, domainMin.$3, domainMax.$3); + if (isOneD) { + double curve(double position, int channel) { + final lower = position.floor(); + final upper = math.min(lower + 1, size - 1); + final t = position - lower; + final a = _values[lower * 3 + channel]; + return a + (_values[upper * 3 + channel] - a) * t; + } + + return (curve(x, 0), curve(y, 1), curve(z, 2)); + } + final x0 = x.floor(); + final y0 = y.floor(); + final z0 = z.floor(); + final x1 = math.min(x0 + 1, size - 1); + final y1 = math.min(y0 + 1, size - 1); + final z1 = math.min(z0 + 1, size - 1); + final tx = x - x0; + final ty = y - y0; + final tz = z - z0; + double channel(int c) { + double entry(int i, int j, int k) => + _values[((k * size + j) * size + i) * 3 + c]; + double along(int j, int k) => + entry(x0, j, k) + (entry(x1, j, k) - entry(x0, j, k)) * tx; + double across(int k) => along(y0, k) + (along(y1, k) - along(y0, k)) * ty; + return across(z0) + (across(z1) - across(z0)) * tz; + } + + return (channel(0), channel(1), channel(2)); + } + + /// [toStrip] uploaded through [device], ready for `LookSettings.lut`; null + /// when the device refuses a texture that size. + /// + /// ```dart + /// final table = CubeLut.parse(await rootBundle.loadString('grade.cube')); + /// final look = LookSettings(lut: table.upload(device)); + /// ``` + TextureHandle? upload(GraphicsDevice device, {int? stripSize}) { + final n = stripSize ?? (isOneD ? 33 : math.min(size, 64)); + return device.createTextureFromPixels( + width: n * n, + height: n, + format: TextureFormat.r8g8b8a8UNormInt, + pixels: ByteData.sublistView(toStrip(stripSize: n)), + ); + } + + /// This table as the strip `LookSettings.lut` grades through: [stripSize] + /// slices of [stripSize]×[stripSize], blue picking the slice, red across + /// and green down, eight bits a channel — `buildIdentityLut`'s layout. + /// + /// [stripSize] defaults to the table's own size for a 3D table, which + /// copies its entries exactly when the domain is the unit cube, and to 33 + /// for a 1D one. Outputs are clamped to 0…1: the strip is read after the + /// picture is encoded for the display, where nothing lies outside them. + Uint8List toStrip({int? stripSize}) { + final n = stripSize ?? (isOneD ? 33 : math.min(size, 64)); + if (n < 2) { + throw ArgumentError.value(n, 'stripSize', 'a table needs two ends'); + } + final width = n * n; + final pixels = Uint8List(width * n * 4); + final last = n - 1; + int byte(double v) => (v.clamp(0.0, 1.0) * 255.0).round(); + double input(int i, double min, double max) => min + (max - min) * i / last; + for (var blue = 0; blue < n; blue++) { + for (var green = 0; green < n; green++) { + for (var red = 0; red < n; red++) { + final (r, g, b) = lookup( + input(red, domainMin.$1, domainMax.$1), + input(green, domainMin.$2, domainMax.$2), + input(blue, domainMin.$3, domainMax.$3), + ); + final at = ((green * width) + blue * n + red) * 4; + pixels[at] = byte(r); + pixels[at + 1] = byte(g); + pixels[at + 2] = byte(b); + pixels[at + 3] = 255; + } + } + } + return pixels; + } +} diff --git a/packages/flutter3d_core/lib/src/formats/lighting_model.dart b/packages/flutter3d_core/lib/src/formats/lighting_model.dart index ac7679f7e..c4ffda2c7 100644 --- a/packages/flutter3d_core/lib/src/formats/lighting_model.dart +++ b/packages/flutter3d_core/lib/src/formats/lighting_model.dart @@ -90,6 +90,25 @@ final class LightingModel { usesMaterialParameters: false, ); + /// A planar reflector's surface drawn again with the mirrored picture over + /// it — `P4`, the stage `renderer_planar_pass.dart` draws with. + /// + /// Absent from [builtIn] for [xray]'s reason: it is not a way to light a + /// material but a second draw over one, and a material asking for it would + /// be a surface with nothing under its reflection. It reads no maps, no + /// lights and no `FragInfo` — the eye is the fog block's — and declares no + /// surface buffer, so it leaves what the surface below it wrote there. + static const LightingModel planarReflection = LightingModel( + 'Planar reflection', + 'PlanarReflection', + usesFragInfo: false, + usesFogInfo: true, + usesAlbedoTexture: false, + usesMaterialMaps: false, + usesMetallicRoughnessMap: false, + usesMaterialParameters: false, + ); + /// A line of constant screen width — `gfx-86n`: [unlit]'s fragment stage /// behind the engine's own `PolylineVertex`, which widens `buildPolyline`'s /// geometry by the number of pixels each point carries. diff --git a/packages/flutter3d_core/lib/src/formats/material_extensions.dart b/packages/flutter3d_core/lib/src/formats/material_extensions.dart index 2922b667b..4a0388ad1 100644 --- a/packages/flutter3d_core/lib/src/formats/material_extensions.dart +++ b/packages/flutter3d_core/lib/src/formats/material_extensions.dart @@ -54,6 +54,7 @@ final class MaterialExtensions { this.thicknessTexture, this.attenuationDistance = double.infinity, Vector3? attenuationColor, + this.convexVolume = false, this.dispersion = 0.0, this.iridescence = 0.0, this.iridescenceTexture, @@ -134,6 +135,21 @@ final class MaterialExtensions { final double attenuationDistance; final Vector3 attenuationColor; + /// Whether the volume is a closed, convex body — a ball of glass, a column + /// of liquid — rather than a slab. Not part of glTF, and not written to it. + /// + /// [thickness] is then taken as the body's depth through its middle, and + /// the path a ray travels inside is that depth times how squarely the bent + /// ray meets the surface, the solid-sphere model Filament uses: the whole + /// [thickness] where the eye looks straight in, next to nothing at the + /// silhouette. Both the colour the volume gives and how far behind it the + /// refracted scene is read from follow that path, so a coloured liquid in + /// a tube is deep in the middle and pale at its edges, and the scene seen + /// through it bends most where it is thickest — which is most of what + /// tells a column of liquid from a painted cylinder. Off, the default, the + /// path is [thickness] everywhere, as glTF's volume means it. + final bool convexVolume; + /// `KHR_materials_dispersion`: how far apart the index of refraction is /// spread over the spectrum, in the extension's own units (20 / Abbe /// number). Nought refracts every colour alike. @@ -248,6 +264,7 @@ final class MaterialExtensions { thicknessTexture: each(thicknessTexture), attenuationDistance: attenuationDistance, attenuationColor: attenuationColor.clone(), + convexVolume: convexVolume, dispersion: dispersion, iridescence: iridescence, iridescenceTexture: each(iridescenceTexture), diff --git a/packages/flutter3d_core/lib/src/formats/material_language/material_ast.dart b/packages/flutter3d_core/lib/src/formats/material_language/material_ast.dart index 6a61da047..b3d1f78df 100644 --- a/packages/flutter3d_core/lib/src/formats/material_language/material_ast.dart +++ b/packages/flutter3d_core/lib/src/formats/material_language/material_ast.dart @@ -77,13 +77,25 @@ final class MaterialType { /// language would be a member of that block; it is not written, and the reason /// is scope rather than impossibility. final class MaterialParameter { - const MaterialParameter(this.name, this.type, this.defaultValue); + const MaterialParameter( + this.name, + this.type, + this.defaultValue, { + this.uniform = false, + }); final String name; final MaterialType type; /// As many numbers as [type] has components. final List defaultValue; + + /// Declared with `uniform` rather than `param` — `P8`: not folded, but a + /// member of the `MaterialParams` block, read from `Material.parameters` + /// on every draw, so a game sets it without a second entry point. A + /// variant cannot set one; [defaultValue] is what a material that names + /// no value is given. + final bool uniform; } /// One texture slot the material samples, by the name the engine binds it @@ -136,8 +148,44 @@ const List kMaterialInputs = [ MaterialInput('ambient', MaterialType.vec3, 's.ambient'), MaterialInput('uv', MaterialType.vec2, 'v_texcoord'), MaterialInput('world', MaterialType.vec3, 'v_world_position'), + // `P8`: an instance's own four numbers, `InstancedMeshNode.setInstanceData` + // — nought for a draw that is not instanced. The emitted stage declares + // the varying only when the source reads it. + MaterialInput('instance', MaterialType.vec4, 'v_instance'), +]; + +/// What the `light` block reads about the one light it is shading for, on +/// top of [kMaterialInputs] — `P8`, the lighting hook. +/// +/// `surface.glsl`'s `LightSample` and `cpu_shaders_lighting.dart`'s, the same +/// struct written twice, as the surface inputs are. Read only inside the +/// block: outside it there is no one light to read them of. +const List kMaterialLightInputs = [ + MaterialInput('lightDir', MaterialType.vec3, 'light.l'), + MaterialInput('halfDir', MaterialType.vec3, 'light.h'), + MaterialInput('nDotL', MaterialType.float, 'light.n_dot_l'), + MaterialInput('nDotH', MaterialType.float, 'light.n_dot_h'), + MaterialInput('vDotH', MaterialType.float, 'light.v_dot_h'), ]; +/// The surface lit as the engine lights it, through the material's own +/// `light` block — `P8`. Read in the fragment body of a material that has +/// one. +/// +/// **Everything a lit model of the engine's adds up, not the lights alone**: +/// every light through the block, by its radiance, `n·l` and shadow, then the +/// ambient and the lightmap, then the emissive, with the occlusion over the +/// first two — what `lambert.frag` writes. Whole, because a compiled shader +/// keeps a sampler only if something reads it, and the engine binds the maps +/// and the shadow atlases to a lit model: a material whose output skipped +/// the emissive would leave its map unread and the bind refused. A material +/// adds to it what is not lighting — a rim, a glow. +const MaterialInput kMaterialLitInput = MaterialInput( + 'lit', + MaterialType.vec3, + 'lit', +); + /// A function the language knows, its type rule and how to compute it. /// /// **One table, read by the emitter and by the evaluator.** A builtin that @@ -443,6 +491,7 @@ final class MaterialProgram { required this.textures, required this.body, required this.inputsUsed, + this.light, }); final String name; @@ -450,6 +499,13 @@ final class MaterialProgram { final List textures; final List body; + /// The `light` block, or null for a material that gathers no lights — + /// `P8`. Run once per light, its return a `vec3`: how the surface responds + /// to that light, which the engine multiplies by the light's radiance, its + /// `n·l` and its shadow, as it does `ShadeLight` of every lit model. The + /// sum reaches the fragment body as [kMaterialLitInput]. + final List? light; + /// Which of [kMaterialInputs] the body actually reads. /// /// **This is the binding metadata `gfx-84n` asks the toolchain to @@ -480,7 +536,8 @@ final class MaterialVariant { final String name; - /// Parameter name to value. Anything not named keeps its default. + /// Parameter name to value. Anything not named keeps its default; a + /// `uniform` cannot be named here. final Map> values; } @@ -516,20 +573,39 @@ MaterialProgram specialiseMaterial( } } + for (final key in variant.values.keys) { + if (program.parameter(key)!.uniform) { + throw ArgumentError( + 'Variant "${variant.name}" sets "$key", which "${program.name}" ' + 'declares as a uniform: set it on the material, in ' + 'Material.parameters, rather than in a variant.', + ); + } + } + + List fold(List body) => + [ + for (final statement in body) + switch (statement) { + MaterialLet(:final name, :final value) => MaterialLet( + name, + _fold(value, variant), + ), + MaterialReturn(:final value) => MaterialReturn( + _fold(value, variant), + ), + }, + ]; + return MaterialProgram( name: variant.name, parameters: program.parameters, textures: program.textures, - body: [ - for (final statement in program.body) - switch (statement) { - MaterialLet(:final name, :final value) => MaterialLet( - name, - _fold(value, variant), - ), - MaterialReturn(:final value) => MaterialReturn(_fold(value, variant)), - }, - ], + body: fold(program.body), + light: switch (program.light) { + final light? => fold(light), + null => null, + }, inputsUsed: program.inputsUsed, ); } @@ -540,6 +616,9 @@ MaterialExpression _fold(MaterialExpression expression, MaterialVariant v) { case MaterialInputRef(): case MaterialLocalRef(): return expression; + // A uniform stays a reference: its value arrives with each draw. + case MaterialParamRef(:final parameter) when parameter.uniform: + return expression; case MaterialParamRef(:final parameter): return MaterialConstant( v.values[parameter.name] ?? parameter.defaultValue, diff --git a/packages/flutter3d_core/lib/src/formats/material_language/material_bundle.dart b/packages/flutter3d_core/lib/src/formats/material_language/material_bundle.dart new file mode 100644 index 000000000..14864ac00 --- /dev/null +++ b/packages/flutter3d_core/lib/src/formats/material_language/material_bundle.dart @@ -0,0 +1,90 @@ +/// The materials a bundle carries in the engine's material language — `P8`. +library; + +import 'dart:typed_data'; + +import 'package:flutter3d_hardware/flutter3d_hardware.dart'; + +import '../lighting_model.dart'; +import 'material_glsl.dart'; +import 'material_parser.dart'; + +/// The lighting models of the materials a bundle was built from, read off +/// the sources the bundle carries — `P8`. +/// +/// **What a game draws a built `.f3dmat` with.** The build compiles each +/// source into a stage of the bundle and keeps the source beside it; this +/// reads the source back and builds the [LightingModel] from the program +/// itself, through [MaterialBindings.lightingModel], so which maps it samples +/// and whether it reads the light list are the program's answers rather than +/// a second description by hand. Load the same bytes with +/// `GraphicsDevice.loadShaders` and hand the library to the renderer as its +/// `materials`; a `Material` whose `lighting` is one of these draws the +/// stage of that name, on every backend that has a section for it. +final class BundledMaterials { + BundledMaterials._(this.bundle, this._bindings) + : lighting = { + for (final MapEntry(key: name, value: bindings) in _bindings.entries) + name: bindings.lightingModel(label: name, shaderName: name), + }; + + /// Reads [bytes], a bundle as `GraphicsDevice.loadShaders` takes it. + /// + /// Throws a [FormatException] for a bundle with no material in it — one + /// compiled from hand-written GLSL, whose stages say nothing about how to + /// bind them — and a `MaterialSyntaxError` for a source that does not + /// parse, which a bundle the build made never has. + factory BundledMaterials.read(ByteData bytes) { + final bundle = ShaderBundle.decode(bytes); + final sources = decodeMaterialSection(bundle); + if (sources.isEmpty) { + throw FormatException( + 'bundle "${bundle.name}" carries no material-language source, so ' + 'nothing in it says how its stages bind: build it from a .f3dmat, or ' + 'describe its LightingModel by hand', + ); + } + return BundledMaterials._(bundle, { + for (final MapEntry(key: name, value: source) in sources.entries) + name: describeMaterial(parseMaterial(source)), + }); + } + + /// The bundle, decoded. + final ShaderBundle bundle; + + final Map _bindings; + + /// Each material's lighting model, by its name, which is also its stage's. + final Map lighting; + + /// The lighting model of material [name]. Throws an [ArgumentError] naming + /// the ones the bundle has, rather than handing back nothing to draw with. + LightingModel operator [](String name) => + lighting[name] ?? + (throw ArgumentError.value( + name, + 'name', + 'bundle "${bundle.name}" has the materials ' + '${lighting.keys.join(', ')}', + )); + + /// What `Material.parameters` starts as for material [name]: each + /// `uniform` the source declares, at its default — `P8`. A fresh map every + /// call, so one material's edits are not another's. Empty for a material + /// with no uniform. + Map parameters(String name) { + final bindings = + _bindings[name] ?? + (throw ArgumentError.value( + name, + 'name', + 'bundle "${bundle.name}" has the materials ' + '${lighting.keys.join(', ')}', + )); + return { + for (final MapEntry(:key, :value) in bindings.uniforms.entries) + key: Float32List.fromList(value), + }; + } +} diff --git a/packages/flutter3d_core/lib/src/formats/material_language/material_eval.dart b/packages/flutter3d_core/lib/src/formats/material_language/material_eval.dart index 55e2c3589..8fd43ceac 100644 --- a/packages/flutter3d_core/lib/src/formats/material_language/material_eval.dart +++ b/packages/flutter3d_core/lib/src/formats/material_language/material_eval.dart @@ -32,11 +32,20 @@ import 'material_ast.dart'; /// [checkMaterialSurface] exists to ask that question once rather than per /// draw. final class MaterialSurfaceValues { - const MaterialSurfaceValues({required this.inputs, required this.sample}); + const MaterialSurfaceValues({ + required this.inputs, + required this.sample, + this.uniforms = const >{}, + }); /// Input name to value, as many numbers as the input's type has components. final Map> inputs; + /// The draw's value of each `uniform` the program declares — `P8`, what + /// `Material.parameters` bound as `MaterialParams`. One it lacks reads its + /// default, as the engine's own block reads nothing bound as nought. + final Map> uniforms; + /// One texel, RGBA, from the slot the material declared. final List Function(MaterialTextureSlot slot, double u, double v) sample; @@ -51,9 +60,31 @@ final class MaterialSurfaceValues { List evaluateMaterial( MaterialProgram program, MaterialSurfaceValues surface, +) => _run(program.name, program.body, surface); + +/// How the surface answers one light, from [program]'s `light` block — +/// `P8`: three numbers, which the caller multiplies by the light's radiance, +/// `n·l` and shadow, as `AccumulateLights` does with `ShadeLight`. +/// +/// [surface]'s inputs carry [kMaterialLightInputs] for the light as well as +/// the surface's own. Throws a [StateError] for a program with no block. +List evaluateMaterialLight( + MaterialProgram program, + MaterialSurfaceValues surface, +) => _run( + program.name, + program.light ?? + (throw StateError('"${program.name}" has no light block.')), + surface, +); + +List _run( + String name, + List body, + MaterialSurfaceValues surface, ) { final scope = >{}; - for (final statement in program.body) { + for (final statement in body) { switch (statement) { case MaterialLet(:final name, :final value): scope[name] = _evaluate(value, surface, scope); @@ -63,7 +94,7 @@ List evaluateMaterial( } // The parser refuses a body whose last statement is not a return, so this is // a program nobody parsed rather than a case with a sensible answer. - throw StateError('"${program.name}" has no return.'); + throw StateError('"$name" has no return.'); } /// Throws [ArgumentError] unless [inputs] answers every name in @@ -108,6 +139,11 @@ List _evaluate( throw StateError('"$name" is read before it is bound.'); } return value; + case MaterialParamRef(:final parameter) when parameter.uniform: + final value = surface.uniforms[parameter.name]; + return value != null && value.length >= parameter.type.components + ? value.sublist(0, parameter.type.components) + : parameter.defaultValue; case MaterialParamRef(:final parameter): throw StateError( 'The parameter "${parameter.name}" has no value. Specialise the ' diff --git a/packages/flutter3d_core/lib/src/formats/material_language/material_glsl.dart b/packages/flutter3d_core/lib/src/formats/material_language/material_glsl.dart index 7b0eddf12..6f5e83309 100644 --- a/packages/flutter3d_core/lib/src/formats/material_language/material_glsl.dart +++ b/packages/flutter3d_core/lib/src/formats/material_language/material_glsl.dart @@ -11,11 +11,12 @@ /// `glsl_to_wgsl.dart` prepares it for glslang and naga, all without knowing a /// material language exists. /// -/// **What it does not emit is as deliberate.** No uniform block of its own: -/// the engine's shared blocks are frozen by offset agreement across four -/// backends, and the one block a material may add — `MaterialParams`, filled -/// from `Material.parameters` — is not something the language writes yet, -/// because its parameters are folded constants. No sampler declaration, because `surface.glsl` already declares the ones the +/// **What it does not emit is as deliberate.** No uniform block of its own +/// but one: the engine's shared blocks are frozen by offset agreement across +/// four backends, and the one block a material may add — `MaterialParams`, +/// filled from `Material.parameters` — is written for the program's +/// `uniform`s, and only when it has one (`P8`); a `param` is still a folded +/// constant. No sampler declaration, because `surface.glsl` already declares the ones the /// engine binds and a second declaration is a duplicate symbol. No `#define` /// the author chose, because a material that could switch headers on and off /// could turn off the surface buffer and lie to every screen-space effect. @@ -28,6 +29,7 @@ import 'material_ast.dart'; /// /// [describeMaterial] answers the other half — what the engine may bind to it. String emitMaterialFragment(MaterialProgram program) { + final light = program.light; final out = StringBuffer() ..writeln('#version 460 core') ..writeln() @@ -35,45 +37,126 @@ String emitMaterialFragment(MaterialProgram program) { ..writeln('// — gfx-84n. The source is the thing to edit; this file is') ..writeln('// what the shader build compiles and what the three GPU') ..writeln('// backends translate. The software backend evaluates the same') - ..writeln('// tree instead of reading this.') - // It gathers no lights, so it keeps no light list: see - // `LightingModel.usesLightList`. - ..writeln('#define F3D_NO_LIGHT_LIST') - ..writeln('#include ') - ..writeln(); - - // Both prototypes have to be satisfied whether or not anything calls them, - // which is what `unlit.frag` says about its own pair. A material in this - // language never accumulates lights — it returns the light the surface - // emits — so these are the same honest stubs. + ..writeln('// tree instead of reading this.'); + if (light == null) { + // It gathers no lights, so it keeps no light list — see + // `LightingModel.usesLightList` — and no point-shadow block either, as + // `unlit.frag` keeps none: a block declared and never bound is a + // refused draw on WebGL2. + out + ..writeln('#define F3D_NO_POINT_SHADOW') + ..writeln('#define F3D_NO_LIGHT_LIST') + ..writeln('#include ') + ..writeln(); + } else { + // `P8`: a material with a lighting hook is a lit model, and includes + // what `lambert.frag` does — the maps, the shadows, the light list. + out + ..writeln('#include ') + ..writeln('#include ') + ..writeln(); + } + + // `P8`: an instance's own numbers, declared after every header's inputs so + // it takes the location the engine's vertex stages give it, after + // `v_lightmap_uv` — and only when read, since a varying no stage writes is + // a link error on a stage that writes it not. + if (program.inputsUsed.contains('instance')) { + out + ..writeln('in vec4 v_instance;') + ..writeln(); + } + + // `P8`: the uniforms, as the block the engine binds `Material.parameters` + // to. Declaration order, as std140 lays it out and every backend's + // reflection reports it. + final uniforms = [ + for (final parameter in program.parameters) + if (parameter.uniform) parameter, + ]; + if (uniforms.isNotEmpty) { + out.writeln('uniform MaterialParams {'); + for (final parameter in uniforms) { + out.writeln(' ${parameter.type.name} ${parameter.name};'); + } + out + ..writeln('}') + ..writeln('material_params;') + ..writeln(); + } + + if (light == null) { + // Both prototypes have to be satisfied whether or not anything calls + // them, which is what `unlit.frag` says about its own pair. A material + // without a `light` block never accumulates lights — it returns the light + // the surface emits — so these are the same honest stubs. + out + ..writeln('// Never called: this material returns the light its surface') + ..writeln('// emits rather than gathering any. The prototypes in') + ..writeln('// surface.glsl still have to be satisfied.') + ..writeln('vec3 ShadeLight(Surface s, LightSample light) {') + ..writeln(' return s.albedo;') + ..writeln('}') + ..writeln() + ..writeln('float LightVisibility(Surface s, LightSample light, int i) {') + ..writeln(' return 1.0;') + ..writeln('}') + ..writeln(); + } else { + // The `light` block is `ShadeLight`, which `AccumulateLights` multiplies + // by the light's radiance, n·l and visibility; the visibility is the + // shadow, as every lit model of the engine's has it. + out + ..writeln('// The material\'s light block, run once per light.') + ..writeln('vec3 ShadeLight(Surface s, LightSample light) {'); + _statements(out, light, (value) => ' return ${_glsl(value)};'); + out + ..writeln('}') + ..writeln() + ..writeln('float LightVisibility(Surface s, LightSample light, int i) {') + ..writeln(' return ShadowFactor(s, light, i);') + ..writeln('}') + ..writeln(); + } + out - ..writeln('// Never called: a material in this language returns the light') - ..writeln('// its surface emits rather than gathering any. The prototypes') - ..writeln('// in surface.glsl still have to be satisfied.') - ..writeln('vec3 ShadeLight(Surface s, LightSample light) {') - ..writeln(' return s.albedo;') - ..writeln('}') - ..writeln() - ..writeln('float LightVisibility(Surface s, LightSample light, int i) {') - ..writeln(' return 1.0;') - ..writeln('}') - ..writeln() ..writeln('void main() {') ..writeln(' Surface s = ReadSurface();'); + if (light != null) { + // `lit` as `lambert.frag` adds it up — see `kMaterialLitInput`. + out + ..writeln(' ApplyCommonMaps(s);') + ..writeln( + ' vec3 lit = AccumulateLights(s) * s.occlusion + ' + 's.albedo * (s.ambient + SampleLightmap()) * s.occlusion + ' + 's.emissive;', + ); + } + _statements( + out, + program.body, + (value) => + ' vec4 result = ${_glsl(value)};\n' + ' WriteSurface(result.rgb, result.a);', + ); + out.writeln('}'); + return out.toString(); +} - for (final statement in program.body) { +/// Writes [body]'s bindings, and its return as [returns] spells it. +void _statements( + StringBuffer out, + List body, + String Function(MaterialExpression value) returns, +) { + for (final statement in body) { switch (statement) { case MaterialLet(:final name, :final value): out.writeln(' ${value.type.name} $name = ${_glsl(value)};'); case MaterialReturn(:final value): - out - ..writeln(' vec4 result = ${_glsl(value)};') - ..writeln(' WriteSurface(result.rgb, result.a);'); + out.writeln(returns(value)); } } - - out.writeln('}'); - return out.toString(); } /// What the engine may bind to [program]'s compiled shader. @@ -94,6 +177,11 @@ MaterialBindings describeMaterial(MaterialProgram program) { for (final slot in program.textures) slot.bindingName, }; final reads = program.inputsUsed; + // `P8`: a lighting hook makes it a lit model, which applies the normal, + // occlusion and emissive maps and reads the shadows and the light list — + // all of it live, because the parser holds the fragment body to reading + // `lit`, which adds every one of them up. + final lit = program.light != null; // A material that reads nothing off the lit surface still goes through // `ReadSurface`, which is what applies the base colour tint and the mask @@ -101,14 +189,25 @@ MaterialBindings describeMaterial(MaterialProgram program) { // every model except `Normals`. return MaterialBindings( name: program.name, - usesAlbedoTexture: samples.contains('base_color_texture'), + // Always: `ReadSurface` samples the base colour map for the albedo every + // material reads, whether or not the source names the slot. Asked of the + // source alone, a material that never wrote `texture` was drawn with the + // map unbound — black on Impeller and a refused draw on WebGL2, found + // the first time one was drawn on a GPU (`P8`, `material-language`). + usesAlbedoTexture: true, usesMaterialMaps: + lit || samples.contains('normal_texture') || samples.contains('occlusion_texture') || samples.contains('emissive_texture') || samples.contains('metallic_roughness_texture'), usesMetallicRoughnessMap: samples.contains('metallic_roughness_texture'), usesMetallic: reads.contains('metallic'), + usesLightList: lit, + uniforms: >{ + for (final parameter in program.parameters) + if (parameter.uniform) parameter.name: parameter.defaultValue, + }, ); } @@ -125,6 +224,8 @@ final class MaterialBindings { required this.usesMaterialMaps, required this.usesMetallicRoughnessMap, required this.usesMetallic, + this.usesLightList = false, + this.uniforms = const >{}, }); final String name; @@ -133,9 +234,19 @@ final class MaterialBindings { final bool usesMetallicRoughnessMap; final bool usesMetallic; - /// Always false: a material in this language returns the light its surface - /// emits and gathers none, so the emitted stage declares no light list. - bool get usesLightList => false; + /// Each `uniform` the program declares, with its default, in declaration + /// order — `P8`: the members of the `MaterialParams` block, and what + /// `Material.parameters` has to hold for the renderer to bind it. + final Map> uniforms; + + /// Whether the stage declares `MaterialParams`, which is whether the + /// program has a uniform. + bool get usesMaterialParameters => uniforms.isNotEmpty; + + /// Whether the stage gathers lights: true for a material with a `light` + /// block — `P8` — and false for one that returns the light its surface + /// emits, whose stage declares no light list. + final bool usesLightList; /// The [LightingModel] these bindings describe, with the label and entry /// point the application chose. @@ -159,6 +270,7 @@ final class MaterialBindings { usesMetallicRoughnessMap: usesMetallicRoughnessMap, usesMetallic: usesMetallic, usesLightList: usesLightList, + usesMaterialParameters: usesMaterialParameters, vertexStageMorphs: vertexStageMorphs, ); } @@ -172,6 +284,8 @@ String _glsl(MaterialExpression expression) { return input.glsl; case MaterialLocalRef(:final name): return name; + case MaterialParamRef(:final parameter) when parameter.uniform: + return 'material_params.${parameter.name}'; case MaterialParamRef(:final parameter): // Reachable only for a program nothing specialised, which is a caller // skipping a step rather than a shape this emitter supports: a diff --git a/packages/flutter3d_core/lib/src/formats/material_language/material_parser.dart b/packages/flutter3d_core/lib/src/formats/material_language/material_parser.dart index 859f33145..9e07b845d 100644 --- a/packages/flutter3d_core/lib/src/formats/material_language/material_parser.dart +++ b/packages/flutter3d_core/lib/src/formats/material_language/material_parser.dart @@ -14,7 +14,27 @@ /// } /// ``` /// -/// **The vocabulary is GLSL's, and that is the whole design.** `clamp`, `mix`, +/// A material may also answer each light itself — `P8`, the lighting hook: +/// +/// ```text +/// material Toon { +/// light { +/// let banded = floor(nDotL * 3.0) / 3.0; +/// return albedo * banded / max(nDotL, 0.001); +/// } +/// fragment { +/// return vec4(lit, alpha); +/// } +/// } +/// ``` +/// +/// The `light` block runs once per light inside the engine's light loop and +/// returns how the surface answers it, which the engine multiplies by the +/// light's radiance, its `n·l` and its shadow; it reads the light through +/// `lightDir`, `halfDir`, `nDotL`, `nDotH` and `vDotH`. The fragment body +/// reads the result as `lit` — see `kMaterialLitInput` for what that adds up. +/// +/// /// **The vocabulary is GLSL's, and that is the whole design.** `clamp`, `mix`, /// `pow`, the swizzles and the broadcasting rules all mean what they mean in /// GLSL, because the emitted shader has to read like the source for anybody /// debugging the pair — and because a language that invented its own `mix` @@ -179,11 +199,12 @@ List<_Token> _lex(String source) { /// inputs are written as (shadowing them compiles and reads the wrong value), /// and the rest are GLSL words a declaration cannot take. const Set _reservedNames = { - // The emitter's own. - 's', 'result', 'main', + // The emitter's own, `P8`'s uniform block among them. + 's', 'result', 'main', 'material_params', 'MaterialParams', 'light', 'lit', // `surface.glsl`'s, as the emitted shader spells them. 'Surface', 'LightSample', 'ReadSurface', 'WriteSurface', 'ShadeLight', - 'LightVisibility', 'v_texcoord', 'v_world_position', + 'LightVisibility', 'v_texcoord', 'v_world_position', 'AccumulateLights', + 'ShadowFactor', 'ApplyCommonMaps', 'SampleLightmap', ...kMaterialTextureBindings, // GLSL: the one builtin the emitter calls that is not a [MaterialBuiltin], // and the keywords and type names a declaration cannot reuse. @@ -218,6 +239,13 @@ final class _Parser { final List parameters = []; final List textures = []; final Map locals = {}; + + /// Whether the body being read is the `light` block — `P8`. + bool inLight = false; + + /// Where the fragment body first read `lit`, to point at when the material + /// turns out to have no `light` block to gather it. + _Token? litAt; final Set inputsUsed = {}; _Token get current => tokens[at]; @@ -259,34 +287,59 @@ final class _Parser { take('{', '"{" after the material\'s name'); List? body; + List? light; + _Token? lightAt; while (!takeIf('}')) { if (current.kind == 'end') fail('the material is never closed with "}".'); if (takeWordIf('param')) { parseParameter(); + } else if (takeWordIf('uniform')) { + parseParameter(uniform: true); } else if (takeWordIf('texture')) { parseTexture(); } else if (takeWordIf('fragment')) { if (body != null) fail('a material has one fragment body.'); body = parseBody(); + } else if (current.kind == 'name' && current.text == 'light') { + lightAt = current; + at++; + if (light != null) fail('a material has one light block.', lightAt); + light = parseBody(light: true); } else { fail( '"${current.text}" is not a declaration: a material holds "param", ' - '"texture" and one "fragment".', + '"uniform", "texture", one "fragment" and at most one "light".', ); } } if (body == null) fail('the material has no fragment body.'); + if (litAt case final where? when light == null) { + fail( + '"lit" is the light a "light" block gathers, and this material has ' + 'none: add one, or return the colour without it.', + where, + ); + } + if (light != null && litAt == null) { + fail( + 'the light block is never read: a fragment body that does not read ' + '"lit" gathers no lights, and the compiled shader would drop the ' + 'block and everything the engine binds for it.', + lightAt, + ); + } return MaterialProgram( name: name, parameters: parameters, textures: textures, body: body, + light: light, inputsUsed: inputsUsed, ); } - void parseParameter() { + void parseParameter({bool uniform = false}) { final type = parseType(); if (!type.isNumeric) { fail( @@ -296,6 +349,18 @@ final class _Parser { } final nameToken = take('name', 'the parameter\'s name'); checkFreeName(nameToken); + // A uniform reaches the shader under its own name, as a member of the + // block — `P8` — so the names GLSL and the emitter keep are closed to it, + // as they are to a `let`. A `param` is folded and never written. + if (uniform && + (_reservedNames.contains(nameToken.text) || + nameToken.text.startsWith('gl_'))) { + fail( + '"${nameToken.text}" is a name the generated shader already uses, and ' + 'a uniform is written into it under its own name.', + nameToken, + ); + } take( '=', '"=" and a default value — a parameter without one is a ' @@ -303,7 +368,9 @@ final class _Parser { ); final value = parseConstant(type); take(';', '";" after the parameter'); - parameters.add(MaterialParameter(nameToken.text, type, value)); + parameters.add( + MaterialParameter(nameToken.text, type, value, uniform: uniform), + ); } void parseTexture() { @@ -331,7 +398,11 @@ final class _Parser { /// variable — a bug that looks like the lighting being wrong. void checkFreeName(_Token token) { final name = token.text; - for (final input in kMaterialInputs) { + for (final input in [ + ...kMaterialInputs, + ...kMaterialLightInputs, + kMaterialLitInput, + ]) { if (input.name == name) { fail('"$name" is one of the surface inputs.', token); } @@ -378,11 +449,17 @@ final class _Parser { return expression.value; } - List parseBody() { - take('{', '"{" after "fragment"'); + /// A body: the fragment's, or with [light] the `light` block's — `P8`. + /// + /// Each has its own locals: the two are two functions in the shader. + List parseBody({bool light = false}) { + final what = light ? 'light block' : 'fragment body'; + take('{', light ? '"{" after "light"' : '"{" after "fragment"'); + locals.clear(); + inLight = light; final body = []; while (!takeIf('}')) { - if (current.kind == 'end') fail('the fragment body is never closed.'); + if (current.kind == 'end') fail('the $what is never closed.'); if (takeWordIf('let')) { final nameToken = take('name', 'the name being bound'); checkFreeName(nameToken); @@ -407,7 +484,14 @@ final class _Parser { body.add(MaterialLet(nameToken.text, value)); } else if (takeWordIf('return')) { final value = parseExpression(); - if (value.type != MaterialType.vec4) { + if (light && value.type != MaterialType.vec3) { + fail( + 'a light block returns a vec3 — how the surface answers this one ' + 'light, which the engine multiplies by its radiance, n·l and ' + 'shadow — and this one returns a ${value.type}.', + ); + } + if (!light && value.type != MaterialType.vec4) { fail( 'a fragment body returns a vec4 — rgb the light the surface ' 'emits, a its opacity — and this one returns a ${value.type}.', @@ -416,18 +500,19 @@ final class _Parser { take(';', '";" after the returned value'); body.add(MaterialReturn(value)); if (current.kind != '}') { - fail('nothing follows the return in a fragment body.'); + fail('nothing follows the return in a $what.'); } } else { fail( - '"${current.text}" is not a statement: a fragment body is "let" ' + '"${current.text}" is not a statement: a $what is "let" ' 'bindings and one "return".', ); } } if (body.isEmpty || body.last is! MaterialReturn) { - fail('the fragment body has no "return".'); + fail('the $what has no "return".'); } + inLight = false; return body; } @@ -516,6 +601,31 @@ final class _Parser { return MaterialInputRef(input); } } + for (final input in kMaterialLightInputs) { + if (input.name == name) { + if (!inLight) { + fail( + '"$name" is read of one light, in the "light" block; the ' + 'fragment body sees them gathered, as "lit".', + token, + ); + } + inputsUsed.add(name); + return MaterialInputRef(input); + } + } + if (name == kMaterialLitInput.name) { + if (inLight) { + fail( + '"lit" is every light gathered through this block, so the block ' + 'cannot read it; the fragment body does.', + token, + ); + } + litAt ??= token; + inputsUsed.add(name); + return MaterialInputRef(kMaterialLitInput); + } if (textureNamed(name) != null) { fail('"$name" is a texture; it is read with sample($name, uv).', token); } diff --git a/packages/flutter3d_core/lib/src/geometry/liquid_shapes.dart b/packages/flutter3d_core/lib/src/geometry/liquid_shapes.dart new file mode 100644 index 000000000..cba50af59 --- /dev/null +++ b/packages/flutter3d_core/lib/src/geometry/liquid_shapes.dart @@ -0,0 +1,525 @@ +import 'dart:math' as math; + +import 'package:flutter3d_physics/flutter3d_physics.dart'; +import 'package:vector_math/vector_math.dart'; + +import 'mesh_builder.dart'; +import 'mesh_data.dart'; +import 'vertex_layout.dart'; + +/// One layer of a liquid, and the mesh that draws it, in its vessel's frame. +typedef LiquidLayerMesh = ({LiquidLayer layer, MeshData mesh}); + +/// The meshes that draw a [LiquidBody], one per layer, bottom first, in the +/// vessel's own frame — put them on a node that moves with the vessel. +/// +/// **Each layer is the inside of the vessel between two planes**: its +/// boundary with the layer under it, and its top. The vessel's inside — +/// the lathe of a [RevolvedVessel]'s wall, or a [MeshVessel]'s triangles — +/// is cut by both, a triangle at a time (Sutherland and Hodgman), and the +/// cuts are closed with flat caps, so it works for any shape tipped any way. +/// A cap is laid as rings from its outline to its middle where the outline +/// is star-shaped about its middle, as a round vessel's always is, the rings +/// closer together near the wall where the meniscus curls; any other is +/// ear-clipped and subdivided. +/// +/// **The top is the liquid's own surface**: every point on the top plane — +/// the cap and the wall's upper edge both — is lifted by how far the surface +/// stands off its plane there ([LiquidBody.surfaceAt]): the waves, and the +/// meniscus climbing the glass, with no seam between them. +List liquidMeshes( + LiquidBody body, { + int segments = 48, + int rows = 48, + int rings = 12, +}) { + final up = body.up; + final inside = _inside(body.shape, segments, rows); + final layers = body.layers; + final tops = body.layerTops(); + final out = []; + for (var i = 0; i < layers.length; i++) { + if (layers[i].volume <= 0.0) continue; + final lower = i == 0 ? double.negativeInfinity : tops[i - 1]; + final upper = i == layers.length - 1 ? body.height : tops[i]; + final top = i == layers.length - 1; + final mesh = _layer( + body: body, + inside: inside, + up: up, + lower: lower, + upper: upper, + surface: top, + rings: rings, + ); + out.add((layer: layers[i], mesh: mesh)); + } + return out; +} + +/// A point of a triangle: where, and its normal. +typedef _P = ({Vector3 p, Vector3 n}); + +/// The inside of [shape] as triangles, wound outwards; made once a shape +/// and kept, since a liquid is drawn again every frame it moves. +List> _inside(VesselShape shape, int segments, int rows) { + final kept = _insides[shape]; + if (kept != null && kept.segments == segments && kept.rows == rows) { + return kept.triangles; + } + final triangles = _insideOf(shape, segments, rows); + _insides[shape] = (segments: segments, rows: rows, triangles: triangles); + return triangles; +} + +final Expando<({int segments, int rows, List> triangles})> _insides = + Expando(); + +List> _insideOf(VesselShape shape, int segments, int rows) { + final out = >[]; + switch (shape) { + case RevolvedVessel(:final wall): + final along = [0.0]; + for (var i = 1; i < wall.length; i++) { + along.add(along.last + (wall[i] - wall[i - 1]).length); + } + final profile = <({Vector2 p, Vector2 n})>[]; + var segment = 1; + for (var k = 0; k <= rows; k++) { + final d = along.last * k / rows; + while (segment < wall.length - 1 && along[segment] < d) { + segment++; + } + final a = wall[segment - 1]; + final b = wall[segment]; + final span = along[segment] - along[segment - 1]; + final t = span > 0.0 ? (d - along[segment - 1]) / span : 0.0; + final run = b - a; + profile.add(( + p: a + run * t, + n: run.length2 > 0.0 + ? Vector2(run.y, -run.x).normalized() + : Vector2(0, -1), + )); + } + _P at(int column, int row) { + final angle = 2.0 * math.pi * column / segments; + final c = math.cos(angle); + final s = math.sin(angle); + final (:p, :n) = profile[row]; + return ( + p: Vector3(p.x * c, p.y, p.x * s), + n: Vector3(n.x * c, n.y, n.x * s), + ); + } + + for (var i = 0; i < segments; i++) { + for (var j = 0; j < rows; j++) { + final a = at(i, j); + final b = at(i + 1, j); + final c = at(i + 1, j + 1); + final d = at(i, j + 1); + if (profile[j].p.x > 0.0) out.add([a, d, b]); + if (profile[j + 1].p.x > 0.0) out.add([b, d, c]); + } + } + case MeshVessel(:final positions, :final indices): + for (var t = 0; t < indices.length; t += 3) { + final a = positions[indices[t]]; + final b = positions[indices[t + 1]]; + final c = positions[indices[t + 2]]; + final n = (b - a).cross(c - a)..normalize(); + out.add([(p: a, n: n), (p: b, n: n), (p: c, n: n)]); + } + default: + break; + } + return out; +} + +MeshData _layer({ + required LiquidBody body, + required List> inside, + required Vector3 up, + required double lower, + required double upper, + required bool surface, + required int rings, +}) { + final builder = MeshBuilder(VertexLayout.standard); + final topCut = >[]; + final bottomCut = >[]; + // How far the surface stands off the top plane over a point on it. + double lift(Vector3 p) => surface ? body.surfaceAt(p) - upper : 0.0; + + List<_P> clip( + List<_P> polygon, + double h, + bool keepBelow, + List> cut, + ) { + final kept = <_P>[]; + final crossings = []; + for (var k = 0; k < polygon.length; k++) { + final a = polygon[k]; + final b = polygon[(k + 1) % polygon.length]; + final sa = (up.dot(a.p) - h) * (keepBelow ? 1.0 : -1.0); + final sb = (up.dot(b.p) - h) * (keepBelow ? 1.0 : -1.0); + if (sa <= 0.0) kept.add(a); + if ((sa < 0.0) != (sb < 0.0)) { + final t = sa / (sa - sb); + final p = a.p + (b.p - a.p) * t; + kept.add((p: p, n: (a.n + (b.n - a.n) * t)..normalize())); + crossings.add(p); + } + } + if (crossings.length == 2) cut.add(crossings); + return kept; + } + + for (final triangle in inside) { + var polygon = clip(triangle, upper, true, topCut); + if (lower.isFinite && polygon.length >= 3) { + polygon = clip(polygon, lower, false, bottomCut); + } + if (polygon.length < 3) continue; + // The wall's upper edge rides the surface. + final lifted = [ + for (final v in polygon) + // Within a micrometre: the cut's own points are single precision, + // four nanometres out at six centimetres, and a test tighter than + // that left the wall's edge behind the cap it should meet. + (up.dot(v.p) - upper).abs() < 1e-6 + ? (p: v.p + up * lift(v.p), n: v.n) + : v, + ]; + final first = builder.addVertex(position: lifted[0].p, normal: lifted[0].n); + var previous = builder.addVertex( + position: lifted[1].p, + normal: lifted[1].n, + ); + for (var k = 2; k < lifted.length; k++) { + final next = builder.addVertex( + position: lifted[k].p, + normal: lifted[k].n, + ); + builder.addTriangle(first, previous, next); + previous = next; + } + } + _cap(builder, topCut, up, surface ? lift : null, body, upper, rings); + if (lower.isFinite) { + _cap(builder, bottomCut, -up, null, body, lower, 1); + } + return builder.build(); +} + +/// Closes a cut whose [segments] lie on a plane facing [normal]: lifted by +/// [lift] where the surface stands off it. +void _cap( + MeshBuilder builder, + List> segments, + Vector3 normal, + double Function(Vector3)? lift, + LiquidBody body, + double height, + int rings, +) { + if (segments.length < 3) return; + final loops = _loops(segments); + final helper = normal.x.abs() < 0.9 ? Vector3(1, 0, 0) : Vector3(0, 0, 1); + final e1 = normal.cross(helper)..normalize(); + final e2 = normal.cross(e1); + Vector3 raise(Vector3 p) => lift == null ? p : p + normal * lift(p); + Vector3 shade(Vector3 p) { + if (lift == null) return normal; + // The surface's slope, by central differences in the plane. + final eps = 1e-4; + final dx = (lift(p + e1 * eps) - lift(p - e1 * eps)) / (2 * eps); + final dy = (lift(p + e2 * eps) - lift(p - e2 * eps)) / (2 * eps); + return (normal - e1 * dx - e2 * dy)..normalize(); + } + + for (final loop in loops) { + if (loop.length < 3) continue; + final middle = + loop.fold(Vector3.zero(), (s, p) => s + p) / loop.length.toDouble(); + double angle(Vector3 p) { + final d = p - middle; + return math.atan2(d.dot(e2), d.dot(e1)); + } + + // Counter-clockwise seen from the side the cap faces. + var ordered = loop; + if (_signedArea(loop, e1, e2) < 0.0) ordered = loop.reversed.toList(); + if (_starShaped(ordered, angle)) { + // Rings from the outline in to the middle, closer near the outline. + // Raised first, then shaded from their neighbours on the grid: the + // surface asked once a point rather than five times for its slope. + final n = ordered.length; + final raised = [ + for (var j = 0; j <= rings; j++) + [ + for (final p in ordered) + raise( + middle + (p - middle) * ((1.0 - j / rings) * (1.0 - j / rings)), + ), + ], + ]; + Vector3 gridNormal(int j, int k) { + if (lift == null) return normal; + // At the middle every ring point is the same: the ring outside it. + final row = math.min(j, rings - 1); + final along = raised[row][(k + 1) % n] - raised[row][(k - 1 + n) % n]; + final across = + raised[math.max(row - 1, 0)][k] - + raised[math.min(row + 1, rings)][k]; + final m = along.cross(across); + if (m.length2 < 1e-30) return normal; + m.normalize(); + return m.dot(normal) < 0.0 ? -m : m; + } + + final grid = [ + for (var j = 0; j <= rings; j++) + [ + for (var k = 0; k < n; k++) + builder.addVertex( + position: raised[j][k], + normal: gridNormal(j, k), + ), + ], + ]; + for (var j = 0; j < rings; j++) { + for (var k = 0; k < n; k++) { + final a = grid[j][k]; + final b = grid[j][(k + 1) % n]; + final c = grid[j + 1][(k + 1) % n]; + final d = grid[j + 1][k]; + builder + ..addTriangle(a, b, c) + ..addTriangle(a, c, d); + } + } + } else { + for (final t in _earClip(ordered, e1, e2)) { + // Each ear split in four twice, so waves have points to show on. + for (final small in _subdivide(t, 2)) { + final ia = builder.addVertex( + position: raise(small[0]), + normal: shade(small[0]), + ); + final ib = builder.addVertex( + position: raise(small[1]), + normal: shade(small[1]), + ); + final ic = builder.addVertex( + position: raise(small[2]), + normal: shade(small[2]), + ); + builder.addTriangle(ia, ib, ic); + } + } + } + } +} + +/// The cut's segments chained into closed outlines. +List> _loops(List> segments) { + final left = [...segments]; + final loops = >[]; + const tolerance = 1e-9; + bool same(Vector3 a, Vector3 b) => + (a - b).length2 < tolerance * tolerance + 1e-14; + while (left.isNotEmpty) { + final first = left.removeLast(); + final loop = [first[0], first[1]]; + var grew = true; + while (grew) { + grew = false; + for (var i = 0; i < left.length; i++) { + final s = left[i]; + if (same(s[0], loop.last)) { + loop.add(s[1]); + } else if (same(s[1], loop.last)) { + loop.add(s[0]); + } else { + continue; + } + left.removeAt(i); + grew = true; + break; + } + } + if (same(loop.first, loop.last)) loop.removeLast(); + loops.add(loop); + } + return loops; +} + +double _signedArea(List loop, Vector3 e1, Vector3 e2) { + var area = 0.0; + for (var i = 0; i < loop.length; i++) { + final a = loop[i]; + final b = loop[(i + 1) % loop.length]; + area += a.dot(e1) * b.dot(e2) - b.dot(e1) * a.dot(e2); + } + return 0.5 * area; +} + +/// Whether the outline's angle about its middle only ever increases: then +/// every ray from the middle meets it once. +bool _starShaped(List loop, double Function(Vector3) angle) { + var turned = 0.0; + for (var i = 0; i < loop.length; i++) { + var d = angle(loop[(i + 1) % loop.length]) - angle(loop[i]); + if (d <= -math.pi) d += 2 * math.pi; + if (d > math.pi) d -= 2 * math.pi; + if (d <= 0.0) return false; + turned += d; + } + return (turned - 2 * math.pi).abs() < 1e-6; +} + +/// Ear clipping of a counter-clockwise outline. +List> _earClip(List loop, Vector3 e1, Vector3 e2) { + final points = [...loop]; + final out = >[]; + double cross(Vector3 o, Vector3 a, Vector3 b) => + (a - o).dot(e1) * (b - o).dot(e2) - (a - o).dot(e2) * (b - o).dot(e1); + bool insideTriangle(Vector3 p, Vector3 a, Vector3 b, Vector3 c) => + cross(a, b, p) >= 0 && cross(b, c, p) >= 0 && cross(c, a, p) >= 0; + var guard = 0; + while (points.length > 3 && guard++ < 10000) { + var clipped = false; + for (var i = 0; i < points.length; i++) { + final a = points[(i - 1 + points.length) % points.length]; + final b = points[i]; + final c = points[(i + 1) % points.length]; + if (cross(a, b, c) <= 0) continue; + var ear = true; + for (final p in points) { + if (identical(p, a) || identical(p, b) || identical(p, c)) continue; + if (insideTriangle(p, a, b, c)) { + ear = false; + break; + } + } + if (!ear) continue; + out.add([a, b, c]); + points.removeAt(i); + clipped = true; + break; + } + if (!clipped) break; + } + if (points.length == 3) out.add(points); + return out; +} + +/// [t] split in four, [times] over. +List> _subdivide(List t, int times) { + if (times == 0) return [t]; + final ab = (t[0] + t[1]) * 0.5; + final bc = (t[1] + t[2]) * 0.5; + final ca = (t[2] + t[0]) * 0.5; + return [ + for (final small in [ + [t[0], ab, ca], + [ab, t[1], bc], + [ca, bc, t[2]], + [ab, bc, ca], + ]) + ..._subdivide(small, times - 1), + ]; +} + +/// The mesh of a [Jet]'s stream, in the world: each run swept as a tube of +/// elliptical section, [sides] round. Draw it double-sided. +MeshData jetMesh(Jet jet, {int sides = 14}) { + final builder = MeshBuilder(VertexLayout.standard); + for (final run in jet.runs) { + final base = builder.vertexCount; + for (final s in run) { + final e1 = (s.across - s.direction * s.across.dot(s.direction)) + ..normalize(); + final e2 = s.direction.cross(e1); + for (var i = 0; i <= sides; i++) { + final a = 2.0 * math.pi * i / sides; + final c = math.cos(a); + final si = math.sin(a); + final n = + (e1 * (c / math.max(s.wide, 1e-9)) + + e2 * (si / math.max(s.thick, 1e-9))) + ..normalize(); + builder.addVertex( + position: s.position + e1 * (s.wide * c) + e2 * (s.thick * si), + normal: n, + ); + } + } + final columns = sides + 1; + for (var k = 0; k + 1 < run.length; k++) { + for (var i = 0; i < sides; i++) { + final a = base + k * columns + i; + builder + ..addTriangle(a, a + 1, a + columns + 1) + ..addTriangle(a, a + columns + 1, a + columns); + } + } + } + return builder.build(); +} + +/// Particles drawn as small icosahedra of [radius] round [positions], in +/// the world. +MeshData particleMesh(List positions, double radius) { + const t = 1.618033988749895; + final ico = [ + Vector3(-1, t, 0), + Vector3(1, t, 0), + Vector3(-1, -t, 0), + Vector3(1, -t, 0), + Vector3(0, -1, t), + Vector3(0, 1, t), + Vector3(0, -1, -t), + Vector3(0, 1, -t), + Vector3(t, 0, -1), + Vector3(t, 0, 1), + Vector3(-t, 0, -1), + Vector3(-t, 0, 1), + ].map((v) => v.normalized()).toList(); + const faces = >[ + [0, 11, 5], + [0, 5, 1], + [0, 1, 7], + [0, 7, 10], + [0, 10, 11], + [1, 5, 9], + [5, 11, 4], + [11, 10, 2], + [10, 7, 6], + [7, 1, 8], + [3, 9, 4], + [3, 4, 2], + [3, 2, 6], + [3, 6, 8], + [3, 8, 9], + [4, 9, 5], + [2, 4, 11], + [6, 2, 10], + [8, 6, 7], + [9, 8, 1], + ]; + final builder = MeshBuilder(VertexLayout.standard); + for (final centre in positions) { + final base = builder.vertexCount; + for (final v in ico) { + builder.addVertex(position: centre + v * radius, normal: v); + } + for (final f in faces) { + builder.addTriangle(base + f[0], base + f[1], base + f[2]); + } + } + return builder.build(); +} diff --git a/packages/flutter3d_core/pubspec.yaml b/packages/flutter3d_core/pubspec.yaml index 31d9944ee..990be13d9 100644 --- a/packages/flutter3d_core/pubspec.yaml +++ b/packages/flutter3d_core/pubspec.yaml @@ -13,11 +13,26 @@ resolution: workspace environment: sdk: ">=3.12.0 <4.0.0" +# Declared rather than left to pub.dev's detector: plain Dart, its isolates +# and files chosen at compile time (`src/engine/platform/`), so the browser's +# build imports neither `dart:isolate` nor `dart:io`. +platforms: + android: + ios: + linux: + macos: + web: + windows: + dependencies: flutter3d_hardware: ^0.8.0 # The typed uniform blocks the renderer fills, generated from the compiled # bundle's own layout — `H1`. Pure Dart: the shader sources and two tables. flutter3d_shaders: ^0.8.2 + # What a liquid, a stream or a puddle is, as data — the meshes that draw + # them are built here, from it (`geometry/liquid_shapes.dart`). Plain Dart + # like this package, so neither gains Flutter by it. + flutter3d_physics: ^0.8.2 vector_math: ^2.2.0 dev_dependencies: diff --git a/packages/flutter3d_core/test/animation_graph_test.dart b/packages/flutter3d_core/test/animation_graph_test.dart new file mode 100644 index 000000000..c4b92c330 --- /dev/null +++ b/packages/flutter3d_core/test/animation_graph_test.dart @@ -0,0 +1,435 @@ +/// The animation graph takes the transitions its parameters say, once, and +/// the same ones on every run — N1. +/// +/// dart test test/animation_graph_test.dart +/// +/// Every step here is 1/64 s and every fade a power of two, so the weights the +/// tests name are exact rather than close. +library; + +import 'dart:math' as math; +import 'dart:typed_data'; + +import 'package:flutter3d_core/flutter3d_core.dart'; +import 'package:test/test.dart'; +import 'package:vector_math/vector_math.dart'; + +const double _dt = 1.0 / 64.0; + +/// A one-node clip [length] seconds long holding translation x = [x] and, when +/// [yaw] is given, that rotation about Y. +AnimationClip _clip(String name, double x, {double length = 1.0, double? yaw}) { + final half = (yaw ?? 0.0) / 2.0; + final rotation = [0.0, math.sin(half), 0.0, math.cos(half)]; + return AnimationClip( + name: name, + tracks: [ + AnimationTrack( + nodeIndex: 0, + path: AnimationPath.translation, + interpolation: AnimationInterpolation.linear, + times: Float32List.fromList([0.0, length]), + values: Float32List.fromList([x, 0, 0, x, 0, 0]), + componentCount: 3, + ), + if (yaw != null) + AnimationTrack( + nodeIndex: 0, + path: AnimationPath.rotation, + interpolation: AnimationInterpolation.linear, + times: Float32List.fromList([0.0, length]), + values: Float32List.fromList([...rotation, ...rotation]), + componentCount: 4, + ), + ], + ); +} + +Pose _onePose() => Pose( + parents: const [-1], + restTranslations: Float32List(3), + restRotations: Float32List.fromList([0, 0, 0, 1]), + restScales: Float32List.fromList([1, 1, 1]), +); + +final List _clips = [ + // A rotation track at rest, rather than none: the player fades only a track + // both clips have, and the graph is compared with it below. + _clip('idle', 0.0, yaw: 0.0), + _clip('walk', 10.0, yaw: math.pi / 2.0), + _clip('attack', 20.0, length: 0.5), +]; + +AnimationStateMachine _machine({ + List transitions = const [], +}) => AnimationStateMachine( + parameters: AnimationParameterSchema(const [ + AnimationParameter.float('speed'), + AnimationParameter.boolean('grounded', initial: true), + AnimationParameter.integer('stance'), + AnimationParameter.trigger('attack'), + ]), + states: const [ + AnimationState(name: 'idle', clip: 'idle'), + AnimationState(name: 'walk', clip: 'walk'), + AnimationState(name: 'attack', clip: 'attack', wrap: AnimationWrap.once), + ], + transitions: transitions, +); + +AnimationGraph _graph(List transitions) => AnimationGraph( + machine: _machine(transitions: transitions), + clips: _clips, + pose: _onePose(), +); + +/// The yaw [pose]'s node 0 holds, in radians. +double _yaw(Pose pose) => + 2.0 * math.atan2(pose.rotations[1], pose.rotations[3]); + +void main() { + group('conditions', () { + // Mutation: drop the `checks.every(_holds)` test in `_takeTransition` and + // the graph walks at speed 0.4; flip `greater` to `less` in + // `CompareCondition.holds` and it never walks. + test('a transition waits for its condition and then fires', () { + final graph = _graph([ + AnimationTransition( + from: 'idle', + to: 'walk', + conditions: const [ + CompareCondition('speed', AnimationComparison.greater, 0.5), + ], + ), + ]); + + expect(graph.parameters.setFloat('speed', 0.4).written, isTrue); + for (var i = 0; i < 10; i++) { + graph.evaluate(_dt); + } + expect(graph.state, 'idle'); + expect(graph.pose.translations[0], 0.0); + + graph.parameters.setFloat('speed', 0.6); + final pose = graph.evaluate(_dt); + expect(graph.state, 'walk'); + expect(pose.translations[0], 10.0, reason: 'a zero fade cuts'); + }); + + // Mutation: compare a boolean against `== 0.0` rather than `!= 0.0` and + // the bool transition fires while grounded; read `int` through a float + // comparison only and `equals` on an integer never matches. + test('bool and integer conditions all have to hold', () { + final graph = _graph([ + AnimationTransition( + from: 'idle', + to: 'walk', + conditions: const [ + BoolCondition('grounded', value: false), + CompareCondition('stance', AnimationComparison.equals, 2), + ], + ), + ]); + + graph.parameters.setInteger('stance', 2); + graph.evaluate(_dt); + expect(graph.state, 'idle', reason: 'still grounded'); + + graph.parameters.setBool('grounded', false); + graph.evaluate(_dt); + expect(graph.state, 'walk'); + }); + + // Mutation: sort by priority ascending, or drop the sort, and the + // lower-priority walk declared first wins. + test('of two transitions ready on one step, the higher priority wins', () { + final graph = _graph([ + AnimationTransition( + from: 'idle', + to: 'walk', + conditions: const [ + CompareCondition('speed', AnimationComparison.greater, 0.5), + ], + ), + AnimationTransition( + from: 'idle', + to: 'attack', + priority: 1, + conditions: const [TriggerCondition('attack')], + ), + ]); + + graph.parameters + ..setFloat('speed', 1.0) + ..fire('attack'); + graph.evaluate(_dt); + expect(graph.state, 'attack'); + }); + }); + + group('triggers and exit time', () { + List attackAndBack() => [ + AnimationTransition( + from: 'idle', + to: 'attack', + conditions: const [TriggerCondition('attack')], + ), + AnimationTransition(from: 'attack', to: 'idle', exitTime: 1.0), + ]; + + // Mutation: drop `consumeTriggerAt` from `_takeTransition` and the graph + // goes straight back into the attack the moment it returns to idle. + test('a trigger fires its transition once and is consumed', () { + final graph = _graph(attackAndBack()); + final attack = graph.machine.parameters.indexOf('attack'); + + graph.parameters.fire('attack'); + graph.evaluate(_dt); + expect(graph.state, 'attack'); + expect(graph.parameters.valueAt(attack), 0.0); + + final visited = [ + for (var i = 0; i < 200; i++) (graph..evaluate(_dt)).state, + ]; + expect(visited.where((s) => s == 'attack'), hasLength(31)); + expect(visited.skip(31), everyElement('idle')); + }); + + // Mutation: test the exit time against the wrapped playhead rather than + // the time in the state, or drop the test, and the attack leaves early. + test('an exit time holds the state until that much of its clip', () { + final graph = _graph(attackAndBack()); + graph.parameters.fire('attack'); + graph.evaluate(_dt); + + // The attack clip is half a second, 32 steps; it is entered with its + // playhead at zero and leaves on the step that reaches the end. + for (var i = 0; i < 31; i++) { + graph.evaluate(_dt); + } + expect(graph.state, 'attack'); + expect(graph.normalizedTime, closeTo(31 / 32, 1e-12)); + graph.evaluate(_dt); + expect(graph.state, 'idle'); + }); + + // Mutation: clear every trigger at the end of `evaluate` and the press + // made during the fade is lost. + test('a trigger set while a transition cannot fire waits for it', () { + final graph = _graph([ + AnimationTransition( + from: 'idle', + to: 'walk', + duration: 0.25, + conditions: const [ + CompareCondition('speed', AnimationComparison.greater, 0.5), + ], + ), + AnimationTransition( + from: 'walk', + to: 'attack', + conditions: const [TriggerCondition('attack')], + ), + ]); + graph.parameters.setFloat('speed', 1.0); + graph.evaluate(_dt); + graph.parameters.fire('attack'); + + for (var i = 0; i < 15; i++) { + graph.evaluate(_dt); + } + expect(graph.state, 'walk', reason: 'no transition during a fade'); + graph.evaluate(_dt); + expect(graph.state, 'attack'); + }); + }); + + group('crossfade', () { + AnimationGraph fading() => _graph([ + AnimationTransition( + from: 'idle', + to: 'walk', + duration: 0.25, + conditions: const [ + CompareCondition('speed', AnimationComparison.greater, 0.5), + ], + ), + ])..parameters.setFloat('speed', 1.0); + + // Mutation: blend rotation by lerping the four components and + // normalising, and a quarter of the way through reads 21.6° rather than + // 22.5° — the two agree only at a half, which is why this checks a quarter. + test('a quarter of the way through, the pose is a quarter of the way', () { + final graph = fading(); + graph.evaluate(_dt); + expect(graph.fadingFrom, 'idle'); + expect(graph.fadeWeight, 0.0); + expect(graph.pose.translations[0], 0.0, reason: 'weight 0 is the source'); + + for (var i = 0; i < 4; i++) { + graph.evaluate(_dt); + } + expect(graph.fadeWeight, 0.25); + expect(graph.pose.translations[0], closeTo(2.5, 1e-6)); + expect(_yaw(graph.pose), closeTo(math.pi / 8.0, 1e-6)); + + for (var i = 0; i < 4; i++) { + graph.evaluate(_dt); + } + expect(graph.pose.translations[0], closeTo(5.0, 1e-6)); + expect(_yaw(graph.pose), closeTo(math.pi / 4.0, 1e-6)); + }); + + // Mutation: end the fade one step late, or leave `_previous` set, and the + // pose stops short of the walk. + test('when the fade is over the pose is the target clip alone', () { + final graph = fading(); + for (var i = 0; i < 17; i++) { + graph.evaluate(_dt); + } + expect(graph.fadingFrom, isNull); + expect(graph.fadeWeight, 1.0); + expect(graph.pose.translations[0], 10.0); + expect(_yaw(graph.pose), closeTo(math.pi / 2.0, 1e-6)); + }); + + // Mutation: swap the arguments of `shortestArcSlerp` in `Pose.blendFrom` + // and the graph's half-faded pose stops matching the player's. + test('the graph fades as the player does', () { + final graph = fading(); + final node = _Node(); + final player = AnimationPlayer( + clips: _clips, + targets: [node], + )..play(0); + + graph.evaluate(_dt); + player.crossFadeTo(1, duration: 0.25); + for (var i = 0; i < 6; i++) { + graph.evaluate(_dt); + player + ..update(_dt) + ..apply(); + } + // Close rather than equal only because a pose holds 32-bit floats and + // the player hands its targets doubles. + expect(player.fadeWeight, graph.fadeWeight); + expect(node.x, closeTo(graph.pose.translations[0], 1e-6)); + for (var i = 0; i < 4; i++) { + expect(node.rotation[i], closeTo(graph.pose.rotations[i], 1e-6)); + } + }); + }); + + // Mutation: advance by a wall clock, or iterate the outgoing transitions + // out of a hash set, and two runs over the same script part ways. + test('the same parameters on the same steps give the same run', () { + List run() { + final graph = _graph([ + AnimationTransition( + from: 'idle', + to: 'walk', + duration: 0.125, + conditions: const [ + CompareCondition('speed', AnimationComparison.greater, 0.5), + ], + ), + AnimationTransition( + from: 'walk', + to: 'attack', + duration: 0.0625, + conditions: const [TriggerCondition('attack')], + ), + AnimationTransition( + from: 'attack', + to: 'idle', + duration: 0.25, + exitTime: 1.0, + ), + ]); + final trace = []; + for (var step = 0; step < 600; step++) { + graph.parameters.setFloat('speed', (step % 90) / 60.0); + if (step % 47 == 0) graph.parameters.fire('attack'); + final pose = graph.evaluate(_dt); + trace + ..add(graph.state) + ..add(graph.fadeWeight) + ..addAll(pose.translations) + ..addAll(pose.rotations); + } + return trace; + } + + final first = run(); + expect( + first.toSet().containsAll(['idle', 'walk', 'attack']), + isTrue, + ); + expect(run(), first); + }); + + group('refusals', () { + // Mutation: drop the type check in `_write` and a bool lands in a float. + test('a write of the wrong type is refused, naming the right call', () { + final graph = _graph(const []); + final refused = graph.parameters.setBool('speed', true); + expect(refused.written, isFalse); + expect(refused.refusal, contains('setFloat')); + expect( + graph.parameters.setFloat('sped', 1.0).refusal, + contains('`speed`'), + ); + expect(graph.parameters.setFloat('speed', double.nan).written, isFalse); + expect(graph.parameters.values, [0.0, 1.0, 0.0, 0.0]); + }); + + // Mutation: drop `_conditionProblem` and a trigger tested as a bool + // builds a graph whose transition is never taken. + test('a definition that cannot run says why and is not built', () { + final machine = _machine( + transitions: [ + AnimationTransition( + from: 'idle', + to: 'walk', + conditions: const [BoolCondition('attack')], + ), + AnimationTransition( + from: 'idle', + to: 'run', + conditions: const [ + CompareCondition('speed', AnimationComparison.equals, 1.0), + ], + ), + AnimationTransition(from: 'walk', to: 'idle'), + ], + ); + final problems = machine.problems(_clips.take(2).toList()); + expect(problems, hasLength(5)); + expect(problems, contains(contains('TriggerCondition'))); + expect(problems, contains(contains('`run`'))); + expect(problems, contains(contains('exact equality'))); + expect(problems, contains(contains('neither a condition'))); + expect(problems, contains(contains('`attack`, which is not among'))); + expect( + () => AnimationGraph(machine: machine, clips: _clips, pose: _onePose()), + throwsArgumentError, + ); + }); + }); +} + +final class _Node implements AnimationTarget { + double x = 0.0; + List rotation = const [0, 0, 0, 1]; + + @override + void setPosition(double x, double y, double z) => this.x = x; + + @override + void setRotation(Quaternion value) => + rotation = [value.x, value.y, value.z, value.w]; + + @override + void setScale(double x, double y, double z) {} +} diff --git a/packages/flutter3d_core/test/engine/engine_tables_test.dart b/packages/flutter3d_core/test/engine/engine_tables_test.dart index 3a2fa30f6..8235fbfb8 100644 --- a/packages/flutter3d_core/test/engine/engine_tables_test.dart +++ b/packages/flutter3d_core/test/engine/engine_tables_test.dart @@ -15,7 +15,8 @@ import 'package:flutter3d_hardware/flutter3d_hardware.dart' show TextureFormat; import 'package:flutter3d_hardware/testing.dart'; import 'package:test/test.dart'; -import '../../tool/make_tables.dart' show aces2DisplayTable, ltcTable, toHalf; +import '../../tool/make_tables.dart' + show aces2DisplayTable, ltcTable, smaaAreaTable, toHalf; int _fnv1a(Uint8List bytes) => bytes.fold( 0x811c9dc5, @@ -35,6 +36,7 @@ void main() { for (final table in EngineTables.all) { final texel = switch (table.format) { TextureFormat.r8UNormInt => 1, + TextureFormat.r8g8b8a8UNormInt => 4, TextureFormat.r16g16b16a16Float => 8, _ => throw StateError('no size known for ${table.format}'), }; @@ -59,9 +61,36 @@ void main() { // `M2` wrote the sheen's albedo into the second table's z lane, // which the published fit leaves empty and nothing read before. 'ltc': 1806585161, + // `P1`: SMAA 1x's orthogonal areas, without the diagonal half. + 'smaaArea': 2858171383, }); }); + test('the SMAA area table moves nothing on a run crossed nowhere, and ' + 'one side at a time elsewhere — P1', () { + // Mutation: swap red and green in `smaaAreaTable`, and the step below + // gives its share to the far pixel: the second expectation fails. + final bytes = EngineTables.all + .firstWhere((table) => table.name == 'smaaArea') + .bytes; + int red(int x, int y) => bytes[(y * 80 + x) * 4]; + int green(int x, int y) => bytes[(y * 80 + x) * 4 + 1]; + // Codes (0, 0): a straight edge, whatever its length. + for (var j = 0; j < 16; j++) { + for (var i = 0; i < 16; i++) { + expect(red(i, j) + green(i, j), 0); + } + } + // Codes (3, 0): a step on the near side at the first end. One pixel in + // from it, on a run of ten, the near pixel takes the far colour and the + // far one keeps its own. + expect(red(3 * 16 + 1, 3), greaterThan(0)); + expect(green(3 * 16 + 1, 3), 0); + // Codes (1, 0): the same step on the far side moves the far pixel. + expect(red(16 + 1, 3), 0); + expect(green(16 + 1, 3), greaterThan(0)); + }); + test('the LTC table carries the sheen albedo in its spare lane', () { // Row `64 + j` of the second table is sqrt(1 − n·v) = j / 63 and column // `i` is roughness i / 63; z is the half at byte 4 of each texel. @@ -149,6 +178,7 @@ void main() { EngineTables.all.firstWhere((table) => table.name == name).bytes; expect(aces2DisplayTable(), shipped('aces2Display')); expect(ltcTable(), shipped('ltc')); + expect(smaaAreaTable(), shipped('smaaArea')); }); }); diff --git a/packages/flutter3d_core/test/engine/mirror_view_test.dart b/packages/flutter3d_core/test/engine/mirror_view_test.dart new file mode 100644 index 000000000..099f730bd --- /dev/null +++ b/packages/flutter3d_core/test/engine/mirror_view_test.dart @@ -0,0 +1,104 @@ +/// The arithmetic of `P4`'s mirrored camera: a reflection across a plane, +/// and a projection whose near plane is that plane. +/// +/// Tolerances are float32's: vector_math's matrices store their elements in +/// a `Float32List`. +library; + +import 'package:flutter3d_core/flutter3d_core.dart'; +import 'package:test/test.dart'; +import 'package:vector_math/vector_math.dart'; + +/// [point] in clip space through [projection] after [view]. +Vector4 _clip(Matrix4 projection, Matrix4 view, Vector3 point) => projection + .transform(view.transform(Vector4(point.x, point.y, point.z, 1.0))); + +/// Window depth of [point] through [projection] after [view]. +double _depth(Matrix4 projection, Matrix4 view, Vector3 point) { + final clip = _clip(projection, view, point); + return clip.z / clip.w; +} + +void main() { + test('the reflection mirrors a point across a tilted plane and undoes ' + 'itself', () { + final normal = Vector3(0.3, 1.0, -0.2)..normalize(); + final point = Vector3(1.0, 2.0, -0.5); + final mirror = mirrorAcrossPlane(normal, point); + final p = Vector3(-2.0, 4.0, 3.0); + final reflected = mirror.transform3(p.clone()); + // The two are the same distance either side, along the normal. + // Mutation: dropping the translation reflects across a plane through the + // origin, and the distances disagree by twice the plane's offset. + expect( + normal.dot(reflected - point), + closeTo(-normal.dot(p - point), 1e-5), + ); + expect( + (reflected - p).normalized().cross(normal).length, + closeTo(0.0, 1e-5), + ); + expect(mirror.transform3(reflected).distanceTo(p), closeTo(0.0, 1e-5)); + expect(mirror.determinant(), closeTo(-1.0, 1e-5)); + }); + + group('obliqueNearPlane', () { + final camera = CameraNode() + ..setPositionFrom(Vector3(0.0, 3.0, 5.0)) + ..lookAt(Vector3.zero()); + final normal = Vector3(0.0, 1.0, 0.0); + final mirrored = + camera.viewMatrix * mirrorAcrossPlane(normal, Vector3.zero()) + as Matrix4; + final projection = camera.projection.toMatrix(1.0); + final clipped = obliqueNearPlane( + projection, + planeInEyeSpace(mirrored, normal, Vector3.zero()), + )!; + + test( + 'cuts at the plane: what is below it is in front of the near plane', + () { + // Mutation: the ordinary projection keeps both of these in [0, 1]. + expect(_depth(clipped, mirrored, Vector3(0.0, -1.5, 1.8)), lessThan(0)); + expect( + _depth(clipped, mirrored, Vector3(0.0, -0.01, 0.0)), + lessThan(0), + ); + expect( + _depth(clipped, mirrored, Vector3(0.0, 1.0, 0.0)), + inExclusiveRange(0.0, 1.0), + ); + expect( + _depth(clipped, mirrored, Vector3(2.0, 0.0, -1.0)), + closeTo(0.0, 1e-6), + ); + }, + ); + + test('moves only depth: every point lands on the same pixel', () { + for (final point in [ + Vector3(0.0, 1.0, 0.0), + Vector3(1.5, 0.5, -2.0), + Vector3(-1.0, 2.0, 1.0), + ]) { + final a = _clip(projection, mirrored, point); + final b = _clip(clipped, mirrored, point); + expect(a.x / a.w, closeTo(b.x / b.w, 1e-5)); + expect(a.y / a.w, closeTo(b.y / b.w, 1e-5)); + } + }); + + test('refuses a plane the eye stands on the kept side of', () { + // The ordinary camera, not the mirrored one: it is above the plane, + // which is the side kept, so no near plane can stand on it. + expect( + obliqueNearPlane( + projection, + planeInEyeSpace(camera.viewMatrix, normal, Vector3.zero()), + ), + isNull, + ); + }); + }); +} diff --git a/packages/flutter3d_core/test/engine/splat_quads_test.dart b/packages/flutter3d_core/test/engine/splat_quads_test.dart index 87aca9f85..541cdb47b 100644 --- a/packages/flutter3d_core/test/engine/splat_quads_test.dart +++ b/packages/flutter3d_core/test/engine/splat_quads_test.dart @@ -250,6 +250,35 @@ void main() { expect(quads.sorts, 2, reason: '50 mm is outside it'); }); + test('through an orthographic lens a move does not sort again and a turn ' + 'does — P7', () { + // Depth along a fixed axis changes by the same amount for every splat + // when the camera moves, so no order changes; turning the axis does. + // + // Mutation: keep the distance rule for an orthographic lens — the + // metre's move below re-sorts. + final quads = grid(); + SplatLens lens(Vector3 forward) => + SplatLens(forward: forward, focal: 100.0, depthWeight: 0.0); + void look(Vector3 eye, Vector3 forward, Vector3 right) => quads.build( + eye: eye, + right: right, + up: Vector3(0.0, 1.0, 0.0), + lens: lens(forward), + ); + look(Vector3.zero(), Vector3(0.0, 0.0, -1.0), Vector3(1.0, 0.0, 0.0)); + expect(quads.sorts, 1); + look( + Vector3(1.0, 0.0, 3.0), + Vector3(0.0, 0.0, -1.0), + Vector3(1.0, 0.0, 0.0), + ); + expect(quads.sorts, 1, reason: 'a move along any way changes no order'); + final turned = Vector3(0.3, 0.0, -1.0)..normalize(); + look(Vector3.zero(), turned, Vector3(1.0, 0.0, 0.3)..normalize()); + expect(quads.sorts, 2, reason: 'a turn does'); + }); + test('a threshold of nought sorts on every move', () { final quads = grid()..resortFraction = 0.0; _build(quads); diff --git a/packages/flutter3d_core/test/engine/splat_sort_test.dart b/packages/flutter3d_core/test/engine/splat_sort_test.dart index 409f6f0b2..7969b2cf5 100644 --- a/packages/flutter3d_core/test/engine/splat_sort_test.dart +++ b/packages/flutter3d_core/test/engine/splat_sort_test.dart @@ -171,6 +171,31 @@ void main() { expect(sorter.order.sublist(0, 2), orderedEquals([0, 1])); }); + test('along an axis, depth decides and distance does not — P7', () { + // An orthographic camera's order. The first splat is on the axis five + // metres deep; the second four deep and ten to the side, so it is the + // further by distance and the nearer by depth, and through parallel + // rays it covers the first. + // + // Mutation: ignore [axis] in `SplatSorter.sort` — the second is drawn + // first and the first paints over what should cover it. + final cloud = SplatCloud( + centres: Float32List.fromList([0, 0, -5, 10, 0, -4]), + colours: Float32List(8), + scales: Float32List(6), + rotations: Float32List.fromList([0, 0, 0, 1, 0, 0, 0, 1]), + ); + final sorter = SplatSorter() + ..sort(cloud, Vector3.zero(), axis: Vector3(0.0, 0.0, -1.0)); + expect(sorter.order.sublist(0, 2), orderedEquals([0, 1])); + sorter.sort(cloud, Vector3.zero()); + expect( + sorter.order.sublist(0, 2), + orderedEquals([1, 0]), + reason: 'by distance the order is the other way round', + ); + }); + test('an empty cloud and a one-splat cloud sort without complaint', () { final sorter = SplatSorter()..sort(_randomCloud(0, 1), Vector3.zero()); expect(sorter.lastRange, 0.0); diff --git a/packages/flutter3d_core/test/formats/cube_lut_test.dart b/packages/flutter3d_core/test/formats/cube_lut_test.dart new file mode 100644 index 000000000..8314ec2ae --- /dev/null +++ b/packages/flutter3d_core/test/formats/cube_lut_test.dart @@ -0,0 +1,139 @@ +/// `.cube` colour tables read into the strip the composite grades through — +/// `P2`. +/// +/// dart test test/formats/cube_lut_test.dart +library; + +import 'package:flutter3d_core/flutter3d_core.dart'; +import 'package:flutter3d_hardware/testing.dart'; +import 'package:test/test.dart'; + +/// A 3D table of [size] whose entry is what [map] makes of its input. +String _cube( + int size, + (double, double, double) Function(double r, double g, double b) map, { + String header = '', +}) { + final out = StringBuffer() + ..writeln('# written by a test') + ..writeln('TITLE "test table"') + ..write(header) + ..writeln('LUT_3D_SIZE $size'); + final last = size - 1; + for (var b = 0; b < size; b++) { + for (var g = 0; g < size; g++) { + for (var r = 0; r < size; r++) { + final (x, y, z) = map(r / last, g / last, b / last); + out.writeln('$x $y $z'); + } + } + } + return out.toString(); +} + +void main() { + test('the identity table is the identity strip, byte for byte', () { + // Mutation: run green fastest instead of red in `lookup`, and the strip + // comes out transposed. + final lut = CubeLut.parse(_cube(17, (r, g, b) => (r, g, b))); + expect(lut.title, 'test table'); + expect(lut.size, 17); + expect(lut.toStrip(), buildIdentityLut(size: 17)); + }); + + test('uploads as the strip the composite grades through', () { + final lut = CubeLut.parse(_cube(17, (r, g, b) => (r, g, b))); + final texture = lut.upload(FakeBackend())!; + expect(texture.width, 17 * 17); + expect(texture.height, 17); + }); + + test('a table is resampled into a strip of another size', () { + final lut = CubeLut.parse(_cube(9, (r, g, b) => (r, g, b))); + expect(lut.toStrip(stripSize: 33), buildIdentityLut()); + }); + + test('entries are interpolated between, and red runs fastest', () { + // Swaps red and blue: a channel-order mistake reads as this table being + // the identity. + final lut = CubeLut.parse(_cube(2, (r, g, b) => (b, g, r))); + final (r, g, b) = lut.lookup(0.25, 0.5, 0.75); + expect(r, closeTo(0.75, 1e-6)); + expect(g, closeTo(0.5, 1e-6)); + expect(b, closeTo(0.25, 1e-6)); + }); + + test('a domain wider than the unit cube is mapped onto it', () { + final lut = CubeLut.parse( + _cube( + 2, + (r, g, b) => (r, g, b), + header: 'DOMAIN_MIN 0 0 0\nDOMAIN_MAX 2 2 2\n', + ), + ); + // Input 1 is halfway along a domain of 0…2. + expect(lut.lookup(1.0, 1.0, 1.0).$1, closeTo(0.5, 1e-6)); + // Past the edge is held to it. + expect(lut.lookup(3.0, 3.0, 3.0).$1, closeTo(1.0, 1e-6)); + }); + + test('a 1D table is three curves', () { + const text = ''' +LUT_1D_SIZE 3 +0 0 0 +0.25 0.5 0.75 +1 1 1 +'''; + final lut = CubeLut.parse(text); + expect(lut.isOneD, isTrue); + final (r, g, b) = lut.lookup(0.5, 0.5, 0.5); + expect(r, closeTo(0.25, 1e-6)); + expect(g, closeTo(0.5, 1e-6)); + expect(b, closeTo(0.75, 1e-6)); + expect(lut.toStrip().length, 33 * 33 * 33 * 4); + }); + + group('refuses', () { + test('a table with no size', () { + expect( + () => CubeLut.parse('0 0 0\n1 1 1\n'), + throwsA(isA()), + ); + }); + + test('a table with the wrong number of entries', () { + expect( + () => CubeLut.parse('LUT_3D_SIZE 2\n0 0 0\n1 1 1\n'), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('expected 8 entries'), + ), + ), + ); + }); + + test('a line that is not three numbers, naming it', () { + expect( + () => CubeLut.parse('LUT_1D_SIZE 2\n0 0 0\n1 one 1\n'), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('line 3'), + ), + ), + ); + }); + + test('an empty domain', () { + expect( + () => CubeLut.parse( + 'LUT_1D_SIZE 2\nDOMAIN_MIN 1 1 1\nDOMAIN_MAX 1 1 1\n0 0 0\n1 1 1\n', + ), + throwsA(isA()), + ); + }); + }); +} diff --git a/packages/flutter3d_core/test/formats/ktx2_supercompression_test.dart b/packages/flutter3d_core/test/formats/ktx2_supercompression_test.dart index b63f5dd0a..c44d50901 100644 --- a/packages/flutter3d_core/test/formats/ktx2_supercompression_test.dart +++ b/packages/flutter3d_core/test/formats/ktx2_supercompression_test.dart @@ -157,7 +157,7 @@ void main() { }); test('an uncompressed file is untouched by any of this', () { - // The path seventy-eight goldens and every existing asset take. A level with + // The path ninety-five goldens and every existing asset take. A level with // no supercompression is handed back as a view on the file's own bytes, // with nothing copied and nothing decoded. final file = buildKtx2( diff --git a/packages/flutter3d_core/test/formats/material_language_test.dart b/packages/flutter3d_core/test/formats/material_language_test.dart index 4311b58bd..e86393327 100644 --- a/packages/flutter3d_core/test/formats/material_language_test.dart +++ b/packages/flutter3d_core/test/formats/material_language_test.dart @@ -37,7 +37,9 @@ MaterialSurfaceValues _surface({ List albedo = const [0.5, 0.5, 0.5], double nDotV = 1.0, List texel = const [1, 1, 1, 1], + Map> uniforms = const >{}, }) => MaterialSurfaceValues( + uniforms: uniforms, inputs: >{ 'albedo': albedo, 'alpha': const [1], @@ -264,7 +266,10 @@ void main() { final plain = describeMaterial( parseMaterial('material M { fragment { return vec4(albedo, alpha); } }'), ); - expect(plain.usesAlbedoTexture, isFalse); + // True for every material: `ReadSurface` samples the base colour map for + // the albedo whether or not the source names it, and an unbound map was + // black on Impeller and a refused draw on WebGL2 — `P8`. + expect(plain.usesAlbedoTexture, isTrue); expect(plain.usesMaterialMaps, isFalse); expect(plain.usesMetallic, isFalse); @@ -325,4 +330,222 @@ void main() { throwsA(isA()), ); }); + + group('a uniform — P8', () { + const source = ''' +material Tint { + param float gain = 2.0; + uniform vec3 tint = vec3(1.0, 0.5, 0.25); + fragment { + return vec4(albedo * tint * gain, alpha); + } +} +'''; + + test('is a member of MaterialParams, and a param is still folded', () { + // Mutation: fold a uniform with the params in `_fold`, and the block + // disappears and the default is baked in. + final glsl = emitMaterialFragment( + specialiseMaterial(parseMaterial(source), const MaterialVariant('T')), + ); + expect(glsl, contains('uniform MaterialParams {')); + expect(glsl, contains(' vec3 tint;')); + expect(glsl, contains('material_params.tint')); + expect(glsl, isNot(contains('gain'))); + }); + + test('takes the draw\'s value, and its default without one', () { + final program = specialiseMaterial( + parseMaterial(source), + const MaterialVariant('T'), + ); + expect( + evaluateMaterial( + program, + _surface(albedo: [1, 1, 1]), + ).sublist(0, 3), + [2.0, 1.0, 0.5], + ); + expect( + evaluateMaterial( + program, + _surface( + albedo: [1, 1, 1], + uniforms: >{ + 'tint': [0.0, 1.0, 0.0], + }, + ), + ).sublist(0, 3), + [0.0, 2.0, 0.0], + ); + }); + + test('cannot be set by a variant', () { + expect( + () => specialiseMaterial( + parseMaterial(source), + const MaterialVariant('T', >{ + 'tint': [0, 0, 0], + }), + ), + throwsArgumentError, + ); + }); + + test( + 'says the stage binds Material.parameters, and what they start as', + () { + final bindings = describeMaterial(parseMaterial(source)); + expect(bindings.usesMaterialParameters, isTrue); + expect(bindings.uniforms, >{ + 'tint': [1.0, 0.5, 0.25], + }); + expect( + bindings + .lightingModel(label: 'Tint', shaderName: 'Tint') + .usesMaterialParameters, + isTrue, + ); + expect( + describeMaterial( + parseMaterial( + 'material M { fragment { return vec4(albedo, 1.0); } }', + ), + ).usesMaterialParameters, + isFalse, + ); + }, + ); + + test('cannot take the block\'s own name', () { + expect( + () => parseMaterial( + 'material M { uniform float material_params = 1.0; ' + 'fragment { return vec4(albedo, 1.0); } }', + ), + throwsA(isA()), + ); + }); + }); + + group('a lighting hook — P8', () { + const toon = ''' +material Toon { + light { + let banded = floor(nDotL * 3.0) / 3.0; + return albedo * banded / max(nDotL, 0.001); + } + fragment { + return vec4(lit, alpha); + } +} +'''; + + void refuses(String source, String says) { + expect( + () => parseMaterial(source), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains(says), + ), + ), + reason: source, + ); + } + + test('parses into a light block beside the fragment body', () { + final program = parseMaterial(toon); + expect(program.light, hasLength(2)); + expect(program.inputsUsed, containsAll(['nDotL', 'lit'])); + }); + + test('emits the block as ShadeLight, shadowed, and lit as Lambert adds ' + 'it up', () { + // Mutation: keep the unlit stubs for a material with a light block — + // the engine's loop calls a ShadeLight that ignores the block. + final glsl = emitMaterialFragment( + specialiseMaterial(parseMaterial(toon), const MaterialVariant('Toon')), + ); + expect(glsl, isNot(contains('F3D_NO_LIGHT_LIST'))); + expect(glsl, contains('#include ')); + expect(glsl, contains('vec3 ShadeLight(Surface s, LightSample light) {')); + expect(glsl, contains('light.n_dot_l')); + expect(glsl, contains('return ShadowFactor(s, light, i);')); + expect(glsl, contains('ApplyCommonMaps(s);')); + expect(glsl, contains('vec3 lit = AccumulateLights(s) * s.occlusion')); + }); + + test('binds as a lit model: the maps, the shadows, the light list', () { + // Mutation: leave `usesLightList: lit` out of `describeMaterial` — the + // stage gathers lights from a list nothing bound. + final model = describeMaterial( + parseMaterial(toon), + ).lightingModel(label: 'Toon', shaderName: 'Toon'); + expect(model.usesMaterialMaps, isTrue); + expect(model.usesShadowMap, isTrue); + expect(model.usesLightList, isTrue); + }); + + test('a light input outside the block is refused', () { + refuses( + 'material M { light { return albedo; } ' + 'fragment { return vec4(lit * nDotL, 1.0); } }', + 'is read of one light', + ); + }); + + test('lit inside the block is refused', () { + refuses( + 'material M { light { return lit; } ' + 'fragment { return vec4(lit, 1.0); } }', + 'cannot read it', + ); + }); + + test('lit without a block is refused, where it was read', () { + refuses( + 'material M { fragment { return vec4(lit, 1.0); } }', + 'this material has none', + ); + }); + + test('a block nothing reads is refused', () { + refuses( + 'material M { light { return albedo; } ' + 'fragment { return vec4(albedo, 1.0); } }', + 'the light block is never read', + ); + }); + + test('the block returns a vec3', () { + refuses( + 'material M { light { return vec4(albedo, 1.0); } ' + 'fragment { return vec4(lit, 1.0); } }', + 'a light block returns a vec3', + ); + }); + + test('a let in each body may share a name', () { + final program = parseMaterial( + 'material M { light { let k = nDotL; return albedo * k; } ' + 'fragment { let k = 0.5; return vec4(lit * k, 1.0); } }', + ); + expect(program.light, isNotNull); + }); + }); + + test('an instance\'s numbers are a varying declared only when read — P8', () { + // A fragment input no vertex stage writes is a link error, so a stage + // that does not read `instance` must not declare it. + String emitted(String body) => emitMaterialFragment( + specialiseMaterial( + parseMaterial('material M { fragment { return $body; } }'), + const MaterialVariant('M'), + ), + ); + expect(emitted('vec4(instance.rgb, 1.0)'), contains('in vec4 v_instance;')); + expect(emitted('vec4(albedo, 1.0)'), isNot(contains('v_instance'))); + }); } diff --git a/packages/flutter3d_core/test/geometry/liquid_shapes_test.dart b/packages/flutter3d_core/test/geometry/liquid_shapes_test.dart new file mode 100644 index 000000000..dae3dbeff --- /dev/null +++ b/packages/flutter3d_core/test/geometry/liquid_shapes_test.dart @@ -0,0 +1,116 @@ +import 'dart:math' as math; + +import 'package:flutter3d_core/flutter3d_core.dart'; +import 'package:flutter3d_physics/flutter3d_physics.dart'; +import 'package:test/test.dart'; +import 'package:vector_math/vector_math.dart'; + +/// The volume a closed mesh encloses: the divergence theorem, tetrahedra +/// from the origin to every face. +double _enclosed(MeshData mesh) { + final v = mesh.vertices; + final f = mesh.layout.floatsPerVertex; + Vector3 at(int i) => Vector3(v[i * f], v[i * f + 1], v[i * f + 2]); + var six = 0.0; + for (var t = 0; t < mesh.indices.length; t += 3) { + final a = at(mesh.indices[t]); + final b = at(mesh.indices[t + 1]); + final c = at(mesh.indices[t + 2]); + six += a.dot(b.cross(c)); + } + return six / 6; +} + +LiquidBody _body(VesselShape shape, double volume, {Matrix3? turn}) { + final body = LiquidBody( + shape: shape, + medium: FluidMedium.water, + volume: volume, + modes: 2, + ); + body + ..place(turn ?? Matrix3.identity(), Vector3.zero()) + ..step(1e-4, gravity: Vector3(0, -9.81, 0)) + ..surface.calm(); + return body; +} + +MeshVessel _box(Vector3 size) { + final x = size.x, y = size.y, z = size.z; + final p = [ + for (final yy in [0.0, y]) + for (final (xx, zz) in [(0.0, 0.0), (x, 0.0), (x, z), (0.0, z)]) + Vector3(xx, yy, zz), + ]; + const quads = >[ + [0, 1, 2, 3], [4, 7, 6, 5], + [0, 4, 5, 1], [1, 5, 6, 2], [2, 6, 7, 3], [3, 7, 4, 0], + ]; + return MeshVessel( + positions: p, + indices: [for (final q in quads) ...[q[0], q[1], q[2], q[0], q[2], q[3]]], + rim: [p[4], p[5], p[6], p[7]], + ); +} + +void main() { + final tube = RevolvedVessel([ + Vector2(0, 0), + Vector2(0.008, 0), + Vector2(0.008, 0.1), + ]); + + test('the liquid drawn holds the volume it has, upright and tipped', () { + // Mutation: lift only the cap by the surface and not the wall's edge, + // and the meniscus opens a gap the volume leaks through. + for (final tilt in [0.0, 0.5, 1.2]) { + final volume = math.pi * 0.008 * 0.008 * 0.04; + final body = _body(tube, volume, turn: Matrix3.rotationX(tilt)); + final meshes = liquidMeshes(body); + expect(meshes, hasLength(1)); + expect( + _enclosed(meshes.single.mesh), + closeTo(volume, volume * 0.02), + reason: 'tilt $tilt', + ); + } + }); + + test('so does liquid in a vessel that is any mesh', () { + final box = _box(Vector3(0.04, 0.05, 0.03)); + final body = _body(box, 2e-5, turn: Matrix3.rotationZ(0.4)); + expect(_enclosed(liquidMeshes(body).single.mesh), closeTo(2e-5, 2e-5 * 0.01)); + }); + + test('oil on water is two meshes, each its own volume', () { + final body = _body(tube, 8e-6)..pour(4e-6, medium: FluidMedium.oil); + body + ..place(Matrix3.identity(), Vector3.zero()) + ..step(1e-4, gravity: Vector3(0, -9.81, 0)) + ..surface.calm(); + final meshes = liquidMeshes(body); + expect(meshes.map((m) => m.layer.medium.name), ['water', 'oil']); + expect(_enclosed(meshes[0].mesh), closeTo(8e-6, 8e-6 * 0.02)); + expect(_enclosed(meshes[1].mesh), closeTo(4e-6, 4e-6 * 0.02)); + }); + + test('a stream and particles draw what is there', () { + final jet = Jet(medium: FluidMedium.water, breakupGrowth: 1e9); + for (var i = 0; i < 20; i++) { + jet + ..emit( + flow: 1e-6, + dt: 1e-3, + point: Vector3.zero(), + velocity: Vector3(0.2, 0, 0), + width: 1e-3, + across: Vector3(0, 0, 1), + ) + ..step(1e-3, gravity: Vector3(0, -9.81, 0)); + } + expect(jetMesh(jet).triangleCount, greaterThan(0)); + final dots = particleMesh([Vector3.zero(), Vector3(1, 0, 0)], 0.001); + expect(dots.vertexCount, 24); + expect(_enclosed(dots), greaterThan(0)); + }); +} diff --git a/packages/flutter3d_core/tool/make_tables.dart b/packages/flutter3d_core/tool/make_tables.dart index fde8e8c26..261de511d 100644 --- a/packages/flutter3d_core/tool/make_tables.dart +++ b/packages/flutter3d_core/tool/make_tables.dart @@ -86,6 +86,136 @@ void main() { format: 'r16g16b16a16Float', bytes: ltcTable(), ); + + _write( + 'smaa_area.dart', + name: 'smaaArea', + doc: ''' +/// How much of each pixel along an edge the line behind it covers — `P1`, +/// SMAA 1x's orthogonal area table: 80×80 rgba8, red and green used. +/// +/// An edge is a run of pixels between two ends, and at each end a crossing +/// edge may stand on either side of it or on both. Those two ends pick one +/// of 5×5 cells 16 texels square (only the cells at 0, 1, 3 and 4 are ever +/// read, the codes `round(4·e)` a crossing's bilinear fetch gives), and +/// inside a cell the texel at (i, j) is the pixel `i²` from the run's first +/// end and `j²` from its last: the square root, so a short run gets a texel +/// per pixel and a long one shares them. Red is how much of the pixel on the +/// near side of the edge takes the far side's colour; green, the far +/// pixel's share of the near one. +/// +/// The lines are the ones Jimenez, Echevarria, Sousa and Gutierrez +/// reconstruct in "SMAA: Enhanced Subpixel Morphological Antialiasing", +/// Eurographics 2012, for the sixteen patterns of crossing edges, without +/// the subsample offsets of the 2x and 4x modes and without the diagonal +/// half of their table, which this engine does not search.''', + width: 80, + height: 80, + format: 'r8g8b8a8UNormInt', + bytes: smaaAreaTable(), + ); +} + +/// SMAA's orthogonal area table — `P1`. See the doc written above it. +Uint8List smaaAreaTable() { + const cell = 16; + const size = cell * 5; + // The crossing codes of the two ends, per pattern. A pattern's bits: + // 1 a crossing below the first end, 2 below the last, 4 above the first, + // 8 above the last; below is 3 and above is 1 in a code, both 4. + const codes = <(int, int)>[ + (0, 0), (3, 0), (0, 3), (3, 3), (1, 0), (4, 0), (1, 3), (4, 3), // + (0, 1), (3, 1), (0, 4), (3, 4), (1, 1), (4, 1), (1, 4), (4, 4), + ]; + final table = Uint8List(size * size * 4); + for (var pattern = 0; pattern < 16; pattern++) { + final (first, last) = codes[pattern]; + for (var j = 0; j < cell; j++) { + for (var i = 0; i < cell; i++) { + final (near, far) = smaaOrthogonalArea( + pattern, + (i * i).toDouble(), + (j * j).toDouble(), + ); + final at = ((last * cell + j) * size + first * cell + i) * 4; + table[at] = (near.clamp(0.0, 1.0) * 255.0).round(); + table[at + 1] = (far.clamp(0.0, 1.0) * 255.0).round(); + } + } + } + return table; +} + +/// The area a pixel [left] pixels from a run's first end and [right] from +/// its last keeps of the line [pattern] reconstructs, as (near, far): the +/// share of the pixel on the near side of the edge that the far colour +/// takes, and the far pixel's share of the near colour. One of the two is +/// always nought except where the line crosses the edge inside the pixel. +(double, double) smaaOrthogonalArea(int pattern, double left, double right) { + final d = left + right + 1.0; + // Half a pixel above the edge and half a pixel below it: where a run's + // line starts at an end with a crossing on that side. + const above = 0.5; + const below = -0.5; + (double, double) at((double, double) a, (double, double) b) => + _smaaArea(a, b, left); + (double, double) smooth((double, double) a, (double, double) b) { + // A U turns its corners less sharply the longer it is: the square root + // of the area for a short run, the area itself from 32 pixels on. + final p = (d / 32.0).clamp(0.0, 1.0); + double soften(double v) { + final rounded = math.sqrt(v * 2.0) * 0.5; + return rounded + (v - rounded) * p; + } + + return (soften(a.$1) + soften(b.$1), soften(a.$2) + soften(b.$2)); + } + + final middle = (d / 2.0, 0.0); + return switch (pattern) { + 1 => left <= right ? at((0.0, below), middle) : (0.0, 0.0), + 2 => left >= right ? at(middle, (d, below)) : (0.0, 0.0), + 3 => smooth(at((0.0, below), middle), at(middle, (d, below))), + 4 => left <= right ? at((0.0, above), middle) : (0.0, 0.0), + 6 => at((0.0, above), (d, below)), + 7 => at((0.0, above), (d, below)), + 8 => left >= right ? at(middle, (d, above)) : (0.0, 0.0), + 9 => at((0.0, below), (d, above)), + 11 => at((0.0, below), (d, above)), + 12 => smooth(at((0.0, above), middle), at(middle, (d, above))), + 13 => at((0.0, below), (d, above)), + 14 => at((0.0, above), (d, below)), + // Nothing to smooth: no crossing at all (0), a crossing on both sides of + // one end (5, 10), or of both (15). + _ => (0.0, 0.0), + }; +} + +/// The area the line from [p1] to [p2] keeps of the pixel from [x] to +/// `x + 1` along the edge, signed by side, as SMAA's reference generator +/// computes it. +(double, double) _smaaArea((double, double) p1, (double, double) p2, double x) { + final dx = p2.$1 - p1.$1; + final dy = p2.$2 - p1.$2; + final x1 = x; + final x2 = x + 1.0; + final y1 = p1.$2 + dy * (x1 - p1.$1) / dx; + final y2 = p1.$2 + dy * (x2 - p1.$1) / dx; + final inside = (x1 >= p1.$1 && x1 < p2.$1) || (x2 > p1.$1 && x2 <= p2.$1); + if (!inside) return (0.0, 0.0); + final trapezoid = y1.sign == y2.sign || y1.abs() < 1e-4 || y2.abs() < 1e-4; + if (trapezoid) { + final a = (y1 + y2) / 2.0; + return a < 0.0 ? (a.abs(), 0.0) : (0.0, a.abs()); + } + // The line crosses the edge inside the pixel: two triangles, one on each + // side. + final crossing = -p1.$2 * dx / dy + p1.$1; + final fraction = crossing - crossing.truncateToDouble(); + final a1 = crossing > p1.$1 ? y1 * fraction / 2.0 : 0.0; + final a2 = crossing < p2.$1 ? y2 * (1.0 - fraction) / 2.0 : 0.0; + final a = a1.abs() > a2.abs() ? a1 : -a2; + return a < 0.0 ? (a1.abs(), a2.abs()) : (a2.abs(), a1.abs()); } /// The two published LTC tables, headers dropped, one under the other. diff --git a/packages/flutter3d_cpu/CHANGELOG.md b/packages/flutter3d_cpu/CHANGELOG.md index 6715f67a3..1d16c4c8c 100644 --- a/packages/flutter3d_cpu/CHANGELOG.md +++ b/packages/flutter3d_cpu/CHANGELOG.md @@ -1,3 +1,71 @@ +## Unreleased + +- **`kVInstance`**: a mesh stage's varyings carry an instance's own four + numbers, from slot 1 in `MeshInstancedVertexShader` and nought from every + other; `kMeshVaryings` is twenty-two. + +- The software caustic stages: a refracting caster's faces, the photon + vertex stage with its ray differentials, and the photon quad. +- The software `ShadowTransmittance` reads the base colour map and lets the + transmittance pass one, as the GLSL stage does. +- **`ShadowTransmittance`** and the coloured shadow it feeds, the software + twin of the stage and of the light loop's new term + (`ShadowSettings.translucentCasters`). +- **A bundle built from a `.f3dmat` loads with nothing registered by + hand.** `CpuDevice(materialCompiler:)` compiles each stage the bundle + carries the source of; a reload points the stage already handed out at the + new source, and a source that does not compile leaves the library as it + was. `ensureCpuBackendRegistered` takes the compiler too. + +- **A mask's surviving fragment is opaque**, as the GLSL now writes it. + +- **The orthographic camera in software**: `towardsEye`, `eyeDistance` and + the light shafts' start follow the GLSL. + +- **Debug views in software.** `writeDebugView` mirrors `WriteDebugView` + for every lit model and the impostor, and the composite passes the debug + side of the split through its encode alone, as the GLSL does. + +- **The lens in software.** The composite bends its coordinate as + `composite.frag` does, and `LensFlareShader` mirrors `lens_flare.frag`; + `lens-flare` and `lens-distortion` match Impeller's references to the + pixel. +- **SMAA 1x in software.** `SmaaEdgesShader`, `SmaaWeightsShader` and + `SmaaBlendShader` mirror `smaa_edges.frag`, `smaa_weights.frag` and + `smaa_blend.frag` line for line, so the software backend smooths the same + edges by the same amounts; its `smaa-teapot` reference matches Impeller's + to the pixel. + +**The decal stage, as the GLSL has it.** `DecalShader` mirrors +`post/decal.frag` line for line, the mip level of each picture chosen from +the same footprint the GLSL computes by hand from neighbouring texels of the +surface buffer. + +**`P4`'s two stages.** `PlanarReflectionShader` mirrors +`planar_reflection.frag` and `RenderTextureEncodeShader` mirrors +`render_texture_encode.frag`; `planar_reflection_test.dart` holds ten +claims about both, and the `planar-mirror` and `render-texture` goldens are +in this set. + +**A triangle is clipped to the depth range, as a GPU clips it.** The +rasteriser cut triangles at `w` and nowhere else, so a fragment in front of +the near plane or past the far one was drawn wherever its `w` was positive. +Nothing showed it while every near plane was parallel to the screen and a +little way off the eye; the mirrored camera stands its near plane on the +mirror, and what was below the mirror came up through it here and on no +other backend. A fragment with a window depth outside `[0, 1]` is dropped +now, which is the same cut as clipping the triangle, because that depth is +linear across the screen. No golden of this set moved. + +**`SkyPhysicalShader`**, the software `SkyPhysical`, with the stars' hash +rounded to single precision as the GPU computes it, and **height fog in +`applyFog`**, as `color.glsl` has it. + +- **`PngStripWriter` writes a PNG a strip at a time**, one IDAT chunk per + strip and an empty final deflate block at `close`, keeping only the running + Adler-32 between strips. It refuses rows past the bottom and a `close` with + rows missing. `encodePng` is unchanged, byte for byte. + ## 0.8.2+1 **Resolves on Flutter 3.44 and Dart 3.12.0.** The constraints asked for Dart diff --git a/packages/flutter3d_cpu/lib/src/cpu_backend_registration.dart b/packages/flutter3d_cpu/lib/src/cpu_backend_registration.dart index 7ab9e5dbc..e86719b24 100644 --- a/packages/flutter3d_cpu/lib/src/cpu_backend_registration.dart +++ b/packages/flutter3d_cpu/lib/src/cpu_backend_registration.dart @@ -22,7 +22,11 @@ bool _registered = false; /// A fallback, not a preferred opener: the software rasteriser is where a /// native build lands when nothing faster starts, not a backend a build /// prefers. -void ensureCpuBackendRegistered() { +/// +/// [materialCompiler] is what the device compiles a bundle's material-language +/// stages with — `P8`; `flutter3d_app` hands its own in. The first call +/// decides it, as it decides everything else here. +void ensureCpuBackendRegistered({CpuMaterialCompiler? materialCompiler}) { if (_registered) return; _registered = true; registerBackendOpener( @@ -31,6 +35,7 @@ void ensureCpuBackendRegistered() { width: width, height: height, shaders: CpuShaderLibrary(builtinCpuShaders()), + materialCompiler: materialCompiler, ), asFallback: true, ); diff --git a/packages/flutter3d_cpu/lib/src/cpu_device.dart b/packages/flutter3d_cpu/lib/src/cpu_device.dart index db78b3ca5..076c4f261 100644 --- a/packages/flutter3d_cpu/lib/src/cpu_device.dart +++ b/packages/flutter3d_cpu/lib/src/cpu_device.dart @@ -129,6 +129,7 @@ final class CpuDevice implements GraphicsDevice { this.maxColorAttachments = 3, this.hdrOutputFormats = const [], this.supportsIndependentBlend = true, + this.materialCompiler, }); final int width; @@ -137,12 +138,17 @@ final class CpuDevice implements GraphicsDevice { @override final CpuShaderLibrary shaders; + /// What makes a Dart stage of a material written in the engine's language, + /// for a bundle that carries one — `P8`. Null, the default, refuses such a + /// bundle by name, as it refuses any stage [shaders] does not have. + final CpuMaterialCompiler? materialCompiler; + /// The bundle's names, answered with this device's own Dart stages; a name /// it has no Dart for is a refusal naming the bundle and the stage. See /// [CpuLoadedShaderLibrary]. Nothing is compiled, so nothing is waited for. @override Future loadShaders(ByteData bytes) async => - CpuLoadedShaderLibrary.load(shaders, bytes); + CpuLoadedShaderLibrary.load(shaders, bytes, compiler: materialCompiler); @override // The engine's own convention, and here it is a choice rather than a @@ -194,6 +200,10 @@ final class CpuDevice implements GraphicsDevice { // the contract exists to forbid. bool get supportsWireframe => false; + /// False — `P7`: one sample a pixel, so no coverage to spread. + @override + bool get supportsAlphaToCoverage => false; + @override // Both halves are here now: the chain is stored as whole textures and the // level is chosen from a per-triangle derivative. See `BoundTexture.sample` diff --git a/packages/flutter3d_cpu/lib/src/cpu_encoder.dart b/packages/flutter3d_cpu/lib/src/cpu_encoder.dart index 66097dbd6..72a20181b 100644 --- a/packages/flutter3d_cpu/lib/src/cpu_encoder.dart +++ b/packages/flutter3d_cpu/lib/src/cpu_encoder.dart @@ -181,6 +181,10 @@ final class CpuEncoder implements CommandEncoder { @override void setDepthWrite(bool enabled) => _depthWrite = enabled; + /// Nothing — `P7`: one sample a pixel has no coverage to spread; `supportsAlphaToCoverage` is false. + @override + void setAlphaToCoverage(bool enabled) {} + @override void setDepthCompare(CompareFunction compare) => _depthCompare = compare; @@ -371,7 +375,16 @@ final class CpuEncoder implements CommandEncoder { } @override - void draw({int instanceCount = 1}) { + void draw({int instanceCount = 1, int firstIndex = 0, int? indexCount}) { + // Refused before the first instance rather than inside the loop, so a + // window past the binding draws nothing at all, as on the other three. + final window = _indices == null + ? null + : indexWindow( + _indexCount, + firstIndex: firstIndex, + indexCount: indexCount, + ); // Honestly, and to no advantage — which is the point. A scene the software // backend refuses to draw is a scene with no cross-backend check, and the // two most expensive bugs this repository has found were both found by @@ -383,11 +396,11 @@ final class CpuEncoder implements CommandEncoder { // attributes means on any backend. The instance *index* arrives with the // vertex layouts that give a stage something to read it for. for (var instance = 0; instance < instanceCount; instance++) { - _drawOnce(instance); + _drawOnce(instance, window?.first ?? 0, window?.count ?? 0); } } - void _drawOnce(int instance) { + void _drawOnce(int instance, int firstIndex, int indexCount) { final pipeline = _pipeline; final vertices = _vertices; if (pipeline == null || vertices == null) return; @@ -424,13 +437,13 @@ final class CpuEncoder implements CommandEncoder { final perPrimitive = _primitive == PrimitiveType.line ? 2 : 3; final count = _indices != null - ? _indexCount ~/ perPrimitive + ? indexCount ~/ perPrimitive : _vertexCount ~/ perPrimitive; for (var t = 0; t < count; t++) { for (var corner = 0; corner < perPrimitive; corner++) { final vertex = _indices != null - ? _indexAt(t * perPrimitive + corner) + ? _indexAt(firstIndex + t * perPrimitive + corner) : t * perPrimitive + corner; if (!fetch.into(attributes, vertex)) return; // A stage that wants its own index gets it — see @@ -1066,6 +1079,13 @@ final class CpuEncoder implements CommandEncoder { final b2 = w0 / area; final z = _asStored(sz0 * b0 + sz1 * b1 + sz2 * b2); + // The depth clip every GPU does and the clipper above does not: a + // fragment outside `[0, 1]` is in front of the near plane or past + // the far one. Window depth is linear across the screen, so dropping + // the fragment is the same cut as clipping the triangle. It mattered + // once a near plane stopped being parallel to the screen — `P4`'s + // mirrored camera stands its near plane on the mirror. + if (z < 0.0 || z > 1.0) continue; final index = y * target.width + x; final fate = _fateOf(stencil, stencilState, index, z, depth); final op = _operationFor(fate, stencilState); diff --git a/packages/flutter3d_cpu/lib/src/cpu_png.dart b/packages/flutter3d_cpu/lib/src/cpu_png.dart index 8f71eec7d..85ba6c6ae 100644 --- a/packages/flutter3d_cpu/lib/src/cpu_png.dart +++ b/packages/flutter3d_cpu/lib/src/cpu_png.dart @@ -6,6 +6,7 @@ library; import 'dart:convert'; +import 'dart:math' as math; import 'dart:typed_data'; /// [rgba] as a PNG: 8-bit RGBA, one IDAT, rows from the top. @@ -53,6 +54,127 @@ Uint8List encodePng(Uint8List rgba, int width, int height) { return out.toBytes(); } +/// A PNG written a strip of rows at a time — `N8`'s photo of any size. +/// +/// **One IDAT chunk per strip**, which the format allows outright: the +/// chunks' contents are concatenated into one zlib stream before anything +/// reads them. So each strip becomes stored deflate blocks in a chunk of its +/// own and goes to [sink] as soon as it arrives, and the writer keeps only +/// the running Adler-32 between strips — never the picture. +/// +/// **The last block is not known to be last until [close]**, and deflate +/// marks the final block in its header. Rather than hold back a block on the +/// chance it is the end, every block is written as not-final and [close] +/// adds an empty final block, five bytes, which every decoder reads. +/// +/// Not [encodePng] rewritten on top of this: that one writes a single IDAT, +/// and every golden in the tree was written by it. Byte-identical files are +/// worth more than one fewer function. +final class PngStripWriter { + /// Writes the signature and the header to [sink] at once. + PngStripWriter({ + required this.width, + required this.height, + required this.sink, + }) { + if (width <= 0 || height <= 0 || width > 0x7FFFFFFF ~/ 4) { + throw ArgumentError('A PNG of ${width}x$height cannot be written.'); + } + sink(Uint8List.fromList(const [137, 80, 78, 71, 13, 10, 26, 10])); + final header = ByteData(13) + ..setUint32(0, width) + ..setUint32(4, height) + ..setUint8(8, 8) + ..setUint8(9, 6); + _chunk('IHDR', header.buffer.asUint8List()); + } + + final int width; + final int height; + + /// Where the bytes go, in order. + final void Function(Uint8List bytes) sink; + + int _rowsWritten = 0; + int _adlerA = 1; + int _adlerB = 0; + bool _headerWritten = false; + + /// Adds [rows] rows of RGBA, top first, `width × rows × 4` bytes. + void addRows(Uint8List rgba, int rows) { + if (rgba.length < width * rows * 4) { + throw ArgumentError( + '$rows rows of $width pixels are ${width * rows * 4} bytes, and ' + '${rgba.length} were given.', + ); + } + if (_rowsWritten + rows > height) { + throw StateError( + 'The PNG is $height rows tall; $_rowsWritten are written and $rows ' + 'more would run past the bottom.', + ); + } + if (rows == 0) return; + final stride = width * 4 + 1; + final raw = Uint8List(rows * stride); + for (var y = 0; y < rows; y++) { + raw.setRange(y * stride + 1, (y + 1) * stride, rgba, y * width * 4); + } + for (final byte in raw) { + _adlerA = (_adlerA + byte) % 65521; + _adlerB = (_adlerB + _adlerA) % 65521; + } + final out = BytesBuilder(copy: false); + if (!_headerWritten) { + out.add(const [0x78, 0x01]); + _headerWritten = true; + } + for (var offset = 0; offset < raw.length; offset += 65535) { + final length = math.min(65535, raw.length - offset); + _storedBlock(out, Uint8List.sublistView(raw, offset, offset + length)); + } + _chunk('IDAT', out.toBytes()); + _rowsWritten += rows; + } + + /// Ends the stream and the file. Every row has to be in. + void close() { + if (_rowsWritten != height) { + throw StateError( + 'The PNG is $height rows tall and only $_rowsWritten were added; a ' + 'file closed now would be read as damaged.', + ); + } + final out = BytesBuilder(copy: false); + if (!_headerWritten) out.add(const [0x78, 0x01]); + out.add(const [1, 0, 0, 0xFF, 0xFF]); + out.add( + (ByteData( + 4, + )..setUint32(0, (_adlerB << 16) | _adlerA)).buffer.asUint8List(), + ); + _chunk('IDAT', out.toBytes()); + _chunk('IEND', Uint8List(0)); + } + + static void _storedBlock(BytesBuilder out, Uint8List data) { + final n = 0xFFFF - data.length; + out + ..add([0, data.length & 0xFF, (data.length >> 8) & 0xFF]) + ..add([n & 0xFF, (n >> 8) & 0xFF]) + ..add(data); + } + + void _chunk(String type, Uint8List data) { + final body = Uint8List(data.length + 4) + ..setRange(0, 4, ascii.encode(type)) + ..setRange(4, data.length + 4, data); + sink((ByteData(4)..setUint32(0, data.length)).buffer.asUint8List()); + sink(body); + sink((ByteData(4)..setUint32(0, _crc32(body))).buffer.asUint8List()); + } +} + final List _crcTable = List.generate(256, (n) { var c = n; for (var k = 0; k < 8; k++) { diff --git a/packages/flutter3d_cpu/lib/src/cpu_shader_library.dart b/packages/flutter3d_cpu/lib/src/cpu_shader_library.dart index 5c7604d13..2b829c8f9 100644 --- a/packages/flutter3d_cpu/lib/src/cpu_shader_library.dart +++ b/packages/flutter3d_cpu/lib/src/cpu_shader_library.dart @@ -7,6 +7,7 @@ import 'dart:typed_data'; import 'package:flutter3d_hardware/flutter3d_hardware.dart'; import 'package:flutter3d_shaders/stage_bindings.dart'; import 'package:flutter3d_shaders/uniform_blocks.dart' show uniformBlocks; +import 'package:vector_math/vector_math.dart'; import 'cpu_shader.dart'; @@ -43,41 +44,100 @@ final class CpuShaderLibrary implements ShaderLibrary { /// A bundle loaded from bytes, on the backend that compiles nothing. /// -/// **Only the built-in stages, and a refusal by name for anything else.** A -/// bundle carries compiled sections for the hardware backends and nothing -/// this rasteriser could run; what it does carry is the list of names it -/// claims. So a loaded library here answers each of those names with the Dart -/// stage the device already has — which is what lets the same application -/// code load a bundle on every backend — and a bundle naming a stage this -/// backend has no Dart for is refused at load, naming the stages, rather than -/// accepted and answered with nothing. An application that wants its own look -/// here writes it in Dart and hands it to `CpuDevice.shaders`; see -/// `test/custom_material_test.dart`. +/// **The built-in stages, the material language, and a refusal by name for +/// anything else.** A bundle carries compiled sections for the hardware +/// backends and nothing this rasteriser could run; what it does carry is the +/// list of names it claims and, for a stage written in the engine's material +/// language, that stage's source — `P8`. So a loaded library here answers +/// each name with the Dart stage the device already has, or with what the +/// device's [CpuMaterialCompiler] makes of the source, which is what lets the +/// same application code load a bundle on every backend. A bundle naming a +/// stage neither can answer is refused at load, naming the stages, rather +/// than accepted and answered with nothing. An application that wants its +/// own look here in anything but the language writes it in Dart and hands it +/// to `CpuDevice.shaders`; see `test/custom_material_test.dart`. final class CpuLoadedShaderLibrary implements LoadedShaderLibrary { - CpuLoadedShaderLibrary._(this._own, this._bundle); + CpuLoadedShaderLibrary._(this._own, this._bundle, this._compiler); /// Builds the library, or refuses the bundle by name. - static CpuLoadedShaderLibrary load(ShaderLibrary own, ByteData bytes) { + static CpuLoadedShaderLibrary load( + ShaderLibrary own, + ByteData bytes, { + CpuMaterialCompiler? compiler, + }) { final bundle = ShaderBundle.decode(bytes); - _requireEveryStage(own, bundle); - return CpuLoadedShaderLibrary._(own, bundle); + final library = CpuLoadedShaderLibrary._(own, bundle, compiler); + library._materials.addAll( + library._compile(bundle, const {}), + ); + return library; } - static void _requireEveryStage(ShaderLibrary own, ShaderBundle bundle) { - final missing = bundle.names.where((String n) => own[n] == null).toList(); + /// The material stages [bundle] needs that the device has no Dart for, + /// compiled — a stage of [kept] whose source did not change is kept as it + /// is — or a refusal naming every stage nothing can answer. Nothing is + /// replaced here, so a refused reload leaves the library as it was. + Map _compile( + ShaderBundle bundle, + Map kept, + ) { + final sources = _sources(bundle); + final compiler = _compiler; + final compiled = {}; + final missing = []; + for (final name in bundle.names) { + if (_own[name] != null) continue; + final source = sources[name]; + if (source == null || compiler == null) { + missing.add(name); + continue; + } + final previous = kept[name]; + if (previous != null && previous.source == source) { + compiled[name] = previous; + continue; + } + final CpuFragmentShader stage; + try { + stage = compiler(name, source); + } on Object catch (error) { + throw ShaderBundleRefused( + name: bundle.name, + reason: 'its material "$name" does not compile here: $error', + ); + } + compiled[name] = _MaterialStage(source, stage); + } if (missing.isNotEmpty) { throw ShaderBundleRefused( name: bundle.name, reason: 'the software rasteriser runs Dart stages only and has none for ' '${missing.join(', ')}. Hand a Dart stage of that name to ' - 'CpuDevice.shaders, or leave this backend out of the bundle.', + 'CpuDevice.shaders, write the stage in the material language and ' + 'give the device a materialCompiler, or leave this backend out of ' + 'the bundle.', ); } + return compiled; + } + + static Map _sources(ShaderBundle bundle) { + try { + return decodeMaterialSection(bundle); + } on FormatException catch (error) { + throw ShaderBundleRefused(name: bundle.name, reason: error.message); + } } final ShaderLibrary _own; ShaderBundle _bundle; + final CpuMaterialCompiler? _compiler; + + /// The material stages, by name. A reload replaces what each runs, not the + /// stage itself, so a handle already handed out runs the new source. + final Map _materials = {}; + final Map _materialHandles = {}; /// Every name answered with a handle so far — what a reload may not drop. /// @@ -89,12 +149,20 @@ final class CpuLoadedShaderLibrary implements LoadedShaderLibrary { @override String get name => _bundle.name; - /// The device's own handle for a name the bundle claims, so identity is - /// shared with the built-in library and survives any reload for free. + /// The device's own handle for a built-in name the bundle claims, so + /// identity is shared with the built-in library and survives any reload + /// for free; and for a material stage, one handle for the library's life. @override ShaderHandle? operator [](String name) { if (!_bundle.names.contains(name)) return null; - final handle = _own[name]; + final material = _materials[name]; + final handle = material == null + ? _own[name] + : _materialHandles.putIfAbsent( + name, + () => + ShaderHandle(backend: CpuStage.fragment(material), name: name), + ); if (handle != null) _handedOut.add(name); return handle; } @@ -104,7 +172,7 @@ final class CpuLoadedShaderLibrary implements LoadedShaderLibrary { final bundle = ShaderBundle.decode(bytes); // Both checked before anything is replaced: a refused reload leaves the // library as it was, which is the contract. - _requireEveryStage(_own, bundle); + final compiled = _compile(bundle, _materials); final dropped = _handedOut .where((String n) => !bundle.names.contains(n)) .toList(); @@ -118,9 +186,43 @@ final class CpuLoadedShaderLibrary implements LoadedShaderLibrary { ); } _bundle = bundle; + for (final MapEntry(key: name, value: stage) in compiled.entries) { + final existing = _materials[name]; + if (existing == null) { + _materials[name] = stage; + } else if (!identical(existing, stage)) { + existing + ..source = stage.source + ..inner = stage.inner; + } + } } } +/// Turns a stage written in the engine's material language into a Dart stage +/// — `P8`: [stage] is its name in the bundle, [source] its text from the +/// bundle's `ShaderBundle.materialSection`. +/// +/// Handed in rather than built in, because the language lives in +/// `flutter3d_core` and this package does not depend on it; +/// `flutter3d_testing`'s `materialLanguageCompiler` is one that does. Throws +/// to refuse a source, and the throw becomes the bundle's refusal. +typedef CpuMaterialCompiler = + CpuFragmentShader Function(String stage, String source); + +/// A compiled material stage a reload can point at new code under the handle +/// already handed out. +final class _MaterialStage implements CpuFragmentShader { + _MaterialStage(this.source, this.inner); + + String source; + CpuFragmentShader inner; + + @override + Vector4? run(Float32List v, ShaderBindings bindings, FragmentContext c) => + inner.run(v, bindings, c); +} + /// A vertex and a fragment stage, paired. final class CpuPipeline { const CpuPipeline(this.vertex, this.fragment, this.layout); diff --git a/packages/flutter3d_cpu/lib/src/cpu_shaders_builtin.dart b/packages/flutter3d_cpu/lib/src/cpu_shaders_builtin.dart index df140fd70..21ef2eece 100644 --- a/packages/flutter3d_cpu/lib/src/cpu_shaders_builtin.dart +++ b/packages/flutter3d_cpu/lib/src/cpu_shaders_builtin.dart @@ -51,13 +51,16 @@ import 'cpu_shaders_bloom.dart'; import 'cpu_shaders_compute.dart'; import 'cpu_shaders_contact_shadow.dart'; import 'cpu_shaders_debug.dart'; +import 'cpu_shaders_decal.dart'; import 'cpu_shaders_evsm.dart'; import 'cpu_shaders_impostor.dart'; import 'cpu_shaders_irradiance.dart'; +import 'cpu_shaders_lens.dart'; import 'cpu_shaders_lit.dart'; import 'cpu_shaders_mesh_vertex.dart'; import 'cpu_shaders_motion_blur.dart'; import 'cpu_shaders_particles.dart'; +import 'cpu_shaders_planar.dart'; import 'cpu_shaders_polyline.dart'; import 'cpu_shaders_post.dart'; import 'cpu_shaders_probe.dart'; @@ -65,6 +68,7 @@ import 'cpu_shaders_reactive.dart'; import 'cpu_shaders_reflections.dart'; import 'cpu_shaders_shadow_passes.dart'; import 'cpu_shaders_sky.dart'; +import 'cpu_shaders_smaa.dart'; import 'cpu_shaders_ssao.dart'; import 'cpu_shaders_temporal.dart'; import 'cpu_shaders_velocity.dart'; @@ -75,13 +79,16 @@ export 'cpu_shaders_bloom.dart'; export 'cpu_shaders_color.dart'; export 'cpu_shaders_contact_shadow.dart'; export 'cpu_shaders_debug.dart'; +export 'cpu_shaders_decal.dart'; export 'cpu_shaders_irradiance.dart'; export 'cpu_shaders_layout.dart'; +export 'cpu_shaders_lens.dart'; export 'cpu_shaders_lighting.dart'; export 'cpu_shaders_lit.dart'; export 'cpu_shaders_mesh_vertex.dart'; export 'cpu_shaders_motion_blur.dart'; export 'cpu_shaders_particles.dart'; +export 'cpu_shaders_planar.dart'; export 'cpu_shaders_post.dart'; export 'cpu_shaders_probe.dart'; export 'cpu_shaders_reactive.dart'; @@ -90,6 +97,7 @@ export 'cpu_shaders_shadow_directional.dart'; export 'cpu_shaders_shadow_passes.dart'; export 'cpu_shaders_shadow_point.dart'; export 'cpu_shaders_sky.dart'; +export 'cpu_shaders_smaa.dart'; export 'cpu_shaders_ssao.dart'; export 'cpu_shaders_surface.dart'; export 'cpu_shaders_temporal.dart'; @@ -143,6 +151,7 @@ Map builtinCpuShaders() { 'FullscreenVertex': const CpuStage.vertex(FullscreenVertexShader()), 'Unlit': const CpuStage.fragment(UnlitShader()), 'Xray': const CpuStage.fragment(XrayShader()), + 'PlanarReflection': const CpuStage.fragment(PlanarReflectionShader()), 'Lambert': const CpuStage.fragment(LambertShader()), 'BlinnPhong': const CpuStage.fragment(BlinnPhongShader()), 'Pbr': const CpuStage.fragment(PbrShader()), @@ -222,22 +231,32 @@ Map builtinCpuShaders() { 'VelocityNeighborMax': const CpuStage.fragment(VelocityNeighborMaxShader()), 'MotionBlur': const CpuStage.fragment(MotionBlurShader()), 'ViewportShade': const CpuStage.fragment(ViewportShadeShader()), + 'Decal': const CpuStage.fragment(DecalShader()), 'ShadowDepthMasked': const CpuStage.fragment(ShadowDepthMaskedShader()), 'ShadowDistanceMasked': const CpuStage.fragment( ShadowDistanceMaskedShader(), ), 'ProbePrefilter': const CpuStage.fragment(ProbePrefilterShader()), + 'RenderTextureEncode': const CpuStage.fragment(RenderTextureEncodeShader()), 'MrtProbe': const CpuStage.fragment(MrtProbeShader()), 'WboitResolve': const CpuStage.fragment(WboitResolveShader()), 'SceneColourCopy': const CpuStage.fragment(SceneColourCopyShader()), 'Composite': const CpuStage.fragment(CompositeShader()), 'Easu': const CpuStage.fragment(EasuShader()), 'Fxaa': const CpuStage.fragment(FxaaShader()), + 'SmaaEdges': const CpuStage.fragment(SmaaEdgesShader()), + 'SmaaWeights': const CpuStage.fragment(SmaaWeightsShader()), + 'SmaaBlend': const CpuStage.fragment(SmaaBlendShader()), + 'LensFlare': const CpuStage.fragment(LensFlareShader()), 'LocalExposure': const CpuStage.fragment(LocalExposureShader()), 'LocalExposureBlur': const CpuStage.fragment(LocalExposureBlurShader()), 'ShadowDepth': const CpuStage.fragment(ShadowDepthShader()), 'ShadowDistance': const CpuStage.fragment(ShadowDistanceShader()), 'ShadowCopy': const CpuStage.fragment(ShadowCopyShader()), + 'ShadowTransmittance': const CpuStage.fragment(ShadowTransmittanceShader()), + 'CausticSurface': const CpuStage.fragment(CausticSurfaceShader()), + 'CausticPhotonVertex': const CpuStage.vertex(CausticPhotonVertexShader()), + 'CausticPhoton': const CpuStage.fragment(CausticPhotonShader()), 'EvsmFilter': const CpuStage.fragment(EvsmFilterShader()), 'ShadowTileReset': const CpuStage.fragment(ShadowTileResetShader()), 'ShadowTileResetVertex': const CpuStage.vertex( @@ -247,6 +266,10 @@ Map builtinCpuShaders() { 'SkyCubeVertex': const CpuStage.vertex(SkyCubeVertexShader()), 'Sky': const CpuStage.fragment(SkyShader()), 'SkyCube': const CpuStage.fragment(SkyCubeShader()), + // `P5`. The same pass-through vertex stage as the gradient's: see + // [SkyPhysicalShader] for why one class answers to both names. + 'SkyPhysicalVertex': const CpuStage.vertex(SkyVertexShader()), + 'SkyPhysical': const CpuStage.fragment(SkyPhysicalShader()), }; for (final name in kUnimplementedCpuVertexShaders) { diff --git a/packages/flutter3d_cpu/lib/src/cpu_shaders_color.dart b/packages/flutter3d_cpu/lib/src/cpu_shaders_color.dart index 763520771..6b26cb9d6 100644 --- a/packages/flutter3d_cpu/lib/src/cpu_shaders_color.dart +++ b/packages/flutter3d_cpu/lib/src/cpu_shaders_color.dart @@ -68,11 +68,20 @@ Vector3 decodeOctahedral(double ex, double ey) { return n..normalize(); } +/// `Orthographic` — `P7`: whether `FogInfo.projection.x` says the camera +/// projects orthographically. False where the block is not bound, which is +/// what the GLSL's `F3D_NO_FOG` stages are. +bool orthographic(ShaderBindings b) => + b.vec4('FogInfo', 'projection', Vector4.zero()).x > 0.5; + /// `EyeDistance`: how far this fragment is from the camera, in world metres. /// /// What the fog fades by. A distance, because fog is a property of the air -/// between two points and does not care which way the camera faces. +/// between two points and does not care which way the camera faces — through +/// a perspective lens. Through an orthographic one, the depth from the eye's +/// plane, as the GLSL has it. double eyeDistance(Float32List v, ShaderBindings b) { + if (orthographic(b)) return math.max(viewDepth(v, b), 0.0); final eye = b.vec4('FogInfo', 'eye', Vector4.zero()); final dx = v[kVWorld] - eye.x; final dy = v[kVWorld + 1] - eye.y; @@ -80,6 +89,22 @@ double eyeDistance(Float32List v, ShaderBindings b) { return math.sqrt(dx * dx + dy * dy + dz * dz); } +/// `TowardsEye` — `P7`: back along the ray that reached this fragment, to +/// the eye through a perspective lens and against the view axis through an +/// orthographic one. +Vector3 towardsEye(Float32List v, ShaderBindings b) { + if (orthographic(b)) { + final forward = b.vec4('FogInfo', 'forward', Vector4.zero()); + return Vector3(-forward.x, -forward.y, -forward.z); + } + final eye = b.vec4('FogInfo', 'eye', Vector4.zero()); + return Vector3( + eye.x - v[kVWorld], + eye.y - v[kVWorld + 1], + eye.z - v[kVWorld + 2], + )..normalize(); +} + /// `ViewDepth`: how far this fragment is along the view axis, in world metres. /// /// What the surface buffer's alpha holds. A depth rather than a distance, and @@ -192,11 +217,22 @@ void writeSurface( /// has a visible plane where it starts. The early return at zero density is /// not an optimisation — it is what keeps a scene with no fog byte-identical, /// which is what the golden sets are recorded against. +/// +/// A height fog (`P5`) is the same fog with its density averaged over the ray +/// in closed form, from the falloff in `eye.w`; nought there skips it, as the +/// GLSL does, for the same reason the early return is there. Vector3 applyFog(Vector3 colour, Float32List v, ShaderBindings b) { final fog = b.vec4('FogInfo', 'fog', Vector4.zero()); - final density = fog.w; + var density = fog.w; if (density <= 0.0) return colour; + final falloff = b.vec4('FogInfo', 'eye', Vector4.zero()).w; + if (falloff > 0.0) { + final eyeY = b.vec4('FogInfo', 'eye', Vector4.zero()).y; + final k = falloff * (v[kVWorld + 1] - eyeY); + density *= k.abs() > 1e-3 ? (1.0 - math.exp(-k)) / k : 1.0 - 0.5 * k; + } + final t = math.exp(-density * eyeDistance(v, b)).clamp(0.0, 1.0); return Vector3( @@ -224,6 +260,7 @@ Vector4 writeLit( required double alpha, required Vector3 normal, required double roughness, + double passThrough = 0.0, }) { writeSurface(c, v, b, normal, roughness); // `g_premultiply`: a blended material's colour is weighted by its alpha, @@ -234,7 +271,14 @@ Vector4 writeLit( c, v, b, - Vector4(fogged.x * weight, fogged.y * weight, fogged.z * weight, alpha), + // `g_pass_through`: a thin pane lets that share of what is behind it + // through the blend. + Vector4( + fogged.x * weight, + fogged.y * weight, + fogged.z * weight, + alpha * (1.0 - passThrough), + ), ); } diff --git a/packages/flutter3d_cpu/lib/src/cpu_shaders_decal.dart b/packages/flutter3d_cpu/lib/src/cpu_shaders_decal.dart new file mode 100644 index 000000000..b34912ad6 --- /dev/null +++ b/packages/flutter3d_cpu/lib/src/cpu_shaders_decal.dart @@ -0,0 +1,208 @@ +/// `decal.frag` on the software rasteriser — `P3`. +/// +/// Line for line, the order of the stack included: the GLSL paints each decal +/// over the ones before it, and so does this. See the GLSL for why the light +/// is read back through the albedo and why one stage writes two terms. +library; + +import 'dart:math' as math; +import 'dart:typed_data'; + +import 'package:vector_math/vector_math.dart'; + +import 'cpu_shader.dart'; +import 'cpu_shaders_color.dart' show decodeOctahedral, pixelRay, toLinear; + +/// `kMaxDecals` in `decal.frag`. +const int _maxDecals = 16; + +/// `kAlbedoFloor` in `decal.frag`. +const double _albedoFloor = 0.08; + +/// `decal.frag`: the factor a decal multiplies the scene by, or the term it +/// adds, by `params.w`. +final class DecalShader implements CpuFragmentShader { + const DecalShader(); + + @override + Vector4? run(Float32List v, ShaderBindings b, FragmentContext c) { + final params = b.vec4('DecalInfo', 'params', Vector4.zero()); + final factor = params.w < 0.5; + final keep = factor + ? Vector4(1.0, 1.0, 1.0, 1.0) + : Vector4(0.0, 0.0, 0.0, 1.0); + final surfaceMap = b.textures['surface_texture']; + final albedoMap = b.textures['albedo_texture']; + if (surfaceMap == null || albedoMap == null) return keep; + + final u = v[0]; + final w = v[1]; + final surface = surfaceMap.sample(u, w); + if (surface.w <= 0.0) return keep; + + final inverse = b.mat4('DecalInfo', 'inverse_view_projection'); + final camera = b.vec4('DecalInfo', 'camera', Vector4.zero()); + final forward = b.vec4('DecalInfo', 'forward', Vector4.zero()); + final view = b.vec4('DecalInfo', 'view', Vector4(0.0, 0.0, 1.0, 1.0)); + final eye = Vector3(camera.x, camera.y, camera.z); + final axis = Vector3(forward.x, forward.y, forward.z); + + // `WorldAt`, with the target's coordinates taken into the view's. + Vector3 worldAt(double atU, double atV, double depth) { + final (:origin, :along) = pixelRay( + inverse, + (atU - view.x) / view.z, + (atV - view.y) / view.w, + ); + return origin + + along * ((depth - (origin - eye).dot(axis)) / along.dot(axis)); + } + + final depth = surface.w; + final at = worldAt(u, w, depth); + final normal = decodeOctahedral(surface.x, surface.y); + + // `WorldStep`: toward the neighbour nearer in depth. + Vector3 worldStep(double du, double dv) { + final ahead = surfaceMap.sample(u + du, w + dv); + final behind = surfaceMap.sample(u - du, w - dv); + final aheadGap = ahead.w > 0.0 ? (ahead.w - depth).abs() : 1e30; + final behindGap = behind.w > 0.0 ? (behind.w - depth).abs() : 1e30; + if (math.min(aheadGap, behindGap) >= 1e29) return Vector3.zero(); + return aheadGap <= behindGap + ? worldAt(u + du, w + dv, ahead.w) - at + : at - worldAt(u - du, w - dv, behind.w); + } + + final dx = worldStep(params.y, 0.0); + final dy = worldStep(0.0, params.z); + final albedoTexel = albedoMap.sample(u, w); + final albedo = Vector3( + toLinear(albedoTexel.x), + toLinear(albedoTexel.y), + toLinear(albedoTexel.z), + ); + + final painted = albedo.clone(); + var kept = 1.0; + final laid = Vector3.zero(); + final emitted = Vector3.zero(); + final count = (params.x + 0.5).floor(); + for (var i = 0; i < _maxDecals && i < count; i++) { + final rowX = b.vec4('DecalInfo', 'axis_x', Vector4.zero(), at: i); + final rowY = b.vec4('DecalInfo', 'axis_y', Vector4.zero(), at: i); + final rowZ = b.vec4('DecalInfo', 'axis_z', Vector4.zero(), at: i); + double row(Vector4 r, Vector3 p) => r.x * p.x + r.y * p.y + r.z * p.z; + final lx = row(rowX, at) + rowX.w; + final ly = row(rowY, at) + rowY.w; + final lz = row(rowZ, at) + rowZ.w; + if (lx.abs() > 0.5 || ly.abs() > 0.5 || lz.abs() > 0.5) continue; + + final fade = b.vec4('DecalInfo', 'fade', Vector4.zero(), at: i); + final up = Vector3(rowY.x, rowY.y, rowY.z)..normalize(); + final facing = normal.dot(up); + final byAngle = ((facing - fade.y) / math.max(fade.z, 1e-4)).clamp( + 0.0, + 1.0, + ); + final byDepth = fade.w > 0.0 + ? ((0.5 - ly.abs()) / (fade.w * 0.5)).clamp(0.0, 1.0) + : 1.0; + + final region = b.vec4('DecalInfo', 'region', Vector4.zero(), at: i); + final pu = region.x + (lx + 0.5) * region.z; + final pv = region.y + (lz + 0.5) * region.w; + + final slot = fade.x; + final alongXu = row(rowX, dx) * region.z; + final alongXv = row(rowZ, dx) * region.w; + final alongYu = row(rowX, dy) * region.z; + final alongYv = row(rowZ, dy) * region.w; + + final picture = _sampleSlot( + b, + slot, + pu, + pv, + // The sampler picks the level from the larger axis in texels, which + // is the footprint the GLSL computes by hand. + du: math.max(alongXu.abs(), alongYu.abs()), + dv: math.max(alongXv.abs(), alongYv.abs()), + ); + final tint = b.vec4('DecalInfo', 'color', Vector4.zero(), at: i); + final colour = Vector3( + toLinear(picture.x) * tint.x, + toLinear(picture.y) * tint.y, + toLinear(picture.z) * tint.z, + ); + final alpha = (picture.w * tint.w * byAngle * byDepth).clamp(0.0, 1.0); + final glow = b.vec4('DecalInfo', 'emissive', Vector4.zero(), at: i); + + _mixInto(painted, colour, alpha); + kept *= 1.0 - alpha; + _mixInto(laid, colour, alpha); + _mixInto( + emitted, + Vector3(colour.x * glow.x, colour.y * glow.y, colour.z * glow.z), + alpha, + ); + } + + final named = + (math.max(albedo.x, math.max(albedo.y, albedo.z)) / _albedoFloor).clamp( + 0.0, + 1.0, + ); + double swap(double paint, double was) => + 1.0 + (paint - was) / math.max(was, _albedoFloor); + if (factor) { + final unlit = kept * (1.0 - named); + return Vector4( + swap(painted.x, albedo.x) * named + unlit, + swap(painted.y, albedo.y) * named + unlit, + swap(painted.z, albedo.z) * named + unlit, + 1.0, + ); + } + return Vector4( + laid.x * (1.0 - named) + emitted.x, + laid.y * (1.0 - named) + emitted.y, + laid.z * (1.0 - named) + emitted.z, + 1.0, + ); + } + + /// `mix(into, colour, alpha)`, in place. + static void _mixInto(Vector3 into, Vector3 colour, double alpha) { + into + ..x += (colour.x - into.x) * alpha + ..y += (colour.y - into.y) * alpha + ..z += (colour.z - into.z) * alpha; + } + + /// `SampleSlot`: the picture in [slot], or white for none. + static Vector4 _sampleSlot( + ShaderBindings b, + double slot, + double u, + double v, { + required double du, + required double dv, + }) { + final index = slot > 2.5 + ? 3 + : slot > 1.5 + ? 2 + : slot > 0.5 + ? 1 + : slot > -0.5 + ? 0 + : -1; + final BoundTexture? picture = index < 0 + ? null + : b.textures['decal_texture_$index']; + if (picture == null) return Vector4(1.0, 1.0, 1.0, 1.0); + // The texture's own size is the one the GLSL reads out of `slots`. + return picture.sample(u, v, du: du, dv: dv); + } +} diff --git a/packages/flutter3d_cpu/lib/src/cpu_shaders_impostor.dart b/packages/flutter3d_cpu/lib/src/cpu_shaders_impostor.dart index 377fcf029..00d98b236 100644 --- a/packages/flutter3d_cpu/lib/src/cpu_shaders_impostor.dart +++ b/packages/flutter3d_cpu/lib/src/cpu_shaders_impostor.dart @@ -126,6 +126,9 @@ final class ImpostorVertexShader implements CpuVertexShaderByIndex { out[kVColour + 3] = a[kColour + 3]; out[kVLightmap] = 0.0; out[kVLightmap + 1] = 0.0; + for (var i = 0; i < 4; i++) { + out[kVInstance + i] = 0.0; + } return mvp * Vector4(corner.x, corner.y, corner.z, 1.0); } @@ -254,6 +257,8 @@ final class ImpostorShader implements CpuFragmentShader { // on a back face as every lit stage turns it. final geometric = worldFacing.clone(); if (!c.frontFacing) geometric.negate(); + final debug = writeDebugView(c, v, b, s, total, geometric: geometric); + if (debug != null) return debug; return writeLit( c, v, diff --git a/packages/flutter3d_cpu/lib/src/cpu_shaders_layout.dart b/packages/flutter3d_cpu/lib/src/cpu_shaders_layout.dart index b29fbfb29..c5473c3ed 100644 --- a/packages/flutter3d_cpu/lib/src/cpu_shaders_layout.dart +++ b/packages/flutter3d_cpu/lib/src/cpu_shaders_layout.dart @@ -25,7 +25,10 @@ const int kVTangent = 12; // vec4 /// Where the fragment is in the level's lightmap; zero from every stage but /// the lightmapped one, which reads it out of the colour attribute. const int kVLightmap = 16; // vec2 -const int kMeshVaryings = 18; +/// An instance's own four numbers — `P8`; nought from every stage but the +/// instanced one, which reads them out of slot 1. +const int kVInstance = 18; // vec4 +const int kMeshVaryings = 22; /// Lights per frame, from `kMaxLights` in `surface.glsl`. const int kMaxLights = 8; diff --git a/packages/flutter3d_cpu/lib/src/cpu_shaders_lens.dart b/packages/flutter3d_cpu/lib/src/cpu_shaders_lens.dart new file mode 100644 index 000000000..f88e98218 --- /dev/null +++ b/packages/flutter3d_cpu/lib/src/cpu_shaders_lens.dart @@ -0,0 +1,74 @@ +/// `lens_flare.frag`: the ghosts and the halo a lens throws from what is +/// bright in the frame, added to the glow — `P2`, line for line. +library; + +import 'dart:math' as math; +import 'dart:typed_data'; + +import 'package:vector_math/vector_math.dart'; + +import 'cpu_shader.dart'; + +/// `lens_flare.frag`. +final class LensFlareShader implements CpuFragmentShader { + const LensFlareShader(); + + /// `Falloff`: all of a reflection in the middle, nothing at the corners. + static double _falloff(double u, double w, double power) { + final dx = 0.5 - u; + final dy = 0.5 - w; + final d = math.sqrt(dx * dx + dy * dy) / 0.70710678; + return math.pow(math.max(1.0 - d, 0.0), power).toDouble(); + } + + @override + Vector4? run(Float32List v, ShaderBindings b, FragmentContext c) { + final bloom = b.textures['bloom_texture']; + if (bloom == null) return Vector4(0.0, 0.0, 0.0, 1.0); + final params = b.vec4('LensFlareInfo', 'params', Vector4.zero()); + final more = b.vec4('LensFlareInfo', 'more', Vector4.zero()); + + // `Fringed`: the glow at (u, w), its channels taken [spread] apart. + Vector3 fringed(double u, double w, double ax, double ay, double spread) => + Vector3( + bloom.sample(u + ax * spread, w + ay * spread).x, + bloom.sample(u, w).y, + bloom.sample(u - ax * spread, w - ay * spread).z, + ); + + final glow = bloom.sample(v[0], v[1]); + final flippedU = 1.0 - v[0]; + final flippedW = 1.0 - v[1]; + final towardU = (0.5 - flippedU) * params.z; + final towardW = (0.5 - flippedW) * params.z; + final reach = math.sqrt(towardU * towardU + towardW * towardW); + final alongU = reach > 1e-6 ? towardU / reach : 0.0; + final alongW = reach > 1e-6 ? towardW / reach : 0.0; + final spread = more.x; + + final flare = Vector3.zero(); + final ghosts = (params.y + 0.5).floor(); + for (var i = 0; i < 8; i++) { + if (i >= ghosts) break; + final u = flippedU + towardU * i; + final w = flippedW + towardW * i; + flare.add(fringed(u, w, alongU, alongW, spread) * _falloff(u, w, 10.0)); + } + + final aspect = math.max(more.z, 1e-4); + final haloU = flippedU + alongU / aspect * params.w; + final haloW = flippedW + alongW * params.w; + flare.add( + fringed(haloU, haloW, alongU, alongW, spread) * + (_falloff(haloU, haloW, 5.0) * more.y), + ); + + final intensity = params.x; + return Vector4( + glow.x + flare.x * intensity, + glow.y + flare.y * intensity, + glow.z + flare.z * intensity, + 1.0, + ); + } +} diff --git a/packages/flutter3d_cpu/lib/src/cpu_shaders_lighting.dart b/packages/flutter3d_cpu/lib/src/cpu_shaders_lighting.dart index 1ccd787a5..bf71f30fa 100644 --- a/packages/flutter3d_cpu/lib/src/cpu_shaders_lighting.dart +++ b/packages/flutter3d_cpu/lib/src/cpu_shaders_lighting.dart @@ -421,6 +421,7 @@ Vector3 accumulateLights( // has six rows and the slot table eight entries, so a light from the list // has no row to read and asking would index past the table. var visibility = 1.0; + shadowTransmittance.setValues(1.0, 1.0, 1.0); if (i < kMaxLights) { visibility = shadowed ? shadowFactor(s, b, i, light.nDotL, c) : 1.0; visibility *= pointShadowFactor(b, s.world, s.normal, i, c); @@ -428,9 +429,9 @@ Vector3 accumulateLights( if (visibility <= 0.0) continue; final response = shade(s, light); total += Vector3( - response.x * light.radiance.x, - response.y * light.radiance.y, - response.z * light.radiance.z, + response.x * light.radiance.x * shadowTransmittance.x, + response.y * light.radiance.y * shadowTransmittance.y, + response.z * light.radiance.z * shadowTransmittance.z, )..scale(light.nDotL * visibility); } return total; diff --git a/packages/flutter3d_cpu/lib/src/cpu_shaders_lit.dart b/packages/flutter3d_cpu/lib/src/cpu_shaders_lit.dart index 63a94098c..533c8d6b2 100644 --- a/packages/flutter3d_cpu/lib/src/cpu_shaders_lit.dart +++ b/packages/flutter3d_cpu/lib/src/cpu_shaders_lit.dart @@ -25,6 +25,8 @@ final class UnlitShader implements CpuFragmentShader { // `L5`: reflects no light, as `unlit.frag` says. c.albedo = null; if (s == null) return null; + final debug = writeDebugView(c, v, bindings, s, s.albedo); + if (debug != null) return debug; // Fully rough, which is what WriteSurface's one-argument form means: a // surface that cannot say how polished it is should not be reflected off. return writeLit( @@ -99,6 +101,8 @@ final class LambertShader implements CpuFragmentShader { (s.albedo.clone()..multiply(s.ambient + sampleLightmap(v, b, c))) .scaled(s.occlusion); final total = lit + ambient + s.emissive; + final debug = writeDebugView(c, v, b, s, total); + if (debug != null) return debug; return writeLit( c, v, @@ -147,6 +151,8 @@ final class BlinnPhongShader implements CpuFragmentShader { (s.albedo.clone()..multiply(s.ambient + sampleLightmap(v, b, c))) .scaled(s.occlusion); final total = lit + ambient + s.emissive; + final debug = writeDebugView(c, v, b, s, total); + if (debug != null) return debug; return writeLit( c, v, @@ -230,7 +236,21 @@ final class _Layers { final transmission = b.vec4('LayerInfo', 'transmission', Vector4.zero()); final attenuation = b.vec4('LayerInfo', 'attenuation', Vector4(1, 1, 1, 0)); final film = b.vec4('LayerInfo', 'iridescence', Vector4.zero()); - final thickness = math.max(transmission.y * coatTexel.w, 0.0); + var thickness = math.max(transmission.y * coatTexel.w, 0.0); + // `MaterialExtensions.convexVolume` — `ReadLayers` has the reasons. + if (attenuation.w > 0.5 && thickness > 0.0) { + final index = coat.z == 0.0 ? 1.0e4 : math.max(coat.z, 1.0); + final incident = -s.view; + final d = s.normal.dot(incident); + final k = 1.0 - (1.0 - d * d) / (index * index); + final bent = k < 0.0 + ? Vector3.zero() + : incident / index - s.normal * (d / index + math.sqrt(k)); + thickness = math.max( + thickness * math.max(-s.normal.dot(bent), 0.0), + 1e-4 * thickness, + ); + } // Beer's law over the thickness, as `ReadLayers` takes it. final distance = transmission.z; double through(double colour) => distance > 0.0 @@ -801,10 +821,19 @@ final class PbrShader implements CpuFragmentShader { final sceneBound = layers != null && b.vec4('LayerInfo', 'scene_colour', Vector4.zero()).x > 0.0; + // `g_pane`: a blended, transmitting surface with no volume lets the + // target through the blend rather than reading what is behind it. + final pane = + layers != null && + premultiplies(b) && + layers.transmission > 0.0 && + layers.thickness <= 0.0; if (layers != null) { // `M3`: without an environment, the flat ambient passes through too — // unless the scene behind is there to be read. - final (tr, tg, tb) = sceneBound ? (0.0, 0.0, 0.0) : layers.transmittance; + final (tr, tg, tb) = sceneBound || pane + ? (0.0, 0.0, 0.0) + : layers.transmittance; final t = layers.transmission; ambient.multiply( Vector3( @@ -895,7 +924,7 @@ final class PbrShader implements CpuFragmentShader { layers.withFilm(layers.f0Dielectric) * ab.x + Vector3.all(layers.f90 * ab.y); final (tr, tg, tb) = layers.transmittance; - final passed = sceneBound + final passed = sceneBound || pane ? Vector3.zero() : (layers.transmitted(s, environment, levels)..multiply( Vector3( @@ -938,27 +967,31 @@ final class PbrShader implements CpuFragmentShader { ..scale(strength); } } - if (sceneBound) { + var passThrough = 0.0; + if (sceneBound || pane) { // `M3`: the scene behind, less what the dielectric reflects and the // medium takes, tinted by the base colour — light already, so neither - // the ambient strength nor the occlusion scales it. + // the ambient strength nor the occlusion scales it. A pane hands the + // mean of that share to the blend instead. final ab = _envBrdf(b, s.roughness, s.nDotV); final reflects = layers.withFilm(layers.f0Dielectric) * ab.x + Vector3.all(layers.f90 * ab.y); final (tr, tg, tb) = layers.transmittance; - ambient.add( - (diffuseColour.clone()..multiply( - layers.sceneBehind(s, v, b)..multiply( + final passes = + (diffuseColour.clone()..multiply( Vector3( tr * (1.0 - math.min(reflects.x, 1.0)), tg * (1.0 - math.min(reflects.y, 1.0)), tb * (1.0 - math.min(reflects.z, 1.0)), ), - ), - )) - .scaled(layers.transmission), - ); + )) + .scaled(layers.transmission); + if (pane) { + passThrough = ((passes.x + passes.y + passes.z) / 3.0).clamp(0.0, 1.0); + } else { + ambient.add(layers.sceneBehind(s, v, b)..multiply(passes)); + } } // The baked bounce light, diffuse only, as `pbr.frag` adds it. ambient += (diffuseColour.clone()..multiply(sampleLightmap(v, b, c))) @@ -978,6 +1011,8 @@ final class PbrShader implements CpuFragmentShader { .scaled(through) + coatAmbient, }; + final debug = writeDebugView(c, v, b, s, total, transformed: layered); + if (debug != null) return debug; return writeLit( c, v, @@ -986,6 +1021,7 @@ final class PbrShader implements CpuFragmentShader { alpha: s.alpha, normal: s.normal, roughness: s.roughness, + passThrough: passThrough, ); } } @@ -1125,6 +1161,8 @@ final class ToonShader implements CpuFragmentShader { (s.albedo.clone()..multiply(s.ambient + sampleLightmap(v, b, c))) .scaled(s.occlusion); final total = lit + ambient + Vector3.all(rim) + s.emissive; + final debug = writeDebugView(c, v, b, s, total); + if (debug != null) return debug; return writeLit( c, v, diff --git a/packages/flutter3d_cpu/lib/src/cpu_shaders_mesh_vertex.dart b/packages/flutter3d_cpu/lib/src/cpu_shaders_mesh_vertex.dart index 14ccebb53..7a8c47503 100644 --- a/packages/flutter3d_cpu/lib/src/cpu_shaders_mesh_vertex.dart +++ b/packages/flutter3d_cpu/lib/src/cpu_shaders_mesh_vertex.dart @@ -94,6 +94,9 @@ final class MeshVertexShader implements CpuVertexShaderByIndex { } out[kVLightmap] = 0.0; out[kVLightmap + 1] = 0.0; + for (var i = 0; i < 4; i++) { + out[kVInstance + i] = 0.0; + } // The tangent transforms with the model matrix, not the normal matrix: it // lies *in* the surface, so it stretches with the geometry rather than @@ -150,6 +153,9 @@ final class MeshLightmappedVertexShader implements CpuVertexShaderByIndex { final clip = _plain.runAt(vertexIndex, instanceIndex, a, bindings, out); out[kVLightmap] = a[kColour]; out[kVLightmap + 1] = a[kColour + 1]; + for (var i = 0; i < 4; i++) { + out[kVInstance + i] = 0.0; + } for (var i = 0; i < 4; i++) { out[kVColour + i] = 1.0; } @@ -176,6 +182,7 @@ final class MeshInstancedVertexShader implements CpuVertexShaderByIndex { static const int _row1 = 20; static const int _row2 = 24; static const int _colour = 28; + static const int _data = 32; @override int get varyingCount => kMeshVaryings; @@ -265,6 +272,9 @@ final class MeshInstancedVertexShader implements CpuVertexShaderByIndex { } out[kVLightmap] = 0.0; out[kVLightmap + 1] = 0.0; + for (var i = 0; i < 4; i++) { + out[kVInstance + i] = a[_data + i]; + } final Vector3 t = model.getRotation() * @@ -395,6 +405,9 @@ final class MeshSkinnedVertexShader implements CpuVertexShaderByIndex { } out[kVLightmap] = 0.0; out[kVLightmap + 1] = 0.0; + for (var i = 0; i < 4; i++) { + out[kVInstance + i] = 0.0; + } final Vector3 t = model.getRotation() * diff --git a/packages/flutter3d_cpu/lib/src/cpu_shaders_particles.dart b/packages/flutter3d_cpu/lib/src/cpu_shaders_particles.dart index eba9d57c4..c3668b394 100644 --- a/packages/flutter3d_cpu/lib/src/cpu_shaders_particles.dart +++ b/packages/flutter3d_cpu/lib/src/cpu_shaders_particles.dart @@ -42,6 +42,31 @@ double softParticleFade( return stored > 0.0 ? fade : 1.0; } +/// `FogDistance` of `contributor_eye.glsl`: the air between a point and the +/// eye — the distance to it, or the depth from its plane through an +/// orthographic lens. +double fogDistance(ShaderBindings b, double x, double y, double z) { + final eye = b.vec4('FogInfo', 'eye', Vector4.zero()); + final dx = x - eye.x; + final dy = y - eye.y; + final dz = z - eye.z; + if (!orthographic(b)) return math.sqrt(dx * dx + dy * dy + dz * dz); + final forward = b.vec4('FogInfo', 'forward', Vector4.zero()); + return math.max(dx * forward.x + dy * forward.y + dz * forward.z, 0.0); +} + +/// `TowardsEyeFrom` of `contributor_eye.glsl`: the unit direction back along +/// the ray that reached a point, nought at the eye. +Vector3 towardsEyeFrom(ShaderBindings b, double x, double y, double z) { + if (orthographic(b)) { + final forward = b.vec4('FogInfo', 'forward', Vector4.zero()); + return Vector3(-forward.x, -forward.y, -forward.z); + } + final eye = b.vec4('FogInfo', 'eye', Vector4.zero()); + final to = Vector3(eye.x - x, eye.y - y, eye.z - z); + return to.length2 > 0.0 ? (to..normalize()) : to; +} + /// `particle.vert`: a billboard corner, and the world position for the fog. final class ParticleVertexShader implements CpuVertexShader { const ParticleVertexShader(); @@ -100,9 +125,7 @@ final class ParticleTexturedShader implements CpuFragmentShader { var fogged = 1.0; final fog = b.vec4('FogInfo', 'fog', Vector4.zero()); if (fog.w > 0.0) { - final eye = b.vec4('FogInfo', 'eye', Vector4.zero()); - final d = - (Vector3(v[6], v[7], v[8]) - Vector3(eye.x, eye.y, eye.z)).length; + final d = fogDistance(b, v[6], v[7], v[8]); fogged = math.exp(-fog.w * d).clamp(0.0, 1.0); } @@ -192,8 +215,7 @@ final class ParticleSixWayShader implements CpuFragmentShader { // covers what is behind it. final fog = b.vec4('FogInfo', 'fog', Vector4.zero()); if (fog.w > 0.0) { - final eye = b.vec4('FogInfo', 'eye', Vector4.zero()); - final d = (world - Vector3(eye.x, eye.y, eye.z)).length; + final d = fogDistance(b, world.x, world.y, world.z); final fogged = math.exp(-fog.w * d).clamp(0.0, 1.0); colour ..x = fog.x + (colour.x - fog.x) * fogged @@ -261,21 +283,18 @@ final class ParticleMeshShader implements CpuFragmentShader { @override Vector4? run(Float32List v, ShaderBindings b, FragmentContext c) { final fog = b.vec4('FogInfo', 'fog', Vector4.zero()); - final eye = b.vec4('FogInfo', 'eye', Vector4.zero()); - - final tx = eye.x - v[4]; - final ty = eye.y - v[5]; - final tz = eye.z - v[6]; - final distance = math.sqrt(tx * tx + ty * ty + tz * tz); + // `TowardsEyeFrom`: to the eye, or against the view axis through an + // orthographic lens — `P7`. + final towards = towardsEyeFrom(b, v[4], v[5], v[6]); var facing = 1.0; - if (distance > 0.0) { + if (towards.length2 > 0.0) { final n = Vector3(v[7], v[8], v[9]); final length = n.length; if (length > 0.0) { // `abs`, because nothing here is culled and a back face is as visible // as a front one. - facing = ((n.x * tx + n.y * ty + n.z * tz) / (length * distance)).abs(); + facing = (n.dot(towards) / length).abs(); } } // Never to zero: a silhouette edge is exactly perpendicular to the eye, and @@ -284,7 +303,9 @@ final class ParticleMeshShader implements CpuFragmentShader { var fogged = 1.0; if (fog.w > 0.0) { - fogged = math.exp(-fog.w * distance).clamp(0.0, 1.0); + fogged = math + .exp(-fog.w * fogDistance(b, v[4], v[5], v[6])) + .clamp(0.0, 1.0); } final scale = v[3] * intensity * fogged; @@ -313,9 +334,7 @@ final class ParticleShader implements CpuFragmentShader { var fogged = 1.0; final fog = b.vec4('FogInfo', 'fog', Vector4.zero()); if (fog.w > 0.0) { - final eye = b.vec4('FogInfo', 'eye', Vector4.zero()); - final d = - (Vector3(v[6], v[7], v[8]) - Vector3(eye.x, eye.y, eye.z)).length; + final d = fogDistance(b, v[6], v[7], v[8]); fogged = math.exp(-fog.w * d).clamp(0.0, 1.0); } @@ -352,9 +371,7 @@ final class SplatShader implements CpuFragmentShader { var bl = v[2]; final fog = b.vec4('FogInfo', 'fog', Vector4.zero()); if (fog.w > 0.0) { - final eye = b.vec4('FogInfo', 'eye', Vector4.zero()); - final d = - (Vector3(v[6], v[7], v[8]) - Vector3(eye.x, eye.y, eye.z)).length; + final d = fogDistance(b, v[6], v[7], v[8]); final visibility = math.exp(-fog.w * d).clamp(0.0, 1.0); // A mix rather than an attenuation, which is the opposite of the particle // stage and for the opposite reason: this is blended, so a splat that @@ -429,10 +446,7 @@ final class SplatHashedShader implements CpuFragmentShader { final fog = b.vec4('FogInfo', 'fog', Vector4.zero()); if (fog.w <= 0.0) return Vector4(v[0], v[1], v[2], 1.0); - final fogEye = b.vec4('FogInfo', 'eye', Vector4.zero()); - final d = - (Vector3(v[6], v[7], v[8]) - Vector3(fogEye.x, fogEye.y, fogEye.z)) - .length; + final d = fogDistance(b, v[6], v[7], v[8]); final visibility = math.exp(-fog.w * d).clamp(0.0, 1.0); return Vector4( fog.x + (v[0] - fog.x) * visibility, diff --git a/packages/flutter3d_cpu/lib/src/cpu_shaders_planar.dart b/packages/flutter3d_cpu/lib/src/cpu_shaders_planar.dart new file mode 100644 index 000000000..0dcddfdc1 --- /dev/null +++ b/packages/flutter3d_cpu/lib/src/cpu_shaders_planar.dart @@ -0,0 +1,78 @@ +/// `P4`'s two stages: `planar_reflection.frag`, which lays a mirrored picture +/// over a reflector's surface, and `render_texture_encode.frag`, which turns +/// what a camera into a texture saw into sRGB bytes. +library; + +import 'dart:math' as math; +import 'dart:typed_data'; + +import 'package:vector_math/vector_math.dart'; + +import 'cpu_shader.dart'; +import 'cpu_shaders_color.dart'; +import 'cpu_shaders_layout.dart'; + +/// `planar_reflection.frag`: the texel of the reflection under this pixel, +/// by its place in the view, weighted by Schlick's Fresnel and fogged. +/// +/// Writes no surface: the GLSL declares `F3D_NO_SURFACE_BUFFER`, and the +/// surface underneath keeps describing itself, as under `XrayShader`. +final class PlanarReflectionShader implements CpuFragmentShader { + const PlanarReflectionShader(); + + @override + Vector4? run(Float32List v, ShaderBindings b, FragmentContext c) { + final picture = b.textures['reflection_texture']; + if (picture == null) return Vector4.zero(); + final view = b.vec4('PlanarReflectionInfo', 'view', Vector4(0, 0, 1, 1)); + final params = b.vec4('PlanarReflectionInfo', 'params', Vector4.zero()); + final tint = b.vec4('PlanarReflectionInfo', 'tint', Vector4.zero()); + + // `FragCoordFromTop`, and the texture's rows turned back where its first + // row is the bottom of the picture. + final rows = params.z; + final pixelY = rows > 0.0 ? rows - c.coord.y : c.coord.y; + final u = (c.coord.x - view.x) / view.z; + final top = (pixelY - view.y) / view.w; + final texel = picture.sample(u, rows > 0.0 ? 1.0 - top : top); + final reflected = Vector3( + texel.x * tint.x, + texel.y * tint.y, + texel.z * tint.z, + ); + + final normal = Vector3(v[kVNormal], v[kVNormal + 1], v[kVNormal + 2]) + ..normalize(); + if (!c.frontFacing) normal.negate(); + final toEye = towardsEye(v, b); + final cosine = normal.dot(toEye).clamp(0.0, 1.0); + final f0 = params.x; + final grazing = 1.0 - cosine; + final grazing2 = grazing * grazing; + final fresnel = f0 + (1.0 - f0) * grazing2 * grazing2 * grazing; + final alpha = (fresnel * params.y).clamp(0.0, 1.0); + + final fogged = applyFog(reflected, v, b); + return Vector4(fogged.x * alpha, fogged.y * alpha, fogged.z * alpha, alpha); + } +} + +/// `render_texture_encode.frag`: the light times the exposure, clipped to +/// one and encoded as sRGB. +final class RenderTextureEncodeShader implements CpuFragmentShader { + const RenderTextureEncodeShader(); + + @override + Vector4? run(Float32List v, ShaderBindings b, FragmentContext c) { + final source = b.textures['source_texture']; + if (source == null) return Vector4(0.0, 0.0, 0.0, 1.0); + final params = b.vec4('RenderTextureInfo', 'params', Vector4.zero()); + final exposure = params.x; + // Turned over where the backend draws its first row at the bottom; this + // one draws it at the top and never is. + final light = source.sample(v[0], params.y > 0.5 ? 1.0 - v[1] : v[1]); + double encode(double channel) => + toSrgb(math.min(math.max(channel * exposure, 0.0), 1.0)); + return Vector4(encode(light.x), encode(light.y), encode(light.z), 1.0); + } +} diff --git a/packages/flutter3d_cpu/lib/src/cpu_shaders_polyline.dart b/packages/flutter3d_cpu/lib/src/cpu_shaders_polyline.dart index 040360f45..56cb891a8 100644 --- a/packages/flutter3d_cpu/lib/src/cpu_shaders_polyline.dart +++ b/packages/flutter3d_cpu/lib/src/cpu_shaders_polyline.dart @@ -119,6 +119,9 @@ final class PolylineVertexShader implements CpuVertexShaderByIndex { out[kVTangent + 3] = 1.0; out[kVLightmap] = 0.0; out[kVLightmap + 1] = 0.0; + for (var i = 0; i < 4; i++) { + out[kVInstance + i] = 0.0; + } return Vector4( here.x + offsetX / (viewportX * 0.5) * here.w, diff --git a/packages/flutter3d_cpu/lib/src/cpu_shaders_post.dart b/packages/flutter3d_cpu/lib/src/cpu_shaders_post.dart index 5b2aeda28..f40d08e6b 100644 --- a/packages/flutter3d_cpu/lib/src/cpu_shaders_post.dart +++ b/packages/flutter3d_cpu/lib/src/cpu_shaders_post.dart @@ -41,6 +41,24 @@ final class FullscreenVertexShader implements CpuVertexShader { final class CompositeShader implements CpuFragmentShader { const CompositeShader(); + /// `Distort` in `composite.frag`: radially by `1 + k·r²` on the frame's + /// aspect, held on the border — at the corners for a barrel, at the + /// nearest edge for a pincushion. + static (double, double) _distort( + double u, + double w, + double k, + double aspect, + ) { + final a = math.max(aspect, 1e-4); + final dx = u - 0.5; + final dy = w - 0.5; + final r2 = dx * a * dx * a + dy * dy; + final border = k > 0.0 ? 0.25 * (a * a + 1.0) : 0.25 * math.min(a * a, 1.0); + final scale = (1.0 + k * r2) / (1.0 + k * border); + return (0.5 + dx * scale, 0.5 + dy * scale); + } + @override Vector4? run(Float32List v, ShaderBindings bindings, FragmentContext c) { final scene = bindings.textures['scene_texture']; @@ -62,15 +80,35 @@ final class CompositeShader implements CpuFragmentShader { Vector4(0.0, 0.0, 0.0, 1.0), ); + // `P2`: the lens's own bend, before anything is read, so everything laid + // over the scene bends with it — `Distort` in `composite.frag`. Nought + // leaves the coordinate the very value it was. + final lens = bindings.vec4('CompositeInfo', 'lens', Vector4.zero()); + // `P6`: right of a debug view's split the scene holds display values, + // and only the encode touches them. A return here where the GLSL decides + // at its end: this rasteriser has no uniform control flow to keep. + if (lens.y > 0.5 && v[0] >= lens.z) { + final raw = scene.sample(v[0], v[1]); + return Vector4( + toSrgb(raw.x.clamp(0.0, 1.0)), + toSrgb(raw.y.clamp(0.0, 1.0)), + toSrgb(raw.z.clamp(0.0, 1.0)), + raw.w, + ); + } + final (u, w) = lens.x != 0.0 + ? _distort(v[0], v[1], lens.x, lookMore.w) + : (v[0], v[1]); + // Dispersion at sampling, because that is where a lens does it — see the // note in `composite.frag`, which this mirrors operation for operation. - final sampled = scene.sample(v[0], v[1]); + final sampled = scene.sample(u, w); var colour = Vector3(sampled.x, sampled.y, sampled.z); if (look.w > 0.0) { - final ox = (v[0] - 0.5) * look.w; - final oy = (v[1] - 0.5) * look.w; - colour.x = scene.sample(v[0] + ox, v[1] + oy).x; - colour.z = scene.sample(v[0] - ox, v[1] - oy).z; + final ox = (u - 0.5) * look.w; + final oy = (w - 0.5) * look.w; + colour.x = scene.sample(u + ox, w + oy).x; + colour.z = scene.sample(u - ox, w - oy).z; } // Occlusion first, then the glow — the order matters and it is the order @@ -97,7 +135,7 @@ final class CompositeShader implements CpuFragmentShader { final stops = bindings.textures['local_exposure_texture']; if (stops != null) { colour.scale( - math.pow(2.0, stops.sample(v[0], v[1]).x * contactInfo.w).toDouble(), + math.pow(2.0, stops.sample(u, w).x * contactInfo.w).toDouble(), ); } } @@ -108,10 +146,10 @@ final class CompositeShader implements CpuFragmentShader { final texel = bindings.vec4('CompositeInfo', 'ao_texel', Vector4.zero()); final hx = texel.x * 0.5; final hy = texel.y * 0.5; - final t0 = ao.sample(v[0] + hx, v[1] + hy); - final t1 = ao.sample(v[0] - hx, v[1] + hy); - final t2 = ao.sample(v[0] + hx, v[1] - hy); - final t3 = ao.sample(v[0] - hx, v[1] - hy); + final t0 = ao.sample(u + hx, w + hy); + final t1 = ao.sample(u - hx, w + hy); + final t2 = ao.sample(u + hx, w - hy); + final t3 = ao.sample(u - hx, w - hy); // The share left open is in a; the occlusion methods write it into // every channel. final occlusion = 0.25 * (t0.w + t1.w + t2.w + t3.w); @@ -136,13 +174,13 @@ final class CompositeShader implements CpuFragmentShader { final contactMap = bindings.textures['contact_shadow_texture']; final contactStrength = contactInfo.x.clamp(0.0, 1.0); if (contactMap != null && contactStrength > 0.0) { - final contact = contactMap.sample(v[0], v[1]).x; + final contact = contactMap.sample(u, w).x; shade *= 1.0 + (contact - 1.0) * contactStrength; } // Skipped at exactly one, which is what both settings off comes to: a // multiply by one is exact, so this is a shortcut rather than a difference, - // and it keeps the frames seventy-eight goldens hold untouched by arithmetic + // and it keeps the frames ninety-five goldens hold untouched by arithmetic // they never used to go through. if (shade != 1.0) colour.scale(shade); @@ -151,7 +189,7 @@ final class CompositeShader implements CpuFragmentShader { // make here either. final bloom = bindings.textures['bloom_texture']; if (bloom != null) { - final b = bloom.sample(v[0], v[1]); + final b = bloom.sample(u, w); colour += Vector3(b.x, b.y, b.z) * params.y; } if (bounced != null) colour += bounced; diff --git a/packages/flutter3d_cpu/lib/src/cpu_shaders_reflections.dart b/packages/flutter3d_cpu/lib/src/cpu_shaders_reflections.dart index 7dedc3679..e31f9af42 100644 --- a/packages/flutter3d_cpu/lib/src/cpu_shaders_reflections.dart +++ b/packages/flutter3d_cpu/lib/src/cpu_shaders_reflections.dart @@ -258,13 +258,16 @@ final class LightShaftsShader implements CpuFragmentShader { final ndcY = 1.0 - v[1] * 2.0; final nearH = inverse.transformed(Vector4(ndcX, ndcY, 0.0, 1.0)); final farH = inverse.transformed(Vector4(ndcX, ndcY, 1.0, 1.0)); - final origin = Vector3(nearH.x, nearH.y, nearH.z)..scale(1.0 / nearH.w); + final nearPoint = Vector3(nearH.x, nearH.y, nearH.z)..scale(1.0 / nearH.w); final farPoint = Vector3(farH.x, farH.y, farH.z)..scale(1.0 / farH.w); - final along = (farPoint - origin)..normalize(); + final along = (farPoint - nearPoint)..normalize(); final axis = Vector3(forward.x, forward.y, forward.z); - final surfaceDepth = surfaceTexture.sample(v[0], v[1]).w; final cosine = math.max(along.dot(axis), 1e-4); + // From the eye's plane, not the near plane — `P7`, as the GLSL has it. + final eye = Vector3(camera.x, camera.y, camera.z); + final origin = nearPoint - along * ((nearPoint - eye).dot(axis) / cosine); + final surfaceDepth = surfaceTexture.sample(v[0], v[1]).w; final toSurface = surfaceDepth > 0.0 ? surfaceDepth / cosine : 1e9; final distance = math.min(camera.w, toSurface); if (distance <= 0.0) return scene; @@ -314,7 +317,6 @@ final class LightShaftsShader implements CpuFragmentShader { final sigma = math.max(scatter.w, 0.0); final stepTransmittance = math.exp(-sigma * stride); var transmittance = math.exp(-sigma * offset); - final eye = Vector3(camera.x, camera.y, camera.z); var inscatter = 0.0; for (var i = 0; i < steps && i < 64; i++) { final travelled = offset + i * stride; diff --git a/packages/flutter3d_cpu/lib/src/cpu_shaders_shadow_directional.dart b/packages/flutter3d_cpu/lib/src/cpu_shaders_shadow_directional.dart index c5b1173f6..c0b3aece7 100644 --- a/packages/flutter3d_cpu/lib/src/cpu_shaders_shadow_directional.dart +++ b/packages/flutter3d_cpu/lib/src/cpu_shaders_shadow_directional.dart @@ -12,6 +12,7 @@ import 'dart:math' as math; import 'package:vector_math/vector_math.dart'; import 'cpu_shader.dart'; +import 'cpu_shaders_color.dart' show orthographic; import 'cpu_shaders_evsm.dart'; import 'cpu_shaders_surface.dart'; @@ -24,6 +25,13 @@ import 'cpu_shaders_surface.dart'; /// [lightNDotL] is the light's `n_dot_l`, which `ShadowFactor` is handed with /// the light and no longer reads: the offset measures the slope along the /// map's own axes instead. +/// What the see-through casters between the sun and the fragment last given +/// to [shadowFactor] let through — `light_transmittance` in `surface.glsl`, +/// and the same arrangement: set as a side effect, white whenever the call +/// does not reach the atlas, read by `accumulateLights` beside the +/// visibility. +final Vector3 shadowTransmittance = Vector3.all(1.0); + double shadowFactor( Surface s, ShaderBindings b, @@ -31,6 +39,7 @@ double shadowFactor( double lightNDotL, [ FragmentContext? c, ]) { + shadowTransmittance.setValues(1.0, 1.0, 1.0); final params = b.vec4('FragInfo', 'shadow_params', Vector4.zero()); final strength = params.w; if (strength <= 0.0) return 1.0; @@ -54,7 +63,18 @@ double shadowFactor( final cascades = b.vec4('FragInfo', 'shadow_cascades', Vector4(0, 0, 1, 0)); final count = (cascades.z + 0.5).floor().clamp(1, 3); final camera = b.vec4('FragInfo', 'camera_position', Vector4.zero()); - final viewDistance = (s.world - Vector3(camera.x, camera.y, camera.z)).length; + // `P7`: by depth along the axis through an orthographic lens. + final double viewDistance; + if (orthographic(b)) { + final eye = b.vec4('FogInfo', 'eye', Vector4.zero()); + final forward = b.vec4('FogInfo', 'forward', Vector4.zero()); + viewDistance = + (s.world.x - eye.x) * forward.x + + (s.world.y - eye.y) * forward.y + + (s.world.z - eye.z) * forward.z; + } else { + viewDistance = (s.world - Vector3(camera.x, camera.y, camera.z)).length; + } var cascade = 0; if (count > 1 && viewDistance > cascades.x) cascade = 1; if (count > 2 && viewDistance > cascades.y) cascade = 2; @@ -159,7 +179,8 @@ double shadowFactor( if (projected == null) return 1.0; // Each cascade's own bias — `shadow_bias` in `surface.glsl`. - final bias = b.vec4('FragInfo', 'shadow_bias', Vector4.zero())[cascadeIndex]; + final biases = b.vec4('FragInfo', 'shadow_bias', Vector4.zero()); + final bias = biases[cascadeIndex]; // Horizontally a texel of the atlas, vertically a texel of a tile. final texelU = params.x; final texelV = cascades.w > 0.0 ? cascades.w : params.x; @@ -171,6 +192,20 @@ double shadowFactor( double tap(double du, double dv) => map.sample((u + du).clamp(loU, hiU), (vv + dv).clamp(loV, hiV)).x; + // What the see-through casters let through, before the filter, whose soft + // path leaves early — `ShadowSettings.translucentCasters`; `shadow.glsl` + // has the layout. + if (biases.w > 0.5) { + final stored = map.sample(u.clamp(loU, hiU), vv.clamp(loV, hiV)); + final k = strength.clamp(0.0, 1.0); + double through(double value) => 1.0 + (value.clamp(0.0, 4.0) - 1.0) * k; + shadowTransmittance.setValues( + through(1.0 - stored.y), + through(1.0 - stored.z), + through(stored.w), + ); + } + // `gfx-15n`: the directional light's apparent size, riding in // `ambient_ground.w` for the reason `surface.glsl` gives. Zero is the 3×3 // kernel this has always had. diff --git a/packages/flutter3d_cpu/lib/src/cpu_shaders_shadow_passes.dart b/packages/flutter3d_cpu/lib/src/cpu_shaders_shadow_passes.dart index 04cbf243a..5c66a2c54 100644 --- a/packages/flutter3d_cpu/lib/src/cpu_shaders_shadow_passes.dart +++ b/packages/flutter3d_cpu/lib/src/cpu_shaders_shadow_passes.dart @@ -94,6 +94,53 @@ final class ShadowDistanceMaskedShader implements CpuFragmentShader { } } +/// `shadow_transmittance.frag`: what a see-through caster lets through to +/// the sun — `ShadowSettings.translucentCasters`. +/// +/// Green and blue are what it takes from red and green, alpha what it leaves +/// of blue, red nought so the depth beneath survives the blend; the GLSL +/// stage has the reasons, and this is the same arithmetic. +final class ShadowTransmittanceShader implements CpuFragmentShader { + const ShadowTransmittanceShader(); + + @override + Vector4? run(Float32List v, ShaderBindings bindings, FragmentContext c) { + final colour = bindings.vec4('TransmittanceInfo', 'color', Vector4.zero()); + final light = bindings.vec4('TransmittanceInfo', 'light', Vector4.zero()); + final params = bindings.vec4('TransmittanceInfo', 'params', Vector4.zero()); + final opacity = colour.w.clamp(0.0, 1.0); + final transmission = params.x.clamp(0.0, 1.0); + final map = bindings.textures['base_color_texture']; + final texel = map?.sample(v[kVUv], v[kVUv + 1]) ?? Vector4.all(1.0); + double body(double channel, double mapped) => + (1.0 - opacity) + + opacity * transmission * math.max(channel, 0.0) * mapped; + + final normal = Vector3(v[kVNormal], v[kVNormal + 1], v[kVNormal + 2]); + final length = normal.length; + final facing = length > 0.0 + ? (normal.dot(Vector3(light.x, light.y, light.z)) / length).abs() + : 1.0; + final f0 = params.y.clamp(0.0, 1.0); + final fresnel = + f0 + (1.0 - f0) * math.pow(1.0 - facing.clamp(0.0, 1.0), 5.0); + // What is reflected is lost only as far as it is turned away: 2 cos²θ + // of it, held to one — `shadow_transmittance.frag` has the reasons. + final lost = fresnel * math.min(2.0 * facing * facing, 1.0); + // A caster whose photons are followed stops all of its light here. + final stops = params.z > 0.5; + double through(double channel, double mapped) => stops + ? 0.0 + : math.sqrt(math.max(body(channel, mapped), 0.0)) * (1.0 - lost); + return Vector4( + 0.0, + 1.0 - through(colour.x, texel.x), + 1.0 - through(colour.y, texel.y), + through(colour.z, texel.z), + ); + } +} + /// `shadow_copy.frag`: one cascade's tile of the static atlas, into colour /// and depth — `S1`. final class ShadowCopyShader implements CpuFragmentShader { @@ -155,3 +202,209 @@ final class ShadowTileResetVertexShader implements CpuVertexShader { return Vector4(a[0], a[1], 1.0, 1.0); } } + +/// `caustic_surface.frag`: a refracting caster's normal and depth, as the sun +/// sees it — `ShadowSettings.caustics`. +final class CausticSurfaceShader implements CpuFragmentShader { + const CausticSurfaceShader(); + + @override + Vector4? run(Float32List v, ShaderBindings bindings, FragmentContext c) { + final normal = Vector3(v[kVNormal], v[kVNormal + 1], v[kVNormal + 2]); + if (normal.length2 > 0.0) normal.normalize(); + return Vector4(normal.x, normal.y, normal.z, c.coord.z); + } +} + +/// `caustic_photon.vert`: one photon followed through a caster to where it +/// lands, sized by where its neighbours land — the same arithmetic, with the +/// instance index as the texel. +final class CausticPhotonVertexShader implements CpuVertexShaderByIndex { + const CausticPhotonVertexShader(); + + @override + int get varyingCount => 5; + + @override + Vector4 run(Float32List a, ShaderBindings bindings, Float32List out) => + runAt(0, 0, a, bindings, out); + + static Vector2 _mapUv(Vector4 p) => Vector2(p.x * 0.5 + 0.5, 0.5 - p.y * 0.5); + + static double _schlick(double f0, double cosine) { + final c = (1.0 - cosine).clamp(0.0, 1.0); + return f0 + (1.0 - f0) * c * c * c * c * c; + } + + /// GLSL's `refract`, zero on total internal reflection. + static Vector3 _refract(Vector3 i, Vector3 n, double eta) { + final d = n.dot(i); + final k = 1.0 - eta * eta * (1.0 - d * d); + if (k < 0.0) return Vector3.zero(); + return i * eta - n * (eta * d + math.sqrt(k)); + } + + static Vector4 _sample(BoundTexture? t, double u, double v) => + t?.sample(u.clamp(0.0, 1.0), v.clamp(0.0, 1.0)) ?? Vector4.zero(); + + /// `Follow`: where the photon at [u], [v] lands in clip space, and what it + /// carries; null if it does not land. + static (Vector2, Vector3)? _follow(ShaderBindings b, double u, double v) { + const block = 'CausticInfo'; + final mapToWorld = b.mat4(block, 'map_to_world'); + final worldToMap = b.mat4(block, 'world_to_map'); + final worldToTile = b.mat4(block, 'world_to_tile'); + final worldToClip = b.mat4(block, 'world_to_clip'); + final light = b.vec4(block, 'light', Vector4.zero()); + final optics = b.vec4(block, 'optics', Vector4.zero()); + final tint = b.vec4(block, 'tint', Vector4.zero()); + final attenuation = b.vec4(block, 'attenuation', Vector4.zero()); + final frontMap = b.textures['caustic_front']; + final backMap = b.textures['caustic_back']; + final depthMap = b.textures['caustic_depth']; + + final front = _sample(frontMap, u, v); + final frontNormal = Vector3(front.x, front.y, front.z); + if (front.w >= 1.0 || frontNormal.length2 < 0.25) return null; + + final ndc = Vector4(u * 2.0 - 1.0, (0.5 - v) * 2.0, front.w, 1.0); + final entry4 = mapToWorld.transform(ndc); + final entry = Vector3(entry4.x, entry4.y, entry4.z); + final depthAxis = mapToWorld.transform(Vector4(0, 0, 1, 0)); + final range = Vector3(depthAxis.x, depthAxis.y, depthAxis.z).length; + + final l = Vector3(light.x, light.y, light.z)..normalize(); + var n1 = frontNormal.normalized(); + if (n1.dot(l) > 0.0) n1 = -n1; + final index = math.max(optics.x, 1.0); + final inside = _refract(l, n1, 1.0 / index); + + final back = _sample(backMap, u, v); + if (back.w <= front.w) return null; + final across = (back.w - front.w) * range; + final exit = entry + inside * (across / math.max(inside.dot(l), 0.2)); + final atMap = _mapUv( + worldToMap.transform(Vector4(exit.x, exit.y, exit.z, 1)), + ); + final there = _sample(backMap, atMap.x, atMap.y); + final thereNormal = Vector3(there.x, there.y, there.z); + var n2 = thereNormal.length2 > 0.25 + ? thereNormal.normalized() + : Vector3(back.x, back.y, back.z).normalized(); + if (n2.dot(inside) < 0.0) n2 = -n2; + var outRay = _refract(inside, -n2, index); + if (outRay.length2 < 1e-6) return null; + outRay = outRay.normalized(); + if (outRay.dot(l) < 0.3) return null; + + final f0 = optics.y; + final keep = + (1.0 - _schlick(f0, l.dot(n1).abs())) * + (1.0 - _schlick(f0, outRay.dot(n2).abs())); + var er = tint.x * keep, eg = tint.y * keep, eb = tint.z * keep; + final fading = optics.z; + if (fading > 0.0) { + final depth = (exit - entry).length / fading; + er *= math.pow(math.max(attenuation.x, 1e-4), depth); + eg *= math.pow(math.max(attenuation.y, 1e-4), depth); + eb *= math.pow(math.max(attenuation.z, 1e-4), depth); + } + + var p = exit.clone(); + final down = math.max(outRay.dot(l), 0.05); + for (var k = 0; k < 4; k++) { + final q = worldToTile.transform(Vector4(p.x, p.y, p.z, 1)); + final at = _mapUv(q); + final receiver = _sample(depthMap, at.x, at.y).x; + var advance = (receiver - q.z) * range / down; + if (k == 0) advance = math.max(advance, 0.0); + p += outRay * advance; + } + final landed = worldToTile.transform(Vector4(p.x, p.y, p.z, 1)); + final landedAt = _mapUv(landed); + final under = _sample(depthMap, landedAt.x, landedAt.y).x; + if ((under - landed.z).abs() * range > 0.02) return null; + + final clip = worldToClip.transform(Vector4(p.x, p.y, p.z, 1)); + return (Vector2(clip.x / clip.w, clip.y / clip.w), Vector3(er, eg, eb)); + } + + static Vector2 _atLeast(Vector2 a, Vector2 fallback, double least) { + final size = a.length; + if (size < 1e-9) return fallback.normalized() * least; + return size < least ? a * (least / size) : a; + } + + @override + Vector4 runAt( + int vertexIndex, + int instanceIndex, + Float32List a, + ShaderBindings b, + Float32List out, + ) { + out[0] = a[0]; + out[1] = a[1]; + out[2] = 0.0; + out[3] = 0.0; + out[4] = 0.0; + final away = Vector4(4.0, 4.0, 0.5, 1.0); + final grid = b.vec4('CausticInfo', 'grid', Vector4.zero()); + final n = grid.x.round(); + if (n < 1) return away; + final u = (instanceIndex % n + 0.5) / n; + final v = (instanceIndex ~/ n + 0.5) / n; + final texel = 1.0 / n; + + final here = _follow(b, u, v); + if (here == null) return away; + final (at, energy) = here; + + final spacingX = Vector2(grid.z, 0.0); + final spacingY = Vector2(0.0, grid.w); + var across = spacingX; + final nextX = _follow(b, u + texel, v); + if (nextX != null) { + across = nextX.$1 - at; + } else { + final prevX = _follow(b, u - texel, v); + if (prevX != null) across = at - prevX.$1; + } + var down = spacingY; + final nextY = _follow(b, u, v + texel); + if (nextY != null) { + down = nextY.$1 - at; + } else { + final prevY = _follow(b, u, v - texel); + if (prevY != null) down = at - prevY.$1; + } + final least = grid.y; + final ax = _atLeast(across * 1.5, spacingX, least); + final by = _atLeast(down * 1.5, spacingY, least); + final area = math.max((ax.x * by.y - ax.y * by.x).abs(), least * least); + final share = grid.z * grid.w; + final scale = share / (1.0471976 * area); + out[2] = energy.x * scale; + out[3] = energy.y * scale; + out[4] = energy.z * scale; + return Vector4( + at.x + a[0] * ax.x + a[1] * by.x, + at.y + a[0] * ax.y + a[1] * by.y, + 0.5, + 1.0, + ); + } +} + +/// `caustic_photon.frag`: a photon's quad, given back into the atlas. +final class CausticPhotonShader implements CpuFragmentShader { + const CausticPhotonShader(); + + @override + Vector4? run(Float32List v, ShaderBindings bindings, FragmentContext c) { + final r2 = v[0] * v[0] + v[1] * v[1]; + if (r2 >= 1.0) return null; + final k = (1.0 - r2) * (1.0 - r2); + return Vector4(0.0, v[2] * k, v[3] * k, v[4] * k); + } +} diff --git a/packages/flutter3d_cpu/lib/src/cpu_shaders_sky.dart b/packages/flutter3d_cpu/lib/src/cpu_shaders_sky.dart index 6f73b0447..26cc530e7 100644 --- a/packages/flutter3d_cpu/lib/src/cpu_shaders_sky.dart +++ b/packages/flutter3d_cpu/lib/src/cpu_shaders_sky.dart @@ -1,5 +1,5 @@ -/// The sky, gradient and cube-mapped: `sky.vert`, `sky_cube.vert`, `sky.frag` -/// and `sky_cube.frag`. +/// The sky, gradient, cube-mapped and physical: `sky.vert`, `sky_cube.vert`, +/// `sky.frag`, `sky_cube.frag` and `sky_physical.frag`. library; import 'dart:math' as math; @@ -156,3 +156,250 @@ final class SkyCubeShader implements CpuFragmentShader { ); } } + +/// `sky_physical.frag`: sunlight scattered once by molecules and by haze along +/// the view ray, the disc and the stars through what the air leaves, and the +/// ground below the horizon — `P5`. +/// +/// Its vertex stage is [SkyVertexShader] under a second name: the GLSL has a +/// `sky_physical.vert` of its own because varyings are matched by name there, +/// but both pass the same twenty-seven floats straight through, and a copy of +/// a loop that copies would be a transcription of nothing. +/// +/// Transcribed from the GLSL line for line, beside a third copy in +/// `PhysicalSky.look`, for the reason [SkyShader] gives. Doubles here and +/// single precision there; the march is smooth enough that the two agree to +/// well under a step of eight bits. The one place that is not smooth is the +/// stars' hash, where a `fract` decides whether a cell holds a star, and there +/// every product is rounded to single precision, as the GPU computes it. +final class SkyPhysicalShader implements CpuFragmentShader { + const SkyPhysicalShader(); + + static const int _viewSteps = 16; + static const int _lightSteps = 8; + + @override + Vector4? run(Float32List v, ShaderBindings b, FragmentContext c) { + final length = math.sqrt(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]); + final scale = length > 0.0 ? 1.0 / length : 0.0; + final dx = v[0] * scale, dy = v[1] * scale, dz = v[2] * scale; + + final air = _Air(v); + final sx = v[11], sy = v[12], sz = v[13]; + final eyeR = air.eye; + + final ground = _meet(eyeR, dy, air.planet); + final grounded = ground > 0.0; + final span = grounded ? ground : _leave(eyeR, dy, air.top); + + var seenR = 0.0, seenM = 0.0; + var molR = 0.0, molG = 0.0, molB = 0.0; + var hazeR = 0.0, hazeG = 0.0, hazeB = 0.0; + for (var i = 0; i < _viewSteps; i++) { + final a0 = i / _viewSteps; + final a1 = (i + 1) / _viewSteps; + final t = span * 0.5 * (a0 * a0 + a1 * a1); + final stride = span * (a1 * a1 - a0 * a0); + final px = dx * t, py = eyeR + dy * t, pz = dz * t; + final r = math.sqrt(px * px + py * py + pz * pz); + final h = r - air.planet; + final airR = math.exp(-h / air.rayleighHeight) * stride; + final airM = math.exp(-h / air.mieHeight) * stride; + seenR += airR; + seenM += airM; + if (_meet(r, (px * sx + py * sy + pz * sz) / r, air.planet) > 0.0) { + continue; + } + final (sunR, sunM) = air.sunward(px, py, pz, sx, sy, sz); + final mr = seenR - 0.5 * airR + sunR; + final mm = seenM - 0.5 * airM + sunM; + final tr = air.through(0, mr, mm); + final tg = air.through(1, mr, mm); + final tb = air.through(2, mr, mm); + molR += airR * tr; + molG += airR * tg; + molB += airR * tb; + hazeR += airM * tr; + hazeG += airM * tg; + hazeB += airM * tb; + } + + final mu = dx * sx + dy * sy + dz * sz; + final g = v[10]; + final gg = g * g; + final rayleighPhase = 3.0 / (16.0 * math.pi) * (1.0 + mu * mu); + final miePhase = + 3.0 / + (8.0 * math.pi) * + ((1.0 - gg) * (1.0 + mu * mu)) / + ((2.0 + gg) * math.pow(1.0 + gg - 2.0 * g * mu, 1.5)); + + final sun = v[14]; + var r = sun * (molR * v[3] * rayleighPhase + hazeR * v[7] * miePhase); + var gr = sun * (molG * v[4] * rayleighPhase + hazeG * v[7] * miePhase); + var bl = sun * (molB * v[5] * rayleighPhase + hazeB * v[7] * miePhase); + final seen0 = air.through(0, seenR, seenM); + final seen1 = air.through(1, seenR, seenM); + final seen2 = air.through(2, seenR, seenM); + + if (grounded) { + final px = dx * span, py = eyeR + dy * span, pz = dz * span; + final pl = math.sqrt(px * px + py * py + pz * pz); + final lit = (px * sx + py * sy + pz * sz) / pl; + if (lit > 0.0) { + final (sunR, sunM) = air.sunward(px, py, pz, sx, sy, sz); + final k = v[18] / math.pi * lit * sun; + r += seen0 * air.through(0, sunR, sunM) * k; + gr += seen1 * air.through(1, sunR, sunM) * k; + bl += seen2 * air.through(2, sunR, sunM) * k; + } + } else { + // The disc, as `sky.frag` and [SkyShader] draw it, dimmed by the air. + final disc = + (v[24] > 0.0 + ? smoothstep(v[23] - v[24], v[23], mu) + : (mu >= v[23] ? 1.0 : 0.0)) * + v[25]; + r += seen0 * disc; + gr += seen1 * disc; + bl += seen2 * disc; + + final night = 1.0 - smoothstep(-0.2, 0.1, sy); + if (v[19] > 0.0 && night > 0.0) { + final star = v[19] * night * _starField(dx, dy, dz, v[20], v[21]); + r += seen0 * star; + gr += seen1 * star; + bl += seen2 * star; + } + } + + return Vector4(r, gr, bl, 1.0); + } + + /// `StarField`: a grid of cells on each face of a cube, a share [density] + /// of them holding a star. + static double _starField( + double dx, + double dy, + double dz, + double density, + double cells, + ) { + final ax = dx.abs(), ay = dy.abs(), az = dz.abs(); + final double face, u, w; + if (ax >= ay && ax >= az) { + face = dx > 0.0 ? 0.0 : 1.0; + u = dz / ax; + w = dy / ax; + } else if (ay >= az) { + face = dy > 0.0 ? 2.0 : 3.0; + u = dx / ay; + w = dz / ay; + } else { + face = dz > 0.0 ? 4.0 : 5.0; + u = dx / az; + w = dy / az; + } + final gu = (u * 0.5 + 0.5) * cells; + final gw = (w * 0.5 + 0.5) * cells; + final cu = gu.floorToDouble(), cw = gw.floorToDouble(); + if (_hash(cu, cw, face) >= density) return 0.0; + final centreU = _hash(cu + 17.0, cw, face) * 0.6 + 0.2; + final centreW = _hash(cu, cw + 29.0, face) * 0.6 + 0.2; + final ou = gu - cu - centreU, ow = gw - cw - centreW; + final off = math.sqrt(ou * ou + ow * ow); + final magnitude = _hash(cu, cw, face + 13.0); + return (1.0 - smoothstep(0.0, 0.35, off)) * + (0.15 + 0.85 * magnitude * magnitude * magnitude); + } + + /// `Hash`, with every step rounded to single precision as the GPU takes it: + /// a `fract` of a product in the thousands keeps three decimals, and those + /// are the ones that decide whether a cell has a star. + static double _hash(double x, double y, double z) { + final c = _f32(0.1031); + var px = _fract(_f32(x * c)); + var py = _fract(_f32(y * c)); + var pz = _fract(_f32(z * c)); + final k = _f32(31.32); + final dot = _f32( + _f32(_f32(px * _f32(pz + k)) + _f32(py * _f32(py + k))) + + _f32(pz * _f32(px + k)), + ); + px = _f32(px + dot); + py = _f32(py + dot); + pz = _f32(pz + dot); + return _fract(_f32(_f32(px + py) * pz)); + } + + static double _fract(double x) => _f32(x - x.floorToDouble()); + + static final Float32List _round = Float32List(1); + + static double _f32(double x) { + _round[0] = x; + return _round[0]; + } + + static double _leave(double r, double mu, double radius) { + final b = r * mu; + final d = b * b - (r - radius) * (r + radius); + return d < 0.0 ? -1.0 : -b + math.sqrt(d); + } + + static double _meet(double r, double mu, double radius) { + final b = r * mu; + final d = b * b - (r - radius) * (r + radius); + return d < 0.0 ? -1.0 : -b - math.sqrt(d); + } +} + +/// The air off the varyings: `v_rayleigh`, `v_mie` and `v_planet`. +final class _Air { + _Air(this.v) + : rayleighHeight = v[6], + mieExtinction = v[8], + mieHeight = v[9], + planet = v[15], + top = v[16], + eye = v[17]; + + final Float32List v; + final double rayleighHeight; + final double mieExtinction; + final double mieHeight; + final double planet; + final double top; + final double eye; + + /// `Through`, one channel at a time. + double through(int channel, double molecules, double haze) => + math.exp(-(v[3 + channel] * molecules + mieExtinction * haze)); + + /// `SunwardAir`. + (double, double) sunward( + double px, + double py, + double pz, + double sx, + double sy, + double sz, + ) { + final r = math.sqrt(px * px + py * py + pz * pz); + final span = SkyPhysicalShader._leave( + r, + (px * sx + py * sy + pz * sz) / r, + top, + ); + final stride = span / SkyPhysicalShader._lightSteps; + var airR = 0.0, airM = 0.0; + for (var j = 0; j < SkyPhysicalShader._lightSteps; j++) { + final u = (j + 0.5) * stride; + final qx = px + sx * u, qy = py + sy * u, qz = pz + sz * u; + final h = math.sqrt(qx * qx + qy * qy + qz * qz) - planet; + airR += math.exp(-h / rayleighHeight) * stride; + airM += math.exp(-h / mieHeight) * stride; + } + return (airR, airM); + } +} diff --git a/packages/flutter3d_cpu/lib/src/cpu_shaders_smaa.dart b/packages/flutter3d_cpu/lib/src/cpu_shaders_smaa.dart new file mode 100644 index 000000000..33197a8eb --- /dev/null +++ b/packages/flutter3d_cpu/lib/src/cpu_shaders_smaa.dart @@ -0,0 +1,210 @@ +/// `smaa_edges.frag`, `smaa_weights.frag` and `smaa_blend.frag`: SMAA 1x — +/// `P1` — line for line, so the software backend smooths the same edges by +/// the same amounts as the GPUs. +/// +/// Three passes over the finished picture. The first marks where the luma +/// steps: red for a step across a pixel's left side, green across its top. +/// The second walks along each marked edge to both of its ends, reads which +/// sides of each end a crossing edge stands on, and looks up in the engine's +/// area table how much of this pixel the line behind the staircase covers. +/// The third moves each pixel that far towards its neighbour across the +/// edge. +/// +/// Orthogonal edges only. The diagonal search and the corner rounding of +/// the paper are not here, on either side of the backend boundary. +library; + +import 'dart:math' as math; +import 'dart:typed_data'; + +import 'package:vector_math/vector_math.dart'; + +import 'cpu_shader.dart'; + +/// `kSmaaMaxSearch` in the GLSL: the most pixels a search walks from the one +/// it starts at, either way. +const int kSmaaMaxSearch = 32; + +/// `Luma` in `smaa_edges.frag`: Rec. 709 weights on the encoded picture. +double _luma(Vector4 c) => 0.2126 * c.x + 0.7152 * c.y + 0.0722 * c.z; + +/// GLSL's `step(edge, x)`. +double _step(double edge, double x) => x < edge ? 0.0 : 1.0; + +/// `smaa_edges.frag`. +final class SmaaEdgesShader implements CpuFragmentShader { + const SmaaEdgesShader(); + + @override + Vector4? run(Float32List v, ShaderBindings b, FragmentContext c) { + final source = b.textures['source_texture']; + if (source == null) return Vector4.zero(); + final params = b.vec4('SmaaInfo', 'params', Vector4(0.0, 0.0, 0.1, 2.0)); + double at(double dx, double dy) => + _luma(source.sample(v[0] + dx * params.x, v[1] + dy * params.y)); + + final middle = at(0.0, 0.0); + final left = at(-1.0, 0.0); + final top = at(0.0, -1.0); + final deltaLeft = (middle - left).abs(); + final deltaTop = (middle - top).abs(); + var edgeLeft = _step(params.z, deltaLeft); + var edgeTop = _step(params.z, deltaTop); + if (edgeLeft + edgeTop == 0.0) return Vector4.zero(); + + // Local contrast adaptation: a step is not an edge when a much larger + // one sits right beside it, which is what keeps the inside of a + // high-contrast texture from being smoothed as a staircase. + final right = (middle - at(1.0, 0.0)).abs(); + final bottom = (middle - at(0.0, 1.0)).abs(); + final leftLeft = (left - at(-2.0, 0.0)).abs(); + final topTop = (top - at(0.0, -2.0)).abs(); + final largest = math.max( + math.max(math.max(deltaLeft, deltaTop), math.max(right, bottom)), + math.max(leftLeft, topTop), + ); + edgeLeft *= _step(largest, params.w * deltaLeft); + edgeTop *= _step(largest, params.w * deltaTop); + return Vector4(edgeLeft, edgeTop, 0.0, 0.0); + } +} + +/// `smaa_weights.frag`. +final class SmaaWeightsShader implements CpuFragmentShader { + const SmaaWeightsShader(); + + @override + Vector4? run(Float32List v, ShaderBindings b, FragmentContext c) { + final edges = b.textures['edges_texture']; + final area = b.textures['area_texture']; + if (edges == null || area == null) return Vector4.zero(); + final params = b.vec4('SmaaInfo', 'params', Vector4.zero()); + // Red: an edge across the left side. Green: across the top. + Vector4 e(int dx, int dy) => + edges.sample(v[0] + dx * params.x, v[1] + dy * params.y); + + final here = e(0, 0); + final weights = Vector4.zero(); + + if (here.y > 0.5) { + // Along a top edge, to the left: an end where this pixel's own left + // side is crossed, or where the next one has no top edge. + var first = kSmaaMaxSearch; + for (var i = 0; i < kSmaaMaxSearch; i++) { + if (e(-i, 0).x > 0.5 || e(-i - 1, 0).y < 0.5) { + first = i; + break; + } + } + var last = kSmaaMaxSearch; + for (var i = 0; i < kSmaaMaxSearch; i++) { + final next = e(i + 1, 0); + if (next.x > 0.5 || next.y < 0.5) { + last = i; + break; + } + } + // A crossing on the near side of the edge counts three, on the far + // side one: the codes the bilinear fetch of the reference gives. + final codeFirst = first == kSmaaMaxSearch + ? 0.0 + : 3.0 * e(-first, 0).x + e(-first, -1).x; + final codeLast = last == kSmaaMaxSearch + ? 0.0 + : 3.0 * e(last + 1, 0).x + e(last + 1, -1).x; + final share = _area(area, first, last, codeFirst, codeLast); + weights + ..x = share.x + ..y = share.y; + } + + if (here.x > 0.5) { + // Along a left edge, upwards and then down. + var first = kSmaaMaxSearch; + for (var i = 0; i < kSmaaMaxSearch; i++) { + if (e(0, -i).y > 0.5 || e(0, -i - 1).x < 0.5) { + first = i; + break; + } + } + var last = kSmaaMaxSearch; + for (var i = 0; i < kSmaaMaxSearch; i++) { + final next = e(0, i + 1); + if (next.y > 0.5 || next.x < 0.5) { + last = i; + break; + } + } + final codeFirst = first == kSmaaMaxSearch + ? 0.0 + : 3.0 * e(0, -first).y + e(-1, -first).y; + final codeLast = last == kSmaaMaxSearch + ? 0.0 + : 3.0 * e(0, last + 1).y + e(-1, last + 1).y; + final share = _area(area, first, last, codeFirst, codeLast); + weights + ..z = share.x + ..w = share.y; + } + return weights; + } + + /// `Area` in the GLSL: the table's texel for the two ends' codes and the + /// square roots of the two distances, filtered between square roots. + static Vector2 _area( + BoundTexture area, + int first, + int last, + double codeFirst, + double codeLast, + ) { + const cell = 16.0; + const size = 80.0; + final u = (cell * codeFirst + math.sqrt(first) + 0.5) / size; + final w = (cell * codeLast + math.sqrt(last) + 0.5) / size; + final texel = area.sample(u, w); + return Vector2(texel.x, texel.y); + } +} + +/// `smaa_blend.frag`. +final class SmaaBlendShader implements CpuFragmentShader { + const SmaaBlendShader(); + + @override + Vector4? run(Float32List v, ShaderBindings b, FragmentContext c) { + final source = b.textures['source_texture']; + final blend = b.textures['blend_texture']; + if (source == null) return Vector4(0.0, 0.0, 0.0, 1.0); + if (blend == null) return source.sample(v[0], v[1]); + final params = b.vec4('SmaaInfo', 'params', Vector4.zero()); + + // What the four sides of this pixel ask of it: right and bottom are the + // neighbours' far shares, top and left this pixel's own near ones. + final right = blend.sample(v[0] + params.x, v[1]).w; + final bottom = blend.sample(v[0], v[1] + params.y).y; + final mine = blend.sample(v[0], v[1]); + final top = mine.x; + final left = mine.z; + if (right + bottom + top + left < 1e-5) return source.sample(v[0], v[1]); + + final across = math.max(right, left) > math.max(bottom, top); + final double toward; + final double away; + final Vector4 first; + final Vector4 second; + if (across) { + toward = right; + away = left; + first = source.sample(v[0] + right * params.x, v[1]); + second = source.sample(v[0] - left * params.x, v[1]); + } else { + toward = bottom; + away = top; + first = source.sample(v[0], v[1] + bottom * params.y); + second = source.sample(v[0], v[1] - top * params.y); + } + final total = toward + away; + return first * (toward / total) + second * (away / total); + } +} diff --git a/packages/flutter3d_cpu/lib/src/cpu_shaders_surface.dart b/packages/flutter3d_cpu/lib/src/cpu_shaders_surface.dart index ee7b74e5e..78285e36c 100644 --- a/packages/flutter3d_cpu/lib/src/cpu_shaders_surface.dart +++ b/packages/flutter3d_cpu/lib/src/cpu_shaders_surface.dart @@ -230,7 +230,7 @@ Surface? readSurface( toLinear(texel.y) * toLinear(tint.y) * v[kVColour + 1], toLinear(texel.z) * toLinear(tint.z) * v[kVColour + 2], ); - final alpha = texel.w * tint.w * v[kVColour + 3]; + final textured = texel.w * tint.w * v[kVColour + 3]; // Alpha masking, glTF's third alpha mode, before anything else for the // reason the GLSL gives: a discarded fragment should not pay for the @@ -239,8 +239,14 @@ Surface? readSurface( final cutoff = bindings .vec4('FragInfo', 'material2', Vector4(-1.0, 1.0, 1.0, 1.0)) .x; - if (cutoff >= 0.0) { - if (alpha < cutoff) return null; + if (cutoff > 1.0) { + // `P7`'s coverage, which never reaches this rasteriser: it answers false + // to `supportsAlphaToCoverage`, so the engine writes the plain cutoff. + // Cut at the cutoff it carries rather than keep a fragment that one + // sample a pixel has no coverage to spread. + if (textured < cutoff - 1.0) return null; + } else if (cutoff >= 0.0) { + if (textured < cutoff) return null; } else if (cutoff < -1.5) { // `gfx-16n`: hashed, the fourth mode. Anchored to world position rather // than to the screen so the pattern travels with the surface — see @@ -256,8 +262,10 @@ Surface? readSurface( anchored.x * 12.9898 + anchored.y * 78.233 + anchored.z * 37.719, ) * 43758.5453; - if (alpha < t - t.floorToDouble()) return null; + if (textured < t - t.floorToDouble()) return null; } + // What survives a mask's cut is opaque, as `surface.glsl` writes it. + final alpha = cutoff >= 0.0 && cutoff <= 1.0 ? 1.0 : textured; final normal = Vector3(v[kVNormal], v[kVNormal + 1], v[kVNormal + 2]); final length = normal.length; @@ -269,7 +277,12 @@ Surface? readSurface( final material = bindings.vec4('FragInfo', 'material', Vector4.zero()); final camera = bindings.vec4('FragInfo', 'camera_position', Vector4.zero()); final world = Vector3(v[kVWorld], v[kVWorld + 1], v[kVWorld + 2]); - final view = Vector3(camera.x, camera.y, camera.z) - world; + // `P7`: against the view axis through an orthographic lens — `TowardsEye`. + // The stages without the fog block keep the eye's point, as the GLSL's + // `F3D_NO_FOG` ones do, and read as perspective here. + final view = orthographic(bindings) + ? towardsEye(v, bindings) + : Vector3(camera.x, camera.y, camera.z) - world; final viewLength = view.length; if (viewLength > 1e-6) view.scale(1.0 / viewLength); @@ -500,3 +513,86 @@ void applyCommonMaps( applyOcclusionMap(s, v, b, c, transformed: transformed); applyEmissiveMap(s, v, b, c, transformed: transformed); } + +/// `NonFinite` from `surface.glsl`: NaN or infinite in any channel. +bool nonFinite(Vector3 c) => + c.x.isNaN || + c.y.isNaN || + c.z.isNaN || + c.x.isInfinite || + c.y.isInfinite || + c.z.isInfinite; + +/// `WriteDebugView` from `surface.glsl` — `P6`: the material channel +/// `FragInfo.debug_view` asks for, in place of [lit], or null when no view +/// is on or the fragment sits left of the split, and the caller writes the +/// light. +/// +/// Display values converted to linear, through the surface buffer and the +/// weighted-blended targets as `writeLit` writes them, and without the fog, +/// for the reasons the GLSL gives. [geometric] is the normal the surface +/// buffer takes when the stage hands `writeLit` another than [Surface.normal] +/// — the impostor's card. +Vector4? writeDebugView( + FragmentContext c, + Float32List v, + ShaderBindings b, + Surface s, + Vector3 lit, { + bool transformed = false, + Vector3? geometric, +}) { + final debug = b.vec4('FragInfo', 'debug_view', Vector4.zero()); + if (debug.x < 0.5) return null; + if (c.coord.x < debug.y * debug.z) return null; + Vector3 srgbOf(Vector3 linear) => Vector3( + toSrgb(linear.x.clamp(0.0, 1.0)), + toSrgb(linear.y.clamp(0.0, 1.0)), + toSrgb(linear.z.clamp(0.0, 1.0)), + ); + final shown = switch ((debug.x + 0.5).floor()) { + 1 => srgbOf(s.albedo), + 2 => Vector3( + s.normal.x * 0.5 + 0.5, + s.normal.y * 0.5 + 0.5, + s.normal.z * 0.5 + 0.5, + ), + 3 => Vector3.all(s.roughness.clamp(0.0, 1.0)), + 4 => Vector3.all(s.metallic.clamp(0.0, 1.0)), + 5 => Vector3.all(s.occlusion.clamp(0.0, 1.0)), + 6 => srgbOf(s.emissive), + 7 => () { + final (u: u, v: w, footprint: _) = mapUv( + kMapBaseColor, + v, + b, + c, + transformed: transformed, + ); + return Vector3(fract(u), fract(w), 0.0); + }(), + 8 => + nonFinite(lit) + ? Vector3(1.0, 0.0, 1.0) + : () { + final e = srgbOf(lit); + return Vector3.all( + (0.2126 * e.x + 0.7152 * e.y + 0.0722 * e.z) * 0.5, + ); + }(), + _ => Vector3.zero(), + }; + writeSurface(c, v, b, geometric ?? s.normal, s.roughness); + final weight = premultiplies(b) ? s.alpha : 1.0; + return writeWeightedBlended( + c, + v, + b, + Vector4( + toLinear(shown.x) * weight, + toLinear(shown.y) * weight, + toLinear(shown.z) * weight, + s.alpha, + ), + ); +} diff --git a/packages/flutter3d_cpu/lib/src/cpu_texture.dart b/packages/flutter3d_cpu/lib/src/cpu_texture.dart index 2251fa559..01a420ba0 100644 --- a/packages/flutter3d_cpu/lib/src/cpu_texture.dart +++ b/packages/flutter3d_cpu/lib/src/cpu_texture.dart @@ -300,7 +300,7 @@ final class BoundTexture { // from the short one. // // **A sampler that did not ask is untouched**, which is what makes this - // safe to add to a backend seventy-eight golden scenes are recorded on: with + // safe to add to a backend ninety-five golden scenes are recorded on: with // `anisotropy` at one — the default everywhere in this engine — the // arithmetic below is not reached and the bytes are the ones that were // recorded. `anisotropic-floor` is the one scene that asks. diff --git a/packages/flutter3d_cpu/lib/src/portable_log2.dart b/packages/flutter3d_cpu/lib/src/portable_log2.dart index 932f7365b..758758462 100644 --- a/packages/flutter3d_cpu/lib/src/portable_log2.dart +++ b/packages/flutter3d_cpu/lib/src/portable_log2.dart @@ -10,7 +10,7 @@ /// twenty-five five. `anisotropic-floor` is the scene built to sit exactly /// there: 2059 of its 172800 pixels differed between a reference recorded on /// macOS and the same code run on a Linux runner, and nothing else in -/// seventy-eight scenes differed at all. +/// ninety-five scenes differed at all. /// /// **The same rule the simulation already keeps.** `flutter3d_sim`'s /// `Portable` exists for this, and a step in that package is forbidden from diff --git a/packages/flutter3d_cpu/test/conformance_test.dart b/packages/flutter3d_cpu/test/conformance_test.dart index d301e34c7..81ab579cc 100644 --- a/packages/flutter3d_cpu/test/conformance_test.dart +++ b/packages/flutter3d_cpu/test/conformance_test.dart @@ -1,4 +1,4 @@ -/// The forty-one checks, against a backend with no GPU underneath it. +/// The forty-two checks, against a backend with no GPU underneath it. /// /// This is the only backend that can run them in a plain `dart test`: the /// other two need a device, so Impeller runs them from an application and diff --git a/packages/flutter3d_cpu/test/cross_backend_test.dart b/packages/flutter3d_cpu/test/cross_backend_test.dart index f59819d81..65d19e737 100644 --- a/packages/flutter3d_cpu/test/cross_backend_test.dart +++ b/packages/flutter3d_cpu/test/cross_backend_test.dart @@ -54,6 +54,23 @@ import 'package:test/test.dart'; /// multisampling on silhouettes, and an additive quad's edge deposits too /// little to cross a channel threshold of eight. const Map _budgets = { + // 0.291% measured, on the silhouette, which Impeller multisamples; the + // body and the rim the source computes agree inside it. + 'material-language': 0.35, + 'material-light-hook': 0.6, + 'material-instance-data': 0.5, + 'widget-scene': 0.05, + // 0.005% measured: both draw the hard cutoff, Impeller and this set alike. + 'alpha-to-coverage': 0.01, + // 0.575% measured, on the spheres' and the floor's edges, which Impeller + // multisamples and this set does not; the highlights, fog and sky agree. + // Recorded first from an Impeller build by mistake, which read 0%. + 'orthographic-metal': 0.65, + 'orthographic-shadows': 1.1, + 'orthographic-particles': 0.1, + // 0.567% measured, on the silhouette and the floor's far edge, which + // Impeller multisamples; both halves of the wipe agree inside. + 'debug-view-split': 0.65, // 0.025% measured, down from 0.633%. Screen-space reflections are a march, // and what is left is the two rasterisers disagreeing about where a ray ends // — an edge inside the reflection rather than an edge in the scene. The rest @@ -250,6 +267,14 @@ const Map _budgets = { // 1.501% measured: the upscale sharpens the rasteriser's edges and // Impeller's multisampled ones differently. 'easu-half': 1.66, + // 0.028% measured, on the panel's edge; the flare's ghosts agree. + 'lens-flare': 0.04, + // 0.725% measured, on the silhouette: `shadow-teapot`'s edge magnified + // by the barrel. + 'lens-distortion': 0.8, + // 0.778% measured: SMAA here smooths edges that Impeller has already + // multisampled, so the two smooth different steps by different amounts. + 'smaa-teapot': 0.85, 'evsm-soft': 0.5, // 0.069% measured: the air right round a torch, where the light falls off // as one over the distance squared, is shadowed by one unfiltered tap of @@ -271,6 +296,13 @@ const Map _budgets = { 'rough-metals': 0.26, 'scan-chunks': 0.57, 'sheen-fabric': 0.39, + // `P5`. 0.451% measured, the boxes' silhouettes and the row where the + // floor meets the far plane: Impeller multisamples both and the software + // rasteriser does not. The sky itself agrees, march and all. + 'sky-physical-dusk': 0.5, + // 0.028% measured, edges of the block; every star is where Impeller put it, + // because the hash is rounded to single precision here as it is there. + 'sky-physical-night': 0.04, 'smoke-six-way': 0.01, 'splat-gltf': 0.02, // 0.011% measured, since the hash reads the pixel the same way up and @@ -297,6 +329,16 @@ const Map _budgets = { 'transmission-glass': 0.01, 'velocity-shapes': 0.01, 'window-interior': 0.39, + // `P3`. 0.067% measured, single pixels along the ring's alpha edge, the + // boxes' edges and the box's shadow: where a sample lands on either side + // of a picture's texel or a box's face is decided in float32 here and in + // half floats and the GPU's own arithmetic there. + 'decal-floor': 0.08, + // `P4`. 0.841% and 0.962% measured, every pixel on a silhouette edge, + // which Impeller multisamples and this rasteriser does not: the inside of + // the mirror's reflection and of the monitor's picture agree. + 'planar-mirror': 0.9, + 'render-texture': 1.0, }; /// How far apart two channels may be before the pixel counts as differing. diff --git a/packages/flutter3d_cpu/test/debug_view_test.dart b/packages/flutter3d_cpu/test/debug_view_test.dart new file mode 100644 index 000000000..c5dc04f57 --- /dev/null +++ b/packages/flutter3d_cpu/test/debug_view_test.dart @@ -0,0 +1,145 @@ +/// A material channel in place of the light — `P6`: what the materials write +/// when `RenderSettings.debugView` asks, and the wipe between the two. +/// +/// dart test test/debug_view_test.dart +/// +/// Read through the composite, so every number here is what reaches the +/// screen: a channel has to come out as the value the material holds, with +/// the exposure and the tone curve left off its side of the split. +library; + +import 'dart:typed_data'; + +import 'package:flutter3d_core/flutter3d_core.dart'; +import 'package:flutter3d_cpu/flutter3d_cpu.dart'; +import 'package:test/test.dart'; +import 'package:vector_math/vector_math.dart'; + +const int _width = 64; +const int _height = 48; + +/// A card filling the middle of the frame, of [material], through +/// [settings], lit by one light from the front. +Float32List _render(RenderSettings settings, {Material? material}) { + final device = CpuDevice( + width: _width, + height: _height, + shaders: CpuShaderLibrary(builtinCpuShaders()), + ); + final card = + MeshNode( + DeviceMesh.upload(device, CuboidShape().build()), + material ?? + Material( + baseColor: Vector4(0.6, 0.3, 0.1, 1.0), + roughness: 0.35, + metallic: 0.8, + ), + ) + ..setPosition(0.0, 0.0, -2.0) + ..setScale(2.4, 1.8, 0.05); + final camera = CameraNode(); + final light = LightNode(intensity: 3.0)..lookAt(Vector3(0.0, 0.0, -1.0)); + final result = Renderer.create(device: device).render( + width: _width, + height: _height, + scene: Scene() + ..add(card) + ..add(light) + ..add(camera), + views: [ + RenderView(camera: camera, clearColor: Vector4(0.0, 0.0, 0.0, 1.0)), + ], + settings: settings, + ); + return Float32List.fromList(device.readHdrPixels(result.frame)); +} + +/// The colour at pixel ([x], [y]). +Vector3 _at(Float32List frame, int x, int y) { + final i = (y * _width + x) * 4; + return Vector3(frame[i], frame[i + 1], frame[i + 2]); +} + +const RenderSettings _plain = RenderSettings( + bloom: BloomSettings(enabled: false), + look: LookSettings(dither: 0.0), +); + +RenderSettings _showing(DebugView view, {double split = 0.0}) => + _plain.copyWith( + debugView: DebugViewSettings(view: view, split: split), + ); + +void main() { + test('off is the picture as it was, to the bit', () { + // Mutation: write `debug_view.x` whatever `active` says, or leave the + // composite's `lens.y` at one, and a frame nobody asked to debug moves. + expect(_render(_showing(DebugView.off)), _render(_plain)); + }); + + test('a channel reaches the screen as the value the material holds', () { + // Mutation: leave the debug side under the exposure or the tone curve in + // `composite.frag`, and the greys come out brighter or flatter than the + // material's own numbers. A doubled exposure is there to show it. + final exposed = _plain.copyWith(exposure: 2.0); + Vector3 centre(DebugView view) => _at( + _render(exposed.copyWith(debugView: DebugViewSettings(view: view))), + _width ~/ 2, + _height ~/ 2, + ); + + // `baseColor` is given in sRGB and the view shows it in sRGB: the + // numbers come back as they went in. + final albedo = centre(DebugView.albedo); + expect(albedo.x, closeTo(0.6, 0.01)); + expect(albedo.y, closeTo(0.3, 0.01)); + expect(albedo.z, closeTo(0.1, 0.01)); + + expect(centre(DebugView.roughness).x, closeTo(0.35, 0.01)); + expect(centre(DebugView.metallic).x, closeTo(0.8, 0.01)); + expect(centre(DebugView.occlusion).x, closeTo(1.0, 0.01)); + expect(centre(DebugView.emissive).x, closeTo(0.0, 0.01)); + + // The card faces the camera, so its normal is +z: (0.5, 0.5, 1). + final normal = centre(DebugView.normal); + expect(normal.x, closeTo(0.5, 0.02)); + expect(normal.y, closeTo(0.5, 0.02)); + expect(normal.z, closeTo(1.0, 0.02)); + }); + + test('the coordinate runs across the card', () { + // Mutation: read `v_world_position` for the UV view, or swap its lanes. + final frame = _render(_showing(DebugView.uv)); + final left = _at(frame, _width ~/ 4, _height ~/ 2); + final right = _at(frame, _width * 3 ~/ 4, _height ~/ 2); + expect(right.x, greaterThan(left.x + 0.1)); + expect(left.z, 0.0); + }); + + test('the split leaves the left lit and shows the channel on the right', () { + // Mutation: compare against the output's width instead of the scene's, + // or let the composite pass the whole frame through. + final lit = _render(_plain); + final wiped = _render(_showing(DebugView.roughness, split: 0.5)); + final y = _height ~/ 2; + expect(_at(wiped, _width ~/ 4, y), _at(lit, _width ~/ 4, y)); + expect(_at(wiped, _width * 3 ~/ 4, y).x, closeTo(0.35, 0.01)); + }); + + test('a NaN in the light shows as magenta, and nothing else does', () { + // Mutation: test the light with `isNaN` on one channel only, or show the + // grey for every fragment, and one of the two cards below goes wrong. + final broken = _render( + _showing(DebugView.nonFinite), + // Emission is added as it is, so a NaN there reaches the light. + material: Material(emissive: Vector3(double.nan, 0.0, 0.0)), + ); + expect(_at(broken, _width ~/ 2, _height ~/ 2), Vector3(1.0, 0.0, 1.0)); + + final sound = _render(_showing(DebugView.nonFinite)); + final grey = _at(sound, _width ~/ 2, _height ~/ 2); + expect(grey.x, grey.y); + expect(grey.x, lessThan(0.5)); + }); +} diff --git a/packages/flutter3d_cpu/test/decal_test.dart b/packages/flutter3d_cpu/test/decal_test.dart new file mode 100644 index 000000000..216a060b9 --- /dev/null +++ b/packages/flutter3d_cpu/test/decal_test.dart @@ -0,0 +1,466 @@ +/// A box projects a picture onto the geometry inside it, and the picture is +/// lit as the surface was — `P3`. +/// +/// dart test test/decal_test.dart +/// +/// Most of these read the lit scene before the post chain — the target the +/// decals are painted into — through a node registered behind them, and set +/// it against the same frame with the decal hidden. Where a decal is opaque +/// the two differ by the change of albedo alone, so the ratio of the two is +/// a number the test can state rather than a picture it has to look at. +library; + +import 'dart:math' as math; +import 'dart:typed_data'; + +import 'package:flutter3d_core/flutter3d_core.dart'; +import 'package:flutter3d_cpu/flutter3d_cpu.dart'; +import 'package:test/test.dart'; +import 'package:vector_math/vector_math.dart'; + +const int _size = 32; + +/// Reads the scene target as the decals and the glass left it. +final class _SceneProbe extends RenderNode { + _SceneProbe(this._device); + + final CpuDevice _device; + Float32List? last; + + @override + String get name => 'scene probe'; + + @override + List get reads => const [FrameResourceIds.hdrColour]; + + @override + List get writes => const [FrameResourceIds.hdrColour]; + + @override + void execute(NodeFrame frame) { + final scene = frame.resources.texture(FrameResourceIds.hdrColour); + last = _device.readHdrPixels(scene); + frame.resources.provide(FrameResourceIds.hdrColour, scene); + } +} + +typedef _Stage = ({ + CpuDevice device, + Renderer renderer, + Scene scene, + CameraNode camera, + LightNode sun, + _SceneProbe probe, +}); + +/// sRGB 0.5, which the lit models take as a linear albedo of 0.214. +final Vector4 _grey = Vector4(0.5, 0.5, 0.5, 1.0); + +double _linear(double c) => + c <= 0.04045 ? c / 12.92 : math.pow((c + 0.055) / 1.055, 2.4).toDouble(); + +/// A floor eight metres square with its top at y = 0, a sun, and a camera +/// [from] somewhere above it looking at the middle. +_Stage _stage({Material? floor, Vector3? from, Vector3? up}) { + final device = CpuDevice( + width: _size, + height: _size, + shaders: CpuShaderLibrary(builtinCpuShaders()), + ); + final camera = CameraNode() + ..setPositionFrom(from ?? Vector3(0.0, 6.0, 0.0)) + ..lookAt(Vector3.zero(), up: up ?? Vector3(0.0, 0.0, -1.0)); + final sun = LightNode(intensity: 2.0) + ..setPosition(0.4, 5.0, 0.3) + ..lookAt(Vector3.zero()); + final scene = Scene() + ..add( + MeshNode( + DeviceMesh.upload( + device, + CuboidShape(size: Vector3(8.0, 0.2, 8.0)).build(), + ), + floor ?? + Material( + name: 'floor', + lighting: LightingModel.lambert, + baseColor: _grey, + ), + )..setPosition(0.0, -0.1, 0.0), + ) + ..add(sun) + ..add(camera); + scene.ambientIntensity = 0.3; + final probe = _SceneProbe(device); + final renderer = Renderer.create(device: device)..addNode(probe); + return ( + device: device, + renderer: renderer, + scene: scene, + camera: camera, + sun: sun, + probe: probe, + ); +} + +const RenderSettings _on = RenderSettings( + bloom: BloomSettings(enabled: false), + decals: DecalSettings(enabled: true), +); + +/// A decal three metres square on the floor, painted [colour]. +DecalNode _decal(Vector4 colour, {int order = 0}) => + DecalNode(color: colour, order: order)..setScale(3.0, 1.0, 3.0); + +FrameResult _render(_Stage it, [RenderSettings settings = _on]) => + it.renderer.render( + width: _size, + height: _size, + scene: it.scene, + views: [RenderView(camera: it.camera)], + settings: settings, + ); + +Float32List _scene(_Stage it, [RenderSettings settings = _on]) { + _render(it, settings); + return Float32List.fromList(it.probe.last!); +} + +/// The texel at [x], [y] of a frame read back as floats. +Vector4 _at(Float32List frame, int x, int y) { + final i = (y * _size + x) * 4; + return Vector4(frame[i], frame[i + 1], frame[i + 2], frame[i + 3]); +} + +/// Every pixel whose colour [a] and [b] disagree on. +List _changed(Float32List a, Float32List b) => [ + for (var p = 0; p < _size * _size; p++) + if ([0, 1, 2].any((c) => (a[p * 4 + c] - b[p * 4 + c]).abs() > 1e-4)) + p, +]; + +void main() { + test('off by default: the pass does not run and the frame is the same', () { + final it = _stage(); + final without = _scene(it, const RenderSettings()); + it.scene.add(_decal(Vector4(1.0, 0.0, 0.0, 1.0))); + final result = _render(it, const RenderSettings()); + // Mutation: an `isActive` that forgets the setting runs the pass on every + // frame with a decal in it. + expect(result.passes.map((p) => p.name), isNot(contains('decals'))); + expect(_changed(it.probe.last!, without), isEmpty); + }); + + test('on, with every decal hidden, nothing splits and nothing moves', () { + final it = _stage(); + final without = _scene(it); + it.scene.add(_decal(Vector4(1.0, 0.0, 0.0, 1.0))..visible = false); + final result = _render(it); + final ran = result.passes.map((p) => p.name); + // Mutation: asking `decals.isNotEmpty` instead of whether any is visible + // splits the scene and gives up multisampling for a decal nobody sees. + expect(ran, isNot(contains('decals'))); + expect(ran, isNot(contains('transparent'))); + expect(_changed(it.probe.last!, without), isEmpty); + }); + + test('the colour is swapped under the light the surface was lit by', () { + final it = _stage(); + // A blocker over the middle of the decal, high enough to stay out of the + // box, under a sun low enough that its shadow falls clear of it: part of + // the painted floor the camera sees is in that shadow. + it.sun.setPosition(3.0, 4.0, 0.0); + it.sun.lookAt(Vector3.zero()); + it.scene.add( + MeshNode( + DeviceMesh.upload( + it.device, + CuboidShape(size: Vector3(1.5, 0.2, 4.0)).build(), + ), + Material(lighting: LightingModel.lambert, baseColor: _grey), + )..setPosition(0.0, 1.2, 0.0), + ); + final decal = it.scene.add(_decal(Vector4(1.0, 0.0, 0.0, 1.0))); + decal.visible = false; + final reference = _scene(it); + decal.visible = true; + final painted = _scene(it); + + final inside = _changed(painted, reference); + expect(inside.length, greaterThan(40)); + final albedo = _linear(0.5); + final lights = []; + for (final p in inside) { + final was = reference[p * 4]; + lights.add(was); + // Red is a whole albedo over the floor's 0.214: the light that reached + // the point, times the new colour. Green and blue are nought. + // + // Mutation: laying the decal's colour over the lit picture rather than + // swapping it under the light makes the ratio a function of how lit the + // point is, and the shadowed half fails. + expect(painted[p * 4] / was, closeTo(1.0 / albedo, 0.02 / albedo)); + expect(painted[p * 4 + 1], closeTo(0.0, 1e-3)); + expect(painted[p * 4 + 2], closeTo(0.0, 1e-3)); + } + // And the region really did hold two lights, so the line above was asked + // of both of them. + expect(lights.reduce(math.max) / lights.reduce(math.min), greaterThan(2.0)); + }); + + test('a surface turned away from the box is left alone', () { + // A blue wall standing inside the box; the camera sees its side. + final it = _stage(from: Vector3(5.0, 4.0, 3.0), up: Vector3(0.0, 1.0, 0.0)); + it.scene.add( + MeshNode( + DeviceMesh.upload( + it.device, + CuboidShape(size: Vector3(0.2, 2.0, 2.0)).build(), + ), + Material( + lighting: LightingModel.lambert, + baseColor: Vector4(0.1, 0.2, 0.9, 1.0), + ), + )..setPosition(0.5, 1.0, 0.0), + ); + final decal = it.scene.add(_decal(Vector4(1.0, 0.0, 0.0, 1.0))); + decal.visible = false; + final reference = _scene(it); + decal.visible = true; + + bool onWall(int p) => reference[p * 4 + 2] > reference[p * 4] * 2.0; + final defaultLimit = _changed(_scene(it), reference); + expect(defaultLimit, isNotEmpty); + // Mutation: dropping the angle test paints the wall's side, which faces + // across the box rather than up it. + expect(defaultLimit.where(onWall), isEmpty); + + decal.angleLimit = math.pi; + final anyAngle = _changed(_scene(it), reference); + expect(anyAngle.where(onWall), isNotEmpty); + }); + + test('higher order is painted over lower, then attachment order', () { + final it = _stage(); + final red = it.scene.add(_decal(Vector4(1.0, 0.0, 0.0, 1.0), order: 1)); + final green = it.scene.add(_decal(Vector4(0.0, 1.0, 0.0, 1.0))); + Vector4 centre() => _at(_scene(it), _size ~/ 2, _size ~/ 2); + + // Mutation: drawing in attachment order ignores `order`, and green, the + // later, wins. + expect(centre().x, greaterThan(0.1)); + expect(centre().y, closeTo(0.0, 1e-3)); + + red.order = 0; + // Equal now, so the later attached is on top. + expect(centre().y, greaterThan(0.1)); + expect(centre().x, closeTo(0.0, 1e-3)); + + green.order = -1; + expect(centre().x, greaterThan(0.1)); + }); + + test('more decals and pictures than one draw holds are all painted', () { + final it = _stage(); + final colours = [ + Vector4(1.0, 0.0, 0.0, 1.0), + Vector4(0.0, 1.0, 0.0, 1.0), + Vector4(0.0, 0.0, 1.0, 1.0), + Vector4(1.0, 1.0, 0.0, 1.0), + Vector4(0.0, 1.0, 1.0, 1.0), + ]; + final pictures = [ + for (final colour in colours) SolidColorTexture(colour).upload(it.device), + ]; + // Twenty-five, a metre apart: two draws' worth of decals, and five + // pictures where a draw binds four. + final decals = [ + for (var row = 0; row < 5; row++) + for (var column = 0; column < 5; column++) + it.scene.add( + DecalNode(texture: pictures[(row + column) % 5]) + ..setScale(0.6, 1.0, 0.6) + ..setPosition(column - 2.0, 0.0, row - 2.0), + ), + ]; + final painted = _scene(it); + for (final decal in decals) { + decal.visible = false; + } + final reference = _scene(it); + + final project = it.camera.viewProjection(1.0); + for (var i = 0; i < decals.length; i++) { + final at = decals[i].readWorldPosition(); + final clip = project.transform(Vector4(at.x, at.y, at.z, 1.0)); + final x = ((clip.x / clip.w * 0.5 + 0.5) * _size).floor(); + final y = ((0.5 - clip.y / clip.w * 0.5) * _size).floor(); + final colour = colours[(i ~/ 5 + i % 5) % 5]; + final got = _at(painted, x, y); + final was = _at(reference, x, y); + // The picture's channels that are off are off; the ones that are on + // are the floor's light over its albedo. + // + // Mutation: a batch that stops at sixteen without starting another, or + // a fifth picture read through a slot holding the fourth, leaves a + // decal here the floor's colour or another's. + for (final (channel, on) in <(double, double)>[ + (got.x, colour.x), + (got.y, colour.y), + (got.z, colour.z), + ]) { + expect( + channel, + on > 0.0 ? greaterThan(was.x * 2.0) : closeTo(0.0, 1e-3), + reason: 'decal $i at ($x, $y)', + ); + } + } + }); + + test('the picture faces up its box, unmirrored', () { + final it = _stage(); + // Red, green / blue, white, from the top left of the picture. + final picture = it.device.createTextureFromPixels( + width: 2, + height: 2, + format: TextureFormat.r8g8b8a8UNormInt, + pixels: Uint8List.fromList([ + 255, 0, 0, 255, 0, 255, 0, 255, // + 0, 0, 255, 255, 255, 255, 255, 255, + ]).buffer.asByteData(), + )!; + it.scene.add(_decal(Vector4(1.0, 1.0, 1.0, 1.0))..texture = picture); + final painted = _scene(it); + // The camera looks straight down with the box's -z at the top of the + // screen, so the picture reads as it was drawn. A quarter in from each + // edge of the box is a texel centre, where bilinear is the texel. + final project = it.camera.viewProjection(1.0); + Vector4 quarter(double x, double z) { + final clip = project.transform(Vector4(x, 0.0, z, 1.0)); + return _at( + painted, + ((clip.x / clip.w * 0.5 + 0.5) * _size).floor(), + ((0.5 - clip.y / clip.w * 0.5) * _size).floor(), + ); + } + + // Mutation: taking v from -z, or u from -x, mirrors the picture, and the + // red corner moves. + final topLeft = quarter(-0.75, -0.75); + final topRight = quarter(0.75, -0.75); + final bottomLeft = quarter(-0.75, 0.75); + expect(topLeft.x, greaterThan(topLeft.y * 4.0)); + expect(topRight.y, greaterThan(topRight.x * 4.0)); + expect(bottomLeft.z, greaterThan(bottomLeft.x * 4.0)); + }); + + test( + 'an unlit surface has no light to lend, so the decal is its own colour', + () { + final it = _stage( + floor: Material(lighting: LightingModel.unlit, baseColor: _grey), + ); + it.scene.add(_decal(Vector4(0.2, 0.6, 1.0, 1.0))); + final centre = _at(_scene(it), _size ~/ 2, _size ~/ 2); + // Mutation: dividing by the albedo buffer's nought with no floor under + // it paints the unlit floor white, or infinite. + expect(centre.x, closeTo(_linear(0.2), 2e-3)); + expect(centre.y, closeTo(_linear(0.6), 2e-3)); + expect(centre.z, closeTo(1.0, 2e-3)); + }, + ); + + test('an emissive decal adds its glow over the light it borrowed', () { + final it = _stage(); + final decal = it.scene.add(_decal(Vector4(1.0, 0.5, 0.0, 1.0))); + final lit = _at(_scene(it), _size ~/ 2, _size ~/ 2); + decal.emissive = Vector3(3.0, 3.0, 3.0); + final glowing = _at(_scene(it), _size ~/ 2, _size ~/ 2); + // Mutation: an emission multiplied in with the factor rather than added + // with the term scales with the light and is nought in the dark. + expect(glowing.x - lit.x, closeTo(3.0, 0.01)); + expect(glowing.y - lit.y, closeTo(3.0 * _linear(0.5), 0.01)); + expect(glowing.z - lit.z, closeTo(0.0, 1e-3)); + }); + + test('glass above a decal is drawn over it, not painted', () { + final it = _stage(); + // A pane a metre over the box's top, so its own points are outside it. + it.scene.add( + MeshNode( + DeviceMesh.upload( + it.device, + CuboidShape(size: Vector3(5.0, 0.05, 5.0)).build(), + ), + Material( + lighting: LightingModel.unlit, + baseColor: Vector4(1.0, 1.0, 1.0, 0.3), + alphaMode: MaterialAlphaMode.blend, + ), + )..setPosition(0.0, 1.5, 0.0), + ); + final decal = it.scene.add(_decal(Vector4(1.0, 0.0, 0.0, 1.0))); + decal.visible = false; + final reference = _scene(it); + decal.visible = true; + final result = _render(it); + final painted = it.probe.last!; + expect(result.passes.map((p) => p.name), contains('transparent')); + final centre = _at(painted, _size ~/ 2, _size ~/ 2); + final was = _at(reference, _size ~/ 2, _size ~/ 2); + // Mutation: painting after the glass reads the pane's depth out of the + // surface buffer, finds it outside the box, and the decal is gone. + expect(centre.x, greaterThan(was.x + 0.1)); + expect(centre.y, lessThan(was.y)); + }); + + test('a decal near the top of the frame is painted to its edges', () { + // The pass cuts each draw to the screen rectangle its boxes cover, counted + // from the top left as every rectangle the engine hands a backend is. A + // decal in the top rows of the picture, with nothing in the bottom ones, + // is the arrangement where a rectangle counted from the wrong edge cuts + // away all of it. + final it = _stage(); + final decal = it.scene.add( + DecalNode(color: Vector4(1.0, 0.0, 0.0, 1.0)) + ..setScale(2.0, 1.0, 1.0) + ..setPosition(0.0, 0.0, -1.5), + ); + decal.visible = false; + final reference = _scene(it); + decal.visible = true; + final painted = _scene(it); + + // Where the box's footprint on the floor lands, row by row. + final project = it.camera.viewProjection(1.0); + int row(double z) { + final clip = project.transform(Vector4(0.0, 0.0, z, 1.0)); + return ((0.5 - clip.y / clip.w * 0.5) * _size).floor(); + } + + final top = row(-2.0); + final bottom = row(-1.0); + expect(bottom, lessThan(_size ~/ 2), reason: 'the box is in the top half'); + final rows = _changed(painted, reference).map((p) => p ~/ _size).toSet(); + // Mutation: a scissor whose y is measured from the bottom, or flipped + // again for a backend whose rows run upward, leaves none of these rows + // painted. + for (var y = top + 1; y < bottom; y++) { + expect(rows, contains(y), reason: 'row $y of $top..$bottom'); + } + expect(rows.every((y) => y >= top && y <= bottom), isTrue); + }); + + test('switched off by name like any other pass', () { + final it = _stage(); + final without = _scene(it); + it.scene.add(_decal(Vector4(1.0, 0.0, 0.0, 1.0))); + final result = _render( + it, + _on.copyWith(disabledPasses: const {'decals'}), + ); + expect(result.passes.map((p) => p.name), isNot(contains('decals'))); + expect(_changed(it.probe.last!, without), isEmpty); + }); +} diff --git a/packages/flutter3d_cpu/test/goldens/alpha-to-coverage.png b/packages/flutter3d_cpu/test/goldens/alpha-to-coverage.png new file mode 100644 index 000000000..0fa9c6485 Binary files /dev/null and b/packages/flutter3d_cpu/test/goldens/alpha-to-coverage.png differ diff --git a/packages/flutter3d_cpu/test/goldens/debug-view-split.png b/packages/flutter3d_cpu/test/goldens/debug-view-split.png new file mode 100644 index 000000000..188fa0283 Binary files /dev/null and b/packages/flutter3d_cpu/test/goldens/debug-view-split.png differ diff --git a/packages/flutter3d_cpu/test/goldens/decal-floor.png b/packages/flutter3d_cpu/test/goldens/decal-floor.png new file mode 100644 index 000000000..edede9747 Binary files /dev/null and b/packages/flutter3d_cpu/test/goldens/decal-floor.png differ diff --git a/packages/flutter3d_cpu/test/goldens/lens-distortion.png b/packages/flutter3d_cpu/test/goldens/lens-distortion.png new file mode 100644 index 000000000..4787e7741 Binary files /dev/null and b/packages/flutter3d_cpu/test/goldens/lens-distortion.png differ diff --git a/packages/flutter3d_cpu/test/goldens/lens-flare.png b/packages/flutter3d_cpu/test/goldens/lens-flare.png new file mode 100644 index 000000000..2b683350b Binary files /dev/null and b/packages/flutter3d_cpu/test/goldens/lens-flare.png differ diff --git a/packages/flutter3d_cpu/test/goldens/material-instance-data.png b/packages/flutter3d_cpu/test/goldens/material-instance-data.png new file mode 100644 index 000000000..a97c2ce77 Binary files /dev/null and b/packages/flutter3d_cpu/test/goldens/material-instance-data.png differ diff --git a/packages/flutter3d_cpu/test/goldens/material-language.png b/packages/flutter3d_cpu/test/goldens/material-language.png new file mode 100644 index 000000000..fa5934307 Binary files /dev/null and b/packages/flutter3d_cpu/test/goldens/material-language.png differ diff --git a/packages/flutter3d_cpu/test/goldens/material-light-hook.png b/packages/flutter3d_cpu/test/goldens/material-light-hook.png new file mode 100644 index 000000000..7e264f2ac Binary files /dev/null and b/packages/flutter3d_cpu/test/goldens/material-light-hook.png differ diff --git a/packages/flutter3d_cpu/test/goldens/orthographic-metal.png b/packages/flutter3d_cpu/test/goldens/orthographic-metal.png new file mode 100644 index 000000000..6e315af5c Binary files /dev/null and b/packages/flutter3d_cpu/test/goldens/orthographic-metal.png differ diff --git a/packages/flutter3d_cpu/test/goldens/orthographic-particles.png b/packages/flutter3d_cpu/test/goldens/orthographic-particles.png new file mode 100644 index 000000000..2c1cd83cf Binary files /dev/null and b/packages/flutter3d_cpu/test/goldens/orthographic-particles.png differ diff --git a/packages/flutter3d_cpu/test/goldens/orthographic-shadows.actual.png b/packages/flutter3d_cpu/test/goldens/orthographic-shadows.actual.png new file mode 100644 index 000000000..18bfbacb9 Binary files /dev/null and b/packages/flutter3d_cpu/test/goldens/orthographic-shadows.actual.png differ diff --git a/packages/flutter3d_cpu/test/goldens/orthographic-shadows.png b/packages/flutter3d_cpu/test/goldens/orthographic-shadows.png new file mode 100644 index 000000000..210ffc7d7 Binary files /dev/null and b/packages/flutter3d_cpu/test/goldens/orthographic-shadows.png differ diff --git a/packages/flutter3d_cpu/test/goldens/planar-mirror.png b/packages/flutter3d_cpu/test/goldens/planar-mirror.png new file mode 100644 index 000000000..d3b1bb18a Binary files /dev/null and b/packages/flutter3d_cpu/test/goldens/planar-mirror.png differ diff --git a/packages/flutter3d_cpu/test/goldens/render-texture.png b/packages/flutter3d_cpu/test/goldens/render-texture.png new file mode 100644 index 000000000..0c68fb5f5 Binary files /dev/null and b/packages/flutter3d_cpu/test/goldens/render-texture.png differ diff --git a/packages/flutter3d_cpu/test/goldens/sky-physical-dusk.png b/packages/flutter3d_cpu/test/goldens/sky-physical-dusk.png new file mode 100644 index 000000000..482de97b4 Binary files /dev/null and b/packages/flutter3d_cpu/test/goldens/sky-physical-dusk.png differ diff --git a/packages/flutter3d_cpu/test/goldens/sky-physical-night.png b/packages/flutter3d_cpu/test/goldens/sky-physical-night.png new file mode 100644 index 000000000..b29707566 Binary files /dev/null and b/packages/flutter3d_cpu/test/goldens/sky-physical-night.png differ diff --git a/packages/flutter3d_cpu/test/goldens/smaa-teapot.png b/packages/flutter3d_cpu/test/goldens/smaa-teapot.png new file mode 100644 index 000000000..243d06298 Binary files /dev/null and b/packages/flutter3d_cpu/test/goldens/smaa-teapot.png differ diff --git a/packages/flutter3d_cpu/test/goldens/widget-scene.png b/packages/flutter3d_cpu/test/goldens/widget-scene.png new file mode 100644 index 000000000..3626b7b60 Binary files /dev/null and b/packages/flutter3d_cpu/test/goldens/widget-scene.png differ diff --git a/packages/flutter3d_cpu/test/lens_test.dart b/packages/flutter3d_cpu/test/lens_test.dart new file mode 100644 index 000000000..8c06067a8 --- /dev/null +++ b/packages/flutter3d_cpu/test/lens_test.dart @@ -0,0 +1,166 @@ +/// What the lens does to a picture — `P2`: it bends it, and it throws a +/// bright light's reflections across the middle of the frame. +/// +/// dart test test/lens_test.dart +library; + +import 'dart:typed_data'; + +import 'package:flutter3d_core/flutter3d_core.dart'; +import 'package:flutter3d_cpu/flutter3d_cpu.dart'; +import 'package:test/test.dart'; +import 'package:vector_math/vector_math.dart'; + +const int _width = 96; +const int _height = 64; + +/// A card of [colour] at ([x], [y]) on black, through [settings]. +Float32List _render( + RenderSettings settings, { + double x = 0.0, + double y = 0.0, + double size = 0.6, + double colour = 1.0, +}) { + final device = CpuDevice( + width: _width, + height: _height, + shaders: CpuShaderLibrary(builtinCpuShaders()), + ); + final card = + MeshNode( + DeviceMesh.upload(device, CuboidShape().build()), + Material( + lighting: LightingModel.unlit, + baseColor: Vector4(colour, colour, colour, 1.0), + ), + ) + ..setPosition(x, y, -3.0) + ..setScale(size, size, 0.05); + final camera = CameraNode(); + final result = Renderer.create(device: device).render( + width: _width, + height: _height, + scene: Scene() + ..add(card) + ..add(camera), + views: [ + RenderView(camera: camera, clearColor: Vector4(0.0, 0.0, 0.0, 1.0)), + ], + settings: settings, + ); + return Float32List.fromList(device.readHdrPixels(result.frame)); +} + +/// How many pixels are mostly lit. +int _lit(Float32List frame) { + var count = 0; + for (var i = 0; i < frame.length; i += 4) { + if (frame[i] > 0.5) count++; + } + return count; +} + +/// The red at pixel ([x], [y]). +double _at(Float32List frame, int x, int y) => frame[(y * _width + x) * 4]; + +const RenderSettings _plain = RenderSettings( + bloom: BloomSettings(enabled: false), + look: LookSettings(dither: 0.0), +); + +void main() { + group('distortion', () { + test('nought is the picture as it was', () { + expect( + _render(_plain.copyWith(look: _plain.look.copyWith(distortion: 0.0))), + _render(_plain), + ); + }); + + test('a barrel magnifies the middle and a pincushion shrinks it', () { + // Mutation: turn the sign of `k` in `Distort`, and the two swap. + // A card big enough that a pixel's worth of quantisation at its edge + // does not hide the bend. + int lit(double k) => _lit( + _render( + _plain.copyWith(look: _plain.look.copyWith(distortion: k)), + size: 1.2, + ), + ); + final straight = lit(0.0); + final barrel = lit(0.5); + final pincushion = lit(-0.5); + // ignore: avoid_print + print('lit: pincushion $pincushion, straight $straight, barrel $barrel'); + expect(barrel, greaterThan(straight * 1.15)); + expect(pincushion, lessThan(straight * 0.9)); + }); + }); + + group('flare', () { + // A small bright card up and to the left, well over the bloom + // threshold: its reflections land down and to the right. + RenderSettings flaring({bool flare = true, bool bloom = true}) => + _plain.copyWith( + // A tight glow: the flare is as soft as the glow it is drawn + // from, and the default five full levels throw a skirt half the + // frame wide. + bloom: BloomSettings( + enabled: bloom, + intensity: 0.02, + levels: 3, + scatter: 0.4, + lensFlare: LensFlareSettings(enabled: flare, intensity: 6.0), + ), + ); + Float32List light(RenderSettings settings) => + _render(settings, x: -0.9, y: 0.45, size: 0.15, colour: 6.0); + + /// What [after] adds over [before] in the quarter of the frame from + /// column [x0] and row [y0]. + double added(Float32List before, Float32List after, int x0, int y0) { + var sum = 0.0; + for (var y = y0; y < y0 + _height ~/ 2; y++) { + for (var x = x0; x < x0 + _width ~/ 2; x++) { + sum += _at(after, x, y) - _at(before, x, y); + } + } + return sum; + } + + test('throws light across the middle from a bright one', () { + // The light is in the top left quarter; its reflections land in the + // bottom right one, across the middle, and not beside it in the top + // right. Mutation: drop the flip in `lens_flare.frag`, and the light + // lands on itself instead. + final without = light(flaring(flare: false)); + final withFlare = light(flaring()); + final across = added(without, withFlare, _width ~/ 2, _height ~/ 2); + final beside = added(without, withFlare, _width ~/ 2, 0); + // ignore: avoid_print + print('flare added: $across across the middle, $beside beside'); + expect(across, greaterThan(1.0)); + expect(across, greaterThan(beside * 4.0)); + }); + + test('is nothing with the bloom off', () { + expect(light(flaring(bloom: false)), light(_plain)); + }); + + test('is nothing when nothing blooms', () { + // A card well under the bloom's knee: no glow, so no reflection of + // one. + Float32List dim({required bool flare}) => _render( + _plain.copyWith( + bloom: BloomSettings( + lensFlare: LensFlareSettings(enabled: flare, intensity: 6.0), + ), + ), + size: 0.15, + colour: 0.2, + ); + expect(dim(flare: true), dim(flare: false)); + }); + }); +} diff --git a/packages/flutter3d_cpu/test/orthographic_test.dart b/packages/flutter3d_cpu/test/orthographic_test.dart new file mode 100644 index 000000000..062e67f14 --- /dev/null +++ b/packages/flutter3d_cpu/test/orthographic_test.dart @@ -0,0 +1,217 @@ +/// An orthographic camera through the effects that used to assume the rays +/// meet at the eye — `P7`. +/// +/// dart test test/orthographic_test.dart +/// +/// Through an orthographic lens the rays are parallel, and the eye's position +/// along the axis is only where the camera was put: moving it changes nothing +/// in the picture. Each test here holds one thing that read that position as a +/// point the light travels to — a highlight, the fog, the sky — to the +/// parallel rays instead. +library; + +import 'dart:typed_data'; + +import 'package:flutter3d_core/flutter3d_core.dart'; +import 'package:flutter3d_cpu/flutter3d_cpu.dart'; +import 'package:test/test.dart'; +import 'package:vector_math/vector_math.dart'; + +const int _width = 64; +const int _height = 48; + +/// A wall facing the camera and filling the frame, of [material], seen from +/// [distance] metres through [projection], with one light from the front +/// and above. +Float32List _render( + Projection projection, { + double distance = 4.0, + RenderSettings settings = const RenderSettings( + bloom: BloomSettings(enabled: false), + look: LookSettings(dither: 0.0), + ), + Material? material, + bool wall = true, +}) { + final device = CpuDevice( + width: _width, + height: _height, + shaders: CpuShaderLibrary(builtinCpuShaders()), + ); + final scene = Scene(); + if (wall) { + scene.add( + MeshNode( + DeviceMesh.upload(device, CuboidShape().build()), + material ?? + Material( + baseColor: Vector4(0.5, 0.5, 0.5, 1.0), + roughness: 0.2, + metallic: 1.0, + ), + ) + ..setPosition(0.0, 0.0, -distance) + ..setScale(40.0, 40.0, 0.1), + ); + } + scene.add( + LightNode(intensity: 3.0)..lookAt(Vector3(0.0, -0.3, -1.0).normalized()), + ); + final camera = CameraNode(projection: projection); + scene.add(camera); + final result = Renderer.create(device: device).render( + width: _width, + height: _height, + scene: scene, + views: [ + RenderView(camera: camera, clearColor: Vector4(0.0, 0.0, 0.0, 1.0)), + ], + settings: settings, + ); + return Float32List.fromList(device.readHdrPixels(result.frame)); +} + +Vector3 _at(Float32List frame, int x, int y) { + final i = (y * _width + x) * 4; + return Vector3(frame[i], frame[i + 1], frame[i + 2]); +} + +/// How far apart the brightest and darkest of five points across the frame +/// are, in the red channel. +double _spread(Float32List frame) { + final reds = [ + for (final (x, y) in <(int, int)>[ + (_width ~/ 2, _height ~/ 2), + (4, 4), + (_width - 5, 4), + (4, _height - 5), + (_width - 5, _height - 5), + ]) + _at(frame, x, y).x, + ]; + return reds.reduce((a, b) => a > b ? a : b) - + reds.reduce((a, b) => a < b ? a : b); +} + +const OrthographicProjection _ortho = OrthographicProjection(height: 6.0); +const PerspectiveProjection _perspective = PerspectiveProjection( + fovYRadians: 1.2, +); + +void main() { + test('an orthographic matrix is told from a perspective one', () { + // Mutation: test the bottom row's last entry alone, and a perspective + // matrix that happens to keep one there reads as orthographic. + final view = Matrix4.identity()..setTranslationRaw(0.0, -1.0, -5.0); + for (final range in DepthRange.values) { + expect( + isOrthographic( + toDepthRange(_ortho.toMatrix(1.5), range) * view as Matrix4, + ), + isTrue, + ); + expect( + isOrthographic( + toDepthRange(_perspective.toMatrix(1.5), range) * view as Matrix4, + ), + isFalse, + ); + } + }); + + test('a highlight does not slide across a flat wall', () { + // Mutation: read `FragInfo.camera_position` for the view direction in + // `readSurface`, and the metal wall brightens towards wherever the eye's + // point projects — the very slide a pan would show. + expect(_spread(_render(_ortho)), lessThan(0.02)); + // The same wall through a perspective lens does vary: the test can see a + // slide when there is one. + expect(_spread(_render(_perspective)), greaterThan(0.05)); + }); + + test('where the camera stands along its axis changes nothing lit', () { + // Mutation: as above — a view direction from the eye's point moves with + // the eye, and a highlight with it. + final near = _render(_ortho, distance: 3.0); + final far = _render(_ortho, distance: 12.0); + for (final (x, y) in <(int, int)>[(8, 8), (_width - 9, _height - 9)]) { + final a = _at(near, x, y); + final b = _at(far, x, y); + expect((a - b).length, lessThan(0.01), reason: 'at ($x, $y)'); + } + }); + + test('fog lies flat on a wall the camera faces', () { + // Mutation: keep the radial `EyeDistance` through an orthographic lens, + // and the fog rings round the middle of the frame. + final fogged = _render( + _ortho, + distance: 20.0, + material: Material( + lighting: LightingModel.unlit, + baseColor: Vector4(1.0, 1.0, 1.0, 1.0), + ), + settings: RenderSettings( + bloom: const BloomSettings(enabled: false), + look: const LookSettings(dither: 0.0), + fog: FogSettings(color: Vector3.zero(), density: 0.05), + ), + ); + expect(_spread(fogged), lessThan(0.01)); + }); + + test('what stands behind the camera, ahead of its near plane, is measured ' + 'from that plane', () { + // Through an orthographic lens the rays begin on the near plane, so a + // camera at the origin with its near plane ten metres behind it and one + // stood on that plane draw the same picture of a wall three metres + // behind the first — fog and all. + // + // Mutation: measure from the camera's own position in `readViewOrigin`, + // and the wall behind it has a depth below nought: no fog at all, and a + // surface buffer that reads it as sky. + final settings = RenderSettings( + bloom: const BloomSettings(enabled: false), + look: const LookSettings(dither: 0.0), + fog: FogSettings(color: Vector3.zero(), density: 0.1), + ); + final material = Material( + lighting: LightingModel.unlit, + baseColor: Vector4(1.0, 1.0, 1.0, 1.0), + ); + final behind = _render( + const OrthographicProjection(height: 6.0, near: -10.0), + distance: -3.0, + settings: settings, + material: material, + ); + final onThePlane = _render( + const OrthographicProjection(height: 6.0, near: 0.0), + distance: 7.0, + settings: settings, + material: material, + ); + for (final (x, y) in <(int, int)>[(_width ~/ 2, _height ~/ 2), (8, 8)]) { + final a = _at(behind, x, y); + expect(a.x, lessThan(0.9), reason: 'the wall is not fogged'); + expect((a - _at(onThePlane, x, y)).length, lessThan(0.005)); + } + }); + + test('the sky is a gradient, not one colour', () { + // Mutation: hand the sky the orthographic matrix's own corner rays, which + // are all the view axis, and the frame is a single colour. + final sky = _render( + _ortho, + wall: false, + settings: const RenderSettings( + bloom: BloomSettings(enabled: false), + look: LookSettings(dither: 0.0), + sky: SkySettings(enabled: true), + ), + ); + final top = _at(sky, _width ~/ 2, 1); + final bottom = _at(sky, _width ~/ 2, _height - 2); + expect((top - bottom).length, greaterThan(0.05)); + }); +} diff --git a/packages/flutter3d_cpu/test/photo_capture_test.dart b/packages/flutter3d_cpu/test/photo_capture_test.dart new file mode 100644 index 000000000..72d9bd953 --- /dev/null +++ b/packages/flutter3d_cpu/test/photo_capture_test.dart @@ -0,0 +1,308 @@ +/// Photo mode's capture — `N8`: a picture of any size drawn in tiles on the +/// game's own device, finished over the whole frame and written out a strip +/// at a time. +/// +/// dart test test/photo_capture_test.dart +/// +/// The reference every claim is held to is the same scene drawn as one frame +/// on one device, with the settings the capture says it used. +library; + +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter3d_core/flutter3d_core.dart'; +import 'package:flutter3d_cpu/flutter3d_cpu.dart'; +import 'package:test/test.dart'; +import 'package:vector_math/vector_math.dart'; + +const int _width = 480; +const int _height = 360; + +/// Two cubes off the axes, one of them bright enough to bloom, so a tile edge +/// in the wrong place moves geometry and a tile edge with no margin cuts a +/// glow. +({Scene scene, CameraNode camera}) _scene(GraphicsDevice device) { + final scene = Scene() + ..add( + LightNode(type: LightType.directional, name: 'key') + ..intensity = 3.0 + ..setLocalForward(Vector3(-0.4, -1.0, -0.3)), + ) + ..add( + MeshNode( + DeviceMesh.upload(device, CuboidShape(size: Vector3.all(1.4)).build()), + Material( + lighting: LightingModel.pbr, + baseColor: Vector4(0.8, 0.3, 0.2, 1.0), + ), + name: 'a', + )..setPosition(0.6, 0.2, 0.0), + ) + ..add( + MeshNode( + DeviceMesh.upload(device, CuboidShape(size: Vector3.all(0.5)).build()), + Material( + lighting: LightingModel.unlit, + baseColor: Vector4(1.0, 1.0, 1.0, 1.0), + emissive: Vector3.all(12.0), + ), + name: 'lamp', + )..setPosition(-0.55, 0.15, 0.4), + ); + final camera = + CameraNode( + name: 'eye', + projection: const PerspectiveProjection(fovYRadians: 0.9), + ) + ..setPosition(2.2, 1.4, 3.4) + ..lookAt(Vector3.zero()); + scene.add(camera); + return (scene: scene, camera: camera); +} + +({Renderer renderer, Scene scene, CameraNode camera}) _game() { + final device = CpuDevice( + width: _width, + height: _height, + shaders: CpuShaderLibrary(builtinCpuShaders()), + ); + final built = _scene(device); + return ( + renderer: Renderer.create(device: device), + scene: built.scene, + camera: built.camera, + ); +} + +/// The scene as one frame, opaque, with [settings] as they stand. +Future _whole(RenderSettings settings) async { + final game = _game(); + final result = game.renderer.render( + width: _width, + height: _height, + scene: game.scene, + views: [RenderView(camera: game.camera)], + settings: settings, + ); + final pixels = (await game.renderer.device.readPixels( + result.frame, + ))!.buffer.asUint8List(); + for (var i = 3; i < pixels.length; i += 4) { + pixels[i] = 255; + } + return pixels; +} + +/// The capture, stitched back into one buffer. +Future<({Uint8List pixels, PhotoCaptureReport report, CameraNode camera})> +_capture( + RenderSettings settings, { + PhotoFilter filter = PhotoFilter.none, + int margin = 32, +}) async { + final game = _game(); + final out = Uint8List(_width * _height * 4); + final report = await capturePhoto( + renderer: game.renderer, + scene: game.scene, + camera: game.camera, + width: _width, + height: _height, + settings: settings, + filter: filter, + // Ragged on purpose: three columns of 200, 200 and 80, three rows of 152, + // 152 and 56. Multiples of four, so the composite's 4x4 dither cell lines + // up across tiles and is not a difference this file has to explain. + tileWidth: 200, + tileHeight: 152, + margin: margin, + onRows: (rgba, top, rows) => + out.setRange(top * _width * 4, (top + rows) * _width * 4, rgba), + ); + return (pixels: out, report: report, camera: game.camera); +} + +/// The worst difference in any channel, over the pixels within [band] of a +/// tile edge. +int _worstAtSeams(Uint8List a, Uint8List b, {int band = 3}) { + bool nearEdge(int v, int tile, int size) { + for (var edge = tile; edge < size; edge += tile) { + if ((v - edge).abs() <= band) return true; + } + return false; + } + + var worst = 0; + for (var y = 0; y < _height; y++) { + for (var x = 0; x < _width; x++) { + if (!nearEdge(x, 200, _width) && !nearEdge(y, 152, _height)) continue; + for (var c = 0; c < 3; c++) { + final i = (y * _width + x) * 4 + c; + final d = (a[i] - b[i]).abs(); + if (d > worst) worst = d; + } + } + } + return worst; +} + +const RenderSettings _noBloom = RenderSettings( + bloom: BloomSettings(enabled: false), +); + +void main() { + test('a ragged grid of tiles stitches into the whole frame', () async { + final capture = await _capture(_noBloom, margin: 0); + final reference = await _whole(_noBloom); + // Mutation: build the crop from the aspect the renderer hands + // `CropProjection.toMatrix` — the tile's — instead of `frameAspect`. The + // 200 x 152 tiles and the 80-pixel column each squash the cubes their own + // way, and thousands of pixels move. + // + // Within one step, as `tiled_projection_stitch_test.dart` explains: a + // crop folded into the matrix interpolates through different arithmetic. + final difference = compareFrames( + capture.pixels, + reference, + channel: 1, + alpha: true, + ); + expect(difference.differing, 0, reason: '$difference'); + }); + + test('a margin round each tile takes the seams out of a bloom', () async { + const bloom = RenderSettings( + bloom: BloomSettings(enabled: true, intensity: 0.6), + ); + final reference = await _whole(bloom); + final bare = await _capture(bloom, margin: 0); + final framed = await _capture(bloom, margin: 32); + final bareSeam = _worstAtSeams(bare.pixels, reference); + final framedSeam = _worstAtSeams(framed.pixels, reference); + // Mutation: crop the tile without the margin (`from` at row `row`, column + // 0). The glow is cut off at every tile edge it reaches, which is the + // seam with no margin at all. + expect( + framedSeam, + lessThan(bareSeam), + reason: 'seams: $bareSeam without a margin, $framedSeam with one', + ); + expect(framedSeam, lessThanOrEqualTo(6), reason: 'seam $framedSeam'); + }); + + test( + 'the vignette darkens the picture\'s corners, not every tile\'s', + () async { + final look = const RenderSettings( + bloom: BloomSettings(enabled: false), + ).copyWith(look: const LookSettings(vignette: 0.6)); + final capture = await _capture(look, margin: 0); + final reference = await _whole(look); + // Mutation: leave `vignette` in the tiles' look in `photoSettings`. Every + // tile darkens its own corners, and the picture has nine vignettes — the + // centre of the frame, a tile corner, comes out darker than its edges. + // + // Within two steps: the reference darkens linear light before the + // encode, the finish decodes a byte, darkens it and encodes again. + final difference = compareFrames( + capture.pixels, + reference, + channel: 2, + alpha: true, + ); + expect(difference.differing, 0, reason: '$difference'); + }, + ); + + test('a capture says what it could not carry over, and puts the camera ' + 'back', () async { + final settings = const RenderSettings( + bloom: BloomSettings(enabled: false), + autoExposure: AutoExposureSettings(enabled: true), + motionBlur: MotionBlurSettings(enabled: true), + ); + final capture = await _capture(settings, filter: PhotoFilter.noir); + // Mutation: drop the `finally` that restores the projection. The game's + // next frame is drawn through the last tile's crop — the bottom-right + // corner of the picture, magnified to fill the screen. + expect(capture.camera.projection, isA()); + expect(capture.report.tilesX, 3); + expect(capture.report.tilesY, 3); + expect( + capture.report.setAside.join('\n'), + allOf(contains('auto exposure is held'), contains('motion blur')), + ); + }); + + test('a filter composes onto the game\'s own look', () { + const game = LookSettings(saturation: 0.8, temperature: -0.2); + final graded = PhotoFilter.warm.applyTo(game); + // Mutation: replace the game's look with the filter's. A night level + // graded cold turns orange under "warm" rather than a little less cold. + expect(graded.temperature, closeTo(0.15, 1e-12)); + expect(graded.saturation, closeTo(0.84, 1e-12)); + expect(PhotoFilter.named('noir'), same(PhotoFilter.noir)); + expect(PhotoFilter.named('gone in this build'), same(PhotoFilter.none)); + }); + + group('PngStripWriter', () { + Uint8List gradient(int width, int height) { + final out = Uint8List(width * height * 4); + for (var y = 0; y < height; y++) { + for (var x = 0; x < width; x++) { + final i = (y * width + x) * 4; + out + ..[i] = x * 7 & 0xFF + ..[i + 1] = y * 11 & 0xFF + ..[i + 2] = (x ^ y) & 0xFF + ..[i + 3] = 255 - x; + } + } + return out; + } + + test('three strips read back as one picture, checksum and all', () { + const width = 37; + const height = 23; + final pixels = gradient(width, height); + final file = BytesBuilder(); + final writer = PngStripWriter( + width: width, + height: height, + sink: file.add, + ); + for (final (top, rows) in const [(0, 10), (10, 1), (11, 12)]) { + writer.addRows(Uint8List.sublistView(pixels, top * width * 4), rows); + } + writer.close(); + final bytes = file.toBytes(); + + final decoded = decodePng(bytes)!; + expect(decoded.width, width); + expect(decoded.rgba, pixels); + + // The decoder above does not check the Adler-32; zlib does. Mutation: + // fold the filter bytes out of the running checksum, and a picture + // that decodes perfectly is refused by every browser. + final idat = BytesBuilder(); + var at = 8; + while (at < bytes.length) { + final length = ByteData.sublistView(bytes, at).getUint32(0); + final type = String.fromCharCodes(bytes.sublist(at + 4, at + 8)); + if (type == 'IDAT') idat.add(bytes.sublist(at + 8, at + 8 + length)); + at += 12 + length; + } + expect(ZLibDecoder().convert(idat.toBytes()), hasLength(height * 149)); + }); + + test('a file closed short is refused rather than written damaged', () { + final writer = PngStripWriter(width: 4, height: 4, sink: (_) {}); + writer.addRows(Uint8List(4 * 2 * 4), 2); + // Mutation: let `close` write the end with rows missing. The file is a + // valid-looking PNG that every reader rejects at the end of the data. + expect(writer.close, throwsStateError); + expect(() => writer.addRows(Uint8List(4 * 3 * 4), 3), throwsStateError); + }); + }); +} diff --git a/packages/flutter3d_cpu/test/planar_reflection_test.dart b/packages/flutter3d_cpu/test/planar_reflection_test.dart new file mode 100644 index 000000000..b25dce8ca --- /dev/null +++ b/packages/flutter3d_cpu/test/planar_reflection_test.dart @@ -0,0 +1,384 @@ +/// A planar reflector shows the world mirrored in its plane, and a camera +/// into a texture shows what it sees in the same frame — `P4`. +/// +/// dart test test/planar_reflection_test.dart +/// +/// The reflection claims read the lit scene before the post chain, through a +/// node registered behind it, and set it against a second scene built to be +/// what the mirror should show: the same world with every mesh mirrored in +/// the plane and the mirror taken away. Unlit materials and no sky, so a +/// mesh's colour does not depend on which way it faces the light, and the +/// two pictures can be compared pixel for pixel. +library; + +import 'dart:typed_data'; + +import 'package:flutter3d_core/flutter3d_core.dart'; +import 'package:flutter3d_cpu/flutter3d_cpu.dart'; +import 'package:test/test.dart'; +import 'package:vector_math/vector_math.dart'; + +const int _size = 48; + +/// Reads the scene target as the opaque half, the reflections and the glass +/// left it. +final class _SceneProbe extends RenderNode { + _SceneProbe(this._device); + + final CpuDevice _device; + Float32List? last; + + @override + String get name => 'scene probe'; + + @override + List get reads => const [FrameResourceIds.hdrColour]; + + @override + List get writes => const [FrameResourceIds.hdrColour]; + + @override + void execute(NodeFrame frame) { + final scene = frame.resources.texture(FrameResourceIds.hdrColour); + last = _device.readHdrPixels(scene); + frame.resources.provide(FrameResourceIds.hdrColour, scene); + } +} + +typedef _Stage = ({ + CpuDevice device, + Renderer renderer, + Scene scene, + CameraNode camera, + _SceneProbe probe, +}); + +_Stage _stage({Vector3? from}) { + final device = CpuDevice( + width: _size, + height: _size, + shaders: CpuShaderLibrary(builtinCpuShaders()), + ); + final camera = CameraNode() + ..setPositionFrom(from ?? Vector3(0.0, 3.0, 5.0)) + ..lookAt(Vector3.zero()); + final scene = Scene()..add(camera); + final probe = _SceneProbe(device); + final renderer = Renderer.create(device: device)..addNode(probe); + return ( + device: device, + renderer: renderer, + scene: scene, + camera: camera, + probe: probe, + ); +} + +Material _unlit(double r, double g, double b) => + Material(lighting: LightingModel.unlit, baseColor: Vector4(r, g, b, 1.0)); + +MeshNode _cube(_Stage it, Material material, Vector3 at, {double size = 1}) => + it.scene.add( + MeshNode( + DeviceMesh.upload( + it.device, + CuboidShape(size: Vector3.all(size)).build(), + ), + material, + )..setPositionFrom(at), + ); + +/// A black floor eight metres square at y = 0, which shows nothing of its +/// own: under a reflector of F0 one, every pixel of it is the reflection. +MeshNode _floor(_Stage it, {Material? material}) => it.scene.add( + MeshNode( + DeviceMesh.upload( + it.device, + const PlaneShape(width: 8.0, depth: 8.0).build(), + ), + material ?? _unlit(0.0, 0.0, 0.0), + ), +); + +const RenderSettings _on = RenderSettings( + bloom: BloomSettings(enabled: false), + planarReflections: PlanarReflectionSettings(enabled: true), +); + +FrameResult _render(_Stage it, [RenderSettings settings = _on]) => + it.renderer.render( + width: _size, + height: _size, + scene: it.scene, + views: [RenderView(camera: it.camera)], + settings: settings, + ); + +Float32List _scene(_Stage it, [RenderSettings settings = _on]) { + _render(it, settings); + return Float32List.fromList(it.probe.last!); +} + +/// Every pixel whose colour [a] and [b] disagree on by more than [by]. +List _changed(Float32List a, Float32List b, {double by = 1e-4}) => [ + for (var p = 0; p < _size * _size; p++) + if ([0, 1, 2].any((c) => (a[p * 4 + c] - b[p * 4 + c]).abs() > by)) p, +]; + +/// How many pixels are mostly [channel] and nothing else. +int _count(Float32List frame, int channel) => [ + for (var p = 0; p < _size * _size; p++) + if (frame[p * 4 + channel] > 0.3 && + [ + 0, + 1, + 2, + ].where((c) => c != channel).every((c) => frame[p * 4 + c] < 0.1)) + p, +].length; + +void main() { + group('planar reflections', () { + test('off by default: the pass does not run and the frame is the same', () { + final it = _stage(); + final floor = _floor(it); + _cube(it, _unlit(1.0, 0.1, 0.1), Vector3(0.0, 1.0, 0.0)); + final without = _scene(it, const RenderSettings()); + it.scene.add(PlanarReflectorNode(surfaces: [floor])); + final result = _render(it, const RenderSettings()); + // Mutation: an `isActive` that forgets the setting draws the scene a + // second time on every frame with a reflector in it. + expect( + result.passes.map((p) => p.name), + isNot(contains('planar reflections')), + ); + expect(_changed(it.probe.last!, without), isEmpty); + }); + + test( + 'on, with the reflector hidden, nothing is drawn and nothing moves', + () { + final it = _stage(); + final floor = _floor(it); + _cube(it, _unlit(1.0, 0.1, 0.1), Vector3(0.0, 1.0, 0.0)); + final without = _scene(it); + it.scene.add( + PlanarReflectorNode(surfaces: [floor])..visible = false, + ); + final result = _render(it); + // Mutation: asking whether the scene holds a reflector rather than + // whether one is visible draws a picture nobody is shown. + expect( + result.passes.map((p) => p.name), + isNot(contains('planar reflections')), + ); + expect(_changed(it.probe.last!, without), isEmpty); + }, + ); + + test('a mirror shows the world mirrored in its plane', () { + final it = _stage(); + final floor = _floor(it); + _cube(it, _unlit(1.0, 0.1, 0.1), Vector3(0.0, 1.0, 0.0)); + _cube(it, _unlit(0.1, 0.2, 1.0), Vector3(1.4, 0.4, -0.8), size: 0.8); + it.scene.add( + PlanarReflectorNode(surfaces: [floor], resolution: 1.0), + ); + final mirrored = _scene(it); + + // The same world with every mesh mirrored in y = 0 and no floor: what + // the mirror ought to show, seen directly. + final reference = _stage(); + _cube(reference, _unlit(1.0, 0.1, 0.1), Vector3(0.0, 1.0, 0.0)); + _cube(reference, _unlit(1.0, 0.1, 0.1), Vector3(0.0, -1.0, 0.0)); + _cube( + reference, + _unlit(0.1, 0.2, 1.0), + Vector3(1.4, 0.4, -0.8), + size: 0.8, + ); + _cube( + reference, + _unlit(0.1, 0.2, 1.0), + Vector3(1.4, -0.4, -0.8), + size: 0.8, + ); + final seen = _scene(reference); + + // The reflection is there at all: twice the red the cube alone has. + expect(_count(mirrored, 0), greaterThan(_count(seen, 0) ~/ 2 + 20)); + // And it is where the mirrored world is, texel for pixel: what differs + // is a pixel or two along the edges, where a triangle drawn through + // the mirror and the same triangle mirrored in its vertices round + // their edges apart. + // + // Mutation: a mirror built from the camera's own view (no reflection) + // shows the cubes from above on the floor and moves hundreds of + // pixels; a picture read with its rows the wrong way up moves them too. + expect(_changed(mirrored, seen, by: 0.05).length, lessThan(12)); + }); + + test('what is below the plane is not reflected up through it', () { + final it = _stage(); + final floor = _floor(it); + // Under the floor, on the line from the mirrored camera to the middle + // of the mirror, where it would cover the whole reflection. + _cube(it, _unlit(0.1, 1.0, 0.1), Vector3(0.0, -1.5, 1.8)); + _cube(it, _unlit(1.0, 0.1, 0.1), Vector3(0.0, 1.0, 0.0)); + it.scene.add( + PlanarReflectorNode(surfaces: [floor], resolution: 1.0), + ); + final frame = _scene(it); + // Mutation: drawing the mirrored camera with its ordinary projection + // rather than `obliqueNearPlane` covers the mirror in green. + expect(_count(frame, 1), 0); + expect(_count(frame, 0), greaterThan(40)); + }); + + test('water reflects more at a grazing angle than looking down', () { + final it = _stage(from: Vector3(0.0, 1.2, 6.0)); + // A tall wall behind the pool, so its reflection runs from the far + // edge, seen at a grazing angle, to near the camera, seen steeply. + _cube(it, _unlit(1.0, 1.0, 1.0), Vector3(0.0, 3.0, -4.2), size: 6.0); + final floor = _floor(it, material: _unlit(0.0, 0.0, 0.0)); + final reflector = it.scene.add( + PlanarReflectorNode( + surfaces: [floor], + reflectance: 0.02, + resolution: 1.0, + ), + ); + final water = _scene(it); + reflector.reflectance = 1.0; + final mirror = _scene(it); + + // Under the mirror a floor pixel is the reflection; under the water it + // is the reflection times the Fresnel term over black, so the ratio of + // the two is the term. + final terms = <(int, double)>[ + for (var y = 0; y < _size; y++) + if (mirror[(y * _size + _size ~/ 2) * 4] > 0.5) + ( + y, + water[(y * _size + _size ~/ 2) * 4] / + mirror[(y * _size + _size ~/ 2) * 4], + ), + ]; + final floorRows = terms.where((t) => t.$1 > _size ~/ 2).toList(); + expect(floorRows.length, greaterThan(4)); + // Mutation: a term without the angle — F0 alone — is 0.02 on every row. + expect(floorRows.first.$2, greaterThan(floorRows.last.$2 * 2)); + for (final (_, term) in floorRows) { + expect(term, inInclusiveRange(0.02 - 1e-3, 1.0)); + } + }); + + test('a camera behind the plane sees no reflection', () { + final it = _stage(from: Vector3(0.0, -3.0, 5.0)); + final floor = _floor( + it, + material: _unlit(0.3, 0.3, 0.3)..doubleSided = true, + ); + _cube(it, _unlit(1.0, 0.1, 0.1), Vector3(0.0, 1.0, 0.0)); + final without = _scene(it); + it.scene.add( + PlanarReflectorNode(surfaces: [floor], resolution: 1.0), + ); + // Mutation: without the check, the near plane gives up on the eye's + // side and the mirrored camera draws through the plane anyway. + expect(_changed(_scene(it), without), isEmpty); + }); + }); + + group('render textures', () { + test('a scene without one does not run the pass', () { + final it = _stage(); + _cube(it, _unlit(1.0, 0.1, 0.1), Vector3.zero()); + final result = _render(it, const RenderSettings()); + expect( + result.passes.map((p) => p.name), + isNot(contains('render textures')), + ); + }); + + test('holds the camera\'s picture as the sRGB a picture is painted in', () { + final it = _stage(from: Vector3(0.0, 0.0, 3.0)); + // An unlit cube emits its base colour as linear light, so a picture + // encoded right hands the sRGB it was painted with back, unchanged. + _cube(it, _unlit(0.9, 0.3, 0.1), Vector3.zero(), size: 1.0); + final texture = RenderTexture.create( + it.device, + camera: it.camera, + width: _size, + height: _size, + clearColor: Vector4(0.2, 0.4, 0.6, 1.0), + ); + it.scene.addRenderTexture(texture); + _render(it); + final picture = it.device.readHdrPixels(texture.texture); + expect(texture.isDrawn, isTrue); + final centre = (_size ~/ 2 * _size + _size ~/ 2) * 4; + // Mutation: leaving the light linear turns 0.3 into 0.07 and 0.1 into + // 0.01; an exposure applied twice, or a tone curve, moves all three. + expect(picture[centre], closeTo(0.9, 1e-3)); + expect(picture[centre + 1], closeTo(0.3, 1e-3)); + expect(picture[centre + 2], closeTo(0.1, 1e-3)); + // And where nothing was drawn, the clear colour as it was given. + expect(picture[0], closeTo(0.2, 1e-3)); + expect(picture[1], closeTo(0.4, 1e-3)); + expect(picture[2], closeTo(0.6, 1e-3)); + }); + + test('a material shows the picture in the frame it was taken', () { + final it = _stage(from: Vector3(0.0, 0.0, 3.0)); + // The camera into the texture looks at a red cube far off to the side. + final watcher = CameraNode() + ..setPosition(20.0, 0.0, 3.0) + ..lookAt(Vector3(20.0, 0.0, 0.0)); + it.scene.add(watcher); + _cube(it, _unlit(1.0, 0.0, 0.0), Vector3(20.0, 0.0, 0.0), size: 3.0); + final texture = RenderTexture.create( + it.device, + camera: watcher, + width: 16, + height: 16, + ); + it.scene.addRenderTexture(texture); + // A screen filling the view, showing it unlit. + _cube( + it, + Material(lighting: LightingModel.unlit, albedo: texture.texture), + Vector3.zero(), + size: 2.0, + ); + final frame = _scene(it); + final middle = (_size ~/ 2 * _size + _size ~/ 2) * 4; + // Mutation: drawing the textures after the scene shows whatever the + // allocation held on the first frame, which here is black. + expect(frame[middle], closeTo(1.0, 0.02)); + expect(frame[middle + 1], lessThan(0.02)); + }); + + test('one that does not refresh is drawn once, and again on asking', () { + final it = _stage(); + _cube(it, _unlit(0.5, 0.5, 0.5), Vector3.zero()); + final texture = RenderTexture.create( + it.device, + camera: it.camera, + width: 8, + height: 8, + refreshEveryFrame: false, + ); + it.scene.addRenderTexture(texture); + int drawn() => _render( + it, + ).passes.firstWhere((p) => p.name == 'render textures').drawCalls; + // Mutation: ignoring `refreshEveryFrame` draws the picture every frame. + expect(drawn(), greaterThan(0)); + expect(drawn(), 0); + texture.invalidate(); + expect(drawn(), greaterThan(0)); + expect(drawn(), 0); + }); + }); +} diff --git a/packages/flutter3d_cpu/test/render_stats_test.dart b/packages/flutter3d_cpu/test/render_stats_test.dart new file mode 100644 index 000000000..dfd604f64 --- /dev/null +++ b/packages/flutter3d_cpu/test/render_stats_test.dart @@ -0,0 +1,97 @@ +/// What a frame says it cost — `P6`: the draws and triangles it made and the +/// memory its targets hold, without a capture asked for. +/// +/// dart test test/render_stats_test.dart +library; + +import 'package:flutter3d_core/flutter3d_core.dart'; +import 'package:flutter3d_cpu/flutter3d_cpu.dart'; +import 'package:test/test.dart'; +import 'package:vector_math/vector_math.dart'; + +const int _width = 64; +const int _height = 48; + +/// One frame of [cubes] cubes, through [settings]. +FrameResult _render(RenderSettings settings, {int cubes = 1}) { + final device = CpuDevice( + width: _width, + height: _height, + shaders: CpuShaderLibrary(builtinCpuShaders()), + ); + final mesh = DeviceMesh.upload(device, CuboidShape().build()); + final scene = Scene(); + for (var i = 0; i < cubes; i++) { + scene.add( + MeshNode(mesh, Material(baseColor: Vector4(0.5, 0.5, 0.5, 1.0))) + ..setPosition(i * 1.5 - (cubes - 1) * 0.75, 0.0, -6.0), + ); + } + final camera = CameraNode(); + scene.add(camera); + return Renderer.create(device: device).render( + width: _width, + height: _height, + scene: scene, + views: [RenderView(camera: camera)], + settings: settings, + ); +} + +void main() { + test('a texture is counted as its texels, every slice and sample', () { + // Mutation: leave out the slices, and a cube target is a sixth of itself. + TextureHandle handle(TextureFormat format, {TextureType? type}) => + TextureHandle( + backend: Object(), + width: 8, + height: 4, + format: format, + sampleCount: 2, + type: type ?? TextureType.texture2D, + ); + expect(textureBytes(handle(TextureFormat.r8UNormInt)), 8 * 4 * 2); + expect( + textureBytes(handle(TextureFormat.r16g16b16a16Float)), + 8 * 4 * 2 * 8, + ); + expect( + textureBytes( + handle(TextureFormat.r8g8b8a8UNormInt, type: TextureType.textureCube), + ), + 8 * 4 * 6 * 2 * 4, + ); + }); + + test('a frame says what its targets hold', () { + // Mutation: count only the pooled scratch, and the scene's own colour — + // provided, not pooled — drops out, taking the frame below what one + // half-float target of its size holds. + final frame = _render(const RenderSettings()); + expect(frame.targetBytes, greaterThanOrEqualTo(_width * _height * 8)); + }); + + test('a glow is paid for in targets, and a smaller scene in fewer bytes', () { + // Mutation: count a texture each time a version names it, and the glow's + // chain, read and written in place, is counted twice over. + final plain = _render( + const RenderSettings(bloom: BloomSettings(enabled: false)), + ); + final glowing = _render( + const RenderSettings(bloom: BloomSettings(enabled: true)), + ); + expect(glowing.targetBytes, greaterThan(plain.targetBytes)); + expect( + glowing.targetBytes, + lessThan(plain.targetBytes * 3), + reason: 'a chain of halvings is about a third of its first level', + ); + }); + + test('more cubes are more draws and more triangles', () { + final one = _render(const RenderSettings(), cubes: 1); + final three = _render(const RenderSettings(), cubes: 3); + expect(three.drawCalls, greaterThan(one.drawCalls)); + expect(three.triangles, greaterThanOrEqualTo(one.triangles + 24)); + }); +} diff --git a/packages/flutter3d_cpu/test/smaa_test.dart b/packages/flutter3d_cpu/test/smaa_test.dart new file mode 100644 index 000000000..c6a03c3a0 --- /dev/null +++ b/packages/flutter3d_cpu/test/smaa_test.dart @@ -0,0 +1,150 @@ +/// SMAA 1x smooths a staircase towards the edge it stands for — `P1`. +/// +/// dart test test/smaa_test.dart +/// +/// Against the coverage an 8×8 supersample gives, at both slopes and both +/// orientations: a share applied to the wrong side of an edge, or a pattern +/// read the wrong way up, makes the staircase worse rather than better, and +/// one tilt alone would not see it. +library; + +import 'dart:typed_data'; + +import 'package:flutter3d_core/flutter3d_core.dart'; +import 'package:flutter3d_cpu/flutter3d_cpu.dart'; +import 'package:test/test.dart'; +import 'package:vector_math/vector_math.dart'; + +const int _width = 96; +const int _height = 64; + +/// A white card tilted by [roll] on black, [factor] times the size, through +/// [antiAlias]. Unlit, so the only thing to smooth is the silhouette. +Float32List _render({ + required double roll, + AntiAliasSettings antiAlias = const AntiAliasSettings(), + int factor = 1, + bool tall = false, +}) { + final width = _width * factor; + final height = _height * factor; + final device = CpuDevice( + width: width, + height: height, + shaders: CpuShaderLibrary(builtinCpuShaders()), + ); + final card = + MeshNode( + DeviceMesh.upload(device, CuboidShape().build()), + Material( + lighting: LightingModel.unlit, + baseColor: Vector4(1.0, 1.0, 1.0, 1.0), + ), + ) + ..setPosition(0.0, -0.3, -3.0) + ..setRotationYawPitchRoll(0.0, 0.0, roll) + ..setScale(tall ? 1.4 : 6.0, tall ? 6.0 : 1.4, 0.05); + final camera = CameraNode(); + final result = Renderer.create(device: device).render( + width: width, + height: height, + scene: Scene() + ..add(card) + ..add(camera), + views: [ + RenderView(camera: camera, clearColor: Vector4(0.0, 0.0, 0.0, 1.0)), + ], + settings: RenderSettings( + bloom: const BloomSettings(enabled: false), + look: const LookSettings(dither: 0.0), + antiAlias: antiAlias, + ), + ); + return device.readHdrPixels(result.frame); +} + +/// [big] box-filtered down by [factor]: the coverage each pixel should have. +Float32List _downsample(Float32List big, int factor) { + final out = Float32List(_width * _height); + for (var y = 0; y < _height; y++) { + for (var x = 0; x < _width; x++) { + var sum = 0.0; + for (var dy = 0; dy < factor; dy++) { + for (var dx = 0; dx < factor; dx++) { + sum += + big[((y * factor + dy) * _width * factor + x * factor + dx) * 4]; + } + } + out[y * _width + x] = sum / (factor * factor); + } + } + return out; +} + +/// The summed distance of [frame]'s red from [reference]. +double _error(Float32List frame, Float32List reference) { + var sum = 0.0; + for (var i = 0; i < reference.length; i++) { + sum += (frame[i * 4] - reference[i]).abs(); + } + return sum; +} + +const AntiAliasSettings _smaa = AntiAliasSettings( + enabled: true, + method: EdgeSmoothing.smaa, +); + +void main() { + for (final (name, roll, tall) in <(String, double, bool)>[ + ('a shallow edge rising', 0.07, false), + ('a shallow edge falling', -0.07, false), + ('a steep edge leaning right', 0.07, true), + ('a steep edge leaning left', -0.07, true), + ]) { + test('$name comes out nearer its supersampled coverage', () { + // Mutation: swap red and green in the table, or the near and far + // codes, and the share goes to the wrong side of the edge — the error + // grows past the hard edge's. + final reference = _downsample( + _render(roll: roll, factor: 8, tall: tall), + 8, + ); + final hard = _error(_render(roll: roll, tall: tall), reference); + final smoothed = _error( + _render(roll: roll, antiAlias: _smaa, tall: tall), + reference, + ); + // ignore: avoid_print + print('$name: hard $hard, SMAA $smoothed'); + expect(smoothed, lessThan(hard * 0.6)); + }); + } + + test('a picture with no edge comes back as it went in', () { + final device = CpuDevice( + width: 16, + height: 16, + shaders: CpuShaderLibrary(builtinCpuShaders()), + ); + final camera = CameraNode(); + Float32List draw(AntiAliasSettings antiAlias) { + final result = Renderer.create(device: device).render( + width: 16, + height: 16, + scene: Scene()..add(camera), + views: [ + RenderView(camera: camera, clearColor: Vector4(0.3, 0.5, 0.7, 1.0)), + ], + settings: RenderSettings( + bloom: const BloomSettings(enabled: false), + look: const LookSettings(dither: 0.0), + antiAlias: antiAlias, + ), + ); + return Float32List.fromList(device.readHdrPixels(result.frame)); + } + + expect(draw(_smaa), draw(const AntiAliasSettings())); + }); +} diff --git a/packages/flutter3d_cpu/test/splat_fog_test.dart b/packages/flutter3d_cpu/test/splat_fog_test.dart index 94445c1e8..95bb38de6 100644 --- a/packages/flutter3d_cpu/test/splat_fog_test.dart +++ b/packages/flutter3d_cpu/test/splat_fog_test.dart @@ -8,6 +8,7 @@ /// at the draw; the others drew the unfogged cloud without a word. library; +import 'dart:math' as math; import 'dart:typed_data'; import 'package:flutter3d_core/flutter3d_core.dart'; @@ -77,5 +78,70 @@ void main() { expect(fogged[0], lessThan(0.05)); expect(fogged[2], greaterThan(0.5)); }); + + test('through an orthographic camera a ${composite.name} splat fogs by ' + 'depth, not by distance — P7', () { + // Two splats at the same depth, one on the axis and one four metres + // off it. The air between each and the eye's plane is the same five + // metres, so the fog must be too. + // + // Mutation: measure `FogDistance` from the eye's position, as the + // stages did, and the one off the axis is six and a half metres away + // and the bluer of the two. + final device = CpuDevice( + width: _width, + height: _height, + shaders: CpuShaderLibrary(builtinCpuShaders()), + ); + final camera = CameraNode() + ..projection = const OrthographicProjection(height: 12.0) + ..setPosition(0.0, 0.0, 5.0) + ..lookAt(Vector3.zero()); + final scene = Scene() + ..ambientIntensity = 0.0 + ..add(camera); + final cloud = SplatCloud( + centres: Float32List.fromList([0.0, 0.0, 0.0, 4.0, 0.0, 0.0]), + colours: Float32List.fromList([ + 1.0, 0.0, 0.0, 1.0, // + 1.0, 0.0, 0.0, 1.0, + ]), + scales: Float32List.fromList([0.6, 0.6, 0.6, 0.6, 0.6, 0.6]), + rotations: Float32List.fromList([ + 0.0, 0.0, 0.0, 1.0, // + 0.0, 0.0, 0.0, 1.0, + ]), + ); + final renderer = Renderer.create(device: device) + ..addContributor(SplatContributor(cloud, composite: composite)); + final result = renderer.render( + width: _width, + height: _height, + scene: scene, + views: [ + RenderView(camera: camera, clearColor: Vector4(0.0, 0.0, 0.0, 1.0)), + ], + settings: RenderSettings( + tonemap: false, + bloom: const BloomSettings(enabled: false), + fog: FogSettings(color: Vector3(0.0, 0.0, 1.0), density: 0.2), + ), + ); + final pixels = device.readHdrPixels(result.frame); + // Two pixels to the metre: the axis between columns 15 and 16, four + // metres off it between 23 and 24. The brightest pixel of each, because + // a hashed splat keeps or drops each pixel at random and the ones it + // keeps carry its colour, fogged. + double brightest(int from, int c) => [ + for (var x = from; x < from + 8; x++) + pixels[((_height ~/ 2) * _width + x) * 4 + c], + ].reduce(math.max); + final onAxis = brightest(12, 0); + final offAxis = brightest(20, 0); + expect(onAxis, greaterThan(0.1), reason: 'no splat on the axis'); + expect(offAxis, greaterThan(0.1), reason: 'no splat off it'); + expect(offAxis, closeTo(onAxis, 0.02)); + expect(brightest(20, 2), closeTo(brightest(12, 2), 0.02)); + }); } } diff --git a/packages/flutter3d_cpu/test/transmission_glass_test.dart b/packages/flutter3d_cpu/test/transmission_glass_test.dart index 783c44f50..d5300411e 100644 --- a/packages/flutter3d_cpu/test/transmission_glass_test.dart +++ b/packages/flutter3d_cpu/test/transmission_glass_test.dart @@ -17,7 +17,7 @@ import 'package:flutter3d_cpu/flutter3d_cpu.dart'; import 'package:flutter3d_hardware/testing.dart'; import 'package:flutter3d_shaders/stage_bindings.dart'; import 'package:test/test.dart'; -import 'package:vector_math/vector_math.dart' show Vector4; +import 'package:vector_math/vector_math.dart' show Vector3, Vector4; const int _size = 48; @@ -369,6 +369,101 @@ void main() { } }); + test('a thin blended pane shows the glass behind it, not the copy', () { + // A tinted volume behind a thin pane that blends. Both transmit, so + // neither is in the copy; the pane lets the target through instead, + // and the target by then holds the volume. + MeshNode volume(DeviceMesh quad) => + MeshNode( + quad, + Material( + lighting: LightingModel.pbrLayered, + baseColor: Vector4(1.0, 1.0, 1.0, 1.0), + roughness: 0.0, + extensions: MaterialExtensions( + transmission: 1.0, + thickness: 0.1, + attenuationColor: Vector3(0.1, 0.9, 0.1), + attenuationDistance: 0.1, + ), + doubleSided: true, + ), + ) + ..setPosition(0.0, 0.0, -2.5) + ..setRotationYawPitchRoll(0.0, math.pi / 2, 0.0); + MeshNode pane(DeviceMesh quad) => + MeshNode( + quad, + Material( + lighting: LightingModel.pbrLayered, + baseColor: Vector4(1.0, 1.0, 1.0, 0.2), + roughness: 0.0, + alphaMode: MaterialAlphaMode.blend, + extensions: MaterialExtensions(transmission: 1.0), + doubleSided: true, + ), + ) + ..setPosition(0.0, 0.0, -1.5) + ..setRotationYawPitchRoll(0.0, math.pi / 2, 0.0); + final bare = _at(_render(pane: volume).hdr, _left.x, _left.y); + final paned = _at( + _render(pane: volume, extra: [pane]).hdr, + _left.x, + _left.y, + ); + // Mutation: read the copy for a thin pane as before. A fifth of the + // red wall comes back over the green, and red more than doubles. + for (var c = 0; c < 3; c++) { + expect(paned[c], closeTo(bare[c], 0.01), reason: 'channel $c'); + } + // And the volume is there to be seen: it takes most of the wall's red. + expect(bare.x, lessThan(_linear(_red).x * 0.2)); + }); + + test('a convex volume is thinner where it is crossed at a slant', () { + // The same tinted pane as a slab and as a convex body. Head-on the ray + // crosses all of either; turned, the body's path is shortened by how + // squarely the bent ray meets it and the slab's is not. + MeshNode Function(DeviceMesh) tinted({ + required bool convex, + double yaw = 0.0, + }) => + (quad) => + MeshNode( + quad, + Material( + lighting: LightingModel.pbrLayered, + baseColor: Vector4(1.0, 1.0, 1.0, 1.0), + roughness: 0.0, + extensions: MaterialExtensions( + transmission: 1.0, + ior: 1.5, + thickness: 0.2, + convexVolume: convex, + attenuationColor: Vector3(0.2, 0.2, 0.2), + attenuationDistance: 0.2, + ), + doubleSided: true, + ), + ) + ..setPosition(0.0, 0.0, -2.0) + ..setRotationYawPitchRoll(yaw, math.pi / 2, 0.0); + Vector4 seen(bool convex, double yaw) => _at( + _render( + pane: tinted(convex: convex, yaw: yaw), + seam: 1.0, + ).hdr, + _size ~/ 2, + _size ~/ 2, + ); + expect(seen(true, 0.0).x, closeTo(seen(false, 0.0).x, 0.005)); + // Turned by 0.9 the bent ray meets the body at a cosine of 0.85, so it + // keeps 0.2^0.85 of the red where the slab keeps 0.2: 1.27 times as much. + // Mutation: drop the `convexVolume` lane in `_encodeNode`, and the two + // turned panes are the same. + expect(seen(true, 0.9).x, greaterThan(seen(false, 0.9).x * 1.2)); + }); + test('every declared slot is bound on a split frame', () { // `scene_colour_texture` is the layered stage's sixteenth sampler; a // draw that left it unbound is a crash on Metal. diff --git a/packages/flutter3d_editor_core/CHANGELOG.md b/packages/flutter3d_editor_core/CHANGELOG.md index 0ed464793..63b056ece 100644 --- a/packages/flutter3d_editor_core/CHANGELOG.md +++ b/packages/flutter3d_editor_core/CHANGELOG.md @@ -1,3 +1,9 @@ +## Unreleased + +- **The draw order of a brush, in the scene and the inspector.** + `LevelScene` gives each batch its brushes' `drawOrder`, a duplicate keeps + it, and the inspector offers it on a brush that has never said. + ## 0.8.0+1 **Resolves on Flutter 3.44 and Dart 3.12.0.** The constraints asked for Dart diff --git a/packages/flutter3d_editor_core/lib/src/editing.dart b/packages/flutter3d_editor_core/lib/src/editing.dart index bb285f1e6..d065d97aa 100644 --- a/packages/flutter3d_editor_core/lib/src/editing.dart +++ b/packages/flutter3d_editor_core/lib/src/editing.dart @@ -253,6 +253,7 @@ final class Editing { shadowCasting: brush.shadowCasting, layer: brush.layer, ramp: brush.ramp, + drawOrder: brush.drawOrder, ), ); selected = level.brushes.length - 1; @@ -481,6 +482,7 @@ final class Editing { 'surface': '', 'layer': 0, 'ramp': '+x', + 'drawOrder': 0, }, Piece.light => const { 'type': 'point', diff --git a/packages/flutter3d_editor_core/lib/src/level_scene.dart b/packages/flutter3d_editor_core/lib/src/level_scene.dart index c30c7c038..39dbdebe3 100644 --- a/packages/flutter3d_editor_core/lib/src/level_scene.dart +++ b/packages/flutter3d_editor_core/lib/src/level_scene.dart @@ -218,7 +218,10 @@ final class LevelScene { // from both faces. See `Brush.shadowCasting` — and note that this // is why surfaces are batched by that answer as well as by // material: a batch is the smallest thing that can answer it. - ..shadowCasting = shadowModeOf(surface.shadowCasting); + ..shadowCasting = shadowModeOf(surface.shadowCasting) + // `P7`: the document's order, which is also why surfaces are + // batched by it. + ..drawOrder = surface.drawOrder; return ( node: node, mesh: mesh, diff --git a/packages/flutter3d_editor_core/test/level_draw_order_test.dart b/packages/flutter3d_editor_core/test/level_draw_order_test.dart new file mode 100644 index 000000000..ad5df597c --- /dev/null +++ b/packages/flutter3d_editor_core/test/level_draw_order_test.dart @@ -0,0 +1,73 @@ +/// A brush's draw order through the editor — `P7`. +/// +/// dart test test/level_draw_order_test.dart +/// +/// The level document says where a brush draws among others of its bucket; +/// these hold that the scene the editor builds draws it there, that a copy +/// keeps it, and that the inspector offers it on a brush that has never said. +library; + +import 'dart:convert'; + +import 'package:flutter3d_cpu/flutter3d_cpu.dart'; +import 'package:flutter3d_editor_core/flutter3d_editor_core.dart'; +import 'package:flutter3d_sim/flutter3d_sim.dart'; +import 'package:test/test.dart'; + +Map _document() => { + 'brushes': [ + { + 'at': [0.0, 0.0, 0.0], + 'size': [4.0, 1.0, 4.0], + 'material': 'stone', + }, + { + 'at': [0.0, 0.6, 0.0], + 'size': [4.0, 0.1, 4.0], + 'material': 'stone', + 'drawOrder': 1, + }, + ], +}; + +void main() { + test('each batch is drawn at its brushes\' place in the order', () { + // Mutation: drop `..drawOrder = surface.drawOrder` from + // `LevelScene._batchOf` — both batches draw at nought, and which of the + // two lands on top is the sort's to decide. + final level = Level.fromJson(_document()); + final batches = const LevelScene().brushBatches( + level, + device: CpuDevice( + width: 4, + height: 4, + shaders: CpuShaderLibrary(builtinCpuShaders()), + ), + ); + expect(batches, hasLength(2)); + expect(batches.map((LevelBatch it) => it.node.drawOrder).toSet(), { + 0, + 1, + }); + }); + + test('a copy keeps the order of the brush it copied', () { + // Mutation: leave `drawOrder` out of the brush `Editing.duplicate` builds. + final editing = Editing.parse( + jsonEncode(_document()), + path: '/levels/order.json', + )..select(Piece.brush, 1); + editing.duplicate(); + expect(editing.brush!.drawOrder, 1); + }); + + test('the inspector offers it on a brush that has never said', () { + final editing = Editing.parse( + jsonEncode(_document()), + path: '/levels/order.json', + )..select(Piece.brush, 0); + expect(editing.offerable, containsPair('drawOrder', 0)); + expect(editing.setField('drawOrder', 2), isTrue); + expect(editing.brush!.drawOrder, 2); + }); +} diff --git a/packages/flutter3d_editor_mcp/CHANGELOG.md b/packages/flutter3d_editor_mcp/CHANGELOG.md index 12f094002..524e7a099 100644 --- a/packages/flutter3d_editor_mcp/CHANGELOG.md +++ b/packages/flutter3d_editor_mcp/CHANGELOG.md @@ -1,3 +1,13 @@ +## Unreleased + +- **An agent can play the level it is editing.** `play` runs the game the + level belongs to through `flutter3d_editor_play`, the editor's own Play, + and waits until it is up or has failed; `play_status`, `play_swap`, + `play_stop` and `play_devices` follow it. While it runs, `save` sends the + level to the game, which takes it without starting over. +- **`EditorSession.save` returns a `Future`**, for that send. + `EditorSession(play:)` takes a `PlaySession` of the caller's making. + ## 0.8.0 Twenty-one tools, where 0.7 had seventeen, and the server can see the level. diff --git a/packages/flutter3d_editor_mcp/README.md b/packages/flutter3d_editor_mcp/README.md index dc3b4131e..5dd4ef9f9 100644 --- a/packages/flutter3d_editor_mcp/README.md +++ b/packages/flutter3d_editor_mcp/README.md @@ -47,6 +47,7 @@ one name in the undo stack, and come back out under the same key. What an edit | `save` | Write it out, or say why it will not | | `screenshot` | A flat picture of the level from a camera you may name | | `report` | What that camera sees of every brush, light and entity, and what is in the way | +| `play`, `play_status`, `play_swap`, `play_stop`, `play_devices` | Run the game the level belongs to, see its console, swap its code, stop it, and choose the device — the editor's Play button, from [`flutter3d_editor_play`](../flutter3d_editor_play). While it runs, `save` sends the level to it | Ten of those are the document commands. The two that are not, `list` and `validate`, were missing from every sketch of this, and missing in the same diff --git a/packages/flutter3d_editor_mcp/lib/flutter3d_editor_mcp.dart b/packages/flutter3d_editor_mcp/lib/flutter3d_editor_mcp.dart index 020584292..e6c55b820 100644 --- a/packages/flutter3d_editor_mcp/lib/flutter3d_editor_mcp.dart +++ b/packages/flutter3d_editor_mcp/lib/flutter3d_editor_mcp.dart @@ -38,3 +38,4 @@ export 'src/editor_server.dart'; export 'src/editor_session.dart'; export 'src/editor_tools.dart'; export 'src/level_view.dart'; +export 'src/play_session.dart'; diff --git a/packages/flutter3d_editor_mcp/lib/src/editor_server.dart b/packages/flutter3d_editor_mcp/lib/src/editor_server.dart index dc31ff8a0..3b6e4d8b1 100644 --- a/packages/flutter3d_editor_mcp/lib/src/editor_server.dart +++ b/packages/flutter3d_editor_mcp/lib/src/editor_server.dart @@ -61,4 +61,8 @@ worth calling before `save`. `undo` goes back sixty-four steps. `screenshot` draws the level in software, untextured, from a camera you may name; `report` says for every brush, light and entity how much of it that camera sees and what is in the way. Look before and after you change things. + +`play` runs the game this level belongs to. While it runs, every `save` goes +to it and the game takes the level without starting over; `play_status` +shows its console, `play_swap` puts changed code into it. '''; diff --git a/packages/flutter3d_editor_mcp/lib/src/editor_session.dart b/packages/flutter3d_editor_mcp/lib/src/editor_session.dart index 1a768c1d5..df40197d7 100644 --- a/packages/flutter3d_editor_mcp/lib/src/editor_session.dart +++ b/packages/flutter3d_editor_mcp/lib/src/editor_session.dart @@ -7,6 +7,7 @@ import 'package:flutter3d_sim/flutter3d_sim.dart'; import 'package:vector_math/vector_math.dart'; import 'level_view.dart'; +import 'play_session.dart'; // What a tool call did, and the sentence to say about it — the one `Answer` // every server here shares, and the `PictureAnswer` that carries a PNG @@ -30,7 +31,8 @@ export 'package:flutter3d_mcp_kit/flutter3d_mcp_kit.dart' /// sixty-four snapshots behind it would quietly become sixty-four snapshots of /// somebody else's work. final class EditorSession { - EditorSession(this.editing); + EditorSession(this.editing, {PlaySession? play}) + : play = play ?? PlaySession(levelPath: editing.path); /// Reads the document at [path]. /// @@ -44,6 +46,9 @@ final class EditorSession { final Editing editing; + /// The game this level belongs to, run from here. + final PlaySession play; + /// What this editor writes into `generatedBy` when it takes ownership of a /// copy. The application writes `apps/flutter3d_editor` for the same reason: /// a document should name what last wrote it. @@ -275,7 +280,10 @@ final class EditorSession { /// opened, changed and saved *somewhere else*, and the copy claims itself — /// a file saved beside the original still naming the generator is a file that /// invites somebody to regenerate it. - Answer save(String? path) { + /// + /// Written over the level the game is playing, it goes to the game too + /// when one is running, as the editor application's save does. + Future save(String? path) async { final elsewhere = path != null && path != editing.path; if (!elsewhere && !editing.mayOverwrite) { return ( @@ -287,10 +295,14 @@ final class EditorSession { ); } final to = path ?? editing.path; - File( - to, - ).writeAsStringSync(editing.write(claiming: elsewhere ? author : null)); + final document = editing.write(claiming: elsewhere ? author : null); + File(to).writeAsStringSync(document); editing.saved(); - return (did: true, says: 'written to $to'); + // Not a copy: the running game plays the original, not the file beside it. + final game = elsewhere ? null : await play.sendSaved(document); + return ( + did: true, + says: game == null ? 'written to $to' : 'written to $to; $game', + ); } } diff --git a/packages/flutter3d_editor_mcp/lib/src/editor_tools.dart b/packages/flutter3d_editor_mcp/lib/src/editor_tools.dart index f9d5481cb..78827cb01 100644 --- a/packages/flutter3d_editor_mcp/lib/src/editor_tools.dart +++ b/packages/flutter3d_editor_mcp/lib/src/editor_tools.dart @@ -111,6 +111,102 @@ Answer Function(EditorSession, Map) _command(String name) => return session.run(command); }; +/// `HR5`: the game the level belongs to, run and driven — the editor +/// application's Play toolbar, as tools. +List get _playTools => [ + _told( + Tool( + name: 'play', + description: + 'Run the game this level belongs to (the Flutter project above the ' + 'level file) with flutter run, and wait until it is up or has ' + 'failed; answers with where it runs and the last lines it printed. ' + 'Once it runs, every save of this level is sent to it and the game ' + 'takes it without starting over. Does nothing if it is already ' + 'running on that device.', + inputSchema: ObjectSchema( + properties: { + 'device': StringSchema( + description: + 'an id play_devices lists; leave out for the tool\'s own choice', + ), + }, + ), + ), + (EditorSession session, Map arguments) { + final device = arguments['device']; + return session.play.start(device: device is String ? device : null); + }, + ), + _told( + Tool( + name: 'play_status', + description: + 'Where the game started by play is — starting, running, stopped — ' + 'and the last lines of its console: what the game printed, build ' + 'errors, what the last play_swap said.', + inputSchema: ObjectSchema( + properties: { + 'lines': IntegerSchema( + description: 'how many console lines; default 40', + minimum: 0, + ), + }, + ), + ), + (EditorSession session, Map arguments) { + final lines = arguments['lines']; + return ( + did: true, + says: session.play.status(lines: lines is int ? lines : 40), + ); + }, + ), + _told( + Tool( + // `play_swap` and not the word flutter itself uses for it: CONTRIBUTING + // keeps that word out of the packages for a weapon's, and `HotSwap` is + // what the engine calls the same thing on the game's side. + name: 'play_swap', + description: + 'Swap the running game\'s code after it changed, the way flutter ' + 'run does on r: the game keeps its state and picks up the new code, ' + 'shaders and models. With restart, a hot restart from main instead. ' + 'Answers with what the tool said, including why it refused.', + inputSchema: ObjectSchema( + properties: { + 'restart': BooleanSchema( + description: 'true for a hot restart; default false', + ), + }, + ), + ), + (EditorSession session, Map arguments) => + session.play.swap(restart: arguments['restart'] == true), + ), + _told( + Tool( + name: 'play_stop', + description: + 'Stop the game started by play, and the flutter run with it.', + inputSchema: ObjectSchema(), + ), + (EditorSession session, Map arguments) => + session.play.stop(), + ), + _told( + Tool( + name: 'play_devices', + description: + 'The devices play can run the game on — this computer, a browser, ' + 'a phone on the cable — one per line with the id play takes.', + inputSchema: ObjectSchema(), + ), + (EditorSession session, Map arguments) => + session.play.listDevices(), + ), +]; + /// The ten document commands, one tool each, under the names they already have. /// /// **Named from [editorCommandNames] and nowhere else.** That list lives beside @@ -493,6 +589,7 @@ List get editorTools => [ return session.save(path is String ? path : null); }, ), + ..._playTools, EditorTool( Tool( name: 'screenshot', diff --git a/packages/flutter3d_editor_mcp/lib/src/play_session.dart b/packages/flutter3d_editor_mcp/lib/src/play_session.dart new file mode 100644 index 000000000..0412a01fb --- /dev/null +++ b/packages/flutter3d_editor_mcp/lib/src/play_session.dart @@ -0,0 +1,172 @@ +import 'dart:async'; + +import 'package:flutter3d_editor_play/flutter3d_editor_play.dart'; +import 'package:flutter3d_mcp_kit/flutter3d_mcp_kit.dart' show Answer; + +/// Makes a run of [projectRoot] on [device]. A parameter of [PlaySession] so +/// a test hands it a run over a tool of its own making. +typedef NewRun = FlutterRun Function(String projectRoot, String? device); + +FlutterRun _newRun(String projectRoot, String? device) => + FlutterRun(projectRoot: projectRoot, device: device); + +/// `HR5` for an agent: the game the open level belongs to, run, reloaded, +/// stopped and sent the level when it is saved — the editor application's +/// Play, through the same [FlutterRun]. +/// +/// **One run per process**, as there is one document: the level belongs to +/// one project, and a second run of it would be a second window of the same +/// game that nothing here could tell apart from the first. +final class PlaySession { + PlaySession({ + required this.levelPath, + this.projectRootOf = projectRootOnDisk, + this.newRun = _newRun, + this.devices = flutterDevices, + this.push = pushLevel, + this.waitFor = const Duration(minutes: 3), + }); + + /// The level being edited; its project is what [start] runs. + final String levelPath; + + final String? Function(String levelPath) projectRootOf; + final NewRun newRun; + final Future> Function() devices; + final Future> Function(String vmService, String level) + push; + + /// How long [start] waits for the game to be up before answering that it + /// is still starting. A first build of a desktop game takes a minute. + final Duration waitFor; + + FlutterRun? _run; + + /// Runs the project, on [device] or the tool's own choice, and waits until + /// it is up, has failed, or [waitFor] has passed. + Future start({String? device}) async { + final root = projectRootOf(levelPath); + if (root == null) { + return ( + did: false, + says: + '$levelPath is not inside a Flutter project, so there is nothing ' + 'to run', + ); + } + final running = _run; + if (running != null && _going(running.state.value)) { + if (device == null || device == running.device) { + return (did: true, says: 'already ${_describe(running)}'); + } + return ( + did: false, + says: + 'the game is running on ${running.device ?? 'the default device'}; ' + 'call play_stop first to run it on $device', + ); + } + unawaited(running?.dispose()); + final run = _run = newRun(root, device); + await run.start(); + await _settled(run); + return ( + did: run.state.value is! PlayStopped, + says: '${_describe(run)}\n${_tail(run, 12)}'.trim(), + ); + } + + /// Where the run is and the last [lines] of its console. + String status({int lines = 40}) { + final run = _run; + if (run == null) return 'not running; call play to start the game'; + return '${_describe(run)}\n${_tail(run, lines)}'.trim(); + } + + /// A hot reload, or a hot restart when [restart], and what the tool said. + Future swap({bool restart = false}) async { + final run = _run; + if (run == null || run.state.value is! PlayRunning) { + return (did: false, says: 'nothing is running; call play first'); + } + try { + final said = restart ? await run.hotRestart() : await run.hotSwap(); + final what = restart ? 'hot restart' : 'hot swap'; + return ( + did: true, + says: said == null || said.isEmpty ? '$what done' : '$what: $said', + ); + } on StateError catch (error) { + return (did: false, says: error.message); + } + } + + /// Stops the game. + Future stop() async { + final run = _run; + if (run == null || !_going(run.state.value)) { + return (did: false, says: 'nothing is running'); + } + await run.stop(); + return (did: true, says: 'asked the game to stop'); + } + + /// What `play` can be pointed at, one line each, with the id it takes. + Future listDevices() async { + try { + final found = await devices(); + if (found.isEmpty) return (did: true, says: 'no devices'); + return (did: true, says: found.map((it) => '$it').join('\n')); + } on Object catch (error) { + return (did: false, says: '$error'); + } + } + + /// Sends [document], just saved, to the game when it is running, and says + /// what the game did with it; null when nothing is running to send it to. + Future sendSaved(String document) async { + if (_run?.state.value case PlayRunning(:final vmService)) { + try { + return describeLevelApplied(await push(vmService, document)); + } on Object catch (error) { + return 'the running game did not take it: $error'; + } + } + return null; + } + + Future dispose() async => _run?.dispose(); + + Future _settled(FlutterRun run) async { + if (_settledState(run.state.value)) return; + try { + await run.state.changes.firstWhere(_settledState).timeout(waitFor); + } on TimeoutException { + // Still starting; the answer says so and `play_status` follows it. + } on StateError { + // The run closed its state before it settled. + } + } + + static bool _settledState(PlayState state) => + state is PlayRunning || state is PlayStopped; + + static bool _going(PlayState state) => + state is PlayStarting || state is PlayRunning; + + static String _describe(FlutterRun run) => switch (run.state.value) { + PlayIdle() => 'not started', + PlayStarting(:final message) => 'still starting: $message', + PlayRunning(:final vmService) => + 'running ${run.projectRoot} on ${run.device ?? 'the default device'}, ' + 'VM service $vmService', + PlayStopped(:final exitCode) => 'stopped (exit code $exitCode)', + }; + + static String _tail(FlutterRun run, int lines) { + final console = run.console.value; + return console + .skip(console.length > lines ? console.length - lines : 0) + .join('\n'); + } +} diff --git a/packages/flutter3d_editor_mcp/pubspec.yaml b/packages/flutter3d_editor_mcp/pubspec.yaml index 23442b6c6..be2e0e241 100644 --- a/packages/flutter3d_editor_mcp/pubspec.yaml +++ b/packages/flutter3d_editor_mcp/pubspec.yaml @@ -21,6 +21,10 @@ dependencies: # `EditorCommand.fromJson` rather than a switch written a second time here. flutter3d_editor_core: ^0.8.0 + # Play: the game the level belongs to, run with `flutter run --machine` + # and sent the level when it is saved — the editor application's own. + flutter3d_editor_play: ^0.8.0 + # `Level`, `LevelIssue` and `LevelValidator`, which `validate` reports. flutter3d_sim: ^0.8.0 diff --git a/packages/flutter3d_editor_mcp/test/play_test.dart b/packages/flutter3d_editor_mcp/test/play_test.dart new file mode 100644 index 000000000..71216a8ce --- /dev/null +++ b/packages/flutter3d_editor_mcp/test/play_test.dart @@ -0,0 +1,221 @@ +/// `HR5` for an agent: Play through the server's tools, over a +/// `flutter run --machine` of the test's own making. +/// +/// dart test test/play_test.dart +library; + +import 'dart:io'; + +import 'package:flutter3d_editor_core/flutter3d_editor_core.dart'; +import 'package:flutter3d_editor_mcp/flutter3d_editor_mcp.dart'; +import 'package:flutter3d_editor_play/flutter3d_editor_play.dart'; +import 'package:flutter3d_editor_play/testing.dart'; +import 'package:test/test.dart'; + +const String _level = ''' +{ + "version": 1, + "materials": {"stone": {"baseColor": [0.5, 0.5, 0.5, 1]}}, + "brushes": [{"at": [0, 0, 0], "size": [4, 1, 4], "material": "stone"}] +} +'''; + +/// A session on a level in a project at `/game`, whose runs are fake, and +/// what the game was sent. +({ + EditorSession session, + List tools, + List pushed, + List devices, +}) +_session({String levelPath = '/game/assets/levels/one.json'}) { + final tools = []; + final pushed = []; + final devices = []; + final play = PlaySession( + levelPath: levelPath, + projectRootOf: (String path) => path.startsWith('/game/') ? '/game' : null, + newRun: (String root, String? device) { + devices.add(device); + final fake = fakeFlutterRun(projectRoot: root, device: device); + tools.add(fake.tool); + return fake.run; + }, + devices: () async => const [ + FlutterDevice(id: 'macos', name: 'macOS', platform: 'darwin'), + ], + push: (String vmService, String level) async { + pushed.add(level); + return { + 'diff': {'fog': true}, + }; + }, + waitFor: const Duration(seconds: 5), + ); + return ( + session: EditorSession(Editing.parse(_level, path: levelPath), play: play), + tools: tools, + pushed: pushed, + devices: devices, + ); +} + +Future _call( + EditorSession session, + String name, [ + Map arguments = const {}, +]) async { + final answer = await editorTools + .firstWhere((EditorTool it) => it.name == name) + .run(session, arguments); + return (did: answer.did, says: answer.says); +} + +/// `play`, answered by the tool as a game coming up. +Future _played( + ({ + EditorSession session, + List tools, + List pushed, + List devices, + }) + it, { + String? device, +}) async { + final answer = _call(it.session, 'play', { + 'device': ?device, + }); + await pumpEventQueue(); + it.tools.last + ..say('Launching lib/main.dart') + ..running(); + return answer; +} + +void main() { + test('a level outside any project has nothing to play', () async { + final it = _session(levelPath: '/elsewhere/one.json'); + final answer = await _call(it.session, 'play'); + expect(answer.did, isFalse); + expect(answer.says, contains('not inside a Flutter project')); + expect(it.tools, isEmpty); + }); + + test('play waits for the game and says where it runs', () async { + final it = _session(); + final answer = await _played(it, device: 'macos'); + + expect(answer.did, isTrue); + expect(answer.says, contains('running /game on macos')); + expect(answer.says, contains('ws://game')); + expect(answer.says, contains('Launching lib/main.dart')); + expect(it.devices, ['macos']); + + final status = await _call(it.session, 'play_status'); + expect(status.says, contains('running /game')); + }); + + test('a game that fails to build is a refusal with its console', () async { + final it = _session(); + final answer = _call(it.session, 'play'); + await pumpEventQueue(); + it.tools.last + ..say('lib/main.dart:3: Error: expected a semicolon') + ..exit(1); + + final said = await answer; + expect(said.did, isFalse); + expect(said.says, contains('stopped (exit code 1)')); + expect(said.says, contains('expected a semicolon')); + }); + + test('a reload is the tool\'s, and so is its answer', () async { + final it = _session(); + await _played(it); + + final reload = _call(it.session, 'play_swap'); + await pumpEventQueue(); + final sent = it.tools.last.sent.single; + expect(sent['method'], 'app.restart'); + expect((sent['params']! as Map)['fullRestart'], isFalse); + it.tools.last.answer(0, { + 'code': 0, + 'message': 'Reloaded 3 of 812 libraries', + }); + + final said = await reload; + expect(said.did, isTrue); + expect(said.says, 'hot swap: Reloaded 3 of 812 libraries'); + }); + + test('nothing running is nothing to reload or stop', () async { + final it = _session(); + expect((await _call(it.session, 'play_swap')).did, isFalse); + expect((await _call(it.session, 'play_stop')).did, isFalse); + expect( + (await _call(it.session, 'play_status')).says, + contains('not running'), + ); + }); + + test('another device while one runs is refused, not a second game', () async { + final it = _session(); + await _played(it, device: 'macos'); + + final again = await _call(it.session, 'play', { + 'device': 'chrome', + }); + expect(again.did, isFalse); + expect(again.says, contains('play_stop')); + expect(it.tools, hasLength(1)); + }); + + test('a save reaches the running game; a copy does not', () async { + final directory = await Directory.systemTemp.createTemp('play_test'); + addTearDown(() => directory.delete(recursive: true)); + final path = '${directory.path}/one.json'; + File(path).writeAsStringSync(_level); + final it = _session(levelPath: path); + // The temporary directory is not under `/game`: the session's project + // is found by path, so a level there is played by pointing at it. + final play = PlaySession( + levelPath: path, + projectRootOf: (String _) => directory.path, + newRun: (String root, String? device) { + final fake = fakeFlutterRun(projectRoot: root, device: device); + it.tools.add(fake.tool); + return fake.run; + }, + push: (String vmService, String level) async { + it.pushed.add(level); + return { + 'diff': {'fog': true}, + }; + }, + waitFor: const Duration(seconds: 5), + ); + final session = EditorSession( + Editing.parse(_level, path: path), + play: play, + ); + final started = _call(session, 'play'); + await pumpEventQueue(); + it.tools.last.running(); + await started; + + final saved = await _call(session, 'save'); + expect(saved.says, contains('the game took the new look')); + expect(it.pushed, hasLength(1)); + + await _call(session, 'save', { + 'path': '${directory.path}/copy.json', + }); + expect(it.pushed, hasLength(1), reason: 'the game plays the original'); + }); + + test('the devices are listed with the id play takes', () async { + final it = _session(); + final answer = await _call(it.session, 'play_devices'); + expect(answer.says, 'macOS (macos, darwin)'); + }); +} diff --git a/packages/flutter3d_editor_mcp/test/tools_test.dart b/packages/flutter3d_editor_mcp/test/tools_test.dart index d4382935e..3bead7a35 100644 --- a/packages/flutter3d_editor_mcp/test/tools_test.dart +++ b/packages/flutter3d_editor_mcp/test/tools_test.dart @@ -41,6 +41,11 @@ void main() { 'screenshot', 'report', 'optimizeLights', + 'play', + 'play_status', + 'play_swap', + 'play_stop', + 'play_devices', }; expect( namesOf(editorTools).difference(editorCommandNames.toSet()), diff --git a/packages/flutter3d_editor_play/CHANGELOG.md b/packages/flutter3d_editor_play/CHANGELOG.md new file mode 100644 index 000000000..8d9c6be9b --- /dev/null +++ b/packages/flutter3d_editor_play/CHANGELOG.md @@ -0,0 +1,42 @@ +## Unreleased + +- **A save goes to the running game as a patch.** `pushLevel(base:)` sends + the patch from `base` to the saved document when the game has + `ext.flutter3d.level.patch` and the patch is shorter, and the whole + document when the game answers that the patch is stale; the answer then + carries `fellBack`, which `describeLevelApplied` puts at the end of its + line. `levelPatchArguments` builds the parameters. +- **`AttachedRun` plays a game somebody else started**, by its VM service + address: the console (with what the game printed before the attach, which + DDS replays), hot reload and restart through the services the flutter tool + registers on the game, and stop as a detach that leaves the game running. + It is the only Play a browser has, and the editor's attach button on a + desktop uses it too. `PlayedGame` is what it and `FlutterRun` both are, so + one panel shows either; `PlayState` moves to `src/play_state.dart`, and + `PlayStopped` takes a `reason` for an end with no exit code. +- **`package:flutter3d_editor_play/attach.dart`**: everything here that + starts no process — `AttachedRun`, `pushLevel`, `connectVmService`, + `PlayState`, `Watched` — with no `dart:io` anywhere under it, which a test + walks the imports to check. +- **`connectVmService` instead of `vmServiceConnectUri`**, over + `web_socket_channel`, so a level is sent from a browser as well; + `vmServiceWebSocket` reads the `http://` address a game prints as well as + the `ws://…/ws` one. `pushLevel` takes a `connect` for tests. +- **`FakeGame` and `fakeAttachedRun`** in `testing.dart`: a VM service with + a flutter tool on it, or without one, that a test controls. +- **A stop on Windows ends the tool, not only its shell.** `flutter` there + starts through `cmd.exe`, and `Process.kill` left the Dart process behind + it building; a stop before the game has started now runs `taskkill /t`. + `FlutterRun` takes the kill as a parameter, and `treeKillCommand` says + which command a platform needs. + +- **Play leaves the editor application.** `FlutterRun`, `projectRootFor` + and `pushLevel` move here from `apps/flutter3d_editor/lib/src/play`, with + `Watched` in place of Flutter's `ValueNotifier`, so the editor's MCP server + runs the same Play. `FlutterRun.device` is settable between runs, and + `hotSwap`/`hotRestart` return what the tool said. +- **`flutterDevices` lists what `flutter run -d` can be given**, from + `flutter devices --machine`, reading past whatever the tool prints first + and leaving out devices it marks unsupported. +- **`package:flutter3d_editor_play/testing.dart`**: `FakeFlutterTool` and + `fakeFlutterRun`, a `flutter run --machine` a test controls. diff --git a/packages/flutter3d_editor_play/LICENSE b/packages/flutter3d_editor_play/LICENSE new file mode 100644 index 000000000..c602d8d40 --- /dev/null +++ b/packages/flutter3d_editor_play/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Dmitrii Zolotov + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/flutter3d_editor_play/README.md b/packages/flutter3d_editor_play/README.md new file mode 100644 index 000000000..24b19429d --- /dev/null +++ b/packages/flutter3d_editor_play/README.md @@ -0,0 +1,73 @@ +# flutter3d_editor_play + +Play from a level editor. The game a level belongs to is run with +`flutter run --machine`, reloaded, restarted and stopped from the editor, and +a level saved while it runs is sent to it and taken without the game starting +over. + +It is what the Play button of `apps/flutter3d_editor` runs, and what the +`play` tools of [`flutter3d_editor_mcp`](../flutter3d_editor_mcp) run, so an +agent's Play and a person's are the same code. + +```dart +final root = projectRootOnDisk('game/assets/levels/first.json'); +final run = FlutterRun(projectRoot: root!, device: 'macos'); +await run.start(); +run.state.changes.listen((PlayState state) => print(state)); + +// Later, after the code changed: +print(await run.hotSwap()); // "Reloaded 3 of 812 libraries" + +// After a save: +if (run.state.value case PlayRunning(:final vmService)) { + print(describeLevelApplied(await pushLevel(vmService, savedDocument))); +} +``` + +| | | +|---|---| +| `FlutterRun` | One `flutter run --machine`: its state, its console, reload, restart, stop | +| `projectRootFor`, `projectRootOnDisk` | The project a level file belongs to: the nearest `pubspec.yaml` above it | +| `flutterDevices`, `parseFlutterDevices` | What `-d` can be given, from `flutter devices --machine` | +| `pushLevel` | A saved level, sent to the running game's `ext.flutter3d.level.apply` — or, given the `base` it was saved over, as a patch to `ext.flutter3d.level.patch`, whole only when the game says the patch is stale | +| `Watched` | A value and a stream of its changes, where Flutter's `ValueNotifier` cannot go | +| `AttachedRun` | A game somebody else started, by its VM service address: console, hot reload and restart, detach | +| `PlayedGame` | What `FlutterRun` and `AttachedRun` both are, for one panel over either | +| `connectVmService` | A VM service connection over a socket a browser has too | + +`package:flutter3d_editor_play/testing.dart` has `FakeFlutterTool`, a +`flutter run --machine` a test controls, and `fakeFlutterRun` over it; +`FakeGame`, a VM service a test controls, and `fakeAttachedRun` over it. + +## In a browser + +A web build has no process to start, so it imports +`package:flutter3d_editor_play/attach.dart`, which has no `dart:io` under it, +and attaches to a game that is already running: + +```dart +final game = AttachedRun('http://127.0.0.1:8181/abc=/'); // what the game printed +await game.start(); +print(await game.hotSwap()); // "Swapped in the new code" +print(describeLevelApplied(await pushLevel(game.vmService, savedDocument))); +await game.stop(); // lets go; the game keeps running +``` + +The hot reload is not the VM's: a Flutter game cannot compile its own new +code. The `flutter run` or `flutter attach` connected to it registers +`reloadSources` and `hotRestart` on its VM service, and `AttachedRun` calls +those, as DevTools does. With no tool connected it says so and what to run. +Checked against a real macOS game from the Dart VM and from Chrome, on a +page served from `localhost`. + +## Why a package of its own + +Play starts a process and talks to it. `flutter3d_editor_core` is written +never to do either, so a validator or a web build can open a level with it, +and the editor's MCP server could not reach code that lived inside the editor +application. Here both depend on it, and neither on the other. It is plain +Dart with `dart:io`: it runs under `dart run` and in a desktop app, and only +`attach.dart` of it in a browser, where there is no process to start. + +The game side of it, the extension a level is sent to, is `LiveLevel` in +`flutter3d_game`. diff --git a/packages/flutter3d_editor_play/analysis_options.yaml b/packages/flutter3d_editor_play/analysis_options.yaml new file mode 100644 index 000000000..0fdfb4706 --- /dev/null +++ b/packages/flutter3d_editor_play/analysis_options.yaml @@ -0,0 +1,11 @@ +include: ../../analysis_options.yaml + +analyzer: + exclude: + - build/** + - android/** + - ios/** + - web/** + - windows/** + - macos/** + - linux/** diff --git a/packages/flutter3d_editor_play/lib/attach.dart b/packages/flutter3d_editor_play/lib/attach.dart new file mode 100644 index 000000000..00de0f33c --- /dev/null +++ b/packages/flutter3d_editor_play/lib/attach.dart @@ -0,0 +1,16 @@ +/// The half of Play a browser can run: a game somebody else started, +/// attached to by its VM service address, with its console, its hot reload +/// and restart, and a saved level sent to it. +/// +/// **No `dart:io` anywhere under it**, which `test/attach_web_test.dart` +/// checks, since a web build compiles with `dart:io` in it and fails only +/// when it is called: the web editor imports this and not +/// `flutter3d_editor_play.dart`, whose `FlutterRun` starts a process a +/// browser does not have. +library; + +export 'src/attached_run.dart'; +export 'src/level_push.dart'; +export 'src/play_state.dart'; +export 'src/vm_connect.dart'; +export 'src/watched.dart'; diff --git a/packages/flutter3d_editor_play/lib/flutter3d_editor_play.dart b/packages/flutter3d_editor_play/lib/flutter3d_editor_play.dart new file mode 100644 index 000000000..9321787e7 --- /dev/null +++ b/packages/flutter3d_editor_play/lib/flutter3d_editor_play.dart @@ -0,0 +1,12 @@ +/// Play from a level editor: the game a level belongs to, run with +/// `flutter run --machine`, reloaded, restarted and stopped, and handed a +/// level as soon as it is saved. +/// +/// Used by the editor application's toolbar and by `flutter3d_editor_mcp`, +/// so an agent's Play is a person's Play. A browser imports `attach.dart` +/// instead, which is everything here that starts no process. +library; + +export 'attach.dart'; +export 'src/devices.dart'; +export 'src/flutter_run.dart'; diff --git a/packages/flutter3d_editor_play/lib/src/attached_run.dart b/packages/flutter3d_editor_play/lib/src/attached_run.dart new file mode 100644 index 000000000..78bdc2caf --- /dev/null +++ b/packages/flutter3d_editor_play/lib/src/attached_run.dart @@ -0,0 +1,288 @@ +/// `HR5` in a browser: a game somebody else started, attached to by its VM +/// service address. +/// +/// The web editor has no process to start, so it cannot run the project the +/// way the desktop one does. What it can do is open the same WebSocket +/// DevTools opens, and everything the desktop Play panel does once the game +/// is running goes over that socket anyway, except the hot reload itself, +/// which the flutter tool that started the game offers on it. +library; + +import 'dart:async'; +import 'dart:convert'; + +import 'package:vm_service/vm_service.dart'; + +import 'play_state.dart'; +import 'vm_connect.dart'; +import 'watched.dart'; + +/// A running game, attached to at [vmService] rather than started. +/// +/// **The reload is the flutter tool's, found on the socket.** A Flutter game +/// cannot recompile itself: the kernel for a hot reload is built by the +/// `flutter run` (or `flutter attach`) that is connected to it, and that tool +/// registers `reloadSources` and `hotRestart` on the game's VM service for +/// exactly this, which is how DevTools' own reload button works. So the +/// buttons call what the tool registered, and when no tool is connected they +/// say so rather than calling the VM's bare `reloadSources`, which would +/// answer that nothing changed. +/// +/// **Its console starts with what the game printed before.** The service a +/// flutter tool puts in front of the VM (DDS) keeps the game's stdout and its +/// `log` calls and replays them to whoever listens, so an editor attaching +/// late still sees the start-up — seen on a real macOS run, ticks from before +/// the attach arriving first. The replay comes a stream at a time, so prints +/// and logs from before the attach are not interleaved as they happened. A +/// VM with no DDS in front gives only what follows. +final class AttachedRun implements PlayedGame { + AttachedRun(this.vmService, {this._connect = connectVmService}); + + /// The address a person gave, in any spelling `vmServiceWebSocket` reads. + final String vmService; + + final ConnectVmService _connect; + + @override + String get title => vmService; + + @override + bool get ownsTheGame => false; + + @override + final Watched state = Watched(const PlayIdle()); + + @override + final Watched> console = Watched>(const []); + + static const int consoleLimit = 2000; + + /// The names the flutter tool registered its services under, by service: + /// `reloadSources` → `s0.reloadSources`. + final Map _services = {}; + + VmService? _service; + bool _lettingGo = false; + + /// What each output stream has written since its last newline. + final Map _partial = {}; + final List> _listening = + >[]; + + /// The services a flutter tool registers on the game it runs, by the + /// protocol's names. + static const String swapService = 'reloadSources'; + static const String restartService = 'hotRestart'; + + @override + Future start() async { + if (_service != null) return; + state.value = PlayStarting('Attaching to $vmService…'); + final VmService service; + try { + service = await _connect(vmService); + } on Object catch (error) { + _print('could not attach to $vmService: $error'); + state.value = const PlayStopped( + -1, + reason: + 'Could not attach: is the game running, and is this the ' + 'address it printed?', + ); + return; + } + final VM vm; + try { + vm = await service.getVM(); + } on Object catch (error) { + await service.dispose(); + _print('$vmService answered, but not as a VM service: $error'); + state.value = const PlayStopped( + -1, + reason: + 'Could not attach: is the game running, and is this the ' + 'address it printed?', + ); + return; + } + _service = service; + _lettingGo = false; + _listening.addAll(>[ + service.onStdoutEvent.listen((Event it) => _printed('stdout', it)), + service.onStderrEvent.listen((Event it) => _printed('stderr', it)), + service.onLoggingEvent.listen(_logged), + service.onServiceEvent.listen(_registered), + ]); + // Subscribing to `Service` replays what is already registered, so a tool + // that connected long before the editor is still found. + for (final stream in const [ + EventStreams.kStdout, + EventStreams.kStderr, + EventStreams.kLogging, + EventStreams.kService, + ]) { + try { + await service.streamListen(stream); + } on RPCError { + // Already listened to on this connection, or a VM without the + // stream: the console is then a stream shorter, nothing more. + } + } + unawaited(service.onDone.then((_) => _gone())); + state.value = PlayRunning(appId: vm.name ?? 'vm', vmService: vmService); + } + + @override + Future hotSwap() => _tool( + swapService, + isolate: true, + doing: 'swap new code into', + done: 'Swapped in the new code', + ); + + @override + Future hotRestart() => _tool( + restartService, + isolate: false, + doing: 'restart', + done: 'Restarted with the new code', + ); + + /// Lets go of the game, which keeps running. + @override + Future stop() async { + final service = _service; + if (service == null) return; + _lettingGo = true; + await service.dispose(); + await _gone(); + } + + @override + Future dispose() async { + await stop(); + await state.close(); + await console.close(); + } + + /// Calls the flutter tool's service [name]; [doing] and [done] are what + /// the console says of it. + Future _tool( + String name, { + required bool isolate, + required String doing, + required String done, + }) async { + final service = _service; + if (service == null) return null; + final method = _services[name]; + if (method == null) { + return _say( + 'nothing attached to this game can $doing it: the new code is ' + 'compiled by the flutter tool that runs the game, and none has ' + 'registered $name here. Start the game with `flutter run`, or ' + '`flutter attach` to it, and attach to the address that prints', + ); + } + try { + final response = await service.callMethod( + method, + isolateId: isolate ? await _mainIsolate(service) : null, + args: {'pause': false, 'force': false}, + ); + return _say(switch (response.json) { + {'type': 'Success'} || null => done, + final Map other => '${other['type'] ?? other}', + }); + } on RPCError catch (error) { + // A compile error is printed by the tool in its own terminal; what + // comes back here is only that the new code did not go in. + final details = error.details ?? ''; + return _say( + 'the tool refused to $doing the game: ${error.message}' + '${details.isEmpty ? '' : ': $details'}', + ); + } on Object catch (error) { + return _say('could not ask for $name: $error'); + } + } + + /// The isolate the game runs in: the one called `main` when there is one, + /// since a game may have spawned others, and the first otherwise. + Future _mainIsolate(VmService service) async { + final isolates = (await service.getVM()).isolates ?? const []; + if (isolates.isEmpty) { + throw StateError('the VM at $vmService reports no isolates'); + } + return (isolates.where((it) => it.name == 'main').firstOrNull ?? + isolates.first) + .id!; + } + + void _registered(Event event) { + switch (event) { + case Event( + kind: EventKind.kServiceRegistered, + :final String service, + :final String method, + ): + _services[service] = method; + case Event(kind: EventKind.kServiceUnregistered, :final String service): + _services.remove(service); + default: + break; + } + } + + /// **A line is printed when its newline arrives, not with each write.** The + /// engine sends a `print` as two writes, the text and then `\n` — seen on a + /// real macOS run — so a write is not a line, and taking it for one put an + /// empty line after everything the game printed. + void _printed(String stream, Event event) { + if (event.bytes case final String bytes) { + final lines = + ((_partial[stream] ?? '') + + utf8.decode(base64.decode(bytes), allowMalformed: true)) + .split('\n'); + _partial[stream] = lines.last; + lines + .take(lines.length - 1) + .map((line) => line.replaceFirst(RegExp(r'\r$'), '')) + .forEach(_print); + } + } + + void _logged(Event event) { + if (event.logRecord?.message?.valueAsString case final String message) { + _print(message); + } + } + + Future _gone() async { + if (_service == null) return; + _service = null; + _services.clear(); + // A last line the game never ended is still a line it printed. + _partial.values.where((it) => it.isNotEmpty).forEach(_print); + _partial.clear(); + for (final listening in _listening) { + await listening.cancel(); + } + _listening.clear(); + state.value = PlayStopped( + 0, + reason: _lettingGo + ? 'Detached; the game keeps running' + : 'The game closed the connection', + ); + } + + String _say(String line) { + _print(line); + return line; + } + + void _print(String line) { + console.value = appendLine(console.value, line, consoleLimit); + } +} diff --git a/packages/flutter3d_editor_play/lib/src/devices.dart b/packages/flutter3d_editor_play/lib/src/devices.dart new file mode 100644 index 000000000..8b04b067d --- /dev/null +++ b/packages/flutter3d_editor_play/lib/src/devices.dart @@ -0,0 +1,86 @@ +import 'dart:convert'; +import 'dart:io'; + +/// A device `flutter run -d` can be given, as `flutter devices --machine` +/// lists it. +final class FlutterDevice { + const FlutterDevice({ + required this.id, + required this.name, + required this.platform, + this.emulator = false, + }); + + /// What `-d` takes. + final String id; + + /// What a person calls it: "Galaxy A55", "macOS", "Chrome". + final String name; + + /// The tool's `targetPlatform`: `darwin`, `android-arm64`, `web-javascript`. + final String platform; + + final bool emulator; + + Map toJson() => { + 'id': id, + 'name': name, + 'platform': platform, + 'emulator': emulator, + }; + + @override + String toString() => '$name ($id, $platform${emulator ? ', emulator' : ''})'; +} + +/// The devices in what `flutter devices --machine` printed. +/// +/// **From the first `[` on**, because the tool may say something before the +/// list — that it is downloading an artifact, that a newer version exists — +/// and that is not a reason to offer no devices. Entries the tool marks +/// unsupported, or that lack an id, are left out: `-d` would refuse them. +List parseFlutterDevices(String printed) { + final start = printed.indexOf('['); + if (start < 0) return const []; + final Object? decoded; + try { + decoded = jsonDecode(printed.substring(start)); + } on FormatException { + return const []; + } + if (decoded is! List) return const []; + return [ + for (final entry in decoded) + if (entry case { + 'id': final String id, + 'name': final String name, + } when entry['isSupported'] != false) + FlutterDevice( + id: id, + name: name, + platform: '${entry['targetPlatform'] ?? 'unknown'}', + emulator: entry['emulator'] == true, + ), + ]; +} + +/// Runs `flutter` with [arguments] to the end. A parameter of +/// [flutterDevices] so a test answers for the tool. +typedef RunFlutter = Future Function(List arguments); + +Future _runFlutter(List arguments) => + Process.run('flutter', arguments, runInShell: Platform.isWindows); + +/// What `flutter run -d` could be pointed at on this machine now. +/// +/// Throws a [StateError] with what the tool said when it fails, since "no +/// devices" and "flutter is not on the path" are different news. +Future> flutterDevices({ + RunFlutter run = _runFlutter, +}) async { + final result = await run(const ['devices', '--machine']); + if (result.exitCode != 0) { + throw StateError('flutter devices failed: ${result.stderr}'.trim()); + } + return parseFlutterDevices('${result.stdout}'); +} diff --git a/packages/flutter3d_editor_play/lib/src/flutter_run.dart b/packages/flutter3d_editor_play/lib/src/flutter_run.dart new file mode 100644 index 000000000..148475e0c --- /dev/null +++ b/packages/flutter3d_editor_play/lib/src/flutter_run.dart @@ -0,0 +1,297 @@ +/// `HR5`: the project a level belongs to, run from the editor. +/// +/// `flutter run --machine` and not a game built into the editor: the level +/// belongs to another application, with its own code, packages and entry +/// point, and the only thing that runs it the way its author does is the +/// author's own `flutter run`. `--machine` makes it a daemon the editor talks +/// to in JSON lines — the same protocol an IDE uses — so a hot reload from the +/// toolbar is the same hot reload the terminal would have done, and the game +/// answers it through `HotSwap` the same way. +library; + +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'play_state.dart'; +import 'watched.dart'; + +/// Starts `flutter` with [arguments] in [workingDirectory]. A parameter of +/// [FlutterRun] so a test hands it a process of its own making. +typedef StartFlutter = + Future Function(List arguments, String workingDirectory); + +Future _startFlutter( + List arguments, + String workingDirectory, +) => Process.start( + 'flutter', + arguments, + workingDirectory: workingDirectory, + runInShell: Platform.isWindows, +); + +/// Ends a `flutter` process [StartFlutter] started. A parameter of +/// [FlutterRun] so a test sees a stop reach the process. +typedef KillFlutter = void Function(Process process); + +void _killFlutter(Process process) { + switch (treeKillCommand(process.pid, windows: Platform.isWindows)) { + case [final executable, ...final arguments]: + unawaited(Process.run(executable, arguments)); + default: + process.kill(); + } +} + +/// The command that ends [pid] together with everything it started, or null +/// where [Process.kill] already reaches the tool. +/// +/// **On Windows the process is a shell, not the tool.** `flutter` there is +/// `flutter.bat`, so [StartFlutter] goes through `cmd.exe`, and +/// [Process.kill] ends the `cmd.exe` and leaves the Dart process behind it +/// building, holding the project's `build` directory open. `taskkill /t` +/// takes the tree. On macOS and Linux `bin/flutter` `exec`s the tool, so the +/// pid is already the tool's. +List? treeKillCommand(int pid, {required bool windows}) => + windows ? ['taskkill', '/pid', '$pid', '/t', '/f'] : null; + +/// One `flutter run --machine`, from start to exit. +/// +/// **Its console is the tool's, line for line.** What the game prints, what +/// the tool reports and any line that is not the protocol at all — a build +/// error comes out as plain text before the daemon has said anything — all +/// go to [console] in the order they arrived, because the moment somebody +/// needs this panel is the moment something went wrong, and a filtered +/// console hides exactly that. +final class FlutterRun implements PlayedGame { + FlutterRun({ + required this.projectRoot, + this.device, + this._start = _startFlutter, + this._kill = _killFlutter, + }); + + /// The directory with the project's `pubspec.yaml`. + final String projectRoot; + + /// `-d` for `flutter run`; the tool's own choice when null. Read by the + /// next [start], so a picker sets it between runs. + String? device; + + final StartFlutter _start; + + final KillFlutter _kill; + + @override + String get title => projectRoot; + + @override + bool get ownsTheGame => true; + + /// Where the run is; a panel rebuilds from it. + @override + final Watched state = Watched(const PlayIdle()); + + /// Every line the run has printed, oldest first. Capped at [consoleLimit], + /// dropping the oldest, so a game that logs every frame costs a bounded + /// list and not the editor's memory. + @override + final Watched> console = Watched>(const []); + + static const int consoleLimit = 2000; + + Process? _process; + String? _appId; + var _nextId = 0; + final Map> _pending = >{}; + + /// Starts the run. Does nothing while one is already going. + @override + Future start() async { + if (_process != null) return; + state.value = const PlayStarting('Starting flutter run…'); + final Process process; + try { + process = await _start([ + 'run', + '--machine', + if (device case final String id) ...['-d', id], + ], projectRoot); + } on ProcessException catch (error) { + _print('could not start flutter: ${error.message}'); + state.value = const PlayStopped(-1); + return; + } + _process = process; + process.stdout + .transform(utf8.decoder) + .transform(const LineSplitter()) + .listen(_line); + process.stderr + .transform(utf8.decoder) + .transform(const LineSplitter()) + .listen(_print); + unawaited( + process.exitCode.then((int code) { + _process = null; + _appId = null; + for (final pending in _pending.values) { + pending.completeError(StateError('flutter run exited ($code)')); + } + _pending.clear(); + state.value = PlayStopped(code); + }), + ); + } + + /// A hot reload: the game keeps its state, and `HotSwap` relinks shaders + /// and swaps changed models from `reassemble`. What the tool said about it, + /// "Reloaded 3 of 812 libraries" or why it refused, or null when nothing is + /// running. + @override + Future hotSwap() => _restart(fullRestart: false); + + /// A hot restart: the game starts over from `main`, with the new code. + @override + Future hotRestart() => _restart(fullRestart: true); + + /// Asks the game to stop, and the tool with it. + @override + Future stop() async { + final appId = _appId; + if (appId == null) { + if (_process case final Process process) _kill(process); + return; + } + try { + await _send('app.stop', {'appId': appId}); + } on StateError { + // Already gone. + } + } + + /// Stops the run if there is one and lets go of the process. + @override + Future dispose() async { + await stop(); + await state.close(); + await console.close(); + } + + Future _restart({required bool fullRestart}) async { + final appId = _appId; + if (appId == null) return null; + final result = await _send('app.restart', { + 'appId': appId, + 'fullRestart': fullRestart, + 'pause': false, + 'reason': 'manual', + }); + // "Reloaded 3 of 812 libraries" as well as a refusal: after a press of + // the button, the console is where somebody looks to see it happened. + if (result case {'message': final String message} when message.isNotEmpty) { + _print(message); + return message; + } + return ''; + } + + Future _send(String method, Map params) { + final process = _process; + if (process == null) { + return Future.error(StateError('nothing is running')); + } + final id = _nextId++; + final reply = Completer(); + _pending[id] = reply; + process.stdin.writeln( + jsonEncode([ + {'id': id, 'method': method, 'params': params}, + ]), + ); + return reply.future; + } + + void _line(String line) { + // The protocol wraps each message in a one-element list; anything else + // on stdout is the tool talking before or around the daemon. + final Object? decoded; + try { + decoded = line.startsWith('[{') ? jsonDecode(line) : null; + } on FormatException { + _print(line); + return; + } + switch (decoded) { + case [final Map message]: + _message(message); + default: + _print(line); + } + } + + void _message(Map message) { + switch (message) { + case {'id': final int id, 'error': final Object? error}: + _pending.remove(id)?.completeError(StateError('$error')); + case {'id': final int id}: + _pending.remove(id)?.complete(message['result']); + case {'event': 'app.start', 'params': {'appId': final String appId}}: + _appId = appId; + case { + 'event': 'app.debugPort', + 'params': {'appId': final String appId, 'wsUri': final String uri}, + }: + _appId = appId; + state.value = PlayRunning(appId: appId, vmService: uri); + case {'event': 'app.log', 'params': {'log': final String log}}: + _print(log); + case { + 'event': 'daemon.logMessage', + 'params': {'message': final String m}, + }: + _print(m); + case { + 'event': 'app.progress', + 'params': {'message': final String progress}, + }: + _print(progress); + if (state.value is PlayStarting) { + state.value = PlayStarting(progress); + } + default: + // `app.started`, `app.stop`, `daemon.connected`, `device.*` and + // whatever a later tool adds: nothing the panel shows. + break; + } + } + + void _print(String line) { + console.value = appendLine(console.value, line, consoleLimit); + } +} + +/// The directory of the nearest `pubspec.yaml` above [levelPath], or null +/// when the level is not inside a project at all. +/// +/// [hasPubspec] answers for a directory; injected, like +/// `Documents.assetRootFor`'s own check, so this is tested with no disk. +String? projectRootFor( + String levelPath, { + required bool Function(String directory) hasPubspec, +}) => _nearest(File(levelPath).parent, hasPubspec); + +String? _nearest(Directory directory, bool Function(String) hasPubspec) => + switch (directory.parent) { + _ when hasPubspec(directory.path) => directory.path, + final parent when parent.path == directory.path => null, + final parent => _nearest(parent, hasPubspec), + }; + +/// [projectRootFor], asking the disk. +String? projectRootOnDisk(String levelPath) => projectRootFor( + levelPath, + hasPubspec: (String directory) => + File('$directory${Platform.pathSeparator}pubspec.yaml').existsSync(), +); diff --git a/packages/flutter3d_editor_play/lib/src/level_push.dart b/packages/flutter3d_editor_play/lib/src/level_push.dart new file mode 100644 index 000000000..ef2eb668b --- /dev/null +++ b/packages/flutter3d_editor_play/lib/src/level_push.dart @@ -0,0 +1,142 @@ +/// `HR3`'s editor half: a saved level, sent to the game running it. +library; + +import 'dart:convert'; + +import 'package:flutter3d_sim/flutter3d_sim.dart'; +import 'package:vm_service/vm_service.dart'; + +import 'vm_connect.dart'; + +/// The parameters `ext.flutter3d.level.apply` takes for [document]: the text +/// as saved, and the digest of the level it parses to. +/// +/// The digest is taken from the parsed level, not from the text, because the +/// game computes it the same way from what it receives: two documents that +/// differ only in whitespace are the same level and digest the same. +Map levelApplyArguments(String document) => { + 'document': document, + 'hash': Level.fromJson( + jsonDecode(document) as Map, + ).digestHex, +}; + +/// The parameters `ext.flutter3d.level.patch` takes to turn [base], the level +/// the game is believed to have, into [document]; null when the patch would +/// be no shorter than the document, or [base] is not a level at all. +/// +/// **A guess, checked at the other end.** The editor cannot see which +/// version the game is playing — it may have been started from an older +/// file, or have refused the last save — so [base] is what the editor last +/// wrote, and the patch names its digest. A wrong guess costs one refusal and +/// the whole document after it. +Map? levelPatchArguments(String base, String document) { + final Level before; + try { + before = Level.fromJson(jsonDecode(base) as Map); + } on Object { + return null; + } + final after = Level.fromJson(jsonDecode(document) as Map); + final patch = jsonEncode(LevelPatch.between(before, after)); + return patch.length < document.length + ? {'patch': patch} + : null; +} + +/// What the game said it did with a level, in a line for the status strip. +/// +/// A level that went whole after its patch was refused says why at the end: +/// it is the one sign that the editor and the game disagreed about which +/// version was running. +String describeLevelApplied(Map answer) { + final line = _describeApplied(answer); + return switch (answer['fellBack']) { + final String reason => '$line (sent whole: $reason)', + _ => line, + }; +} + +String _describeApplied(Map answer) { + final diff = answer['diff'] as Map? ?? const {}; + final simulation = (diff['simulation'] as List? ?? const []) + .cast(); + return switch (answer) { + {'swappedAt': final int step} => + 'the game replayed from step $step with the new ' + '${simulation.join(', ')}', + {'rebuiltInPlace': true} => + 'the game rebuilt ${simulation.join(', ')} in place ' + '(it has no timeline to replay)', + _ when simulation.isEmpty && diff.values.every(_unchanged) => + 'the game already had this level', + _ => 'the game took the new look', + }; +} + +bool _unchanged(Object? value) => switch (value) { + false => true, + final List list => list.isEmpty, + _ => false, +}; + +/// Sends [document] to the game whose VM service is at [vmService], to the +/// isolate that answers `ext.flutter3d.level.apply`. +/// +/// With a [base] — the level the game is believed to be playing — what goes +/// first is the patch from it to [document] (`ext.flutter3d.level.patch`), +/// and the whole document only when the game answers that the patch is +/// stale, when it predates patches, or when the patch would be no shorter. +/// The answer then carries `fellBack`, the game's reason. +/// +/// Throws when no isolate there has registered it: a game that never called +/// `registerLevelExtension` cannot take a level, and saying so is better +/// than a silence the person reads as "sent". +Future> pushLevel( + String vmService, + String document, { + String? base, + ConnectVmService connect = connectVmService, +}) async { + const method = 'ext.flutter3d.level.apply'; + const patchMethod = 'ext.flutter3d.level.patch'; + final service = await connect(vmService); + try { + final vm = await service.getVM(); + for (final ref in vm.isolates ?? const []) { + final isolate = await service.getIsolate(ref.id!); + final rpcs = isolate.extensionRPCs ?? const []; + if (!rpcs.contains(method)) continue; + final patch = base == null || !rpcs.contains(patchMethod) + ? null + : levelPatchArguments(base, document); + final String? fellBack; + if (patch != null) { + try { + final response = await service.callServiceExtension( + patchMethod, + isolateId: ref.id, + args: patch, + ); + return response.json ?? const {}; + } on RPCError catch (error) { + if (error.code != LevelPatch.staleCode) rethrow; + fellBack = error.details ?? error.message; + } + } else { + fellBack = null; + } + final response = await service.callServiceExtension( + method, + isolateId: ref.id, + args: levelApplyArguments(document), + ); + return {...?response.json, 'fellBack': ?fellBack}; + } + throw StateError( + 'the running game does not take levels: nothing registered $method', + ); + } finally { + await service.dispose(); + } +} diff --git a/packages/flutter3d_editor_play/lib/src/play_state.dart b/packages/flutter3d_editor_play/lib/src/play_state.dart new file mode 100644 index 000000000..886327503 --- /dev/null +++ b/packages/flutter3d_editor_play/lib/src/play_state.dart @@ -0,0 +1,83 @@ +/// Where a game the editor plays is, and what the editor can do to it, +/// whether the editor started it or found it already running. +library; + +import 'watched.dart'; + +/// Where a run is: not yet started, starting, running with a VM service to +/// attach to, or over. +sealed class PlayState { + const PlayState(); +} + +final class PlayIdle extends PlayState { + const PlayIdle(); +} + +final class PlayStarting extends PlayState { + const PlayStarting(this.message); + + /// What the tool says it is doing: "Launching lib/main.dart…". + final String message; +} + +final class PlayRunning extends PlayState { + const PlayRunning({required this.appId, required this.vmService}); + + final String appId; + + /// The game's VM service, for the timeline panel and `ext.flutter3d.*`. + final String vmService; +} + +final class PlayStopped extends PlayState { + const PlayStopped(this.exitCode, {this.reason}); + + final int exitCode; + + /// What ended it, when an exit code says nothing: an attached game has + /// none to give, since the editor let go of it or it closed the socket. + final String? reason; +} + +/// A game the editor plays: one it started with `flutter run --machine` +/// (`FlutterRun`) or one it attached to by its VM service (`AttachedRun`). +/// +/// **One panel for both.** The browser has no process to start, so there the +/// game is always one somebody else ran; what a person does to it is the +/// same — read its console, swap in new code, open its timeline, send it the +/// saved level — and a second panel would be a second place for that to +/// drift. +abstract interface class PlayedGame { + /// What the panel's title says: the project, or the address attached to. + String get title; + + /// Whether [stop] ends the game, or only lets go of it. + bool get ownsTheGame; + + Watched get state; + + /// Every line the game has printed, oldest first, capped. + Watched> get console; + + /// Runs the game, or attaches to it again. Does nothing while it goes. + Future start(); + + /// A hot reload: what was said about it, or null when nothing is running. + Future hotSwap(); + + /// A hot restart: what was said about it, or null when nothing is running. + Future hotRestart(); + + /// Ends the game when [ownsTheGame], and lets go of it otherwise. + Future stop(); + + Future dispose(); +} + +/// The lines of the console a session keeps, with [line] added and the +/// oldest dropped past [limit]. +List appendLine(List lines, String line, int limit) => [ + ...lines.length >= limit ? lines.skip(lines.length - limit + 1) : lines, + line, +]; diff --git a/packages/flutter3d_editor_play/lib/src/vm_connect.dart b/packages/flutter3d_editor_play/lib/src/vm_connect.dart new file mode 100644 index 000000000..bcac19f97 --- /dev/null +++ b/packages/flutter3d_editor_play/lib/src/vm_connect.dart @@ -0,0 +1,54 @@ +/// A running game's VM service, reached from a desktop and from a browser. +library; + +import 'dart:async'; + +import 'package:vm_service/vm_service.dart'; +import 'package:web_socket_channel/web_socket_channel.dart'; + +/// Connects to a VM service. A parameter of what attaches to a game, so a +/// test hands it a service of its own making. +typedef ConnectVmService = Future Function(String uri); + +/// The WebSocket address of a VM service, from whatever a person pasted: the +/// `http://` address a game prints on startup, the `ws://…/ws` one +/// `flutter run --machine` reports, with or without a trailing slash. +String vmServiceWebSocket(String uri) { + final trimmed = uri.trim(); + final withScheme = trimmed + .replaceFirst(RegExp('^http://'), 'ws://') + .replaceFirst(RegExp('^https://'), 'wss://'); + return withScheme.endsWith('/ws') + ? withScheme + : '${withScheme.replaceFirst(RegExp(r'/$'), '')}/ws'; +} + +/// Connects to the VM service at [uri], in any of [vmServiceWebSocket]'s +/// spellings. +/// +/// **Not `vmServiceConnectUri`.** That one opens a `dart:io` `WebSocket`, +/// which a browser does not have, and the web editor reaches a game through +/// nothing else; `WebSocketChannel` is the browser's socket there and +/// `dart:io`'s on a desktop, so both editors connect through the same lines. +Future connectVmService(String uri) async { + final address = vmServiceWebSocket(uri); + final channel = WebSocketChannel.connect(Uri.parse(address)); + await channel.ready; + final closed = Completer(); + final incoming = StreamController(); + channel.stream.listen( + incoming.add, + onError: incoming.addError, + onDone: () { + if (!closed.isCompleted) closed.complete(); + unawaited(incoming.close()); + }, + ); + return VmService( + incoming.stream, + channel.sink.add, + disposeHandler: channel.sink.close, + streamClosed: closed.future, + wsUri: address, + ); +} diff --git a/packages/flutter3d_editor_play/lib/src/watched.dart b/packages/flutter3d_editor_play/lib/src/watched.dart new file mode 100644 index 000000000..3b391c622 --- /dev/null +++ b/packages/flutter3d_editor_play/lib/src/watched.dart @@ -0,0 +1,23 @@ +import 'dart:async'; + +/// A value and the news of it changing, with no Flutter in it. +/// +/// What `ValueNotifier` was to Play inside the editor application: a panel +/// rebuilds from [changes], a server reads [value] when it is asked. +final class Watched { + Watched(this._value); + + T _value; + final StreamController _changes = StreamController.broadcast(); + + T get value => _value; + set value(T next) { + _value = next; + if (!_changes.isClosed) _changes.add(next); + } + + /// Every value set from now on. + Stream get changes => _changes.stream; + + Future close() => _changes.close(); +} diff --git a/packages/flutter3d_editor_play/lib/testing.dart b/packages/flutter3d_editor_play/lib/testing.dart new file mode 100644 index 000000000..31220e16c --- /dev/null +++ b/packages/flutter3d_editor_play/lib/testing.dart @@ -0,0 +1,269 @@ +/// A `flutter run --machine` a test controls, for anything that drives a +/// [FlutterRun] and wants to say what the tool answers, and a game's VM +/// service a test controls, for an [AttachedRun]. +library; + +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:vm_service/vm_service.dart'; + +import 'flutter3d_editor_play.dart'; + +/// A running game's VM service, answering over [service] as the real one +/// answers over a socket, and recording every call it is sent. +/// +/// The game has one isolate, `main`, whose extensions are [extensions]. A +/// flutter tool attached to it is [registered]: its services by name, and +/// what each answers in [toolAnswers], `null` for success or an +/// [RPCError] to refuse. +final class FakeGame { + FakeGame({ + Map? registered, + this.extensions = const [], + }) : registered = registered ?? {}; + + /// The flutter tool `flutter run` attaches, as DevTools finds it. + static Map flutterTool() => { + 'reloadSources': 's0.reloadSources', + 'hotRestart': 's0.hotRestart', + }; + + final Map registered; + final List extensions; + + /// What a registered method answers, by its registered name. + final Map toolAnswers = {}; + + /// What an extension answers, by its name. + final Map> extensionAnswers = + >{}; + + /// Every call, as `{'method': …, 'params': …}`, in order. + final List> calls = >[]; + + final StreamController _out = StreamController(); + late final VmService service = VmService(_out.stream, _receive); + + static const String isolateId = 'isolates/1'; + + /// The game printing [text] to stdout. + void prints(String text) => _event('Stdout', { + 'kind': 'WriteEvent', + 'bytes': base64.encode(utf8.encode(text)), + }); + + /// The game calling `dart:developer`'s `log` with [message]. + void logs(String message) => _event('Logging', { + 'kind': 'Logging', + 'logRecord': { + 'type': 'LogRecord', + 'message': { + 'type': '@Instance', + 'id': 'objects/1', + 'kind': 'String', + 'valueAsString': message, + }, + 'time': 0, + 'level': 800, + 'sequenceNumber': 0, + 'loggerName': { + 'type': '@Instance', + 'id': 'objects/2', + 'kind': 'String', + 'valueAsString': '', + }, + 'zone': {'type': '@Instance', 'kind': 'Null'}, + 'error': {'type': '@Instance', 'kind': 'Null'}, + 'stackTrace': {'type': '@Instance', 'kind': 'Null'}, + }, + }); + + /// The game exiting, which closes the socket. + Future exits() => _out.close(); + + void _receive(String message) { + final request = jsonDecode(message) as Map; + final method = request['method']! as String; + final params = request['params'] as Map? ?? const {}; + calls.add({'method': method, 'params': params}); + final id = request['id']; + void reply(Map result) => + _send({'jsonrpc': '2.0', 'id': id, 'result': result}); + switch (method) { + case 'getVM': + reply({ + 'type': 'VM', + 'name': 'game', + 'isolates': [ + { + 'type': '@Isolate', + 'id': isolateId, + 'name': 'main', + 'number': '1', + 'isSystemIsolate': false, + }, + ], + }); + case 'getIsolate': + reply({ + 'type': 'Isolate', + 'id': isolateId, + 'name': 'main', + 'number': '1', + 'isSystemIsolate': false, + 'extensionRPCs': extensions, + }); + case 'streamListen': + reply(const {'type': 'Success'}); + if (params['streamId'] == 'Service') { + for (final MapEntry(key: name, value: registeredAs) + in registered.entries) { + _event('Service', { + 'kind': 'ServiceRegistered', + 'service': name, + 'method': registeredAs, + 'alias': 'Flutter Tools', + }); + } + } + case final String name when name.startsWith('ext.'): + reply(extensionAnswers[name] ?? const {}); + case final String name when registered.containsValue(name): + switch (toolAnswers[name]) { + case final RPCError error: + _send({ + 'jsonrpc': '2.0', + 'id': id, + 'error': error.toMap(), + }); + case null: + reply(const {'type': 'Success'}); + } + default: + _send({ + 'jsonrpc': '2.0', + 'id': id, + 'error': { + 'code': -32601, + 'message': 'Method not found', + }, + }); + } + } + + void _event(String stream, Map event) => + _send({ + 'jsonrpc': '2.0', + 'method': 'streamNotify', + 'params': { + 'streamId': stream, + 'event': {'type': 'Event', 'timestamp': 0, ...event}, + }, + }); + + void _send(Map message) { + if (!_out.isClosed) _out.add(jsonEncode(message)); + } +} + +/// An [AttachedRun] to [game]. +AttachedRun fakeAttachedRun(FakeGame game, {String uri = 'ws://game/ws'}) => + AttachedRun(uri, connect: (String _) async => game.service); + +/// A `flutter run --machine` that says what [say] is given and records what +/// it is sent. +final class FakeFlutterTool implements Process { + final StreamController> _out = StreamController>(); + final StreamController> _err = StreamController>(); + final Completer _exit = Completer(); + + /// Every message sent to the tool, in order. + final List> sent = >[]; + late final IOSink _in = IOSink(_Sent(this)); + + /// A line on the tool's stdout. + void say(String line) => _out.add(utf8.encode('$line\n')); + + /// A daemon event. + void event(String event, Map params) => say( + jsonEncode([ + {'event': event, 'params': params}, + ]), + ); + + /// The answer to request [id]. + void answer(int id, Object? result) => say( + jsonEncode([ + {'id': id, 'result': result}, + ]), + ); + + /// The tool announcing a game running with its VM service at [wsUri]. + void running({String appId = 'a1', String wsUri = 'ws://game'}) => + event('app.debugPort', {'appId': appId, 'wsUri': wsUri}); + + void exit(int code) { + if (!_exit.isCompleted) _exit.complete(code); + } + + @override + Stream> get stdout => _out.stream; + + @override + Stream> get stderr => _err.stream; + + @override + IOSink get stdin => _in; + + @override + Future get exitCode => _exit.future; + + @override + int get pid => 1; + + @override + bool kill([ProcessSignal signal = ProcessSignal.sigterm]) { + exit(-15); + return true; + } +} + +final class _Sent implements StreamConsumer> { + _Sent(this.tool); + + final FakeFlutterTool tool; + + @override + Future addStream(Stream> stream) => stream + .transform(utf8.decoder) + .transform(const LineSplitter()) + .forEach((String line) { + final [Object? message] = jsonDecode(line) as List; + tool.sent.add(message! as Map); + }); + + @override + Future close() async {} +} + +/// A [FlutterRun] over a [FakeFlutterTool], and the arguments each start +/// was given, the working directory first. +({FlutterRun run, FakeFlutterTool tool, List> started}) +fakeFlutterRun({String projectRoot = '/game', String? device = 'macos'}) { + final tool = FakeFlutterTool(); + final started = >[]; + return ( + run: FlutterRun( + projectRoot: projectRoot, + device: device, + start: (List arguments, String directory) async { + started.add([directory, ...arguments]); + return tool; + }, + ), + tool: tool, + started: started, + ); +} diff --git a/packages/flutter3d_editor_play/pubspec.yaml b/packages/flutter3d_editor_play/pubspec.yaml new file mode 100644 index 000000000..96ed5d23f --- /dev/null +++ b/packages/flutter3d_editor_play/pubspec.yaml @@ -0,0 +1,43 @@ +name: flutter3d_editor_play +description: "Play from a level editor: the game a level belongs to run with `flutter run --machine`, reloaded, stopped, and handed a saved level while it runs." +version: 0.8.0 +repository: https://github.com/pleiondev/flutter3d +homepage: https://flutter3d.pleion.dev +issue_tracker: https://github.com/pleiondev/flutter3d/issues +topics: + - game-development + - level-editor + - hot-reload + - tooling +resolution: workspace + +environment: + sdk: ">=3.12.0 <4.0.0" + +dependencies: + # `Level.digestHex`, which the game checks a level it is sent against. + flutter3d_sim: ^0.8.0 + + # The running game's VM service, where `ext.flutter3d.level.apply` is. + vm_service: ^15.3.0 + + # The socket to that service, in a browser as well as on a desktop: + # `vmServiceConnectUri` opens a `dart:io` `WebSocket`, and the web editor + # attaches to a game through nothing else. See `src/vm_connect.dart`. + web_socket_channel: ^3.0.0 + +dev_dependencies: + test: ^1.25.0 + flutter_lints: ^6.0.0 + +# **Plain Dart with `dart:io` in it, and a package of its own for that.** +# +# Play starts a process and talks to it, which is exactly what +# `flutter3d_editor_core` is written never to do: that package opens a level +# with no disk and no process, so a validator or a web build can use it. The +# editor application had this code under `lib/src/play`, where the editor's +# MCP server, which wants the same Play for an agent, could not reach it +# without depending on an application. Here both depend on it, and neither +# depends on the other. The `dart:io` stays out of `lib/attach.dart` and what +# it exports, which is the part of Play a web build has: attaching to a game +# that is already running. diff --git a/packages/flutter3d_editor_play/test/attach_web_test.dart b/packages/flutter3d_editor_play/test/attach_web_test.dart new file mode 100644 index 000000000..5ebd4edb8 --- /dev/null +++ b/packages/flutter3d_editor_play/test/attach_web_test.dart @@ -0,0 +1,74 @@ +/// `attach.dart` is the part of Play a browser has, so nothing under it may +/// import `dart:io`. +/// +/// dart test test/attach_web_test.dart +library; + +import 'dart:io'; + +import 'package:test/test.dart'; + +final RegExp _directive = RegExp( + r'''^(?:import|export)\s+['"]([^'"]+)['"]''', + multiLine: true, +); + +void main() { + test('nothing attach.dart reaches in this repository imports dart:io', () { + // Workspace packages are followed; a hosted one is taken at its word, + // since `web_socket_channel` itself picks `dart:io` by a conditional + // import that a browser build never takes. + final packages = _workspacePackages(); + final seen = {}; + final reachesIo = []; + + void visit(File file) { + if (!seen.add(file.absolute.path)) return; + for (final match in _directive.allMatches(file.readAsStringSync())) { + final target = match.group(1)!; + if (target == 'dart:io') reachesIo.add(file.path); + final next = switch (target) { + _ when target.startsWith('dart:') => null, + _ when target.startsWith('package:') => () { + final [name, ...rest] = target.substring(8).split('/'); + final root = packages[name]; + return root == null ? null : File('$root/lib/${rest.join('/')}'); + }(), + _ => File.fromUri(file.uri.resolve(target)), + }; + if (next != null) visit(next); + } + } + + visit(File('lib/attach.dart')); + + // Mutation: `level_push.dart` back on `vmServiceConnectUri`, or + // `attach.dart` exporting `flutter_run.dart`, puts `dart:io` under it. + // A web build still compiles then — dart2js takes `dart:io` and throws + // `UnsupportedError` from it at run time — so this test is the only + // thing that notices before somebody presses Send in a browser. + expect(reachesIo, isEmpty); + expect(seen.any((it) => it.endsWith('attached_run.dart')), isTrue); + expect(seen.any((it) => it.contains('flutter3d_sim')), isTrue); + }); +} + +/// Every package of this workspace, by name, to its directory. +Map _workspacePackages() { + final root = Directory('../..').absolute; + return { + for (final pubspec in [ + ...Directory('${root.path}/packages') + .listSync() + .whereType() + .map((d) => File('${d.path}/pubspec.yaml')), + ]) + if (pubspec.existsSync()) + if (RegExp( + r'^name:\s*(\S+)', + multiLine: true, + ).firstMatch(pubspec.readAsStringSync()) + case final match?) + match.group(1)!: pubspec.parent.path, + }; +} diff --git a/packages/flutter3d_editor_play/test/attached_run_test.dart b/packages/flutter3d_editor_play/test/attached_run_test.dart new file mode 100644 index 000000000..68ab22d04 --- /dev/null +++ b/packages/flutter3d_editor_play/test/attached_run_test.dart @@ -0,0 +1,211 @@ +/// `HR5` in a browser: a game attached to by its VM service, with no +/// process of the editor's own, read, reloaded, let go of and sent a level. +/// +/// dart test test/attached_run_test.dart +library; + +import 'package:flutter3d_editor_play/flutter3d_editor_play.dart'; +import 'package:flutter3d_editor_play/testing.dart'; +import 'package:test/test.dart'; +import 'package:vm_service/vm_service.dart'; + +const String _document = '{"version": 1, "name": "yard", "fogDensity": 0.02}'; + +/// Lets the fake's replies and events cross the stream to the run. +Future _settle() => Future.delayed(Duration.zero); + +Future<(FakeGame, AttachedRun)> _attached({ + Map? registered, +}) async { + final game = FakeGame(registered: registered ?? FakeGame.flutterTool()); + final run = fakeAttachedRun(game); + await run.start(); + await _settle(); + return (game, run); +} + +void main() { + test('attaching is running, at the address given', () async { + final (_, run) = await _attached(); + + expect( + run.state.value, + isA().having( + (it) => it.vmService, + 'vmService', + 'ws://game/ws', + ), + ); + expect(run.ownsTheGame, isFalse); + }); + + test('a line is what ends in a newline, not each write', () async { + final (game, run) = await _attached(); + + // What a real macOS run sends for `print('tick 1')`: the text, then the + // newline on its own. + game + ..prints('flutter: tick 1') + ..prints('\n') + ..prints('flutter: half'); + await _settle(); + + // Mutation: taking each write for a line puts an empty line after every + // `print` and shows `half` before it is finished. + expect(run.console.value, ['flutter: tick 1']); + + await game.exits(); + await _settle(); + expect(run.console.value.last, 'flutter: half'); + }); + + test('the console is what the game prints and logs', () async { + final (game, run) = await _attached(); + + game + ..prints('frame 1\nframe 2\n') + ..logs('level loaded'); + await _settle(); + + // Mutation: listening to `Stdout` alone drops what `log()` writes, and + // a game that logs through `dart:developer` shows an empty console. + expect(run.console.value, ['frame 1', 'frame 2', 'level loaded']); + }); + + test( + 'a hot reload is the flutter tool\'s, called in the main isolate', + () async { + final (game, run) = await _attached(); + + expect(await run.hotSwap(), 'Swapped in the new code'); + + // Mutation: calling the VM's own `reloadSources` instead of the name the + // tool registered reaches a VM that cannot compile, which answers that + // nothing changed while the game keeps its old code. + final call = game.calls.last; + expect(call['method'], 's0.reloadSources'); + expect(call['params'], containsPair('isolateId', FakeGame.isolateId)); + + expect(await run.hotRestart(), 'Restarted with the new code'); + expect(game.calls.last['method'], 's0.hotRestart'); + }, + ); + + test( + 'with no tool attached to the game, the reload says what to do', + () async { + final (game, run) = await _attached(registered: {}); + final before = game.calls.length; + + final said = await run.hotSwap(); + + // Mutation: falling back to the bare `reloadSources` would send a call + // here and report success for a reload that did not happen. + expect(game.calls, hasLength(before)); + expect(said, contains('flutter run')); + expect(said, contains('flutter attach')); + expect(run.console.value.last, said); + }, + ); + + test('a reload the tool refuses is said as refused', () async { + final (game, run) = await _attached(); + game.toolAnswers['s0.reloadSources'] = RPCError( + 's0.reloadSources', + RPCErrorKind.kInternalError.code, + 'Unable to reload sources', + ); + + final said = await run.hotSwap(); + + expect(said, startsWith('the tool refused to swap new code into the game')); + expect(said, contains('Unable to reload sources')); + }); + + test('stopping lets go of the game and does not end it', () async { + final (game, run) = await _attached(); + + await run.stop(); + + // Mutation: an `exit` or `kill` on stop would end a game somebody else + // started; the only calls are the attach's own. + expect(game.calls.map((it) => it['method']).toSet(), { + 'getVM', + 'streamListen', + }); + expect( + run.state.value, + isA().having( + (it) => it.reason, + 'reason', + contains('keeps running'), + ), + ); + expect(await run.hotSwap(), isNull); + }); + + test('a game that exits is a run that stopped', () async { + final (game, run) = await _attached(); + + await game.exits(); + await _settle(); + + expect( + run.state.value, + isA().having( + (it) => it.reason, + 'reason', + contains('closed'), + ), + ); + }); + + test('an address with nothing behind it is a stop with a reason', () async { + final run = AttachedRun( + 'http://127.0.0.1:1/', + connect: (String _) async => throw StateError('connection refused'), + ); + + await run.start(); + + expect(run.state.value, isA()); + expect(run.console.value.single, contains('connection refused')); + }); + + test('a saved level reaches the isolate that takes levels', () async { + final game = FakeGame(extensions: ['ext.flutter3d.level.apply']) + ..extensionAnswers['ext.flutter3d.level.apply'] = { + 'type': '_extensionType', + 'swappedAt': 12, + 'diff': { + 'simulation': ['brushes'], + }, + }; + + final answer = await pushLevel( + 'ws://game/ws', + _document, + connect: (String _) async => game.service, + ); + + expect( + describeLevelApplied(answer), + 'the game replayed from step 12 with the new brushes', + ); + final call = game.calls.last; + expect(call['method'], 'ext.flutter3d.level.apply'); + expect(call['params'], containsPair('document', _document)); + }); + + test('every spelling of the address is the same socket', () { + for (final spelling in [ + 'http://127.0.0.1:8181/abc=/', + 'http://127.0.0.1:8181/abc=', + 'ws://127.0.0.1:8181/abc=/ws', + ' ws://127.0.0.1:8181/abc=/ ', + ]) { + expect(vmServiceWebSocket(spelling), 'ws://127.0.0.1:8181/abc=/ws'); + } + expect(vmServiceWebSocket('https://host/x/'), 'wss://host/x/ws'); + }); +} diff --git a/packages/flutter3d_editor_play/test/devices_test.dart b/packages/flutter3d_editor_play/test/devices_test.dart new file mode 100644 index 000000000..8be971238 --- /dev/null +++ b/packages/flutter3d_editor_play/test/devices_test.dart @@ -0,0 +1,88 @@ +/// `HR5`: the devices Play can be pointed at, as the tool lists them. +/// +/// dart test test/devices_test.dart +library; + +import 'dart:io'; + +import 'package:flutter3d_editor_play/flutter3d_editor_play.dart'; +import 'package:test/test.dart'; + +/// Trimmed from a real `flutter devices --machine` on a Mac with a phone on +/// the cable, with the line the tool prints first when it has news. +const String _printed = ''' +A new version of Flutter is available! +[ + { + "name": "Galaxy A55", + "id": "R5CX10ABCDE", + "isSupported": true, + "targetPlatform": "android-arm64", + "emulator": false + }, + { + "name": "macOS", + "id": "macos", + "isSupported": true, + "targetPlatform": "darwin", + "emulator": false + }, + { + "name": "Old iPad", + "id": "00008", + "isSupported": false, + "targetPlatform": "ios" + } +] +'''; + +void main() { + test('reads the list past whatever the tool said first', () { + final devices = parseFlutterDevices(_printed); + + expect(devices.map((it) => it.id), ['R5CX10ABCDE', 'macos']); + expect(devices.first.name, 'Galaxy A55'); + expect(devices.first.platform, 'android-arm64'); + }); + + test('an unsupported device is not offered', () { + expect( + parseFlutterDevices(_printed).where((it) => it.id == '00008'), + isEmpty, + ); + }); + + test('something that is not a list is no devices, not a crash', () { + expect(parseFlutterDevices('No devices found'), isEmpty); + expect(parseFlutterDevices('[{"id": '), isEmpty); + }); + + test('the tool failing is news of its own', () async { + await expectLater( + flutterDevices( + run: (List arguments) async => + ProcessResult(1, 1, '', 'flutter: command not found'), + ), + throwsA( + isA().having( + (it) => it.message, + 'message', + contains('command not found'), + ), + ), + ); + }); + + test('asks the tool the way an IDE does', () async { + final asked = >[]; + final devices = await flutterDevices( + run: (List arguments) async { + asked.add(arguments); + return ProcessResult(1, 0, _printed, ''); + }, + ); + + expect(asked.single, ['devices', '--machine']); + expect(devices, hasLength(2)); + }); +} diff --git a/packages/flutter3d_editor_play/test/flutter_run_test.dart b/packages/flutter3d_editor_play/test/flutter_run_test.dart new file mode 100644 index 000000000..73808558e --- /dev/null +++ b/packages/flutter3d_editor_play/test/flutter_run_test.dart @@ -0,0 +1,161 @@ +/// `HR5`: the editor runs a project through `flutter run --machine` and +/// drives it — against a process of the test's own making, which speaks the +/// daemon protocol the way the tool does. +/// +/// dart test test/flutter_run_test.dart +library; + +import 'dart:io'; + +import 'package:flutter3d_editor_play/flutter3d_editor_play.dart'; +import 'package:flutter3d_editor_play/testing.dart'; +import 'package:test/test.dart'; + +void main() { + test('starts the tool in the project and follows it to running', () async { + final it = fakeFlutterRun(); + await it.run.start(); + expect(it.started.single, [ + '/game', + 'run', + '--machine', + '-d', + 'macos', + ]); + expect(it.run.state.value, isA()); + + it.tool + ..say('Resolving dependencies...') + ..event('app.start', {'appId': 'a1'}) + ..event('app.progress', {'message': 'Building macOS'}) + ..event('app.debugPort', { + 'appId': 'a1', + 'port': 8181, + 'wsUri': 'ws://127.0.0.1:8181/ws', + }) + ..event('app.log', {'log': 'flutter: hello'}); + await pumpEventQueue(); + + final running = it.run.state.value as PlayRunning; + expect(running.appId, 'a1'); + expect(running.vmService, 'ws://127.0.0.1:8181/ws'); + expect(it.run.console.value, [ + 'Resolving dependencies...', + 'Building macOS', + 'flutter: hello', + ]); + }); + + test('a hot reload and a hot restart are the tool\'s own commands', () async { + final it = fakeFlutterRun(); + await it.run.start(); + it.tool.event('app.debugPort', { + 'appId': 'a1', + 'wsUri': 'ws://x', + }); + await pumpEventQueue(); + + final reload = it.run.hotSwap(); + await pumpEventQueue(); + expect(it.tool.sent.single, { + 'id': 0, + 'method': 'app.restart', + 'params': { + 'appId': 'a1', + 'fullRestart': false, + 'pause': false, + 'reason': 'manual', + }, + }); + it.tool.answer(0, {'code': 0, 'message': ''}); + await reload; + + final restart = it.run.hotRestart(); + await pumpEventQueue(); + expect((it.tool.sent.last['params']! as Map)['fullRestart'], isTrue); + it.tool.answer(1, { + 'code': 1, + 'message': 'Reload rejected: a const changed shape', + }); + await restart; + expect(it.run.console.value.last, 'Reload rejected: a const changed shape'); + }); + + test('the tool exiting stops the run and fails what was waiting', () async { + final it = fakeFlutterRun(); + await it.run.start(); + it.tool.event('app.debugPort', { + 'appId': 'a1', + 'wsUri': 'ws://x', + }); + await pumpEventQueue(); + + final reload = it.run.hotSwap(); + await pumpEventQueue(); + it.tool.exit(1); + + await expectLater(reload, throwsStateError); + expect((it.run.state.value as PlayStopped).exitCode, 1); + }); + + test( + 'the console keeps the newest lines when a game logs every frame', + () async { + final it = fakeFlutterRun(); + await it.run.start(); + for (var i = 0; i < FlutterRun.consoleLimit + 5; i++) { + it.tool.say('line $i'); + } + await pumpEventQueue(); + expect(it.run.console.value, hasLength(FlutterRun.consoleLimit)); + expect(it.run.console.value.first, 'line 5'); + }, + ); + + test('a stop before the game has started ends the process', () async { + final tool = FakeFlutterTool(); + final killed = []; + final run = FlutterRun( + projectRoot: '/game', + start: (_, _) async => tool, + kill: killed.add, + ); + await run.start(); + await run.stop(); + // Mutation: `stop` calling `Process.kill` directly passes everything + // else here and leaves the tool building on Windows, where the process + // it was handed is `cmd.exe`. + expect(killed, [tool]); + expect(tool.sent, isEmpty); + }); + + test('on Windows a stop takes the whole tree the shell started', () { + // Mutation: dropping `/t` ends `cmd.exe` and orphans the tool; dropping + // the Windows branch altogether does the same through `Process.kill`. + expect(treeKillCommand(42, windows: true), [ + 'taskkill', + '/pid', + '42', + '/t', + '/f', + ]); + expect(treeKillCommand(42, windows: false), isNull); + }); + + test('a level is run as part of the nearest project above it', () { + final projects = {'/work/game'}; + bool hasPubspec(String directory) => projects.contains(directory); + + expect( + projectRootFor( + '/work/game/assets/levels/one.json', + hasPubspec: hasPubspec, + ), + '/work/game', + ); + expect( + projectRootFor('/elsewhere/one.json', hasPubspec: hasPubspec), + isNull, + ); + }); +} diff --git a/packages/flutter3d_editor_play/test/level_push_test.dart b/packages/flutter3d_editor_play/test/level_push_test.dart new file mode 100644 index 000000000..881844493 --- /dev/null +++ b/packages/flutter3d_editor_play/test/level_push_test.dart @@ -0,0 +1,120 @@ +/// `HR3`'s editor half: what a saved level is sent as, and how the game's +/// answer reads in the status strip. +/// +/// dart test test/level_push_test.dart +library; + +import 'dart:convert'; + +import 'package:flutter3d_editor_play/flutter3d_editor_play.dart'; +import 'package:flutter3d_sim/flutter3d_sim.dart'; +import 'package:test/test.dart'; + +const String _document = '{"version": 1, "name": "yard", "fogDensity": 0.02}'; + +void main() { + test('the hash is the level\'s digest, as the game computes it', () { + final arguments = levelApplyArguments(_document); + + expect(arguments['document'], _document); + expect( + arguments['hash'], + Level.fromJson(jsonDecode(_document) as Map).digestHex, + ); + // Whitespace is not a different level. + expect( + levelApplyArguments( + '{\n "version": 1,\n "name": "yard",\n "fogDensity": 0.02\n}', + )['hash'], + arguments['hash'], + ); + }); + + // Mutation: build the patch from the text rather than from the level the + // game would parse, or send it when it is longer than the document, and + // one of these fails. + test('a save after a save goes as a patch, when that is shorter', () { + final base = jsonEncode({ + 'version': 1, + 'name': 'yard', + 'brushes': [ + for (var i = 0; i < 20; i++) + { + 'at': [i * 2.0, 0, 0], + 'size': [1, 1, 1], + }, + ], + }); + final moved = base.replaceFirst('[6.0,0.0,0.0]', '[6.5,0.0,0.0]'); + expect(moved, isNot(base)); + + final arguments = levelPatchArguments(base, moved)!; + final patch = LevelPatch.fromJson( + jsonDecode(arguments['patch']!) as Map, + ); + expect(patch.rows.single.at, 3); + expect( + (patch.applyTo(Level.fromJson(jsonDecode(base) as Map)) + as LevelPatched) + .level + .digestHex, + levelApplyArguments(moved)['hash'], + ); + + // A one-line level is shorter whole than as a patch. + expect( + levelPatchArguments(_document, _document.replaceFirst('0.02', '0.03')), + isNull, + ); + expect(levelPatchArguments('not json', _document), isNull); + }); + + test('a level sent whole after a stale patch says why', () { + expect( + describeLevelApplied({ + 'diff': {'fog': true}, + 'fellBack': 'the patch was made against level 1 and the game has 2', + }), + 'the game took the new look ' + '(sent whole: the patch was made against level 1 and the game has 2)', + ); + }); + + test('the game\'s answer, in a line', () { + Map answer({ + List simulation = const [], + bool fog = false, + int? swappedAt, + bool rebuiltInPlace = false, + }) => { + 'diff': { + 'lights': [], + 'lightCountChanged': false, + 'materials': [], + 'fog': fog, + 'music': false, + 'simulation': simulation, + }, + 'swappedAt': ?swappedAt, + 'rebuiltInPlace': rebuiltInPlace, + }; + + expect( + describeLevelApplied( + answer(simulation: ['brushes'], swappedAt: 120), + ), + 'the game replayed from step 120 with the new brushes', + ); + expect( + describeLevelApplied( + answer(simulation: ['entities'], rebuiltInPlace: true), + ), + contains('in place'), + ); + expect( + describeLevelApplied(answer(fog: true)), + 'the game took the new look', + ); + expect(describeLevelApplied(answer()), 'the game already had this level'); + }); +} diff --git a/packages/flutter3d_editor_widgets/CHANGELOG.md b/packages/flutter3d_editor_widgets/CHANGELOG.md index 85413f395..047f29366 100644 --- a/packages/flutter3d_editor_widgets/CHANGELOG.md +++ b/packages/flutter3d_editor_widgets/CHANGELOG.md @@ -1,3 +1,10 @@ +## Unreleased + +- **A slider can show its value live while it is dragged.** + `RangeSliderField.onPreview` and `FieldRow.onPreview` are called with every + stepped value the thumb passes; `onChanged`/`onWrite` still come once, where + the drag ends. The editor uses it to show a material in a running game. + ## 0.8.0 **Moves with the stack to 0.8.0**, whose `flutter3d_hardware` changes diff --git a/packages/flutter3d_editor_widgets/lib/src/field_row.dart b/packages/flutter3d_editor_widgets/lib/src/field_row.dart index bc0d52b24..372ebfb85 100644 --- a/packages/flutter3d_editor_widgets/lib/src/field_row.dart +++ b/packages/flutter3d_editor_widgets/lib/src/field_row.dart @@ -40,6 +40,7 @@ final class FieldRow extends StatelessWidget { required this.name, required this.value, required this.onWrite, + this.onPreview, this.hint, this.faded = false, this.offers = nothingToOffer, @@ -49,6 +50,10 @@ final class FieldRow extends StatelessWidget { final Object? value; final void Function(Object? value) onWrite; + /// Every value a slider passes through while it is dragged, before + /// [onWrite] gets the one it stops on. See [RangeSliderField.onPreview]. + final void Function(Object? value)? onPreview; + /// What a control for this value should look like, when anything knows. /// /// Null for every field of a level, and that is the whole reason @@ -116,6 +121,7 @@ final class FieldRow extends StatelessWidget { step: it.step, editable: true, onChanged: onWrite, + onPreview: onPreview, ), (final ColorHint it, final List at) when at.length >= 3 && at.every((Object? e) => e is num) => diff --git a/packages/flutter3d_editor_widgets/lib/src/range_slider_field.dart b/packages/flutter3d_editor_widgets/lib/src/range_slider_field.dart index 58b8894e7..a1efee5b1 100644 --- a/packages/flutter3d_editor_widgets/lib/src/range_slider_field.dart +++ b/packages/flutter3d_editor_widgets/lib/src/range_slider_field.dart @@ -33,6 +33,7 @@ final class RangeSliderField extends StatefulWidget { required this.max, this.step, required this.onChanged, + this.onPreview, this.enabled = true, this.editable = false, }); @@ -61,6 +62,12 @@ final class RangeSliderField extends StatefulWidget { /// this engine keeps strikes for a drag. final ValueChanged onChanged; + /// Called with every value the thumb passes through while it is dragged, + /// stepped as [onChanged]'s would be: for something that shows the value + /// live, a running game, without writing it anywhere. [onChanged] still + /// comes once at the end. + final ValueChanged? onPreview; + final bool enabled; /// Whether the slider is paired with a text box that shows the exact @@ -89,14 +96,13 @@ class _RangeSliderFieldState extends State { if (widget.value != old.value) _dragging = null; } - void _commit(double raw) { + void _commit(double raw) => widget.onChanged(_stepped(raw)); + + double _stepped(double raw) { final double? step = widget.step; - if (step == null || step <= 0.0) { - widget.onChanged(raw); - return; - } + if (step == null || step <= 0.0) return raw; final double stepped = (raw / step).roundToDouble() * step; - widget.onChanged(double.parse(stepped.toStringAsFixed(4))); + return double.parse(stepped.toStringAsFixed(4)); } @override @@ -186,7 +192,10 @@ class _RangeSliderFieldState extends State { max: widget.max, divisions: divisions, onChanged: widget.enabled - ? (double v) => setState(() => _dragging = v) + ? (double v) { + setState(() => _dragging = v); + widget.onPreview?.call(_stepped(v)); + } : null, onChangeEnd: widget.enabled ? (double v) { diff --git a/packages/flutter3d_editor_widgets/test/range_slider_field_test.dart b/packages/flutter3d_editor_widgets/test/range_slider_field_test.dart index e80537dcd..6b606c2e2 100644 --- a/packages/flutter3d_editor_widgets/test/range_slider_field_test.dart +++ b/packages/flutter3d_editor_widgets/test/range_slider_field_test.dart @@ -18,6 +18,7 @@ Future pump( double max = 1.0, double? step, ValueChanged? onChanged, + ValueChanged? onPreview, bool enabled = true, bool editable = false, }) => tester.pumpWidget( @@ -32,6 +33,7 @@ Future pump( enabled: enabled, editable: editable, onChanged: onChanged ?? (double _) {}, + onPreview: onPreview, ), ), ), @@ -72,6 +74,40 @@ void main() { expect(said, hasLength(1)); }); + testWidgets('a drag previews every stop on the way, and writes once', ( + WidgetTester tester, + ) async { + // `HR4`: a running game shows the value while the thumb moves; the + // document still gets one write, where the drag ends. + final previewed = []; + final written = []; + await pump( + tester, + label: 'Metallic', + value: 0.2, + step: 0.05, + onPreview: previewed.add, + onChanged: written.add, + ); + + final gesture = await tester.startGesture( + tester.getCenter(find.byType(Slider)), + ); + for (var i = 0; i < 4; i++) { + await gesture.moveBy(const Offset(15, 0)); + await tester.pump(); + } + await gesture.up(); + await tester.pumpAndSettle(); + + expect(previewed.length, greaterThan(1)); + for (final at in previewed) { + expect((at / 0.05 - (at / 0.05).round()).abs(), lessThan(1e-9)); + } + expect(written, hasLength(1)); + expect(written.single, previewed.last); + }); + testWidgets('a null step writes what the drag produced, unrounded', ( WidgetTester tester, ) async { diff --git a/packages/flutter3d_game/CHANGELOG.md b/packages/flutter3d_game/CHANGELOG.md index b7c2b2121..f868e46d1 100644 --- a/packages/flutter3d_game/CHANGELOG.md +++ b/packages/flutter3d_game/CHANGELOG.md @@ -1,3 +1,93 @@ +## Unreleased + +- **An edit made under a running game goes into its demo.** + `DemoRecording` is the run being written down: start, recorder, + checkpoints and the levels swapped in, with `levelSwapped` turning the + timeline's step into one on this tape and dropping what the swap lived + again. `LiveLevel(swapped:)` hears the step the timeline swapped at, before + `present`. `replayDemo` plays a `Demo` from its start, swaps each level in + where the tape reaches it and compares the file's checkpoints by step. + `rewindBufferFromDemo` refuses a demo with swaps, since a keyframe before a + swap belongs to the other level. +- **`ext.flutter3d.level.patch` takes an edit instead of a whole level.** + `registerLevelExtension` registers it beside `level.apply`; + `answerLevelPatch` applies a `LevelPatch` to `LiveLevel.level` and passes + the patch's own diff to `applyWhenReady(diff:)` and `apply(diff:)`. A + patch made against another version is answered with + `LevelPatch.staleCode`, which tells the sender to send the whole level; a + patched level that does not build is refused as an ordinary error. +- **`BehaviourOverlay` draws what every tree last decided**: a stroke per + node on the running path above each actor, coloured by how it came out, and + a line to where its leaf is taking it; `describe()` gives the same path by + name. It reads boards and never makes one, so switching it on cannot change + a snapshot. +- **`RunTimeline` scrubs without cutting.** `scrubTo` moves the live state + to a held step while paused and keeps the tape and the present; + `returnToPresent` puts the present back exactly, `branchHere` cuts the + future at the scrubbed step and stays paused, and `stepOnce` from a scrub + walks the tape. `resume` from a scrub resumes at the present. `tracks` + replays the buffer into `EntityTracks` and puts the live state back. + Refusals come back as `ScrubRefused` with a reason. +- **`registerTimelineExtensions`** adds `scrubTo`, `returnToPresent`, + `branchHere` and, with an `entityLayout`, `tracks`; `status` answers the + present step, the oldest held and the scrub. +- **`Autosave` writes the run on the way into a pause, at a checkpoint and + when the application goes to the background**, which on a phone is the + only warning before the process is ended. `SaveFile` skips a write of the + run it last wrote, so a menu opened and closed costs nothing. +- **`SaveFile` takes a `SaveSchema`** and migrates older saves on the way in; + a save now carries its schema, its step and its digest. `readRecord`, + `parse`, `writeRecord` and `encode` are for copies kept elsewhere. + `RunSession.stepOf` says how far a run has got, and `save` returns whether + it wrote. +- **`SaveSync` keeps a save in the cloud, and sends nothing until the player + agrees.** Consent is off on a fresh install and is kept in a document of + its own; without it no store is called. Conflicts are settled by + `resolveSaves`; two equal runs come back as `SyncOutcome.ask` for the + player, and a cloud save from a newer build is left alone. Stores: + `HttpCloudSaves` (`GET`/`PUT` with `ETag` preconditions) and + `PlatformCloudSaves` for Play Games and iCloud over the + `flutter3d/cloud_saves` channel. Every failure is an answer, not a throw. + +- **`replayAfterHotSwap` asks the question a hot reload leaves open on its + own.** After every `HotSwap` it lives the last seconds again under the new + code and says whether they came out the same or where they parted, in the + console and as a `flutter3d.timeline.replayedUnderNewCode` VM service + event. Returns the call that stops it. +- **A level that has to be built before it can be swapped in.** + `LiveLevel(prepare:)` is awaited by `applyWhenReady`, which the extension + now calls, before anything changes; a level whose `prepare` throws is + refused with what it threw and the game keeps the one it had. + `answerLevelApply` returns a `Future`. `LiveLevel.level` is settable, for a + game that moved to another level by its own means. +- **`RunSession.replaceLevel` puts a new build of the level being played in + its place** and carries the run over through the game's own `snapshotOf` + and `restoreInto`, without loading anything or starting the level again. + +- **`RunTimeline.replayUnderNewCode` shows what a code reload changed.** It + lives the last seconds again under the new code from the nearest keyframe, + compares the state at each keyframe the old run left and at the present, + and names the first field that parted and the two steps it parted between. + The replay is kept and the buffer rebased there. +- **`registerTuningExtensions`** puts `ext.flutter3d.cvar.set` and + `cvar.list` on the VM service; a set goes through `InputState.tune`, so it + lands on the tape. `registerTimelineExtensions(capture:)` adds + `ext.flutter3d.timeline.replayUnderNewCode`. + +- **`LiveLevel` takes a level edited under a running game.** The game hands + it `present` and `rebuild`; `apply` patches the look in place and sends the + simulation's half through `swapLevel`. `registerLevelExtension` puts it on + the VM service as `ext.flutter3d.level.apply {document, hash}`, which the + editor calls after a save; a document whose digest does not match its hash + is refused. + +- **`RunTimeline.swapLevel` replaces the level under a running game without + the run jumping.** It restores the last keyframe, lets the caller swap the + level, replays the recorded input to the present with the devices muted, + and rebases the buffer there. `TimelineLevelSwapped` records the step and + the level's digest, so a replay that swaps at the same step arrives where + the run did. + ## 0.8.1 **Volumes without SoLoud.** The settings panel names buses and nothing else of diff --git a/packages/flutter3d_game/lib/flutter3d_game.dart b/packages/flutter3d_game/lib/flutter3d_game.dart index e3c2eec9e..d37f0d7da 100644 --- a/packages/flutter3d_game/lib/flutter3d_game.dart +++ b/packages/flutter3d_game/lib/flutter3d_game.dart @@ -35,6 +35,10 @@ /// the run — [RunSession] is an ordinary class. library; +export 'src/cloud/cloud_save_store.dart'; +export 'src/cloud/http_cloud_saves.dart'; +export 'src/cloud/platform_cloud_saves.dart'; +export 'src/cloud/save_sync.dart'; export 'src/config/accommodations.dart'; export 'src/config/game_config.dart'; export 'src/input/bindings.dart'; @@ -42,8 +46,12 @@ export 'src/input/desktop_input.dart'; export 'src/input/pad_actions.dart'; export 'src/input/playing.dart'; export 'src/input/touch_controls.dart'; +export 'src/run/autosave.dart'; export 'src/run/bug_report.dart'; +export 'src/run/demo_recording.dart'; export 'src/run/demo_timeline.dart'; +export 'src/run/live_level.dart'; +export 'src/run/replay_after_swap.dart'; export 'src/run/run_session.dart'; export 'src/run/run_timeline.dart'; export 'src/run/run_timeline_extensions.dart'; @@ -65,6 +73,7 @@ export 'src/screens/tap_to_restart.dart'; export 'src/screens/touch_platform.dart'; export 'src/screens/volumes.dart'; export 'src/visuals/actor_visuals.dart'; +export 'src/visuals/behaviour_overlay.dart'; export 'src/visuals/fixture_visuals.dart'; export 'src/visuals/sound_occlusion.dart'; export 'src/walk/level_walk.dart'; diff --git a/packages/flutter3d_game/lib/src/cloud/cloud_save_store.dart b/packages/flutter3d_game/lib/src/cloud/cloud_save_store.dart new file mode 100644 index 000000000..1238950b1 --- /dev/null +++ b/packages/flutter3d_game/lib/src/cloud/cloud_save_store.dart @@ -0,0 +1,71 @@ +/// Somewhere other than this device that a save can be kept. +/// +/// ## Answers, not exceptions +/// +/// A network that is down, a player signed out of Play Games, an iCloud +/// account with no space: all ordinary, all reached on a launch the player +/// expects to work. So every call answers, and an answer that is not the +/// document says why in a sentence [SaveSync] can show. Nothing here throws +/// for a reason the network gave. +/// +/// ## Versions +/// +/// Every copy comes back with a version the store chose — an HTTP `ETag`, +/// Play Games' snapshot revision, an iCloud change token — and a write names +/// the version it means to replace. A store that has moved on since answers +/// [CloudMoved] rather than writing, so two devices syncing at once cannot +/// both win and silently lose one of the runs. +library; + +abstract interface class CloudSaveStore { + /// The store's name as a player knows it — "Play Games", "iCloud" — for + /// the sentences a sync answers with. + String get name; + + /// The copy kept under [slot], if there is one. + Future fetch(String slot); + + /// Keeps [document] under [slot] in place of the copy at [replacing], or + /// only if there is no copy at all when [replacing] is null. + Future put(String slot, String document, {String? replacing}); +} + +/// What [CloudSaveStore.fetch] found. +sealed class CloudFetch { + const CloudFetch(); +} + +/// What [CloudSaveStore.put] did. +sealed class CloudPut { + const CloudPut(); +} + +final class CloudDocument extends CloudFetch { + const CloudDocument(this.document, {required this.version}); + + final String document; + final String version; +} + +/// Nothing is kept there yet. +final class CloudEmpty extends CloudFetch { + const CloudEmpty(); +} + +final class CloudStored extends CloudPut { + const CloudStored({required this.version}); + + final String version; +} + +/// Another device wrote since the version a write meant to replace. +final class CloudMoved extends CloudPut { + const CloudMoved(); +} + +/// The store could not be reached, or would not do it, and why. +final class CloudUnavailable implements CloudFetch, CloudPut { + const CloudUnavailable(this.reason); + + final String reason; +} diff --git a/packages/flutter3d_game/lib/src/cloud/http_cloud_saves.dart b/packages/flutter3d_game/lib/src/cloud/http_cloud_saves.dart new file mode 100644 index 000000000..65ff95427 --- /dev/null +++ b/packages/flutter3d_game/lib/src/cloud/http_cloud_saves.dart @@ -0,0 +1,123 @@ +import 'package:http/http.dart' as http; + +import 'cloud_save_store.dart'; + +/// Saves kept on a server the game's makers run, over plain HTTP. +/// +/// ## The protocol +/// +/// One resource per slot, at `/saves//`: +/// +/// | | | +/// |---|---| +/// | `GET` | `200` with the document and an `ETag`, or `404` for none | +/// | `PUT` | the document, with `If-Match: ` to replace a copy or `If-None-Match: *` to create one; `200`, `201` or `204` with the new `ETag`, or `412` when another device wrote first | +/// +/// That is all of it, on purpose: conditional requests are what HTTP already +/// has for "write this unless somebody else did", and any server — a function +/// behind a CDN, a few lines of shelf — can answer them. +/// +/// **An `ETag` is required, not hoped for.** Without one a write cannot say +/// what it replaces, and the one thing this store is for — two devices not +/// overwriting each other — would rest on luck. A server that sends none is +/// refused, saying so. +/// +/// Who the player is goes in [headers], asked for on every request so a token +/// that expired between two syncs is fetched again by whoever owns it. +final class HttpCloudSaves implements CloudSaveStore { + HttpCloudSaves({ + required this.base, + required this.game, + this.headers, + http.Client? client, + }) : _client = client ?? http.Client(), + _ownsClient = client == null; + + /// Where the server is. Slots are below `saves//` under it. + final Uri base; + + /// Which game's saves, so one server can keep several. + final String game; + + /// The headers that say who the player is, usually an `Authorization`. + final Future> Function()? headers; + + final http.Client _client; + final bool _ownsClient; + + @override + String get name => base.host.isEmpty ? 'the save server' : base.host; + + Uri _slot(String slot) => base.replace( + pathSegments: [ + ...base.pathSegments.where((segment) => segment.isNotEmpty), + 'saves', + game, + slot, + ], + ); + + @override + Future fetch(String slot) async { + final uri = _slot(slot); + try { + final response = await _client.get(uri, headers: await _headers()); + return switch (response.statusCode) { + 404 => const CloudEmpty(), + 200 => switch (response.headers['etag']) { + final etag? => CloudDocument(response.body, version: etag), + null => CloudUnavailable( + '$name sent the save without an ETag, so a write could not tell ' + 'whether another device wrote in between', + ), + }, + final status => CloudUnavailable('GET $uri answered $status'), + }; + } on Exception catch (error) { + return CloudUnavailable('$name could not be reached ($error)'); + } + } + + @override + Future put( + String slot, + String document, { + String? replacing, + }) async { + final uri = _slot(slot); + try { + final response = await _client.put( + uri, + headers: { + ...await _headers(), + 'Content-Type': 'application/json', + if (replacing != null) + 'If-Match': replacing + else + 'If-None-Match': '*', + }, + body: document, + ); + return switch (response.statusCode) { + 412 => const CloudMoved(), + 200 || 201 || 204 => switch (response.headers['etag']) { + final etag? => CloudStored(version: etag), + null => CloudUnavailable( + '$name kept the save but sent no ETag for it', + ), + }, + final status => CloudUnavailable('PUT $uri answered $status'), + }; + } on Exception catch (error) { + return CloudUnavailable('$name could not be reached ($error)'); + } + } + + Future> _headers() async => + await headers?.call() ?? const {}; + + /// Frees the HTTP client, when this made it. + void close() { + if (_ownsClient) _client.close(); + } +} diff --git a/packages/flutter3d_game/lib/src/cloud/platform_cloud_saves.dart b/packages/flutter3d_game/lib/src/cloud/platform_cloud_saves.dart new file mode 100644 index 000000000..07d30ba1a --- /dev/null +++ b/packages/flutter3d_game/lib/src/cloud/platform_cloud_saves.dart @@ -0,0 +1,111 @@ +import 'package:flutter/services.dart'; + +import 'cloud_save_store.dart'; + +/// A platform service a player is already signed in to. +/// +/// **A class a game can make more of**, not an enum: a store this package does +/// not name — Steam Cloud, a console's own — is one more constant and a native +/// side that answers to its [name], with nothing here to change. +final class CloudProvider { + const CloudProvider(this.name, this.title); + + /// What the platform side is told, in the channel's `provider` argument. + final String name; + + /// As a player knows it. + final String title; + + /// Google Play Games Services' saved games, on Android. + static const CloudProvider playGames = CloudProvider( + 'playGames', + 'Play Games', + ); + + /// iCloud, on iOS and macOS. + static const CloudProvider iCloud = CloudProvider('iCloud', 'iCloud'); +} + +/// Saves kept by Play Games or iCloud, through the platform's own side. +/// +/// ## The channel +/// +/// `flutter3d/cloud_saves`, two methods, each with `provider` (the +/// [CloudProvider]'s name) and `slot`: +/// +/// | | | +/// |---|---| +/// | `fetch` | null for no copy, or `{document, version}` | +/// | `put` | also `document` and `replacing` (null to create); `{version}` when written, `{moved: true}` when another device wrote first | +/// +/// The versions are the service's own: a Play Games snapshot's revision, an +/// iCloud record's change tag. The Dart side never reads them, it only hands +/// them back, so each platform keeps what its service already compares. +/// +/// **A build without the platform side answers, it does not crash.** The +/// method channel throws `MissingPluginException` there, and a game that +/// offered iCloud on a build that cannot reach it should say so on the +/// settings screen rather than in a crash report. +final class PlatformCloudSaves implements CloudSaveStore { + PlatformCloudSaves(this.provider, {MethodChannel? channel}) + : _channel = channel ?? const MethodChannel('flutter3d/cloud_saves'); + + final CloudProvider provider; + final MethodChannel _channel; + + @override + String get name => provider.title; + + @override + Future fetch(String slot) async => switch (await _ask( + 'fetch', + {'slot': slot}, + )) { + (_, final CloudUnavailable failed) => failed, + (null, _) => const CloudEmpty(), + ({'document': final String document, 'version': final String version}, _) => + CloudDocument(document, version: version), + (final answer, _) => CloudUnavailable( + '$name answered a fetch with $answer', + ), + }; + + @override + Future put( + String slot, + String document, { + String? replacing, + }) async => switch (await _ask('put', { + 'slot': slot, + 'document': document, + 'replacing': replacing, + })) { + (_, final CloudUnavailable failed) => failed, + ({'moved': true}, _) => const CloudMoved(), + ({'version': final String version}, _) => CloudStored(version: version), + (final answer, _) => CloudUnavailable( + '$name answered a write with $answer', + ), + }; + + /// The platform's answer, or why there was none. + Future<(Object?, CloudUnavailable?)> _ask( + String method, + Map arguments, + ) async { + try { + final answer = await _channel.invokeMethod( + method, + {'provider': provider.name, ...arguments}, + ); + return (answer, null); + } on MissingPluginException { + return (null, CloudUnavailable('$name is not available in this build')); + } on PlatformException catch (error) { + return ( + null, + CloudUnavailable('$name refused: ${error.message ?? error.code}'), + ); + } + } +} diff --git a/packages/flutter3d_game/lib/src/cloud/save_sync.dart b/packages/flutter3d_game/lib/src/cloud/save_sync.dart new file mode 100644 index 000000000..5d4fd16da --- /dev/null +++ b/packages/flutter3d_game/lib/src/cloud/save_sync.dart @@ -0,0 +1,271 @@ +import 'dart:convert'; + +import 'package:flutter3d_app/flutter3d_app.dart'; +import 'package:flutter3d_sim/flutter3d_sim.dart' + show + SaveFound, + SaveNotRead, + SaveRead, + SaveRecord, + SaveResolution, + resolveSaves; + +import '../screens/save_file.dart'; +import 'cloud_save_store.dart'; + +/// How a sync ended. +/// +/// **Constants rather than an enum**, so a later outcome — a player signed +/// out, a quota reached — is an addition and not a break in every game's +/// `switch`. Compare with `==`; [SyncReport.message] is the sentence. +final class SyncOutcome { + const SyncOutcome._(this.name); + + final String name; + + /// The player has not agreed to cloud saves. Nothing was sent or asked. + static const SyncOutcome notConsented = SyncOutcome._('notConsented'); + + /// Both copies are the same run, or there is none. + static const SyncOutcome inSync = SyncOutcome._('inSync'); + + /// This device's run was sent. + static const SyncOutcome uploaded = SyncOutcome._('uploaded'); + + /// The cloud's run was written here. A run already loaded is stale now. + static const SyncOutcome downloaded = SyncOutcome._('downloaded'); + + /// Two different runs equally far along. Settle with [SaveSync.settle]. + static const SyncOutcome ask = SyncOutcome._('ask'); + + /// The cloud copy is not one this build can use, and was left alone. + static const SyncOutcome refused = SyncOutcome._('refused'); + + /// The store could not be reached, or would not do it. + static const SyncOutcome unavailable = SyncOutcome._('unavailable'); + + @override + String toString() => 'SyncOutcome.$name'; +} + +/// What [SaveSync.sync] did, in a sentence, and for [SyncOutcome.ask] the two +/// runs to choose between. +final class SyncReport { + const SyncReport( + this.outcome, + this.message, { + this.local, + this.remote, + this.remoteVersion, + }); + + final SyncOutcome outcome; + final String message; + + final SaveRecord? local; + final SaveRecord? remote; + + /// The version of [remote] in the store, which a settling write replaces. + final String? remoteVersion; +} + +/// The save on this device and a copy of it in a [CloudSaveStore], made one. +/// +/// ## Consent first +/// +/// **Nothing leaves the device until the player says it may.** [consented] is +/// false on a fresh install, kept in a document of its own beside the save, +/// and every call below that would touch the store checks it first and +/// answers [SyncOutcome.notConsented] without a request. Withdrawing forgets +/// what the two sides last agreed on as well, so turning it back on later is +/// a first sync again rather than a resolution against a stale base. +/// +/// ## When to call it +/// +/// Before `RunSession.begin`, and after `Autosave` wrote at a pause or in the +/// background. A run that is already loaded keeps playing from memory, so a +/// download under it is overwritten by its next autosave — which is why +/// [SyncOutcome.downloaded] says the run is stale and the game should begin +/// again from the save. +/// +/// ## Which copy wins +/// +/// `resolveSaves`, by step and digest against the digest the two last agreed +/// on. Recorded after every sync that ended with both sides holding one run. +final class SaveSync { + SaveSync({required this.saves, required this.store, Storage? storage}) + : storage = storage ?? saves.storage; + + final SaveFile saves; + final CloudSaveStore store; + + /// Where consent and the last agreed digest are kept. + final Storage storage; + + /// The document consent is kept in. Separate from the save so clearing a + /// finished run does not quietly turn cloud saves off, and from the + /// settings so a reset of the controls does not quietly turn them on. + static const String stateName = 'cloud_saves.json'; + + Map get _state { + final text = storage.read(stateName); + if (text == null) return const {}; + try { + final json = jsonDecode(text); + return json is Map ? json : const {}; + } on FormatException { + return const {}; + } + } + + bool _keep(Map state) => + storage.write(stateName, jsonEncode(state)); + + /// Whether the player has agreed to keep saves in [store]. + /// + /// Anything but an explicit `true` on disk is no — a document that will not + /// read is not consent. + bool get consented => _state['consented'] == true; + + /// The player agreed. Returns whether that was kept. + bool consent() => _keep({..._state, 'consented': true}); + + /// The player took it back. + bool withdraw() => _keep(const {'consented': false}); + + /// The digest both sides last held, if they have met. + String? get base => switch (_state['base']) { + final String digest => digest, + _ => null, + }; + + void _agreed(String? digest) => + _keep({..._state, 'base': digest}); + + static const SyncReport _notConsented = SyncReport( + SyncOutcome.notConsented, + 'cloud saves are off, so nothing was sent', + ); + + /// Compares the two copies and moves whichever one should move. + Future sync() => _sync(retried: false); + + Future _sync({required bool retried}) async { + if (!consented) return _notConsented; + final SaveRecord? remote; + final String? version; + switch (await store.fetch(SaveFile.name)) { + case CloudUnavailable(:final reason): + return SyncReport(SyncOutcome.unavailable, reason); + case CloudEmpty(): + (remote, version) = (null, null); + case CloudDocument(:final document, version: final stored): + switch (_read(document)) { + case SaveFound(:final record): + (remote, version) = (record, stored); + case SaveNotRead(:final reason, :final newer): + return SyncReport( + SyncOutcome.refused, + newer + ? 'the save in ${store.name} is from a newer build of the ' + 'game ($reason); update to use it — it was left alone' + : 'the save in ${store.name} could not be read ($reason); ' + 'it was left alone', + ); + } + } + final local = saves.readRecord(); + switch (resolveSaves(local: local, remote: remote, base: base)) { + case SaveResolution.inSync: + _agreed(local?.digest); + return SyncReport( + SyncOutcome.inSync, + 'the save here and in ${store.name} are the same', + ); + case SaveResolution.keepLocal: + return switch (await _send(local!, replacing: version)) { + // Another device wrote between the fetch and the write. Once more + // from the top, against what it wrote; twice in a row is a device + // that is syncing in a loop, and that is said rather than chased. + null when !retried => _sync(retried: true), + null => _movedReport, + final sent => sent, + }; + case SaveResolution.takeRemote: + return _take(remote!); + case SaveResolution.ask: + return SyncReport( + SyncOutcome.ask, + 'this device and ${store.name} hold two different runs, equally ' + 'far along', + local: local, + remote: remote, + remoteVersion: version, + ); + } + } + + /// Finishes an [SyncOutcome.ask] with the player's choice: [keepLocal] + /// sends this device's run, otherwise the cloud's is written here. + Future settle(SyncReport asked, {required bool keepLocal}) async { + if (!consented) return _notConsented; + final local = asked.local; + final remote = asked.remote; + if (asked.outcome != SyncOutcome.ask || local == null || remote == null) { + return const SyncReport( + SyncOutcome.refused, + 'there was no choice to settle; sync again', + ); + } + return keepLocal + ? await _send(local, replacing: asked.remoteVersion) ?? _movedReport + : _take(remote); + } + + SyncReport get _movedReport => SyncReport( + SyncOutcome.unavailable, + '${store.name} changed while this was syncing; try again', + ); + + /// Sends [local]; null when another device wrote first. + Future _send(SaveRecord local, {String? replacing}) async { + switch (await store.put( + SaveFile.name, + SaveFile.encode(local), + replacing: replacing, + )) { + case CloudStored(): + _agreed(local.digest); + return SyncReport( + SyncOutcome.uploaded, + 'this run was saved to ${store.name}', + ); + case CloudMoved(): + return null; + case CloudUnavailable(:final reason): + return SyncReport(SyncOutcome.unavailable, reason); + } + } + + SyncReport _take(SaveRecord remote) { + if (!saves.writeRecord(remote)) { + return SyncReport( + SyncOutcome.unavailable, + 'the run from ${store.name} could not be written on this device', + ); + } + _agreed(remote.digest); + return SyncReport( + SyncOutcome.downloaded, + 'the run from ${store.name} was kept on this device', + ); + } + + SaveRead _read(String document) { + try { + return SaveRecord.read(jsonDecode(document), saves.schema); + } on FormatException catch (error) { + return SaveNotRead(error.message); + } + } +} diff --git a/packages/flutter3d_game/lib/src/run/autosave.dart b/packages/flutter3d_game/lib/src/run/autosave.dart new file mode 100644 index 000000000..e99c56401 --- /dev/null +++ b/packages/flutter3d_game/lib/src/run/autosave.dart @@ -0,0 +1,74 @@ +import 'package:flutter/widgets.dart'; + +import 'run_session.dart'; + +/// When a run is written down without the player asking. +/// +/// ## The moments +/// +/// * **A checkpoint**, because that is what a checkpoint promises: the place +/// you come back to has moved. The game says when — only it knows what its +/// checkpoints are — by calling [checkpoint]. +/// * **A pause**, because a pause is the moment before most of the ways a +/// session ends: the menu opened to quit, the pad put down, the window left. +/// Written on the way in and not on every paused frame. +/// * **The application going to the background**, which on a phone is the +/// only warning before the system ends the process. There is no pause +/// screen between a home-button press and a kill, so a game that saved only +/// on pause lost whatever was played since the last one. +/// +/// Not every frame, and not on a timer: a write in the frame budget is a +/// stutter, and a save from the middle of a jump restores into the air. +/// +/// Repeated writes of the same run cost nothing — `SaveFile` keeps the digest +/// of the last one — so a player toggling the menu does not rewrite the disk. +final class Autosave with WidgetsBindingObserver { + Autosave(this.session); + + final RunSession session; + + bool _paused = false; + bool _watching = false; + + /// Tells this whether the game is paused now. Call once per frame, or on + /// every change; only the step into a pause writes. + /// + /// Returns whether it wrote. + bool paused(bool now) { + final entering = now && !_paused; + _paused = now; + return entering && session.save(); + } + + /// The game passed a checkpoint. Returns whether it wrote. + bool checkpoint() => session.save(); + + /// Starts hearing about the application going to the background. + void watchLifecycle() { + if (_watching) return; + _watching = true; + WidgetsBinding.instance.addObserver(this); + } + + /// Stops it. Call from the game's `dispose`. + void dispose() { + if (!_watching) return; + _watching = false; + WidgetsBinding.instance.removeObserver(this); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + switch (state) { + // `inactive` first, because it is the one every platform sends before + // the others, and on iOS the last one a process is sure to see. + case AppLifecycleState.inactive || + AppLifecycleState.hidden || + AppLifecycleState.paused || + AppLifecycleState.detached: + session.save(); + case AppLifecycleState.resumed: + break; + } + } +} diff --git a/packages/flutter3d_game/lib/src/run/demo_recording.dart b/packages/flutter3d_game/lib/src/run/demo_recording.dart new file mode 100644 index 000000000..fb748bf27 --- /dev/null +++ b/packages/flutter3d_game/lib/src/run/demo_recording.dart @@ -0,0 +1,191 @@ +import 'package:flutter3d_sim/flutter3d_sim.dart'; + +/// A [Demo] being written while the run is played: the tape, the +/// checkpoints, and `HR3`'s levels swapped in under it. +/// +/// **What used to be five fields in every game's widget.** Each game kept a +/// start, a level, a hash, a trace and a recorder beside each other and +/// assembled the [Demo] at the end; a level swapped in mid-run had nowhere to +/// go, so the platformer threw its demo away at every edit and began another. +/// The swap has to touch the trace and the list of swaps together, at a step +/// counted in this tape rather than the timeline's, and that is one object's +/// job. +final class DemoRecording { + DemoRecording({ + required this.level, + required this.levelHash, + required this.start, + required int seed, + int checkpointEvery = 25, + }) : recorder = InputTapeRecorder(seed: seed), + checkpoints = DigestTrace(every: checkpointEvery); + + /// The asset path of the level the run started in. + final String level; + + /// [Level.digestHex] of that level as it was loaded. + final String levelHash; + + /// The state the tape starts from. + final Snapshot start; + + /// Where the loop writes each step's input. Add it to `GameLoop.recorders`. + final InputTapeRecorder recorder; + + /// A digest every so many steps, taken live. + final DigestTrace checkpoints; + + final List _swaps = []; + + /// How many steps have been recorded. + int get steps => recorder.tape.steps; + + /// Takes a checkpoint if the step just run is one. Call after every step; + /// [after] is asked for the state the step left only on a checkpoint step, + /// since a save of every body is what a checkpoint costs. + void observe(Snapshot Function() after) { + final step = recorder.tape.steps; + if (step % checkpoints.every == 0) { + checkpoints.observe(step, after().toJson()); + } + } + + /// Writes down that [next] was put under the run [stepsAgo] steps before + /// the present — `RunTimeline.swapLevel`'s step, counted back from now, + /// since the timeline and this tape did not start at the same step. + /// + /// The swap replaced the run from there on, so a swap already written at + /// or after that step is dropped with the checkpoints taken after it: both + /// describe a run that was lived again. + /// + /// False, and nothing written, when the swap took effect before this + /// recording began. The start this recording holds was then made by the + /// old level and the run since by the new one, and no file can say that; + /// the caller begins a new recording from the present instead and writes + /// the swap at its step zero. + bool levelSwapped(Level next, {required int stepsAgo}) { + final at = recorder.tape.steps - stepsAgo; + if (at < 0) return false; + _swaps + ..removeWhere((swap) => swap.step >= at) + ..add(DemoLevelSwap(step: at, level: next)); + checkpoints.forgetAfter(at); + return true; + } + + /// The run so far, as a file. + Demo demo({ + required String buildStamp, + String? platform, + String? recordedBy, + }) => Demo( + level: level, + levelHash: levelHash, + start: start, + tape: recorder.tape, + buildStamp: buildStamp, + checkpoints: checkpoints, + platform: platform, + recordedBy: recordedBy, + levelSwaps: List.unmodifiable(_swaps), + ); +} + +/// What [replayDemo] found. +final class DemoReplay { + const DemoReplay({required this.steps, this.divergence}); + + /// How many steps were played. + final int steps; + + /// The first checkpoint the replay did not match, or null when it matched + /// every one the file holds. + final Divergence? divergence; +} + +/// Plays [demo] through the simulation from its start, swapping in each of +/// [Demo.levelSwaps] as the tape reaches it, and checks the file's own +/// checkpoints on the way. +/// +/// The level [Demo.level] names must be up when this is called; [swapLevel] +/// puts a swapped document in its place and must carry the run over, as the +/// game did live — the state [save] answers before it is what [save] answers +/// after. It is required exactly when the demo has swaps in it: a replay that +/// played through them would part from the run at the first and report that +/// as a divergence of the simulation. +/// +/// A swap at step K goes in after the checkpoint at K is compared, which is +/// the order the live run met them in: the checkpoint was taken under the +/// old level, and the timeline swapped at the keyframe that state was. +/// +/// Checkpoints are compared by step rather than by position, since a swap +/// leaves a gap in them (`DigestTrace.forgetAfter`). +DemoReplay replayDemo({ + required Demo demo, + required InputState input, + required void Function(Snapshot snapshot) restore, + required Snapshot Function() save, + required void Function(double dt) stepSim, + void Function(Level level)? swapLevel, + double stepSeconds = 1.0 / 60.0, +}) { + if (demo.levelSwaps.isNotEmpty && swapLevel == null) { + throw ArgumentError.value( + demo, + 'demo', + 'replaces its level at step ${demo.levelSwaps.first.step}, and no ' + 'swapLevel was given to replace it with', + ); + } + final expected = { + for (var i = 0; i < demo.checkpoints.steps.length; i++) + demo.checkpoints.steps[i]: demo.checkpoints.digests[i], + }; + final swaps = demo.levelSwaps; + var nextSwap = 0; + void swapAt(int step) { + while (nextSwap < swaps.length && swaps[nextSwap].step == step) { + swapLevel!(swaps[nextSwap++].level); + } + } + + restore(demo.start); + swapAt(0); + final playback = InputTapePlayback(demo.tape); + final wasMuted = input.muted; + input.muted = true; + Divergence? divergence; + var step = 0; + try { + while (!playback.isFinished) { + playback.applyTo(input); + input.beginStep(); + stepSim(stepSeconds); + input.endStep(); + step++; + final digest = expected[step]; + if (digest != null && divergence == null) { + final found = StateDigest.of(save().toJson()); + if (found != digest) { + divergence = Divergence(step: step, expected: digest, found: found); + } + } + swapAt(step); + } + } finally { + input.muted = wasMuted; + } + final unreached = demo.checkpoints.steps.where((s) => s > step).firstOrNull; + return DemoReplay( + steps: step, + divergence: + divergence ?? + (unreached == null + ? null + : Divergence( + step: unreached, + expected: expected[unreached], + found: null, + )), + ); +} diff --git a/packages/flutter3d_game/lib/src/run/demo_timeline.dart b/packages/flutter3d_game/lib/src/run/demo_timeline.dart index dd8314b62..d38cb63ea 100644 --- a/packages/flutter3d_game/lib/src/run/demo_timeline.dart +++ b/packages/flutter3d_game/lib/src/run/demo_timeline.dart @@ -1,5 +1,7 @@ import 'package:flutter3d_sim/flutter3d_sim.dart'; +import 'demo_recording.dart'; + /// Rebuilds a [RewindBuffer] that reaches every step of [demo], not only the /// last few seconds a live [RunTimeline] keeps — `rp-02`'s "скраббер по всему /// прогону из `.f3drun`", built by replaying the whole tape once rather than @@ -21,6 +23,12 @@ import 'package:flutter3d_sim/flutter3d_sim.dart'; /// still meant to be reachable; a caller wanting to bound memory for a very /// long recording can pass a smaller one and accept that only its most /// recent stretch scrubs. +/// +/// A demo with `HR3` level swaps in it is refused: a keyframe before a swap +/// is a state of the old level, and a scrub to it would restore that state +/// under whichever level is up. Scrubbing across a swap needs the buffer to +/// know which level each keyframe belongs to; until it does, [replayDemo] +/// plays such a run and this does not pretend to scrub it. RewindBuffer rewindBufferFromDemo({ required Demo demo, required int stepsPerSecond, @@ -31,6 +39,14 @@ RewindBuffer rewindBufferFromDemo({ required Snapshot Function() save, required void Function(double dt) stepSim, }) { + if (demo.levelSwaps.isNotEmpty) { + throw ArgumentError.value( + demo, + 'demo', + 'replaces its level at step ${demo.levelSwaps.first.step}; a scrub ' + 'across a swap would restore one level\'s state under the other', + ); + } restore(demo.start); final wholeRun = demo.tape.frames.length / stepsPerSecond; final buffer = RewindBuffer( diff --git a/packages/flutter3d_game/lib/src/run/live_level.dart b/packages/flutter3d_game/lib/src/run/live_level.dart new file mode 100644 index 000000000..dd482f5c5 --- /dev/null +++ b/packages/flutter3d_game/lib/src/run/live_level.dart @@ -0,0 +1,253 @@ +import 'dart:convert'; +import 'dart:developer' as developer; + +import 'package:flutter3d_sim/flutter3d_sim.dart'; + +import 'demo_recording.dart'; +import 'run_timeline.dart'; + +/// The level a running game is playing, and how to change it under the game. +/// +/// **The game says how; this says when.** Only the game knows what its level +/// became once loaded — which scene nodes are its lights, which colliders +/// its brushes, what its entities spawned — so it hands over two functions: +/// [present] patches what only the picture reads, [rebuild] replaces what the +/// simulation reads. [apply] asks [diffLevel] which of the two an edit needs +/// and calls them in the order that keeps the run honest: the simulation's +/// half through [timeline] when there is one, so the run is replayed under +/// the new level rather than jumping, then the picture's. +/// +/// Without a [timeline] the simulation's half is rebuilt in place, which is +/// what a game with no rewind buffer can do and what it then gets: a run no +/// replay can reach. The report says which happened. +final class LiveLevel { + LiveLevel({ + required this.level, + required this.present, + required this.rebuild, + this.timeline, + this.prepare, + this.swapped, + }); + + /// Told the step [timeline] swapped [next] in before, right after the swap + /// and before [present]: what a [DemoRecording] needs to write the edit + /// into the run. The step is decided inside `RunTimeline.swapLevel` — the + /// last keyframe — so nothing outside the swap can know it otherwise. + final void Function(Level next, int step)? swapped; + + /// Builds ahead what [rebuild] and [present] will swap in, for a game + /// whose level cannot be made synchronously: textures to upload, meshes, + /// a scene. Awaited by [applyWhenReady] before anything changes, so a + /// level that will not build leaves the one being played untouched. + final Future Function(Level next)? prepare; + + /// Patches the running scene to [next]'s look. [diff] names what changed, + /// so a presenter can touch one lamp rather than rebuild them all. + final void Function(Level next, LevelDiff diff) present; + + /// Replaces what the simulation reads — colliders, spawns, the ground — + /// with [next]'s. Called inside `RunTimeline.swapLevel` when there is a + /// [timeline], so it must leave to the snapshot restore what snapshots + /// carry. + final void Function(Level next) rebuild; + + /// The run to branch when an edit reaches the simulation. + final RunTimeline? timeline; + + /// The level being played now. + /// + /// Set by a game that moved on by its own means — the next level, a + /// restart — so the next edit is compared with what is on screen. + Level level; + + /// [prepare], then [apply]: what the extension does with a level that + /// arrived. An edit that changes nothing prepares nothing. + Future applyWhenReady(Level next, {LevelDiff? diff}) async { + final change = diff ?? diffLevel(level, next); + if (!change.isEmpty) await prepare?.call(next); + return apply(next, diff: change); + } + + /// Makes [next] the level being played, as little disturbed as the change + /// allows. + /// + /// [diff] is what a `LevelPatch` already said changed; without one the two + /// levels are compared by [diffLevel]. + LevelApplied apply(Level next, {LevelDiff? diff}) { + final change = diff ?? diffLevel(level, next); + if (change.isEmpty) { + level = next; + return LevelApplied(diff: change); + } + final int? swappedAt; + if (change.presentationOnly) { + swappedAt = null; + } else if (timeline case final RunTimeline run) { + swappedAt = run.swapLevel( + () => rebuild(next), + levelDigest: next.digestHex, + ); + swapped?.call(next, swappedAt); + } else { + rebuild(next); + swappedAt = null; + } + present(next, change); + level = next; + return LevelApplied( + diff: change, + swappedAt: swappedAt, + rebuiltInPlace: !change.presentationOnly && swappedAt == null, + ); + } +} + +/// What one [LiveLevel.apply] did. +final class LevelApplied { + const LevelApplied({ + required this.diff, + this.swappedAt, + this.rebuiltInPlace = false, + }); + + final LevelDiff diff; + + /// The step the timeline branched at, when the edit reached the + /// simulation and there was a timeline to branch. + final int? swappedAt; + + /// The edit reached the simulation and there was no timeline: the run + /// carries on from a state a replay cannot reach. + final bool rebuiltInPlace; + + Map toJson() => { + 'diff': diff.toJson(), + 'swappedAt': ?swappedAt, + 'rebuiltInPlace': rebuiltInPlace, + }; +} + +/// Answers `ext.flutter3d.level.apply` for [live]: [parameters] carry the +/// level's whole `document`, as JSON, and its `hash`, `Level.digestHex`. +/// +/// **The hash is checked, not trusted.** It is what the editor computed from +/// the level it saved; the document is what arrived. When the two disagree, +/// something between them changed the bytes — an encoding, a truncation — +/// and applying the document would put a level under the run that the editor +/// never showed anybody. So a mismatch is refused, naming both. +/// +/// A level whose [LiveLevel.prepare] throws is refused the same way, with +/// what it threw: the game keeps the level it had. +/// +/// A plain function over the parameters, and [registerLevelExtension] a thin +/// door onto it, so the answers are tested without a VM service. +Future<({Map? result, String? error})> answerLevelApply( + LiveLevel live, + Map parameters, +) async { + final document = parameters['document']; + final hash = parameters['hash']; + if (document == null || hash == null) { + return (result: null, error: 'level.apply takes a document and its hash'); + } + final Level next; + try { + next = Level.fromJson(jsonDecode(document) as Map); + } on Object catch (error) { + return (result: null, error: 'the document is not a level: $error'); + } + if (next.digestHex != hash) { + return ( + result: null, + error: + 'the document digests to ${next.digestHex}, not $hash; ' + 'it changed on the way', + ); + } + try { + return (result: (await live.applyWhenReady(next)).toJson(), error: null); + } on Object catch (error) { + return (result: null, error: 'the level did not build: $error'); + } +} + +/// Answers `ext.flutter3d.level.patch` for [live]: [parameters] carry a +/// `LevelPatch` as JSON, made against the level the editor last saved. +/// +/// **Two kinds of no, because the sender does different things with them.** +/// A patch that does not apply to what the game has — another version, a +/// row that is not the one it names — is [stale]: the editor sends the whole +/// document, which the game can take whatever it had. A patch that applies +/// to a level that will not build is refused like a document that will not +/// build, and is not stale: sent whole, it would be refused again. +Future<({Map? result, String? error, bool stale})> +answerLevelPatch(LiveLevel live, Map parameters) async { + final text = parameters['patch']; + if (text == null) { + return (result: null, error: 'level.patch takes a patch', stale: false); + } + final LevelPatch patch; + try { + patch = LevelPatch.fromJson(jsonDecode(text) as Map); + } on Object catch (error) { + return ( + result: null, + error: 'the patch is not a level patch: $error', + stale: false, + ); + } + switch (patch.applyTo(live.level)) { + case LevelPatchRefused(:final reason): + return (result: null, error: reason, stale: true); + case LevelPatched(:final level, :final diff): + try { + final applied = await live.applyWhenReady(level, diff: diff); + return (result: applied.toJson(), error: null, stale: false); + } on Object catch (error) { + return ( + result: null, + error: 'the level did not build: $error', + stale: false, + ); + } + } +} + +/// Puts [live] on the VM service as `ext.flutter3d.level.apply` and +/// `ext.flutter3d.level.patch`, beside the timeline's own extensions: the +/// editor that saved a level sends it here, to a game on this machine or on +/// a phone, and the running game takes it. +/// +/// A stale patch is answered with `LevelPatch.staleCode` rather than +/// `invalidParams`, so the editor can tell "send it whole" from "no". +void registerLevelExtension(LiveLevel live) { + developer.registerExtension('ext.flutter3d.level.apply', ( + method, + parameters, + ) async { + final (:result, :error) = await answerLevelApply(live, parameters); + if (error != null) { + return developer.ServiceExtensionResponse.error( + developer.ServiceExtensionResponse.invalidParams, + error, + ); + } + return developer.ServiceExtensionResponse.result(jsonEncode(result)); + }); + developer.registerExtension('ext.flutter3d.level.patch', ( + method, + parameters, + ) async { + final (:result, :error, :stale) = await answerLevelPatch(live, parameters); + if (error != null) { + return developer.ServiceExtensionResponse.error( + stale + ? LevelPatch.staleCode + : developer.ServiceExtensionResponse.invalidParams, + error, + ); + } + return developer.ServiceExtensionResponse.result(jsonEncode(result)); + }); +} diff --git a/packages/flutter3d_game/lib/src/run/replay_after_swap.dart b/packages/flutter3d_game/lib/src/run/replay_after_swap.dart new file mode 100644 index 000000000..704e9f514 --- /dev/null +++ b/packages/flutter3d_game/lib/src/run/replay_after_swap.dart @@ -0,0 +1,57 @@ +import 'dart:developer' as developer; + +import 'package:flutter/foundation.dart'; +import 'package:flutter3d_app/flutter3d_app.dart' show HotSwap; +import 'package:flutter3d_sim/flutter3d_sim.dart'; + +import 'run_timeline.dart'; + +/// Lives the last [seconds] of [timeline] again under the new code after +/// every hot reload, and says where the new code parts from the old. +/// +/// **A reload answers what the code does now, not what it changed.** The +/// world survives a hot reload and carries on under the new step, which +/// shows the new code running and hides whether the last seconds would have +/// gone differently under it. `RunTimeline.replayUnderNewCode` answers that; +/// this asks it on its own each time `HotSwap` finishes a swap, which a +/// `SceneSurface`'s `reassemble` starts, so nobody has to remember to. +/// +/// [capture] is the simulation's present, as `registerTimelineExtensions` +/// takes it. What happened goes to [onReplayed] — by default a line in the +/// console — and to the VM service as a `flutter3d.timeline.replayedUnderNewCode` +/// event, for an editor listening there. Nothing happens in a build where +/// `HotSwap` is off, which is every build but a debug one. +/// +/// Returns the call that stops it. +VoidCallback replayAfterHotSwap( + RunTimeline timeline, { + required Snapshot Function() capture, + double seconds = 3.0, + void Function(CodeReplay replay)? onReplayed, + @visibleForTesting HotSwap? hotSwap, +}) { + final swaps = (hotSwap ?? HotSwap.instance).swaps; + void replay() { + final done = timeline.replayUnderNewCode( + seconds: seconds, + capture: capture, + ); + if (done == null) return; + developer.postEvent( + 'flutter3d.timeline.replayedUnderNewCode', + done.toJson(), + ); + (onReplayed ?? _say)(done); + } + + swaps.addListener(replay); + return () => swaps.removeListener(replay); +} + +void _say(CodeReplay replay) => debugPrint(switch (replay.divergence) { + null => + 'flutter3d: steps ${replay.fromStep}–${replay.toStep} replayed under the ' + 'new code and came out the same', + final ReplayDivergence parted => + 'flutter3d: the new code parts from the old $parted', +}); diff --git a/packages/flutter3d_game/lib/src/run/run_session.dart b/packages/flutter3d_game/lib/src/run/run_session.dart index 8599453c6..c838d4a80 100644 --- a/packages/flutter3d_game/lib/src/run/run_session.dart +++ b/packages/flutter3d_game/lib/src/run/run_session.dart @@ -103,6 +103,14 @@ abstract base class RunSession { /// changed. void restoreInto(L level, Snapshot snapshot); + /// How far the run has got, in simulation steps across all its levels. + /// Zero by default. + /// + /// Written beside the save so two copies of it can be told apart by more + /// than a clock — see `resolveSaves`. A game that leaves it at zero still + /// syncs; two different runs of it are simply always the player's call. + int stepOf(L level) => 0; + /// What the player takes with them into [next]. Empty by default. /// /// Called on the level being left, before the next one is read — which is the @@ -304,11 +312,36 @@ abstract base class RunSession { save(); } - /// Writes where the run has got to, if there is one worth writing. - void save() { + /// Writes where the run has got to, if there is one worth writing, and says + /// whether it did. + bool save() { final playing = _status; - if (playing is! RunPlaying || playing.outcome.isOver) return; - saves.write(playing.asset, snapshotOf(playing.level)); + if (playing is! RunPlaying || playing.outcome.isOver) return false; + return saves.write( + playing.asset, + snapshotOf(playing.level), + step: stepOf(playing.level), + ); + } + + /// Puts [next], another build of the level being played, in its place, and + /// carries the run over: [snapshotOf] the old one, [restoreInto] the new, + /// then the old one is [close]d. + /// + /// **For a level edited under a running game**, which has to be built + /// before it can be swapped in and must not start the level again when it + /// is. The asset stays the same: it is the same level, changed. False, and + /// [next] closed, when no level is being played — a load under way wins. + bool replaceLevel(L next) { + final playing = _status; + if (playing is! RunPlaying) { + close(next); + return false; + } + restoreInto(next, snapshotOf(playing.level)); + _emit(RunPlaying(playing.asset, next, outcome: outcomeOf(next))); + close(playing.level); + return true; } void _emit(RunStatus next) { diff --git a/packages/flutter3d_game/lib/src/run/run_timeline.dart b/packages/flutter3d_game/lib/src/run/run_timeline.dart index 81bf7766f..67da57de8 100644 --- a/packages/flutter3d_game/lib/src/run/run_timeline.dart +++ b/packages/flutter3d_game/lib/src/run/run_timeline.dart @@ -27,6 +27,169 @@ final class TimelineStepped extends TimelineCommand { final class TimelineBranched extends TimelineCommand { const TimelineBranched(this.step); final int step; + + @override + bool operator ==(Object other) => + other is TimelineBranched && other.step == step; + + @override + int get hashCode => step.hashCode; +} + +/// The live state was moved to the state before [step] to be looked at; the +/// tape and the present are kept, and nothing has branched yet. +final class TimelineScrubbed extends TimelineCommand { + const TimelineScrubbed(this.step); + final int step; + + @override + bool operator ==(Object other) => + other is TimelineScrubbed && other.step == step; + + @override + int get hashCode => step.hashCode; +} + +/// A scrub was left, and the present put back as it was. +final class TimelineReturned extends TimelineCommand { + const TimelineReturned(); +} + +/// The level under the run was replaced, taking effect before [step]: the +/// run was replayed from there under the level whose document digests to +/// [levelDigest] (`Level.digestHex`). +/// +/// **What makes a run with an edit in it reproducible.** A tape replayed +/// against the old level from the start would part from this run at [step]; +/// with this command in hand, a replay swaps the level at the same step and +/// arrives where the run did. +final class TimelineLevelSwapped extends TimelineCommand { + const TimelineLevelSwapped(this.step, this.levelDigest); + final int step; + final String levelDigest; + + @override + bool operator ==(Object other) => + other is TimelineLevelSwapped && + other.step == step && + other.levelDigest == levelDigest; + + @override + int get hashCode => Object.hash(step, levelDigest); +} + +/// The last seconds were lived again under new code, from [fromStep] to the +/// present — `RunTimeline.replayUnderNewCode`. +final class TimelineReplayed extends TimelineCommand { + const TimelineReplayed(this.fromStep); + final int fromStep; + + @override + bool operator ==(Object other) => + other is TimelineReplayed && other.fromStep == fromStep; + + @override + int get hashCode => fromStep.hashCode; +} + +/// Where the run new code makes first parts from the one old code made. +/// +/// **Between two keyframes, not at one step.** The old run is held as a +/// snapshot a keyframe interval apart and the tape between; what it was at +/// the steps between keyframes is gone. So this names the last step the two +/// runs were seen to agree ([agreedAt]) and the first they were seen to differ +/// ([step]), and the first field that differed there. Closer keyframes narrow +/// it; `RewindBuffer.keyframeEvery` is the dial. +final class ReplayDivergence { + const ReplayDivergence({ + required this.agreedAt, + required this.step, + required this.path, + required this.before, + required this.after, + }); + + /// The last step both runs were compared at and agreed. + final int agreedAt; + + /// The first step they were compared at and differed. + final int step; + + /// Where in the snapshot, as `firstDifferingPath` spells it. + final String path; + + /// The value under the old code. + final Object? before; + + /// The value under the new code. + final Object? after; + + Map toJson() => { + 'agreedAt': agreedAt, + 'step': step, + 'path': path, + 'before': before, + 'after': after, + }; + + @override + String toString() => + 'between step $agreedAt and $step, `$path` went from $before to $after'; +} + +/// What `RunTimeline.replayUnderNewCode` did. +final class CodeReplay { + const CodeReplay({ + required this.fromStep, + required this.toStep, + this.divergence, + }); + + final int fromStep; + final int toStep; + + /// Null when the new code made the same run as the old. + final ReplayDivergence? divergence; + + Map toJson() => { + 'fromStep': fromStep, + 'toStep': toStep, + 'divergence': divergence?.toJson(), + }; +} + +/// What [RunTimeline.scrubTo] and [RunTimeline.branchHere] answered. +sealed class ScrubAnswer { + const ScrubAnswer(); + + Map toJson(); +} + +/// The live state is now the state before [step]. +final class ScrubMoved extends ScrubAnswer { + const ScrubMoved(this.step); + final int step; + + @override + Map toJson() => { + 'moved': true, + 'step': step, + }; +} + +/// Nothing moved, and [reason] says why and what to do instead. +final class ScrubRefused extends ScrubAnswer { + const ScrubRefused(this.reason); + final String reason; + + @override + Map toJson() => { + 'moved': false, + 'refusal': reason, + }; + + @override + String toString() => reason; } /// Pause, step, rewind and branch, built on a live [RewindBuffer]. @@ -102,8 +265,12 @@ final class RunTimeline { _history.add(const TimelinePaused()); } + /// Lets the run go on. From a scrub, the present is put back first: the + /// run resumes where it was paused, and going on from the scrubbed moment + /// instead is [branchHere], asked for by name. void resume() { if (!_paused) return; + returnToPresent(); _paused = false; _history.add(const TimelineResumed()); } @@ -111,10 +278,20 @@ final class RunTimeline { /// Runs one fixed step. Only while [isPaused] — a step taken on a running /// timeline would be a second step nobody asked for, on top of whatever is /// driving the loop already. + /// + /// From a scrub this moves the scrub one step along the tape rather than + /// stepping the simulation off it, so "step" in a debugger walks the + /// recorded run; at the present the scrub ends. void stepOnce() { if (!_paused) { throw StateError('stepOnce is only valid while the timeline is paused'); } + final at = _scrubbedAt; + if (at != null) { + _scrubForward(at + 1); + _history.add(const TimelineStepped()); + return; + } input.beginStep(); stepSim(stepSeconds); input.endStep(); @@ -136,6 +313,7 @@ final class RunTimeline { /// release is asking to keep playing from here, not to pause on arrival — /// call [pause] afterwards for that. void releaseAt(RewindPoint point) { + _forgetScrub(); restore(point.snapshot); final toPoint = InputTapePlayback(point.tapeToPoint); final wasMuted = input.muted; @@ -155,6 +333,128 @@ final class RunTimeline { _history.add(TimelineBranched(point.step)); } + /// Replaces the level under the run without the run jumping: [swap] puts + /// the new level in place, and the steps since the last keyframe are lived + /// again under it, so the present is one the new level could have led to. + /// + /// **For the half of an edit the simulation reads** — brushes, entities, + /// the ground (`LevelDiff.simulation`). Swapped in place, a crate moved in + /// the editor would teleport mid-run, and nothing replaying the tape could + /// reach that state. Swapped here, the keyframe is restored, [swap] runs, + /// and the recorded input plays forward with the live devices muted, as + /// [releaseAt] plays it; at most one keyframe interval is replayed. The + /// buffer is then rebased on that keyframe (`RewindBuffer.rebaseAt`), + /// since nothing held on either side of it belongs to the new level. + /// + /// [swap] must replace only what the snapshots do not carry — the level, + /// its colliders, what it spawned at load — and leave what they do carry to + /// the restore. With no keyframe yet held, [swap] runs at once, as at the + /// first step of a run. + /// + /// Returns the step the new level took effect before, which is what + /// [TimelineLevelSwapped] records. + int swapLevel(void Function() swap, {required String levelDigest}) { + returnToPresent(); + final point = rewind.rewindTo(rewind.step); + if (point == null) { + swap(); + _history.add(TimelineLevelSwapped(rewind.step, levelDigest)); + return rewind.step; + } + restore(point.snapshot); + swap(); + final replay = InputTapePlayback(point.tapeToPoint); + final wasMuted = input.muted; + input.muted = true; + try { + while (!replay.isFinished) { + replay.applyTo(input); + input.beginStep(); + stepSim(stepSeconds); + input.endStep(); + } + } finally { + input.muted = wasMuted; + } + rewind.rebaseAt(point); + final at = point.step - point.replayed; + _history.add(TimelineLevelSwapped(at, levelDigest)); + return at; + } + + /// Lives the last [seconds] again under the code running now, and says + /// where that run parts from the one the old code made. + /// + /// **What a code reload is worth to a simulation.** A hot reload swaps the + /// step function and keeps the state, so the run carries on under new code + /// from a present the old code made. That is enough to see the new code + /// running and not enough to see what it changes. This goes back to the + /// keyframe at or before [seconds] ago, replays the recorded input to the + /// present under the new code with the devices muted, and compares the + /// state at each keyframe the old run left — and at the present, which + /// [capture] reads before anything moves — against what the replay reaches + /// there. The first that differs is the [ReplayDivergence]; none differing + /// means the change did not touch these seconds. + /// + /// The replay is kept: the present is the new code's afterwards, and the + /// buffer is rebased on the keyframe it started from, since the keyframes + /// after it are the old code's. Null when the buffer does not reach back. + CodeReplay? replayUnderNewCode({ + required double seconds, + required Snapshot Function() capture, + }) { + returnToPresent(); + final now = rewind.step; + final point = rewind.rewindBy(seconds); + if (point == null) return null; + final from = point.step - point.replayed; + final before = { + ...rewind.keyframesAfter(from), + now: capture(), + }; + + restore(point.snapshot); + final replay = InputTapePlayback( + InputTape(seed: point.seed, frames: point.frames), + ); + var agreed = from; + ReplayDivergence? divergence; + void compare(int step) { + final old = before[step]; + if (old == null || divergence != null) return; + final differs = firstDifferingPath(old.data, capture().data); + if (differs == null) { + agreed = step; + } else { + divergence = ReplayDivergence( + agreedAt: agreed, + step: step, + path: differs.path, + before: differs.a, + after: differs.b, + ); + } + } + + final wasMuted = input.muted; + input.muted = true; + try { + for (var step = from; !replay.isFinished; step++) { + if (step != from) compare(step); + replay.applyTo(input); + input.beginStep(); + stepSim(stepSeconds); + input.endStep(); + } + } finally { + input.muted = wasMuted; + } + compare(now); + rewind.rebaseAt(point); + _history.add(TimelineReplayed(from)); + return CodeReplay(fromStep: from, toStep: now, divergence: divergence); + } + /// [releaseAt], given the step directly rather than a [RewindPoint] — /// what a caller across a wire has, since a step number survives being /// sent as a string and a [RewindPoint] does not. Returns whether the @@ -166,4 +466,171 @@ final class RunTimeline { releaseAt(point); return true; } + + /// The present, written down when a scrub began; null while not scrubbed. + Snapshot? _present; + + int? _scrubbedAt; + + /// The step the live state is scrubbed to, or null at the present. + int? get scrubbedAt => _scrubbedAt; + + /// Puts the live state where the run was before [step], to be looked at, + /// keeping the tape and the present: the scrubber of a time-travel + /// debugger. + /// + /// **Not [releaseAt].** A release cuts the buffer, so dragging back and + /// forth through it would forget the future on the first drag. A scrub + /// keeps everything: [capture] writes the present down once, on the first + /// scrub, and [returnToPresent] restores it exactly; going on from the + /// scrubbed moment is [branchHere]. Scrubbing forward from a scrubbed step + /// plays on from there instead of from the keyframe, so a drag to the + /// right costs the distance dragged. + /// + /// Only while paused, since the loop would step the scrubbed state as if it + /// were the present. A scrub to the present is [returnToPresent]. + ScrubAnswer scrubTo(int step, {required Snapshot Function() capture}) { + if (!_paused) { + return const ScrubRefused( + 'the run is live, so a scrub would be stepped on by the loop; pause ' + 'it first', + ); + } + if (step == rewind.step) { + returnToPresent(); + return ScrubMoved(step); + } + final point = rewind.rewindTo(step); + if (point == null) { + return ScrubRefused(switch (rewind.oldestStep) { + null => + 'nothing is held yet to scrub through; let the run play a ' + 'keyframe interval first', + final oldest => + 'step $step is not held; the buffer reaches from step ' + '$oldest to ${rewind.step}', + }); + } + _present ??= capture(); + _scrubTo(point); + _history.add(TimelineScrubbed(step)); + return ScrubMoved(step); + } + + /// Puts the present back after a scrub. Does nothing at the present, and + /// answers whether there was a scrub to leave. + bool returnToPresent() { + final present = _present; + if (present == null) return false; + restore(present); + _forgetScrub(); + _history.add(const TimelineReturned()); + return true; + } + + /// Makes the scrubbed moment the present: the tape after it is cut, as + /// [releaseAt] cuts it, and the run goes on from here when resumed. + /// + /// **Stays paused**, unlike [releaseAt]: the person branching is looking + /// at the moment they chose, and the first step of the new branch is + /// theirs to take. + ScrubAnswer branchHere() { + final at = _scrubbedAt; + final point = at == null ? null : rewind.rewindTo(at); + if (at == null || point == null) { + return const ScrubRefused( + 'the run is at the present, which is already where it goes on ' + 'from; scrub to a step first', + ); + } + rewind.cut(point); + _forgetScrub(); + _history.add(TimelineBranched(at)); + return ScrubMoved(at); + } + + /// What every entity did over the steps the buffer holds, read through + /// [layout] from a [capture] of each step. + /// + /// The steps are lived again from the oldest keyframe with the devices + /// muted and the live state is put back afterwards — at the present or at + /// the scrub, wherever it was — so asking costs a replay of the buffer and + /// changes nothing. [every] reads one step in so many, for a buffer whose + /// snapshots are too large to take sixty times a second of history. Null + /// before the first keyframe. + EntityTracks? tracks({ + required Snapshot Function() capture, + required EntityLayout layout, + int every = 1, + }) { + final oldest = rewind.oldestStep; + final point = oldest == null ? null : rewind.rewindTo(oldest); + if (oldest == null || point == null) return null; + final here = capture(); + final tracks = EntityTracks(layout); + restore(point.snapshot); + tracks.observe(oldest, capture()); + final playback = InputTapePlayback( + InputTape(seed: point.seed, frames: point.frames), + ); + final wasMuted = input.muted; + input.muted = true; + try { + while (!playback.isFinished) { + playback.applyTo(input); + input.beginStep(); + stepSim(stepSeconds); + input.endStep(); + final step = oldest + playback.step; + if (step % every == 0 || step == rewind.step) { + tracks.observe(step, capture()); + } + } + } finally { + input.muted = wasMuted; + } + restore(here); + return tracks; + } + + void _scrubTo(RewindPoint point) { + final base = point.step - point.replayed; + final at = _scrubbedAt; + final from = at != null && at >= base && at <= point.step ? at : base; + if (from != at) restore(point.snapshot); + _playMuted(point.frames.sublist(from - base, point.replayed), point.seed); + _scrubbedAt = point.step; + } + + /// [stepOnce] from a scrub: one step further along the tape, which ends the + /// scrub when it reaches the present. + void _scrubForward(int step) { + final point = rewind.rewindTo(step); + if (step >= rewind.step || point == null) { + returnToPresent(); + return; + } + _scrubTo(point); + } + + void _forgetScrub() { + _present = null; + _scrubbedAt = null; + } + + void _playMuted(List frames, int seed) { + final playback = InputTapePlayback(InputTape(seed: seed, frames: frames)); + final wasMuted = input.muted; + input.muted = true; + try { + while (!playback.isFinished) { + playback.applyTo(input); + input.beginStep(); + stepSim(stepSeconds); + input.endStep(); + } + } finally { + input.muted = wasMuted; + } + } } diff --git a/packages/flutter3d_game/lib/src/run/run_timeline_extensions.dart b/packages/flutter3d_game/lib/src/run/run_timeline_extensions.dart index ed3ea7ba0..c17e9f67b 100644 --- a/packages/flutter3d_game/lib/src/run/run_timeline_extensions.dart +++ b/packages/flutter3d_game/lib/src/run/run_timeline_extensions.dart @@ -28,7 +28,7 @@ import 'run_timeline.dart'; /// /// ## What a caller on the other end sees /// -/// Seven extensions always, and up to two more depending on what the caller +/// Nine extensions always, and up to five more depending on what the caller /// hands over — each named `ext.flutter3d.timeline.`, callable the way /// any `package:vm_service` client calls one — /// `service.callServiceExtension(isolateId, method: name, args: params)` — @@ -44,9 +44,20 @@ import 'run_timeline.dart'; /// * `history` — no parameters; returns `{"commands": [...]}`, one short /// string per [TimelineCommand] — `"paused"`, `"resumed"`, `"stepped"`, or /// `"branched:118"` naming the step a branch landed at. -/// * `status` — no parameters; returns `{"paused": true}` — what a panel -/// polls to draw its own pause/play button correctly on first connecting, -/// rather than assuming the timeline was already running. +/// * `status` — no parameters; returns `{"paused": true, "step": 600, +/// "oldest": 0, "scrubbedAt": null}` — what a panel polls to draw its own +/// pause/play button correctly on first connecting, rather than assuming +/// the timeline was already running, and the two ends of its scrubber. +/// * `returnToPresent` — no parameters; returns `{"returned": true}`, false +/// when there was no scrub to leave. +/// * `branchHere` — no parameters; returns `{"moved": true, "step": 118}`, +/// or `{"moved": false, "refusal": "..."}` at the present. +/// * `scrubTo` — registered only when [capture] is given; `step`, an integer +/// as a string; the same answer as `branchHere`. `N4`'s scrubber. +/// * `tracks` — registered only when [capture] and [entityLayout] are given; +/// `every`, steps between reads (one when absent); returns +/// [EntityTracks.toJson] over the steps the buffer holds, or +/// `{"reached": false}` before the first keyframe. /// * `frameTimes` — registered only when [frameTimes] is given; no /// parameters, returns that [StepTimeTrace]'s own [StepTimeTrace.toJson] — /// `rp-06`'s strip, read from wherever the caller is already timing its @@ -57,6 +68,12 @@ import 'run_timeline.dart'; /// button the game itself draws. What the callback returns is the /// caller's business; a `Demo.toJson()` is the obvious shape, and this /// does not require it. +/// * `replayUnderNewCode` — registered only when [capture] is given; +/// `seconds`, a number as a string (three when absent); returns +/// [CodeReplay.toJson], or `{"reached": false}` when the buffer does not +/// reach that far. `HR4`: called after a hot reload, it lives the last +/// seconds again under the new code and names where that run parts from +/// the old one. /// /// Registered once per [RunTimeline]; registering the same [timeline] twice /// throws, the same way [developer.registerExtension] itself refuses a @@ -65,6 +82,8 @@ void registerTimelineExtensions( RunTimeline timeline, { StepTimeTrace? frameTimes, Map Function()? bugReport, + Snapshot Function()? capture, + EntityLayout? entityLayout, }) { developer.registerExtension('ext.flutter3d.timeline.pause', ( method, @@ -152,7 +171,30 @@ void registerTimelineExtensions( parameters, ) async { return developer.ServiceExtensionResponse.result( - jsonEncode({'paused': timeline.isPaused}), + jsonEncode({ + 'paused': timeline.isPaused, + 'step': timeline.rewind.step, + 'oldest': timeline.rewind.oldestStep, + 'scrubbedAt': timeline.scrubbedAt, + }), + ); + }); + + developer.registerExtension('ext.flutter3d.timeline.returnToPresent', ( + method, + parameters, + ) async { + return developer.ServiceExtensionResponse.result( + jsonEncode({'returned': timeline.returnToPresent()}), + ); + }); + + developer.registerExtension('ext.flutter3d.timeline.branchHere', ( + method, + parameters, + ) async { + return developer.ServiceExtensionResponse.result( + jsonEncode(timeline.branchHere().toJson()), ); }); @@ -167,6 +209,67 @@ void registerTimelineExtensions( }); } + if (capture != null) { + developer.registerExtension('ext.flutter3d.timeline.scrubTo', ( + method, + parameters, + ) async { + final step = int.tryParse(parameters['step'] ?? ''); + if (step == null) { + return developer.ServiceExtensionResponse.error( + developer.ServiceExtensionResponse.invalidParams, + 'step must be an integer', + ); + } + return developer.ServiceExtensionResponse.result( + jsonEncode(timeline.scrubTo(step, capture: capture).toJson()), + ); + }); + } + + if (capture != null && entityLayout != null) { + developer.registerExtension('ext.flutter3d.timeline.tracks', ( + method, + parameters, + ) async { + final every = int.tryParse(parameters['every'] ?? '') ?? 1; + if (every < 1) { + return developer.ServiceExtensionResponse.error( + developer.ServiceExtensionResponse.invalidParams, + 'every must be a whole number of steps, one or more', + ); + } + final tracks = timeline.tracks( + capture: capture, + layout: entityLayout, + every: every, + ); + return developer.ServiceExtensionResponse.result( + jsonEncode( + tracks?.toJson() ?? const {'reached': false}, + ), + ); + }); + } + + if (capture != null) { + developer.registerExtension('ext.flutter3d.timeline.replayUnderNewCode', ( + method, + parameters, + ) async { + final seconds = double.tryParse(parameters['seconds'] ?? '') ?? 3.0; + final replay = timeline.replayUnderNewCode( + seconds: seconds, + capture: capture, + ); + return developer.ServiceExtensionResponse.result( + jsonEncode( + replay?.toJson() ?? const {'reached': false}, + ), + ); + }); + } + if (bugReport != null) { developer.registerExtension('ext.flutter3d.timeline.bugReport', ( method, @@ -191,4 +294,55 @@ String _describe(TimelineCommand command) => switch (command) { TimelineResumed() => 'resumed', TimelineStepped() => 'stepped', TimelineBranched(:final step) => 'branched:$step', + TimelineLevelSwapped(:final step, :final levelDigest) => + 'level:$step:$levelDigest', + TimelineReplayed(:final fromStep) => 'replayed:$fromStep', + TimelineScrubbed(:final step) => 'scrubbed:$step', + TimelineReturned() => 'returned', }; + +/// Puts [tunables] on the VM service, so an inspector can change them while +/// the game runs: `ext.flutter3d.cvar.set {name, value}` tunes one through +/// [input] — so the change is on the tape with the step that takes it — and +/// `ext.flutter3d.cvar.list` answers every tunable, its value and its default. +/// +/// A name the table does not declare, or a value that is not a number, is +/// refused rather than dropped, so the inspector that sent it hears why. +void registerTuningExtensions(InputState input, Tunables tunables) { + developer.registerExtension('ext.flutter3d.cvar.set', ( + method, + parameters, + ) async { + final name = parameters['name']; + final value = double.tryParse(parameters['value'] ?? ''); + if (name == null || value == null) { + return developer.ServiceExtensionResponse.error( + developer.ServiceExtensionResponse.invalidParams, + 'cvar.set takes a name and a numeric value', + ); + } + if (!tunables.defaults.containsKey(name)) { + return developer.ServiceExtensionResponse.error( + developer.ServiceExtensionResponse.invalidParams, + 'no tunable is called $name; there are ' + '${tunables.defaults.keys.join(', ')}', + ); + } + input.tune(name, value); + return developer.ServiceExtensionResponse.result('{}'); + }); + developer.registerExtension('ext.flutter3d.cvar.list', ( + method, + parameters, + ) async { + return developer.ServiceExtensionResponse.result( + jsonEncode({ + for (final MapEntry(key: name, :value) in tunables.values.entries) + name: { + 'value': value, + 'default': tunables.defaults[name], + }, + }), + ); + }); +} diff --git a/packages/flutter3d_game/lib/src/screens/save_file.dart b/packages/flutter3d_game/lib/src/screens/save_file.dart index 7177c3c88..3bed2317a 100644 --- a/packages/flutter3d_game/lib/src/screens/save_file.dart +++ b/packages/flutter3d_game/lib/src/screens/save_file.dart @@ -2,7 +2,7 @@ import 'dart:convert'; import 'package:flutter3d_app/flutter3d_app.dart'; import 'package:flutter3d_sim/flutter3d_sim.dart' - show Snapshot, SnapshotFormatException; + show SaveFound, SaveNotRead, SaveRecord, SaveSchema, Snapshot; import 'settings_file.dart'; @@ -24,10 +24,18 @@ import 'settings_file.dart'; /// level's path and a `Snapshot` are what any game that can be quit halfway /// through has to keep, and the only thing that differed was the name of the /// directory — which is what [appName] is. +/// +/// The document is a [SaveRecord]: the step and the digest ride along so a +/// copy kept elsewhere — see `SaveSync` — can be compared with this one, and +/// the game's [schema] version so a later build can migrate it. final class SaveFile { - SaveFile({required this.appName, Storage? storage, IssueSink? onIssue}) - : onIssue = onIssue ?? printIssue, - storage = storage ?? defaultStorage(appName, onIssue: onIssue); + SaveFile({ + required this.appName, + Storage? storage, + IssueSink? onIssue, + this.schema = const SaveSchema(), + }) : onIssue = onIssue ?? printIssue, + storage = storage ?? defaultStorage(appName, onIssue: onIssue); /// Which game's save this is. Two games served from one browser origin would /// otherwise resume into each other's levels — see [SettingsFile.appName], @@ -51,76 +59,95 @@ final class SaveFile { /// nobody can tell the two apart, least of all the person it happened to. final IssueSink onIssue; - /// A separate document from the settings on purpose: settings are what a - /// player chose and a save is where they got to, and one of those must survive - /// a delete of the other. Wiping a corrupt save should not cost the bindings. - static const String _name = 'save.json'; + /// The game's own save fields, by version, and the migrations between them. + /// + /// Empty by default, which is version 0 — what every save written before + /// games had a schema is read as. + final SaveSchema schema; + + /// The name the document is kept under — and the slot a cloud copy of it is + /// kept in, so the two are the same thing in both places. + static const String name = 'save.json'; + + /// The digest of what was last written, so a second write of the same run + /// costs nothing. Autosave writes on every pause, and most pauses are the + /// same run as the last one. + String? _written; /// The run that was saved, or null if there is nothing to resume. /// /// **Never throws**, on the same argument as [SettingsFile.read]: a save that /// cannot be read is a run that has to start again, and that is a much /// smaller loss than a game that will not launch. - ({String level, Snapshot run})? read() { - final text = storage.read(_name); - if (text == null) return null; + ({String level, Snapshot run})? read() => switch (readRecord()) { + final record? => (level: record.level, run: record.run), + null => null, + }; + + /// [read], with the step, the digest and the schema it was written at. + SaveRecord? readRecord() { + final text = storage.read(name); + return text == null ? null : parse(text); + } + + /// Reads a save document from wherever it came from — this device's storage + /// or a cloud copy — migrating it to [schema] on the way. + /// + /// Null, with an issue said, when it is not a save this build can use. + SaveRecord? parse(String text) { + final Object? json; try { - final json = jsonDecode(text); - // **These three said nothing at all**, which is worse than the `catch` - // below: a document that parses as JSON but is not a save — an empty - // object, a half-written file, a save from a build that named its keys - // differently — went back as "no save" without a word anywhere. - if (json is! Map) { - onIssue(Issue('save: the document is not an object, starting fresh')); - return null; - } - final level = json['level']; - final run = json['run']; - if (level is! String || run is! Map) { - onIssue(Issue('save: no level and run in it, starting fresh')); - return null; - } - // A save with no version in it was written by a build that had the - // version and did not send it — `write` used to hand over `run.data`, - // which is the payload without the header `toJson` adds, so the key - // never reached the disk and `Snapshot.fromJson` was never called on the - // way back. That is every save this build can find. The shape did not - // change, so it is a version 1 document that failed to say so, and - // refusing it would cost a player a run to fix a bug that was never - // theirs. Deletable once no save predates the fix. - final versioned = run.containsKey('version') - ? run - : {'version': Snapshot.formatVersion, ...run}; - return (level: level, run: Snapshot.fromJson(versioned)); - } on SnapshotFormatException catch (error) { - // The case the version exists for, and it is worth its own arm: this is - // a save from a *newer* build, and "starting fresh" would silently - // discard a run the player can still open by going back to the build - // that wrote it. Saying which is which is the whole point of refusing - // rather than misreading. - onIssue(Issue('save: ${error.message}, starting fresh')); - return null; + json = jsonDecode(text); } catch (error) { onIssue(Issue('save: could not be read, starting fresh ($error)')); return null; } + switch (SaveRecord.read(json, schema)) { + case SaveFound(:final record): + return record; + case SaveNotRead(:final reason): + // A save from a *newer* build is the case the versions exist for: + // "starting fresh" without a word would silently discard a run the + // player can still open by going back to the build that wrote it. + onIssue(Issue('save: $reason, starting fresh')); + return null; + } } /// Writes the run, and says whether it managed to. /// - /// Through [Snapshot.toJson] rather than around it: the version is added - /// there, and a document written from `run.data` carries the payload with no - /// header, which is what made the refusal above unreachable. - bool write(String level, Snapshot run) => storage.write( - _name, - const JsonEncoder.withIndent( - ' ', - ).convert({'level': level, 'run': run.toJson()}), + /// [step] is how far the run has got across its levels — see + /// `SaveRecord.step` for what it settles. + bool write(String level, Snapshot run, {int step = 0}) => writeRecord( + SaveRecord(level: level, run: run, step: step, schema: schema.version), ); + /// Writes [record] as it is: a run from a cloud copy, kept here. + /// + /// The same run written twice in a row is written once. True either way — + /// the save on disk is that run. + bool writeRecord(SaveRecord record) { + if (record.digest == _written) return true; + final wrote = storage.write(name, encode(record)); + if (wrote) _written = record.digest; + return wrote; + } + + /// The document [writeRecord] keeps, for a caller that keeps it elsewhere. + /// + /// Through `Snapshot.toJson` rather than around it: the version is added + /// there, and a document written from `run.data` carries the payload with no + /// header — which is what once made every refusal of a newer save + /// unreachable. + static String encode(SaveRecord record) => + const JsonEncoder.withIndent(' ').convert(record.toJson()); + /// Forgets the run. Called when it ends, either way. /// /// A finished run left behind is resumed on the next launch, and the player is /// put back on the last checkpoint of a level they already beat. - void clear() => storage.remove(_name); + void clear() { + _written = null; + storage.remove(name); + } } diff --git a/packages/flutter3d_game/lib/src/visuals/behaviour_overlay.dart b/packages/flutter3d_game/lib/src/visuals/behaviour_overlay.dart new file mode 100644 index 000000000..505524372 --- /dev/null +++ b/packages/flutter3d_game/lib/src/visuals/behaviour_overlay.dart @@ -0,0 +1,68 @@ +import 'package:flutter3d/flutter3d.dart'; +import 'package:flutter3d_sim/flutter3d_sim.dart'; +import 'package:vector_math/vector_math.dart'; + +/// What every actor running a behaviour tree last decided, drawn where it +/// stands. +/// +/// **Read-only, and that is the condition for having it at all.** It reads +/// the boards through `BehaviourBrain.pathOf` and `goalOf`, which never make +/// one, so switching the overlay on cannot change a snapshot — an overlay that +/// did would make a run under the debugger a different run. +/// +/// Above each actor, one short upright stroke per node on the path, root +/// lowest, coloured by how the node came out: amber running, green success, +/// red failure. A line from the actor to where its running leaf is taking it. +/// Lines cannot carry words, so the names are [describe]'s, for whatever text +/// the game shows beside the picture. +/// +/// Hand [draw] to `Renderer.debugLines`. +final class BehaviourOverlay { + BehaviourOverlay(this.actors); + + final ActorSystem actors; + + /// How far apart the strokes are, and how tall, in metres. + static const double _pitch = 0.16; + static const double _stroke = 0.12; + + /// A new vector each call: a shared one is a colour anybody can scale. + static Vector4 colourOf(BehaviourStatus status) => switch (status) { + BehaviourStatus.running => Vector4(1.0, 0.75, 0.1, 1.0), + BehaviourStatus.success => Vector4(0.3, 0.9, 0.35, 1.0), + BehaviourStatus.failure => Vector4(0.95, 0.25, 0.2, 1.0), + }; + + void draw(DebugDraw lines) { + for (final actor in actors.actors) { + final body = actor.body; + if (body == null || !actor.isAlive) continue; + final path = BehaviourBrain.pathOf(actor); + if (path.isEmpty) continue; + final at = body.position; + final top = at.y + body.halfExtents.y + 0.2; + for (final (i, step) in path.indexed) { + final y = top + i * _pitch; + lines.addLine( + Vector3(at.x, y, at.z), + Vector3(at.x, y + _stroke, at.z), + colourOf(step.status), + ); + } + final goal = BehaviourBrain.goalOf(actor, actors); + if (goal != null) lines.addLine(at, goal, colourOf(path.last.status)); + } + } + + /// One line per actor running a tree: who, and the path by name — + /// `a1: utility › patrol › goTo (running)`. + List describe() => [ + for (final actor in actors.actors) + if (BehaviourBrain.pathOf(actor) case final path when path.isNotEmpty) + [ + '${actor.name ?? '#${actor.ordinal}'}:', + path.map((step) => step.label).join(' › '), + '(${path.last.status.name})', + ].join(' '), + ]; +} diff --git a/packages/flutter3d_game/pubspec.yaml b/packages/flutter3d_game/pubspec.yaml index bf8be4a67..c3bfa1371 100644 --- a/packages/flutter3d_game/pubspec.yaml +++ b/packages/flutter3d_game/pubspec.yaml @@ -64,6 +64,12 @@ dependencies: vector_math: ^2.2.0 + # `HttpCloudSaves`, a save kept on a server. Not `dart:io`'s client: that + # one does not exist in a browser, and a web build is where a player is most + # likely to come back on another device. Already in the workspace for + # `flutter3d_lti`. + http: ^1.2.0 + dev_dependencies: flutter_test: sdk: flutter diff --git a/packages/flutter3d_game/test/autosave_test.dart b/packages/flutter3d_game/test/autosave_test.dart new file mode 100644 index 000000000..69a12a132 --- /dev/null +++ b/packages/flutter3d_game/test/autosave_test.dart @@ -0,0 +1,150 @@ +/// When a run is written without the player asking: on the way into a pause, +/// at a checkpoint, and when the application goes to the background. +/// +/// flutter test test/autosave_test.dart +library; + +import 'package:flutter/widgets.dart'; +import 'package:flutter3d_app/flutter3d_app.dart'; +import 'package:flutter3d_game/flutter3d_game.dart'; +import 'package:flutter3d_sim/flutter3d_sim.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Counts writes, which is what these tests are about. +final class _Storage implements Storage { + final Map documents = {}; + int writes = 0; + + @override + String? read(String name) => documents[name]; + + @override + bool write(String name, String contents) { + writes++; + documents[name] = contents; + return true; + } + + @override + void remove(String name) => documents.remove(name); +} + +final class _Level { + int coins = 0; + int steps = 0; +} + +final class _Game extends RunSession<_Level> { + _Game({required super.saves}) : super(firstLevel: 'one'); + + @override + Future<_Level> open(String asset) async => _Level(); + + @override + RunOutcome outcomeOf(_Level level) => RunOutcome.playing; + + @override + String? nextOf(_Level level) => null; + + @override + Snapshot snapshotOf(_Level level) => + Snapshot({'coins': level.coins}); + + @override + void restoreInto(_Level level, Snapshot snapshot) => + level.coins = snapshot.data.integer('coins'); + + @override + int stepOf(_Level level) => level.steps; +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late _Storage storage; + late SaveFile saves; + late _Game game; + late Autosave autosave; + + setUp(() async { + storage = _Storage(); + saves = SaveFile(appName: 't', storage: storage); + game = _Game(saves: saves); + await game.begin(); + autosave = Autosave(game); + }); + + test('a pause writes on the way in, and not on every paused frame', () { + // Mutation: write whenever `paused` is true. Every frame behind the menu + // is then a file write — sixty a second for as long as the menu is open. + expect(autosave.paused(false), isFalse); + expect(autosave.paused(true), isTrue); + expect(storage.writes, 1); + + game.level!.coins = 3; + autosave.paused(true); + autosave.paused(true); + expect(storage.writes, 1); + + autosave.paused(false); + expect(storage.writes, 1, reason: 'leaving a pause writes nothing'); + expect(autosave.paused(true), isTrue); + expect(storage.writes, 2); + expect(saves.read()!.run.data['coins'], 3); + }); + + test('the same run is not written twice', () { + // Mutation: drop the digest check in `SaveFile.writeRecord`. A player who + // opens and closes the menu rewrites the same save each time, which on a + // phone is flash wear for nothing. + autosave.checkpoint(); + autosave.paused(true); + autosave.paused(false); + autosave.paused(true); + + expect(storage.writes, 1); + }); + + test('the step is written beside the run', () { + // Mutation: drop `step: stepOf(...)` from `RunSession.save`. Every save is + // then step 0, and two different runs are always the player's call. + game.level!.steps = 4200; + autosave.checkpoint(); + + expect(saves.readRecord()!.step, 4200); + }); + + test('going to the background writes the run', () { + // On a phone there is no pause screen between the home button and the + // system ending the process. Mutation: write only on `paused`, which iOS + // does not always deliver before it kills a backgrounded game. + game.level!.coins = 7; + autosave.didChangeAppLifecycleState(AppLifecycleState.inactive); + expect(saves.read()!.run.data['coins'], 7); + + game.level!.coins = 8; + autosave.didChangeAppLifecycleState(AppLifecycleState.resumed); + expect(saves.read()!.run.data['coins'], 7, reason: 'coming back is not'); + }); + + test('watching the lifecycle hears the binding, and stops', () { + // Mutation: forget `removeObserver` in `dispose`. A game torn down keeps + // writing a run that is no longer there every time the window loses focus. + autosave.watchLifecycle(); + game.level!.coins = 5; + WidgetsBinding.instance.handleAppLifecycleStateChanged( + AppLifecycleState.inactive, + ); + expect(saves.read()!.run.data['coins'], 5); + + autosave.dispose(); + WidgetsBinding.instance.handleAppLifecycleStateChanged( + AppLifecycleState.resumed, + ); + game.level!.coins = 6; + WidgetsBinding.instance.handleAppLifecycleStateChanged( + AppLifecycleState.inactive, + ); + expect(saves.read()!.run.data['coins'], 5); + }); +} diff --git a/packages/flutter3d_game/test/behaviour_overlay_test.dart b/packages/flutter3d_game/test/behaviour_overlay_test.dart new file mode 100644 index 000000000..0df8fdf5c --- /dev/null +++ b/packages/flutter3d_game/test/behaviour_overlay_test.dart @@ -0,0 +1,74 @@ +import 'package:flutter3d/flutter3d.dart'; +import 'package:flutter3d_game/flutter3d_game.dart'; +import 'package:flutter3d_sim/flutter3d_sim.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:vector_math/vector_math.dart'; + +void main() { + ActorSystem patrolling() { + final world = CollisionWorld() + ..addBox(Vector3(0.0, -0.5, 0.0), Vector3(40.0, 1.0, 40.0)) + ..update(); + final system = ActorSystem(world: world, random: GameRandom(1)); + final tree = BehaviourTree.read({ + 'kind': 'sequence', + 'name': 'patrol', + 'children': [ + {'kind': 'goTo', 'key': 'post'}, + {'kind': 'wait', 'seconds': 1}, + ], + }, BehaviourKinds()).tree!; + final actor = system.spawn( + body: CharacterController(world: world, position: Vector3(0, 0.9, 0)), + brain: BehaviourBrain(tree), + name: 'guard', + ); + system.entities.set( + actor.entity, + Blackboard( + values: { + 'post': [5.0, 0.9, 0.0], + }, + ), + ); + return system; + } + + test('draws a stroke per node on the path and a line to the goal', () { + final system = patrolling(); + system + ..beginStep() + // Inside the close range, so the actor thinks on the first step. + ..step(1 / 60, focus: Vector3(0, 0.9, 10)); + final lines = DebugDraw(); + BehaviourOverlay(system).draw(lines); + // Mutation: drawing only the leaf leaves the sequence out, and the + // picture no longer says which branch the leaf belongs to. + expect(lines.lineCount, 3); + expect(BehaviourOverlay(system).describe(), [ + 'guard: patrol › goTo (running)', + ]); + }); + + test('an actor that has not decided yet is not drawn, and gets no board', () { + final system = patrolling(); + final fresh = system.spawn( + body: CharacterController( + world: system.world, + position: Vector3(3, 0.9, 3), + ), + brain: BehaviourBrain( + BehaviourTree.read({ + 'kind': 'wait', + 'seconds': 1, + }, BehaviourKinds()).tree!, + ), + ); + final lines = DebugDraw(); + BehaviourOverlay(system).draw(lines); + expect(lines.lineCount, 0); + // Mutation: an overlay that made a board to read it would put a + // component in the next snapshot just by being switched on. + expect(system.entities.has(fresh.entity), isFalse); + }); +} diff --git a/packages/flutter3d_game/test/cloud_save_stores_test.dart b/packages/flutter3d_game/test/cloud_save_stores_test.dart new file mode 100644 index 000000000..db1b01bb1 --- /dev/null +++ b/packages/flutter3d_game/test/cloud_save_stores_test.dart @@ -0,0 +1,184 @@ +/// The two ways a save leaves the device: a server over HTTP, and Play Games +/// or iCloud through the platform's side. Both answer; neither throws for a +/// reason the network gave. +/// +/// flutter test test/cloud_save_stores_test.dart +library; + +import 'package:flutter/services.dart'; +import 'package:flutter3d_game/flutter3d_game.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('HttpCloudSaves', () { + test('a slot is a resource under saves//', () async { + final asked = []; + final store = HttpCloudSaves( + base: Uri.parse('https://example.test/api/'), + game: 'platformer', + headers: () async => {'Authorization': 'Bearer t'}, + client: MockClient((request) async { + asked.add(request); + return http.Response('{}', 200, headers: {'etag': '"v1"'}); + }), + ); + + final fetched = await store.fetch('save.json'); + + expect( + asked.single.url.toString(), + 'https://example.test/api/saves/platformer/save.json', + ); + expect(asked.single.headers['Authorization'], 'Bearer t'); + expect(fetched, isA()); + expect((fetched as CloudDocument).version, '"v1"'); + }); + + test('nothing there is 404, and empty', () async { + final store = HttpCloudSaves( + base: Uri.parse('https://example.test'), + game: 'g', + client: MockClient((_) async => http.Response('', 404)), + ); + + expect(await store.fetch('save.json'), isA()); + }); + + test('a write is conditional on the version it replaces', () async { + // Mutation: send no precondition. Two devices syncing at once both + // write, and whichever lands second erases the other's run unseen. + final asked = []; + final store = HttpCloudSaves( + base: Uri.parse('https://example.test'), + game: 'g', + client: MockClient((request) async { + asked.add(request); + return request.headers['If-Match'] == '"v1"' || + request.headers['If-None-Match'] == '*' + ? http.Response('', 204, headers: {'etag': '"v2"'}) + : http.Response('', 412); + }), + ); + + final replaced = await store.put('s', '{}', replacing: '"v1"'); + final created = await store.put('s', '{}'); + final stale = await store.put('s', '{}', replacing: '"v0"'); + + expect((replaced as CloudStored).version, '"v2"'); + expect(created, isA()); + expect(asked[1].headers['If-None-Match'], '*'); + expect(asked[1].headers.containsKey('If-Match'), isFalse); + expect(stale, isA()); + }); + + test('a server that sends no ETag is refused, saying why', () async { + // Mutation: accept the document with an empty version. The next write + // then replaces whatever is there, which is the race the versions stop. + final store = HttpCloudSaves( + base: Uri.parse('https://example.test'), + game: 'g', + client: MockClient((_) async => http.Response('{}', 200)), + ); + + final fetched = await store.fetch('s'); + + expect(fetched, isA()); + expect((fetched as CloudUnavailable).reason, contains('ETag')); + }); + + test('a network that is down is an answer', () async { + // Mutation: let the client's exception out. A launch with no network + // then fails at the title card instead of saying the cloud is away. + final store = HttpCloudSaves( + base: Uri.parse('https://example.test'), + game: 'g', + client: MockClient((_) async => throw http.ClientException('down')), + ); + + expect(await store.fetch('s'), isA()); + expect(await store.put('s', '{}'), isA()); + }); + }); + + group('PlatformCloudSaves', () { + const channel = MethodChannel('flutter3d/cloud_saves'); + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + + tearDown(() => messenger.setMockMethodCallHandler(channel, null)); + + test( + 'asks the platform with the provider, and reads its answers', + () async { + final calls = []; + messenger.setMockMethodCallHandler(channel, (call) async { + calls.add(call); + return switch (call.method) { + 'fetch' => {'document': '{}', 'version': 'r7'}, + _ => {'version': 'r8'}, + }; + }); + final store = PlatformCloudSaves(CloudProvider.playGames); + + final fetched = await store.fetch('save.json'); + final put = await store.put('save.json', '{}', replacing: 'r7'); + + expect((fetched as CloudDocument).version, 'r7'); + expect((put as CloudStored).version, 'r8'); + expect(calls.first.arguments, { + 'provider': 'playGames', + 'slot': 'save.json', + }); + expect((calls.last.arguments as Map)['replacing'], 'r7'); + expect(store.name, 'Play Games'); + }, + ); + + test('nothing kept, and a lost race, are their own answers', () async { + messenger.setMockMethodCallHandler( + channel, + (call) async => switch (call.method) { + 'fetch' => null, + _ => {'moved': true}, + }, + ); + final store = PlatformCloudSaves(CloudProvider.iCloud); + + expect(await store.fetch('s'), isA()); + expect(await store.put('s', '{}'), isA()); + }); + + test('a build without the platform side says so', () async { + // Mutation: let `MissingPluginException` out. A build that offers + // iCloud where it cannot reach it then crashes on the settings screen. + final store = PlatformCloudSaves(CloudProvider.iCloud); + + final fetched = await store.fetch('s'); + + expect(fetched, isA()); + expect( + (fetched as CloudUnavailable).reason, + 'iCloud is not available in this build', + ); + }); + + test('a refusal from the service names it', () async { + messenger.setMockMethodCallHandler( + channel, + (_) async => throw PlatformException( + code: 'SIGN_IN_REQUIRED', + message: 'not signed in to Play Games', + ), + ); + final store = PlatformCloudSaves(CloudProvider.playGames); + + final put = await store.put('s', '{}'); + + expect((put as CloudUnavailable).reason, contains('not signed in')); + }); + }); +} diff --git a/packages/flutter3d_game/test/demo_recording_test.dart b/packages/flutter3d_game/test/demo_recording_test.dart new file mode 100644 index 000000000..2dda76bec --- /dev/null +++ b/packages/flutter3d_game/test/demo_recording_test.dart @@ -0,0 +1,195 @@ +/// `HR3`'s tail: a level swapped under a run is written into the run's demo +/// and played back from it. +/// +/// flutter test test/demo_recording_test.dart +/// +/// On a toy whose one level property is its speed, so that a swap is a thing +/// the state visibly depends on. The shipped game's version of the claim, +/// through its own loader and its own swap, is the platformer's +/// `demo_level_swap_test.dart`. +library; + +import 'dart:convert'; + +import 'package:flutter3d_game/flutter3d_game.dart'; +import 'package:flutter3d_sim/flutter3d_sim.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// The toy reads its speed off the level's fog density: any number a level +/// document carries will do, and this one needs no brushes. +Level _level(double speed) => Level(name: 'toy', fogDensity: speed); + +final class _Toy { + double speed = 1.0; + double x = 0.0; + + void step(InputState input) => x += input.moveAxis.x * speed; + + Snapshot save() => Snapshot({'x': x, 'random': 0}); + + void restore(Snapshot snapshot) => x = snapshot.data.number('x'); +} + +void _play(InputState input, int step) => + input.setStickAxis(step % 5 == 0 ? -0.25 : 1.0, 0.0); + +/// A run of [steps] steps on a live timeline with [edits] made at the steps +/// they are keyed by, recorded the way a game records it. +({DemoRecording demo, _Toy toy, List swappedAt}) _live({ + required int steps, + Map edits = const {}, +}) { + final toy = _Toy(); + final input = InputState(); + final rewind = RewindBuffer(stepsPerSecond: 60, keyframeEvery: 20); + final timeline = RunTimeline( + rewind: rewind, + input: input, + stepSim: (dt) => toy.step(input), + restore: toy.restore, + ); + final demo = DemoRecording( + level: 'assets/levels/toy.json', + levelHash: _level(1.0).digestHex, + start: toy.save(), + seed: 0, + checkpointEvery: 10, + ); + final swappedAt = []; + for (var step = 0; step < steps; step++) { + if (edits[step] case final double speed) { + final next = _level(speed); + final at = timeline.swapLevel( + () => toy.speed = speed, + levelDigest: next.digestHex, + ); + swappedAt.add(at); + expect(demo.levelSwapped(next, stepsAgo: rewind.step - at), isTrue); + } + _play(input, step); + rewind.recorder.record(input); + demo.recorder.record(input); + input.beginStep(); + if (rewind.keyframeDue) rewind.keyframe(toy.save()); + toy.step(input); + demo.observe(toy.save); + input.endStep(); + } + return (demo: demo, toy: toy, swappedAt: swappedAt); +} + +/// Plays [demo] on a fresh toy, [swaps] or not. +({DemoReplay replay, _Toy toy}) _replay(Demo demo, {bool swaps = true}) { + final toy = _Toy(); + final input = InputState(); + final replay = replayDemo( + demo: demo, + input: input, + restore: toy.restore, + save: toy.save, + stepSim: (dt) => toy.step(input), + swapLevel: swaps ? (level) => toy.speed = level.fogDensity : (level) {}, + ); + return (replay: replay, toy: toy); +} + +Demo _sent(DemoRecording demo) => Demo.fromJson( + jsonDecode(jsonEncode(demo.demo(buildStamp: 'test').toJson())) + as Map, +); + +void main() { + test('a run swapped at step K, saved and replayed, arrives where it did', () { + final live = _live(steps: 120, edits: const {50: 3.0}); + final file = _sent(live.demo); + + expect(file.levelSwaps.single.step, live.swappedAt.single); + expect(file.levelSwaps.single.step, 40, reason: 'the keyframe before 50'); + + final replayed = _replay(file); + expect(replayed.replay.divergence, isNull); + expect(replayed.replay.steps, 120); + expect(replayed.toy.x, live.toy.x); + + // Mutation: a replay that does not swap — the speed it plays at after + // step 40 is the old one, and the first checkpoint the file holds after + // it says so: 60, since the one at 50 was taken before the swap went + // back over it. + final unswapped = _replay(file, swaps: false); + expect(unswapped.toy.x, isNot(live.toy.x)); + expect(unswapped.replay.divergence?.step, 60); + }); + + test('a second edit within a keyframe replaces the first', () { + // Both swaps go back to the keyframe at 40: the second replays the + // steps the first had replayed, so the first never happened. + final live = _live(steps: 90, edits: const {45: 3.0, 55: 0.5}); + final file = _sent(live.demo); + + // Mutation: keep the earlier swap at the same step — the file is + // refused on reading, since two swaps at one step are out of order. + expect(live.swappedAt, [40, 40]); + expect(file.levelSwaps.single.level.fogDensity, 0.5); + expect(_replay(file).toy.x, live.toy.x); + }); + + test('the checkpoints after the swap step are forgotten, and the ones ' + 'before kept', () { + final live = _live(steps: 60, edits: const {50: 3.0}); + + // Mutation: keep the trace whole — the checkpoint at 50 was taken under + // the old speed, and every honest replay would diverge there. + expect(live.demo.checkpoints.steps, [10, 20, 30, 40, 60]); + }); + + test('a swap from before the recording began is not written into it', () { + final demo = DemoRecording( + level: 'assets/levels/toy.json', + levelHash: _level(1.0).digestHex, + start: _Toy().save(), + seed: 0, + ); + for (var step = 0; step < 5; step++) { + demo.recorder.record(InputState()); + } + + // Mutation: clamp the step to zero — the file would say the start was + // made by the new level, when the old one made it. + expect(demo.levelSwapped(_level(2.0), stepsAgo: 6), isFalse); + expect(demo.demo(buildStamp: 'test').levelSwaps, isEmpty); + expect(demo.levelSwapped(_level(2.0), stepsAgo: 5), isTrue); + expect(demo.demo(buildStamp: 'test').levelSwaps.single.step, 0); + }); + + test('a demo with swaps is refused where it cannot be played honestly', () { + final file = _sent( + _live(steps: 60, edits: const {30: 2.0}).demo, + ); + final toy = _Toy(); + final input = InputState(); + + // Mutation: drop either check — the replay plays through the swap and + // calls the result a divergence of the simulation. + expect( + () => replayDemo( + demo: file, + input: input, + restore: toy.restore, + save: toy.save, + stepSim: (dt) => toy.step(input), + ), + throwsArgumentError, + ); + expect( + () => rewindBufferFromDemo( + demo: file, + stepsPerSecond: 60, + input: input, + restore: toy.restore, + save: toy.save, + stepSim: (dt) => toy.step(input), + ), + throwsArgumentError, + ); + }); +} diff --git a/packages/flutter3d_game/test/fixtures/timeline_target.dart b/packages/flutter3d_game/test/fixtures/timeline_target.dart index d98b93173..5c737457c 100644 --- a/packages/flutter3d_game/test/fixtures/timeline_target.dart +++ b/packages/flutter3d_game/test/fixtures/timeline_target.dart @@ -22,15 +22,29 @@ import 'package:flutter_test/flutter_test.dart'; final class _Toy { _Toy(int seed) : dice = GameRandom(seed); final GameRandom dice; + final Tunables tunables = Tunables(const {'speed': 1.0}); double x = 0.0; - void step(InputState input) => x += 1.0; + void step(InputState input) { + tunables.take(input); + x += tunables['speed']; + } - Snapshot save() => Snapshot({'x': x, 'random': dice.state}); + Snapshot save() => Snapshot({ + 'x': x, + // `N4`'s tracks read the toy as one entity; restore ignores it, since + // it repeats `x`. + 'entities': { + 'toy': {'x': x}, + }, + 'random': dice.state, + 'tunables': tunables.toJson(), + }); void restore(Snapshot snapshot) { x = snapshot.data.number('x'); dice.state = snapshot.data.integer('random'); + tunables.restore(snapshot.data['tunables']! as Map); } } @@ -47,8 +61,31 @@ void main() { ); final frameTimes = StepTimeTrace(); var stepNumber = 0; + // `HR3`: a level the toy plays on, taken from outside as the editor + // sends it. The toy reads nothing from it; what is checked from outside is + // that a brush change branches the timeline and a look change does not. + registerLevelExtension( + LiveLevel( + level: Level.fromJson(const { + 'version': 1, + 'brushes': [ + { + 'at': [0, 0, 0], + 'size': [1, 1, 1], + 'material': 'stone', + }, + ], + }), + present: (next, diff) {}, + rebuild: (next) {}, + timeline: timeline, + ), + ); + registerTuningExtensions(input, toy.tunables); registerTimelineExtensions( timeline, + capture: toy.save, + entityLayout: EntityLayout.rows('entities'), frameTimes: frameTimes, bugReport: () => {'x': toy.x, 'step': stepNumber}, ); diff --git a/packages/flutter3d_game/test/live_level_test.dart b/packages/flutter3d_game/test/live_level_test.dart new file mode 100644 index 000000000..a31053f8d --- /dev/null +++ b/packages/flutter3d_game/test/live_level_test.dart @@ -0,0 +1,255 @@ +/// `HR3`: a level saved in the editor reaches the running game through +/// `ext.flutter3d.level.apply`, and the game takes it as little disturbed as +/// the change allows. +/// +/// flutter test test/live_level_test.dart +/// +/// What the extension does is `answerLevelApply`, tested here over the same +/// string parameters the VM service hands it. +library; + +import 'dart:convert'; + +import 'package:flutter3d_game/flutter3d_game.dart'; +import 'package:flutter3d_sim/flutter3d_sim.dart'; +import 'package:flutter_test/flutter_test.dart'; + +Map _document({double wallAt = 5.0, double fog = 0.0}) => + { + 'version': 1, + 'name': 'corridor', + 'fogDensity': fog, + 'brushes': [ + { + 'at': [wallAt, 0, 0], + 'size': [1, 4, 4], + 'material': 'stone', + }, + ], + }; + +Level _level({double wallAt = 5.0, double fog = 0.0}) => + Level.fromJson(_document(wallAt: wallAt, fog: fog)); + +/// What a game did with each call, in order. +({LiveLevel live, List calls}) _game({ + RunTimeline? timeline, + bool prepares = false, + String? prepareFails, +}) { + final calls = []; + return ( + live: LiveLevel( + level: _level(), + present: (next, diff) => calls.add('present fog=${diff.fog}'), + rebuild: (next) => calls.add('rebuild ${next.brushes.single.centre.x}'), + timeline: timeline, + swapped: (next, step) => calls.add('swapped $step'), + prepare: prepares || prepareFails != null + ? (next) async { + await Future.delayed(Duration.zero); + if (prepareFails != null) throw StateError(prepareFails); + calls.add('prepare'); + } + : null, + ), + calls: calls, + ); +} + +Map _send(Level level) => { + 'document': jsonEncode(level.toJson()), + 'hash': level.digestHex, +}; + +void main() { + test('a look-only edit is presented and the run is left alone', () { + final game = _game(); + final applied = game.live.apply(_level(fog: 0.04)); + + expect(game.calls, ['present fog=true']); + expect(applied.swappedAt, isNull); + expect(applied.rebuiltInPlace, isFalse); + expect(game.live.level.fogDensity, 0.04); + }); + + test('a moved wall goes through the timeline, then the picture', () { + final input = InputState(); + final rewind = RewindBuffer(stepsPerSecond: 60); + final restored = []; + final timeline = RunTimeline( + rewind: rewind, + input: input, + stepSim: (dt) {}, + restore: restored.add, + ); + for (var step = 0; step < 30; step++) { + rewind.recorder.record(input); + if (rewind.keyframeDue) rewind.keyframe(Snapshot({})); + } + final game = _game(timeline: timeline); + + final applied = game.live.apply(_level(wallAt: 8.0)); + + // Mutation: drop the `swapped` call — the run goes on under the new + // level and the demo being recorded never hears of it. + expect(game.calls, [ + 'rebuild 8.0', + 'swapped 0', + 'present fog=false', + ]); + expect(applied.swappedAt, 0); + expect(restored, hasLength(1), reason: 'the keyframe the replay starts at'); + expect( + timeline.history.single, + TimelineLevelSwapped(0, _level(wallAt: 8.0).digestHex), + ); + }); + + test('with no timeline the wall is rebuilt in place, and says so', () { + final game = _game(); + final applied = game.live.apply(_level(wallAt: 8.0)); + + expect(game.calls.first, 'rebuild 8.0'); + expect(applied.rebuiltInPlace, isTrue); + }); + + test('the same level again touches nothing', () { + final game = _game(); + expect(game.live.apply(_level()).diff.isEmpty, isTrue); + expect(game.calls, isEmpty); + }); + + group('ext.flutter3d.level.apply', () { + test('applies a document whose hash matches, and reports how', () async { + final game = _game(); + final (:result, :error) = await answerLevelApply( + game.live, + _send(_level(fog: 0.04)), + ); + + expect(error, isNull); + expect((result!['diff']! as Map)['fog'], isTrue); + expect(result['rebuiltInPlace'], isFalse); + }); + + test('refuses a document that changed on the way', () async { + final game = _game(); + final parameters = _send(_level(fog: 0.04)) + ..['hash'] = _level().digestHex; + + final (:result, :error) = await answerLevelApply(game.live, parameters); + + expect(result, isNull); + expect(error, contains('changed on the way')); + expect(game.calls, isEmpty); + }); + + test('builds the level ahead, then swaps it in', () async { + final game = _game(prepares: true); + final (:result, :error) = await answerLevelApply( + game.live, + _send(_level(wallAt: 8.0)), + ); + + expect(error, isNull); + expect(game.calls, [ + 'prepare', + 'rebuild 8.0', + 'present fog=false', + ]); + expect(game.live.level.brushes.single.centre.x, 8.0); + }); + + test('keeps the level it had when the new one does not build', () async { + final game = _game(prepareFails: 'no texture stone.png'); + final (:result, :error) = await answerLevelApply( + game.live, + _send(_level(wallAt: 8.0)), + ); + + expect(result, isNull); + expect(error, contains('did not build')); + expect(error, contains('no texture stone.png')); + expect(game.calls, isEmpty); + expect(game.live.level.brushes.single.centre.x, 5.0); + }); + + test('the same level again prepares nothing', () async { + final game = _game(prepares: true); + await answerLevelApply(game.live, _send(_level())); + expect(game.calls, isEmpty); + }); + + // Mutation: answer a stale patch as an ordinary refusal and the editor + // never sends the whole level; mark a level that will not build as + // stale and it sends it whole only to be refused again. + test('a patch against the level the game has is applied', () async { + final game = _game(); + final patch = LevelPatch.between(_level(), _level(wallAt: 8.0)); + + final (:result, :error, :stale) = await answerLevelPatch( + game.live, + {'patch': jsonEncode(patch.toJson())}, + ); + + expect(error, isNull); + expect(stale, isFalse); + expect((result!['diff']! as Map)['simulation'], [ + 'brushes', + ]); + expect(game.calls, ['rebuild 8.0', 'present fog=false']); + expect(game.live.level.digestHex, _level(wallAt: 8.0).digestHex); + }); + + test( + 'a patch against another version is stale, and changes nothing', + () async { + final game = _game(); + final patch = LevelPatch.between( + _level(fog: 0.04), + _level(wallAt: 8.0), + ); + + final (:result, :error, :stale) = await answerLevelPatch( + game.live, + {'patch': jsonEncode(patch.toJson())}, + ); + + expect(result, isNull); + expect(stale, isTrue); + expect(error, contains(_level().digestHex)); + expect(game.calls, isEmpty); + }, + ); + + test('a patched level that does not build is refused, not stale', () async { + final game = _game(prepareFails: 'no texture stone.png'); + final patch = LevelPatch.between(_level(), _level(wallAt: 8.0)); + + final (:result, :error, :stale) = await answerLevelPatch( + game.live, + {'patch': jsonEncode(patch.toJson())}, + ); + + expect(stale, isFalse); + expect(error, contains('did not build')); + expect(game.live.level.brushes.single.centre.x, 5.0); + }); + + test('refuses what is not a level, and what is missing', () async { + final game = _game(); + expect( + (await answerLevelApply(game.live, { + 'document': '[1, 2]', + 'hash': 'x', + })).error, + contains('not a level'), + ); + expect( + (await answerLevelApply(game.live, {})).error, + contains('takes a document'), + ); + }); + }); +} diff --git a/packages/flutter3d_game/test/run_session_test.dart b/packages/flutter3d_game/test/run_session_test.dart index 5355a511d..999517bed 100644 --- a/packages/flutter3d_game/test/run_session_test.dart +++ b/packages/flutter3d_game/test/run_session_test.dart @@ -420,6 +420,34 @@ void main() { ], reason: 'the level left behind, then the one the race lost'); }); }); + + group('an edited level', () { + test('takes the run over from the build it replaces', () async { + final game = _game(); + await game.begin(); + final old = (game.status as RunPlaying<_Level>).level; + final edited = _Level('one')..outcome = RunOutcome.won; + + expect(game.replaceLevel(edited), isTrue); + + final now = game.status as RunPlaying<_Level>; + expect(now.level, same(edited)); + expect(now.asset, 'one', reason: 'the same level, changed'); + expect(now.outcome, RunOutcome.won, reason: 'read off the new build'); + expect(edited.restored, {'where': 'one'}); + expect(game.closed, <_Level>[old]); + expect(game.opened, ['one'], reason: 'nothing was loaded again'); + }); + + test('is let go when no level is being played', () async { + final game = _game(); + final edited = _Level('one'); + + expect(game.replaceLevel(edited), isFalse); + expect(game.closed, <_Level>[edited]); + expect(game.level, isNull); + }); + }); } const Snapshot snapshotOfNothing = Snapshot({}); diff --git a/packages/flutter3d_game/test/run_timeline_extensions_test.dart b/packages/flutter3d_game/test/run_timeline_extensions_test.dart index 8f67e78a1..b026a32ea 100644 --- a/packages/flutter3d_game/test/run_timeline_extensions_test.dart +++ b/packages/flutter3d_game/test/run_timeline_extensions_test.dart @@ -22,6 +22,7 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; +import 'package:flutter3d_sim/flutter3d_sim.dart'; import 'package:test/test.dart'; import 'package:vm_service/vm_service.dart'; import 'package:vm_service/vm_service_io.dart'; @@ -148,7 +149,7 @@ void main() { 'ext.flutter3d.timeline.status', isolateId: target.isolateId, ); - expect(_decode(statusBefore), {'paused': false}); + expect(_decode(statusBefore), containsPair('paused', false)); final paused = await target.service.callServiceExtension( 'ext.flutter3d.timeline.pause', @@ -160,7 +161,7 @@ void main() { 'ext.flutter3d.timeline.status', isolateId: target.isolateId, ); - expect(_decode(statusAfter), {'paused': true}); + expect(_decode(statusAfter), containsPair('paused', true)); // stepOnce refuses when the timeline is not paused, from the outside // exactly as it does from a direct call — proved by asking for a @@ -238,4 +239,217 @@ void main() { expect(bugReportJson.containsKey('x'), isTrue); expect(bugReportJson.containsKey('step'), isTrue); }, timeout: const Timeout(Duration(seconds: 60))); + + test('a level sent from outside the process branches the run when it ' + 'moves a brush, is refused when it changed on the way, and the next ' + 'edit goes as a patch', () async { + final target = await _startAndConnect(); + addTearDown(() { + target.service.dispose(); + target.process.kill(); + }); + // A keyframe or two to branch from. + await Future.delayed(const Duration(seconds: 2)); + + final moved = { + 'version': 1, + 'brushes': [ + { + 'at': [3, 0, 0], + 'size': [1, 1, 1], + 'material': 'stone', + }, + ], + }; + final document = jsonEncode(moved); + final hash = Level.fromJson(moved).digestHex; + + final applied = _decode( + await target.service.callServiceExtension( + 'ext.flutter3d.level.apply', + isolateId: target.isolateId, + args: {'document': document, 'hash': hash}, + ), + ); + expect(applied['swappedAt'], isA()); + expect((applied['diff']! as Map)['simulation'], [ + 'brushes', + ]); + + final history = _decode( + await target.service.callServiceExtension( + 'ext.flutter3d.timeline.history', + isolateId: target.isolateId, + ), + ); + expect( + (history['commands']! as List).last, + 'level:${applied['swappedAt']}:$hash', + ); + + await expectLater( + target.service.callServiceExtension( + 'ext.flutter3d.level.apply', + isolateId: target.isolateId, + args: {'document': document, 'hash': 'deadbeef'}, + ), + throwsA(isA()), + ); + + // The next edit goes as a patch against the level the game now has; the + // same patch again is made against a level the game no longer has, and + // is answered with the code that tells the editor to send it whole. + final fogged = {...moved, 'fogDensity': 0.04}; + final patch = jsonEncode( + LevelPatch.between(Level.fromJson(moved), Level.fromJson(fogged)), + ); + final patched = _decode( + await target.service.callServiceExtension( + 'ext.flutter3d.level.patch', + isolateId: target.isolateId, + args: {'patch': patch}, + ), + ); + expect(patched['swappedAt'], isNull); + expect((patched['diff']! as Map)['fog'], isTrue); + + await expectLater( + target.service.callServiceExtension( + 'ext.flutter3d.level.patch', + isolateId: target.isolateId, + args: {'patch': patch}, + ), + throwsA( + isA().having( + (RPCError error) => error.code, + 'code', + LevelPatch.staleCode, + ), + ), + ); + }, timeout: const Timeout(Duration(minutes: 3))); + + test( + 'a tunable set and a replay after reload, from outside the process', + () async { + final target = await _startAndConnect(); + addTearDown(() { + target.service.dispose(); + target.process.kill(); + }); + await Future.delayed(const Duration(seconds: 2)); + + Future> call( + String method, [ + Map? args, + ]) async => _decode( + await target.service.callServiceExtension( + method, + isolateId: target.isolateId, + args: args, + ), + ); + + expect(await call('ext.flutter3d.cvar.list'), { + 'speed': {'value': 1.0, 'default': 1.0}, + }); + expect( + await call('ext.flutter3d.cvar.set', { + 'name': 'speed', + 'value': '2.5', + }), + {}, + ); + await Future.delayed(const Duration(milliseconds: 200)); + expect( + ((await call('ext.flutter3d.cvar.list'))['speed']! + as Map)['value'], + 2.5, + ); + await expectLater( + call('ext.flutter3d.cvar.set', { + 'name': 'gravity', + 'value': '1', + }), + throwsA(isA()), + ); + + // Nothing reloaded, so the replay makes the same run: the tune is on the + // tape and the tunables are in the snapshot. + final replay = await call( + 'ext.flutter3d.timeline.replayUnderNewCode', + {'seconds': '1'}, + ); + expect(replay['fromStep'], isA()); + expect(replay['divergence'], isNull); + }, + timeout: const Timeout(Duration(minutes: 3)), + ); + + test('N4: scrub, read the tracks, go back, and branch, from outside the ' + 'process', () async { + final target = await _startAndConnect(); + addTearDown(() { + target.service.dispose(); + target.process.kill(); + }); + await Future.delayed(const Duration(seconds: 2)); + + Future> call( + String verb, [ + Map? args, + ]) async => _decode( + await target.service.callServiceExtension( + 'ext.flutter3d.timeline.$verb', + isolateId: target.isolateId, + args: args, + ), + ); + + // Mutation: scrub without the pause check. The fixture's loop then + // steps the scrubbed state and the window below moves under it. + final refused = await call('scrubTo', {'step': '10'}); + expect(refused['moved'], isFalse); + expect(refused['refusal'], contains('pause')); + + await call('pause'); + final window = await call('status'); + final present = window['step']! as int; + final oldest = window['oldest']! as int; + expect(present, greaterThan(oldest)); + + final scrub = oldest + (present - oldest) ~/ 2; + expect(await call('scrubTo', {'step': '$scrub'}), { + 'moved': true, + 'step': scrub, + }); + expect((await call('status'))['scrubbedAt'], scrub); + + final tracks = await call('tracks'); + expect(tracks['first'], oldest); + expect(tracks['last'], present); + final lane = + ((tracks['entities']! as Map)['toy']! + as Map)['x']! + as List; + expect(lane, isNotEmpty, reason: 'the toy moves every step'); + + expect(await call('returnToPresent'), {'returned': true}); + expect((await call('status'))['scrubbedAt'], isNull); + + await call('scrubTo', {'step': '$scrub'}); + expect(await call('branchHere'), {'moved': true, 'step': scrub}); + final after = await call('status'); + expect(after['step'], scrub); + expect(after['paused'], isTrue); + + final history = (await call('history'))['commands']! as List; + expect(history, [ + 'paused', + 'scrubbed:$scrub', + 'returned', + 'scrubbed:$scrub', + 'branched:$scrub', + ]); + }, timeout: const Timeout(Duration(minutes: 3))); } diff --git a/packages/flutter3d_game/test/run_timeline_level_swap_test.dart b/packages/flutter3d_game/test/run_timeline_level_swap_test.dart new file mode 100644 index 000000000..edbd42aae --- /dev/null +++ b/packages/flutter3d_game/test/run_timeline_level_swap_test.dart @@ -0,0 +1,151 @@ +/// `HR3`: a level edited under a running game, swapped through the timeline +/// so the run stays one a replay can reach. +/// +/// flutter test test/run_timeline_level_swap_test.dart +/// +/// The toy walks into a wall the snapshots do not carry — the level's part of +/// the world — so a swap that forgot to replay would leave the walker where +/// the old wall stopped it, and one that replayed from the wrong keyframe +/// would stop it somewhere a fresh run never does. +/// +/// Mutation: skip the replay loop in `swapLevel` and the first test's states +/// part; drop `rebaseAt` and the rewind through the old level's keyframe +/// succeeds. +library; + +import 'package:flutter3d_game/flutter3d_game.dart'; +import 'package:flutter3d_sim/flutter3d_sim.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// A walker that stops at [wall], with a die it rolls at every stop. +final class _Walker { + _Walker(this.wall) : dice = GameRandom(3); + + /// The level: not in [save], as a level is not in a game's snapshot. + double wall; + final GameRandom dice; + double x = 0.0; + int bumps = 0; + + void step(InputState input) { + x += input.moveAxis.x * 0.1; + if (x > wall) { + x = wall; + bumps += dice.nextInt(100); + } + } + + Snapshot save() => + Snapshot({'x': x, 'bumps': bumps, 'random': dice.state}); + + void restore(Snapshot snapshot) { + x = snapshot.data.number('x'); + bumps = snapshot.data.integer('bumps'); + dice.state = snapshot.data.integer('random'); + } + + String get state => '$x/$bumps/${dice.state}'; +} + +void _play(InputState input, int step) => + input.setStickAxis(step % 7 == 0 ? -1.0 : 1.0, 0.0); + +({_Walker walker, InputState input, RewindBuffer rewind, RunTimeline timeline}) +_run() { + final walker = _Walker(5.0); + final input = InputState(); + final rewind = RewindBuffer(stepsPerSecond: 60, history: 10.0); + return ( + walker: walker, + input: input, + rewind: rewind, + timeline: RunTimeline( + rewind: rewind, + input: input, + stepSim: (dt) => walker.step(input), + restore: walker.restore, + ), + ); +} + +/// Steps [from] to [to] the way a game loop does: record, keyframe, step. +void _steps( + ({ + _Walker walker, + InputState input, + RewindBuffer rewind, + RunTimeline timeline, + }) + it, + int from, + int to, +) { + for (var step = from; step < to; step++) { + _play(it.input, step); + it.rewind.recorder.record(it.input); + it.input.beginStep(); + if (it.rewind.keyframeDue) it.rewind.keyframe(it.walker.save()); + it.walker.step(it.input); + it.input.endStep(); + } +} + +void main() { + test('a run with a level swapped at step K arrives where a fresh run with ' + 'the new level from step K does', () { + final live = _run(); + _steps(live, 0, 150); + final at = live.timeline.swapLevel( + () => live.walker.wall = 9.0, + levelDigest: 'b', + ); + _steps(live, 150, 400); + + // The keyframe before step 150, a second apart. + expect(at, 120); + expect(live.timeline.history.last, const TimelineLevelSwapped(120, 'b')); + + final fresh = _run(); + _steps(fresh, 0, at); + fresh.walker.wall = 9.0; + _steps(fresh, at, 400); + + expect(live.walker.state, fresh.walker.state); + }); + + test('without the swap the run ends elsewhere, so the check above has ' + 'something to say', () { + final kept = _run(); + _steps(kept, 0, 400); + final swapped = _run(); + _steps(swapped, 0, 150); + swapped.timeline.swapLevel( + () => swapped.walker.wall = 9.0, + levelDigest: 'b', + ); + _steps(swapped, 150, 400); + + expect(swapped.walker.state, isNot(kept.walker.state)); + }); + + test('nothing from before the swap can be rewound into afterwards', () { + final live = _run(); + _steps(live, 0, 150); + live.timeline.swapLevel(() => live.walker.wall = 9.0, levelDigest: 'b'); + + expect(live.rewind.rewindTo(100), isNull, reason: 'the old level\'s'); + expect(live.rewind.rewindTo(130), isNotNull); + expect(live.rewind.step, 150, reason: 'the tape since is kept'); + }); + + test('before the first keyframe the level is simply swapped', () { + final live = _run(); + final at = live.timeline.swapLevel( + () => live.walker.wall = 9.0, + levelDigest: 'b', + ); + + expect(at, 0); + expect(live.walker.wall, 9.0); + }); +} diff --git a/packages/flutter3d_game/test/run_timeline_new_code_replay_test.dart b/packages/flutter3d_game/test/run_timeline_new_code_replay_test.dart new file mode 100644 index 000000000..4b1e9cac0 --- /dev/null +++ b/packages/flutter3d_game/test/run_timeline_new_code_replay_test.dart @@ -0,0 +1,186 @@ +/// `HR4`: after a code reload, the last seconds lived again under the new +/// code, and the first place that run parts from the old one named. +/// +/// flutter test test/run_timeline_new_code_replay_test.dart +/// +/// The toy's step is a function the test swaps, the way a hot reload swaps +/// the code behind a step: the new rule only differs once the walker is past +/// a mark, so the old and new runs agree for a while and then do not. +/// +/// Mutation: skip `compare(step)` inside the loop and the divergence is +/// found only at the present; replay without restoring the keyframe and the +/// state after the replay is not the fresh run's. +library; + +import 'package:flutter3d_app/flutter3d_app.dart' show HotSwap; +import 'package:flutter3d_game/flutter3d_game.dart'; +import 'package:flutter3d_sim/flutter3d_sim.dart'; +import 'package:flutter_test/flutter_test.dart'; + +typedef _Rule = double Function(double x, double push); + +double _old(double x, double push) => x + push * 0.05; + +/// Walks at double speed once past 8: the same run as [_old] until then. +double _new(double x, double push) => x + push * (x > 8.0 ? 0.1 : 0.05); + +final class _Walker { + _Rule rule = _old; + double x = 0.0; + + void step(InputState input) => x = rule(x, input.moveAxis.x); + + Snapshot save() => Snapshot({'x': x}); + void restore(Snapshot snapshot) => x = snapshot.data.number('x'); +} + +({_Walker walker, InputState input, RewindBuffer rewind, RunTimeline timeline}) +_run() { + final walker = _Walker(); + final input = InputState(); + final rewind = RewindBuffer(stepsPerSecond: 60, history: 10.0); + return ( + walker: walker, + input: input, + rewind: rewind, + timeline: RunTimeline( + rewind: rewind, + input: input, + stepSim: (dt) => walker.step(input), + restore: walker.restore, + ), + ); +} + +void _steps( + ({ + _Walker walker, + InputState input, + RewindBuffer rewind, + RunTimeline timeline, + }) + it, + int from, + int to, +) { + for (var step = from; step < to; step++) { + it.input.setStickAxis(step % 5 == 0 ? 0.0 : 1.0, 0.0); + it.rewind.recorder.record(it.input); + it.input.beginStep(); + if (it.rewind.keyframeDue) it.rewind.keyframe(it.walker.save()); + it.walker.step(it.input); + it.input.endStep(); + } +} + +void main() { + test('the replay arrives where a run with the new code from the keyframe ' + 'does, and names where the two first part', () { + final live = _run(); + _steps(live, 0, 300); + // x passes 8 at about step 200, well inside the last three seconds. + live.walker.rule = _new; + + final replay = live.timeline.replayUnderNewCode( + seconds: 3.0, + capture: live.walker.save, + )!; + + expect(replay.fromStep, 120); + expect(replay.toStep, 300); + + final fresh = _run(); + _steps(fresh, 0, replay.fromStep); + fresh.walker.rule = _new; + _steps(fresh, replay.fromStep, 300); + expect(live.walker.x, fresh.walker.x); + + final divergence = replay.divergence!; + expect(divergence.path, 'x'); + // The keyframes are 60 steps apart: agreed at 180, parted by 240. + expect(divergence.agreedAt, 180); + expect(divergence.step, 240); + expect( + divergence.after! as double, + greaterThan(divergence.before! as double), + ); + expect(live.timeline.history.last, const TimelineReplayed(120)); + }); + + test('code that changes nothing here says so', () { + final live = _run(); + _steps(live, 0, 300); + final x = live.walker.x; + live.walker.rule = (x, push) => push * 0.05 + x; + + final replay = live.timeline.replayUnderNewCode( + seconds: 3.0, + capture: live.walker.save, + )!; + + expect(replay.divergence, isNull); + expect(live.walker.x, x); + }); + + test('the old code\'s keyframes are not rewound into afterwards', () { + final live = _run(); + _steps(live, 0, 300); + live.walker.rule = _new; + live.timeline.replayUnderNewCode(seconds: 3.0, capture: live.walker.save); + + expect(live.rewind.keyframesAfter(120), isEmpty); + expect(live.rewind.rewindTo(100), isNull); + }); + + test('a buffer that does not reach back replays nothing', () { + final live = _run(); + expect( + live.timeline.replayUnderNewCode(seconds: 3.0, capture: live.walker.save), + isNull, + ); + }); + + group('after a hot reload, on its own', () { + test('the swap replays the last seconds and names the parting', () async { + // Mutation: drop the listener in `replayAfterHotSwap` and nothing is + // replayed when the swap finishes. + final live = _run(); + _steps(live, 0, 300); + final hotSwap = HotSwap(enabled: true); + final replays = []; + replayAfterHotSwap( + live.timeline, + capture: live.walker.save, + hotSwap: hotSwap, + onReplayed: replays.add, + ); + + live.walker.rule = _new; + await hotSwap.swap(); + + expect(replays, hasLength(1)); + expect(replays.single.toStep, 300); + expect(replays.single.divergence?.path, contains('x')); + }); + + test('stopped, a swap replays nothing', () async { + final live = _run(); + _steps(live, 0, 300); + final hotSwap = HotSwap(enabled: true); + final replays = []; + final stop = replayAfterHotSwap( + live.timeline, + capture: live.walker.save, + hotSwap: hotSwap, + onReplayed: replays.add, + ); + + await hotSwap.swap(); + expect(replays.single.divergence, isNull, reason: 'the same code'); + + stop(); + await hotSwap.swap(); + expect(replays, hasLength(1)); + }); + }); +} diff --git a/packages/flutter3d_game/test/run_timeline_scrub_test.dart b/packages/flutter3d_game/test/run_timeline_scrub_test.dart new file mode 100644 index 000000000..0d40a5a75 --- /dev/null +++ b/packages/flutter3d_game/test/run_timeline_scrub_test.dart @@ -0,0 +1,195 @@ +/// `N4`'s time-travel debugger on a live run: scrub through the buffer +/// without losing the future, go back to the present exactly, branch from a +/// scrubbed moment, and read every entity's lanes over what is held. +/// +/// flutter test test/run_timeline_scrub_test.dart +library; + +import 'package:flutter3d_game/flutter3d_game.dart'; +import 'package:flutter3d_sim/flutter3d_sim.dart'; +import 'package:flutter_test/flutter_test.dart'; + +const GameAction _fire = GameAction('fire'); + +/// Two entities: a runner that follows the stick and a target that loses +/// health, by the dice, whenever fire is pressed. +final class _Toy { + _Toy(int seed) : dice = GameRandom(seed); + + final GameRandom dice; + double x = 0.0; + int hp = 1000; + + void step(InputState input) { + x += input.moveAxis.x; + if (input.pressed(_fire)) hp -= 1 + dice.nextInt(9); + } + + Snapshot save() => Snapshot({ + 'entities': { + 'runner': {'x': x}, + 'target': {'hp': hp}, + }, + 'random': dice.state, + }); + + void restore(Snapshot snapshot) { + final entities = snapshot.data.object('entities')!; + x = entities.object('runner')!.number('x'); + hp = entities.object('target')!.integer('hp'); + dice.state = snapshot.data.integer('random'); + } + + String get state => '$x/$hp/${dice.state}'; +} + +void _play(InputState input, int step) { + input.setStickAxis(step % 3 == 0 ? 1.0 : -0.5, 0.0); + if (step % 11 == 0) input.press(_fire); + if (step % 11 == 4) input.release(_fire); +} + +/// A run of [steps] steps under a game loop's own order, with the state +/// before every step written down to compare a scrub against. +({_Toy toy, RewindBuffer rewind, RunTimeline timeline, Map seen}) +_run(int steps) { + final toy = _Toy(3); + final input = InputState(); + final rewind = RewindBuffer(stepsPerSecond: 60, history: 10.0); + final timeline = RunTimeline( + rewind: rewind, + input: input, + stepSim: (dt) => toy.step(input), + restore: toy.restore, + ); + final seen = {}; + for (var step = 0; step < steps; step++) { + seen[step] = toy.state; + _play(input, step); + rewind.recorder.record(input); + input.beginStep(); + if (rewind.keyframeDue) rewind.keyframe(toy.save()); + toy.step(input); + input.endStep(); + } + seen[steps] = toy.state; + return (toy: toy, rewind: rewind, timeline: timeline, seen: seen); +} + +void main() { + test('a scrub lands on the state the run had before that step, back and ' + 'forth, and the present comes back exactly', () { + // Mutation: play forward from the scrubbed step without checking it is + // at or after the keyframe — a drag to the left then plays on from the + // right and lands in the future. + final run = _run(300); + run.timeline.pause(); + + for (final step in [200, 230, 130, 131, 299, 60]) { + expect( + run.timeline.scrubTo(step, capture: run.toy.save), + isA(), + ); + expect(run.toy.state, run.seen[step], reason: 'scrubbed to $step'); + } + expect(run.timeline.scrubbedAt, 60); + expect(run.rewind.step, 300, reason: 'a scrub keeps the tape whole'); + + expect(run.timeline.returnToPresent(), isTrue); + expect(run.toy.state, run.seen[300]); + expect(run.timeline.scrubbedAt, isNull); + expect(run.timeline.returnToPresent(), isFalse); + }); + + test('stepping from a scrub walks the tape, and ends at the present', () { + // Mutation: let `stepOnce` step the simulation while scrubbed. The state + // then leaves the tape, and the next scrub restores from a keyframe as + // if nothing happened. + final run = _run(300); + run.timeline + ..pause() + ..scrubTo(297, capture: run.toy.save) + ..stepOnce(); + expect(run.toy.state, run.seen[298]); + run.timeline + ..stepOnce() + ..stepOnce(); + expect(run.timeline.scrubbedAt, isNull); + expect(run.toy.state, run.seen[300]); + }); + + test('a scrub on a live run, or outside the buffer, is refused with a ' + 'reason, and nothing moves', () { + // Mutation: scrub a live run. The loop would step the scrubbed state as + // if it were the present, and the run would jump. + final run = _run(300); + final live = run.timeline.scrubTo(100, capture: run.toy.save); + expect(live, isA()); + expect('$live', contains('pause')); + + run.timeline.pause(); + final far = run.timeline.scrubTo(400, capture: run.toy.save); + expect('$far', contains('from step 0 to 300')); + expect(run.toy.state, run.seen[300]); + expect(run.timeline.branchHere(), isA()); + }); + + test('branching from a scrub cuts the future there and stays paused', () { + // Mutation: branch without the cut. The buffer then still holds the + // future the branch replaced, and a later rewind lands in it. + final run = _run(300); + run.timeline + ..pause() + ..scrubTo(150, capture: run.toy.save); + + expect(run.timeline.branchHere(), isA()); + expect(run.rewind.step, 150); + expect(run.toy.state, run.seen[150]); + expect(run.timeline.isPaused, isTrue); + expect(run.timeline.history, [ + const TimelinePaused(), + const TimelineScrubbed(150), + const TimelineBranched(150), + ]); + }); + + test('resuming from a scrub goes on from the present, not the scrub', () { + // Mutation: drop the `returnToPresent` in `resume`. The loop then goes on + // from step 120 with a tape that says 300. + final run = _run(300); + run.timeline + ..pause() + ..scrubTo(120, capture: run.toy.save) + ..resume(); + expect(run.toy.state, run.seen[300]); + expect(run.rewind.step, 300); + }); + + test('tracks hold every change each entity made over the buffer, and ' + 'reading them changes nothing', () { + // Mutation: drop the final restore in `tracks`. The live state is then + // the end of a replay that took one more step's capture, and a scrub + // left open is lost. + final run = _run(300); + run.timeline + ..pause() + ..scrubTo(140, capture: run.toy.save); + final tracks = run.timeline.tracks( + capture: run.toy.save, + layout: EntityLayout.rows('entities'), + )!; + + expect(run.toy.state, run.seen[140]); + expect(tracks.entities, ['runner', 'target']); + expect(tracks.span, (first: 0, last: 300)); + // Fire is pressed on every eleventh step from 0, so the target's health + // changes on the step after each press, 28 times in 300 steps. + final hits = tracks.samples('target', 'hp'); + expect(hits.length, 1 + 28); + expect(hits[1].step, 1); + expect( + tracks.at('runner', 'x', 140)!.value, + double.parse(run.seen[140]!.split('/').first), + ); + }); +} diff --git a/packages/flutter3d_game/test/save_file_test.dart b/packages/flutter3d_game/test/save_file_test.dart index b6efcbc6e..c98ed9a92 100644 --- a/packages/flutter3d_game/test/save_file_test.dart +++ b/packages/flutter3d_game/test/save_file_test.dart @@ -7,7 +7,8 @@ import 'dart:io'; import 'package:flutter3d_app/flutter3d_app.dart'; import 'package:flutter3d_game/flutter3d_game.dart'; -import 'package:flutter3d_sim/flutter3d_sim.dart' show Snapshot; +import 'package:flutter3d_sim/flutter3d_sim.dart' + show SaveMigration, SaveSchema, Snapshot; import 'package:flutter_test/flutter_test.dart'; void main() { @@ -112,8 +113,8 @@ void main() { // shape did not change, so refusing it would cost a player a run to fix a // bug that was never theirs. // - // Mutation: drop the `containsKey('version')` arm in `read`. This fails, - // and every existing save becomes a fresh start. + // Mutation: drop the arm in `SaveRecord.read` that supplies a missing + // version. This fails, and every existing save becomes a fresh start. storage.write('save.json', '{"level": "a.json", "run": {"deaths": 3}}'); final read = saves.read(); @@ -122,6 +123,46 @@ void main() { expect(read!.run.data['deaths'], 3); }); + test('a save from an older schema is migrated on the way in', () { + // A save written before the game split health into hearts. Mutation: + // construct the reader without passing `schema` through to + // `SaveRecord.read`, and the hearts come back as nothing while the old + // field rides along unread. + storage.write( + 'save.json', + '{"level": "a.json", "run": {"version": 1, "health": 30}}', + ); + final reader = SaveFile( + appName: 'test', + storage: storage, + schema: SaveSchema([ + (run) => run..['hearts'] = (run.remove('health')! as num) ~/ 10, + ]), + ); + + final read = reader.readRecord(); + + expect(read!.run.data, {'hearts': 3}); + expect(read.schema, 1); + }); + + test('a save says which schema it was written at', () { + // Mutation: write without the schema. Every save then reads as version + // 0, and the first migration runs again on a run it already changed. + final writer = SaveFile( + appName: 'test', + storage: storage, + schema: SaveSchema([(run) => run]), + ); + writer.write('a.json', Snapshot({}), step: 12); + + final written = + jsonDecode(storage.read('save.json')!) as Map; + expect(written['schema'], 1); + expect(written['step'], 12); + expect(written['digest'], isA()); + }); + test('a finished run is cleared, and clearing twice is fine', () { // Mutation: never clear. The player beats the level, quits, comes back, and // is put on the last checkpoint of the level they already finished. diff --git a/packages/flutter3d_game/test/save_sync_test.dart b/packages/flutter3d_game/test/save_sync_test.dart new file mode 100644 index 000000000..c38409f14 --- /dev/null +++ b/packages/flutter3d_game/test/save_sync_test.dart @@ -0,0 +1,279 @@ +/// A save on this device and a copy in the cloud, made one — and nothing sent +/// until the player says it may be. +/// +/// flutter test test/save_sync_test.dart +library; + +import 'package:flutter3d_app/flutter3d_app.dart'; +import 'package:flutter3d_game/flutter3d_game.dart'; +import 'package:flutter3d_sim/flutter3d_sim.dart'; +import 'package:flutter_test/flutter_test.dart'; + +final class _Storage implements Storage { + final Map documents = {}; + + @override + String? read(String name) => documents[name]; + + @override + bool write(String name, String contents) { + documents[name] = contents; + return true; + } + + @override + void remove(String name) => documents.remove(name); +} + +/// A store that keeps one slot, versions it, and records every call. +final class _Store implements CloudSaveStore { + String? document; + int revision = 0; + final List calls = []; + + /// Another device's write, landed between this one's fetch and its put. + String? writtenMeanwhile; + + @override + String get name => 'the test cloud'; + + @override + Future fetch(String slot) async { + calls.add('fetch $slot'); + final kept = document; + return kept == null + ? const CloudEmpty() + : CloudDocument(kept, version: '$revision'); + } + + @override + Future put( + String slot, + String document, { + String? replacing, + }) async { + calls.add('put $slot'); + final meanwhile = writtenMeanwhile; + if (meanwhile != null) { + writtenMeanwhile = null; + this.document = meanwhile; + revision++; + } + final current = this.document == null ? null : '$revision'; + if (replacing != current) return const CloudMoved(); + this.document = document; + revision++; + return CloudStored(version: '$revision'); + } +} + +SaveRecord _run(int coins, {int step = 0}) => SaveRecord( + level: 'one', + run: Snapshot({'coins': coins}), + step: step, +); + +void main() { + late _Storage storage; + late SaveFile saves; + late _Store store; + late SaveSync sync; + + setUp(() { + storage = _Storage(); + saves = SaveFile(appName: 't', storage: storage, onIssue: (_) {}); + store = _Store(); + sync = SaveSync(saves: saves, store: store); + }); + + group('consent', () { + test('nothing is sent or asked for before the player agrees', () async { + // Mutation: check consent after the fetch, or not at all. The first + // tells a server who is playing before they said it may know; the + // second uploads their run. + saves.writeRecord(_run(5, step: 100)); + + final report = await sync.sync(); + + expect(report.outcome, SyncOutcome.notConsented); + expect(store.calls, isEmpty); + expect(store.document, isNull); + }); + + test('consent is off on a fresh install and off when unreadable', () { + // Mutation: treat a missing or broken consent document as yes. + expect(sync.consented, isFalse); + storage.write(SaveSync.stateName, '{"consented": "yes"'); + expect(sync.consented, isFalse); + storage.write(SaveSync.stateName, '{"consented": "yes"}'); + expect(sync.consented, isFalse, reason: 'only true is consent'); + }); + + test('consent is kept, and withdrawing forgets the base', () async { + expect(sync.consent(), isTrue); + expect(SaveSync(saves: saves, store: store).consented, isTrue); + + saves.writeRecord(_run(5)); + await sync.sync(); + expect(sync.base, isNotNull); + + // Mutation: keep the base on withdrawal. Turning cloud saves back on + // months later resolves against a run both sides have long left. + sync.withdraw(); + expect(sync.consented, isFalse); + expect(sync.base, isNull); + store.calls.clear(); + expect((await sync.sync()).outcome, SyncOutcome.notConsented); + expect(store.calls, isEmpty); + }); + + test('settling a choice also needs consent', () async { + sync.consent(); + saves.writeRecord(_run(1, step: 50)); + store.document = SaveFile.encode(_run(2, step: 50)); + final asked = await sync.sync(); + expect(asked.outcome, SyncOutcome.ask); + + sync.withdraw(); + store.calls.clear(); + final settled = await sync.settle(asked, keepLocal: true); + + expect(settled.outcome, SyncOutcome.notConsented); + expect(store.calls, isEmpty); + }); + }); + + group('with consent', () { + setUp(() => sync.consent()); + + test('a first sync sends the run there is', () async { + saves.writeRecord(_run(5, step: 100)); + + final report = await sync.sync(); + + expect(report.outcome, SyncOutcome.uploaded); + expect(saves.parse(store.document!)!.run.data['coins'], 5); + expect(sync.base, saves.readRecord()!.digest); + }); + + test('a new device takes the run from the cloud', () async { + store.document = SaveFile.encode(_run(9, step: 300)); + + final report = await sync.sync(); + + expect(report.outcome, SyncOutcome.downloaded); + expect(saves.read()!.run.data['coins'], 9); + expect(saves.readRecord()!.step, 300); + }); + + test('the further run wins when both moved', () async { + saves.writeRecord(_run(1, step: 100)); + store.document = SaveFile.encode(_run(2, step: 900)); + + expect((await sync.sync()).outcome, SyncOutcome.downloaded); + expect(saves.read()!.run.data['coins'], 2); + }); + + test('equal runs say so and send nothing', () async { + saves.writeRecord(_run(4, step: 10)); + store.document = SaveFile.encode(_run(4, step: 10)); + store.calls.clear(); + + expect((await sync.sync()).outcome, SyncOutcome.inSync); + expect(store.calls, ['fetch save.json']); + }); + + test('a step back taken on this device is sent, not undone', () async { + // Both sides agreed on the long run; then the player went back to an + // earlier checkpoint here. Mutation: drop the base from `resolveSaves`, + // and the further run in the cloud comes back over the player's choice. + saves.writeRecord(_run(8, step: 900)); + await sync.sync(); + saves.writeRecord(_run(3, step: 200)); + + expect((await sync.sync()).outcome, SyncOutcome.uploaded); + expect(saves.parse(store.document!)!.run.data['coins'], 3); + }); + + test('two runs equally far along wait for the player', () async { + saves.writeRecord(_run(1, step: 500)); + store.document = SaveFile.encode(_run(2, step: 500)); + + final asked = await sync.sync(); + expect(asked.outcome, SyncOutcome.ask); + expect(saves.read()!.run.data['coins'], 1, reason: 'nothing moved yet'); + + final kept = await sync.settle(asked, keepLocal: true); + expect(kept.outcome, SyncOutcome.uploaded); + expect(saves.parse(store.document!)!.run.data['coins'], 1); + }); + + test( + 'a write that lost a race fetches again rather than overwriting', + () async { + // Another device wrote between this one's fetch and its put. Mutation: + // put without `replacing`, and the other device's run is overwritten + // without ever being compared. + saves.writeRecord(_run(1, step: 100)); + store.writtenMeanwhile = SaveFile.encode(_run(7, step: 800)); + + final report = await sync.sync(); + + expect(report.outcome, SyncOutcome.downloaded); + expect(saves.read()!.run.data['coins'], 7); + expect(store.calls, [ + 'fetch save.json', + 'put save.json', + 'fetch save.json', + ]); + }, + ); + + test('a cloud save from a newer build is left alone', () async { + // Mutation: treat an unreadable remote as no remote. The local run is + // then uploaded over progress made on a newer build of the game. + saves.writeRecord(_run(1, step: 100)); + final newer = SaveFile( + appName: 't', + storage: _Storage(), + schema: SaveSchema([(run) => run]), + ); + store.document = SaveFile.encode( + SaveRecord( + level: 'one', + run: Snapshot({'coins': 2}), + schema: newer.schema.version, + ), + ); + final before = store.document; + + final report = await sync.sync(); + + expect(report.outcome, SyncOutcome.refused); + expect(report.message, contains('newer build')); + expect(store.document, before); + }); + + test('a store that is down is an answer, not a throw', () async { + final down = SaveSync(saves: saves, store: _Down()); + + final report = await down.sync(); + + expect(report.outcome, SyncOutcome.unavailable); + expect(report.message, 'no network'); + }); + }); +} + +final class _Down implements CloudSaveStore { + @override + String get name => 'down'; + + @override + Future fetch(String slot) async => + const CloudUnavailable('no network'); + + @override + Future put(String slot, String document, {String? replacing}) => + throw UnimplementedError(); +} diff --git a/packages/flutter3d_game_platformer/CHANGELOG.md b/packages/flutter3d_game_platformer/CHANGELOG.md index 605e7365f..6c1f99c9b 100644 --- a/packages/flutter3d_game_platformer/CHANGELOG.md +++ b/packages/flutter3d_game_platformer/CHANGELOG.md @@ -1,3 +1,12 @@ +## Unreleased + +- **A save carries what the dynamics need beyond the bodies.** The + simulation's snapshot holds `RigidDynamics.saveState()` under + `dynamics` and gives it back after the bodies are restored. For + `Dynamics` that is nothing, so a reference run's saves and digests are + unchanged. For the native core it is the core's own state, without which + a rewind stepped on from a keyframe would not repeat the run. + ## 0.8.0+1 **Resolves on Flutter 3.44 and Dart 3.12.0.** The constraints asked for Dart diff --git a/packages/flutter3d_game_platformer/lib/src/crate.dart b/packages/flutter3d_game_platformer/lib/src/crate.dart index 49f35114e..97ed12e65 100644 --- a/packages/flutter3d_game_platformer/lib/src/crate.dart +++ b/packages/flutter3d_game_platformer/lib/src/crate.dart @@ -57,7 +57,7 @@ final class CrateKind extends EntityKind { CrateKind({this.dynamics}) : super(PlatformerEntities.crate); /// Where a crate goes once there is a world. Null while validating. - Dynamics? dynamics; + RigidDynamics? dynamics; @override void validate(EntityDef entity, LevelScope scope, List out) { diff --git a/packages/flutter3d_game_platformer/lib/src/entity_kinds.dart b/packages/flutter3d_game_platformer/lib/src/entity_kinds.dart index 941100526..93b041c47 100644 --- a/packages/flutter3d_game_platformer/lib/src/entity_kinds.dart +++ b/packages/flutter3d_game_platformer/lib/src/entity_kinds.dart @@ -27,7 +27,7 @@ export 'platformer_entities.dart'; /// A game composes this itself — there is no default registry and that is the /// point — but the eight the format ships are wanted verbatim, so listing them /// here is the honest version of "and the usual". -EntityRegistry platformerRegistry({Dynamics? dynamics}) => +EntityRegistry platformerRegistry({RigidDynamics? dynamics}) => EntityRegistry([ const PlayerSpawnKind(), const DoorKind(), diff --git a/packages/flutter3d_game_platformer/lib/src/simulation.dart b/packages/flutter3d_game_platformer/lib/src/simulation.dart index 8ce1957c4..a768ee56c 100644 --- a/packages/flutter3d_game_platformer/lib/src/simulation.dart +++ b/packages/flutter3d_game_platformer/lib/src/simulation.dart @@ -92,7 +92,7 @@ final class PlatformerSimulation { ); final InputState input; final MechanismWorld? mechanisms; - final Dynamics? dynamics; + final RigidDynamics? dynamics; /// The things that move on their own, or null for a level with none. /// @@ -456,6 +456,10 @@ final class PlatformerSimulation { // the snapshot test in this package never mentioned actors. if (actors != null) 'entities': actors!.entities.save(), if (actors != null) 'actors': actors!.save(), + // What the dynamics carry beyond the bodies: nothing for the reference, + // the core's own state for the native one, without which a rewind + // stepped on from this snapshot would not repeat the run. + 'dynamics': ?dynamics?.saveState(), }); void restore(Snapshot from) { @@ -489,6 +493,9 @@ final class PlatformerSimulation { // it a restored corpse is a wall the runner cannot walk through. actors?.syncCorpses(); + // After the bodies' own restore, which it puts the core's state over. + dynamics?.restoreState(data['dynamics']); + _world.afterRestore(); } } diff --git a/packages/flutter3d_game_racing/CHANGELOG.md b/packages/flutter3d_game_racing/CHANGELOG.md index 9c182fcf5..8b45919bb 100644 --- a/packages/flutter3d_game_racing/CHANGELOG.md +++ b/packages/flutter3d_game_racing/CHANGELOG.md @@ -1,3 +1,12 @@ +## Unreleased + +- **A save carries what the dynamics need beyond the bodies.** The + simulation's snapshot holds `RigidDynamics.saveState()` under + `dynamics` and gives it back after the bodies are restored. For + `Dynamics` that is nothing, so a reference run's saves and digests are + unchanged. For the native core it is the core's own state, without which + a rewind stepped on from a keyframe would not repeat the run. + ## 0.8.0 **Moves with the stack to 0.8.0**, whose `flutter3d_hardware` changes diff --git a/packages/flutter3d_game_racing/lib/src/simulation.dart b/packages/flutter3d_game_racing/lib/src/simulation.dart index 237b8a398..6c32f6f10 100644 --- a/packages/flutter3d_game_racing/lib/src/simulation.dart +++ b/packages/flutter3d_game_racing/lib/src/simulation.dart @@ -74,7 +74,7 @@ final class RacingSimulation { final RaceState race; final MechanismWorld? mechanisms; - final Dynamics? dynamics; + final RigidDynamics? dynamics; /// What each driver is asking for. Filled in before [step] — by the player's /// keys for car nought, and by an AI for the rest. @@ -699,6 +699,10 @@ final class RacingSimulation { // to be here. A circuit's own machinery — a gate, a lamp, whatever a track // document names — came back at whatever state the level file starts in. if (mechanisms != null) 'mechanisms': mechanisms!.save(), + // What the dynamics carry beyond the bodies: nothing for the reference, + // the core's own state for the native one, without which a rewind + // stepped on from this snapshot would not repeat the run. + 'dynamics': ?dynamics?.saveState(), }); void restore(Snapshot snapshot) { @@ -720,6 +724,9 @@ final class RacingSimulation { mechanisms?.restore(from['mechanisms']); + // After the bodies' own restore, which it puts the core's state over. + dynamics?.restoreState(from['dynamics']); + // **`reindex` alone was a third of the job.** The broadphase disagrees // with every car that moved, which that call fixes — but the overlap set // and the kinematic deltas still describe the pre-restore world, so the diff --git a/packages/flutter3d_game_shooter/CHANGELOG.md b/packages/flutter3d_game_shooter/CHANGELOG.md index 9d2e5273d..f7afd905c 100644 --- a/packages/flutter3d_game_shooter/CHANGELOG.md +++ b/packages/flutter3d_game_shooter/CHANGELOG.md @@ -1,3 +1,12 @@ +## Unreleased + +- **A save carries what the dynamics need beyond the bodies.** The + simulation's snapshot holds `RigidDynamics.saveState()` under + `dynamics` and gives it back after the bodies are restored. For + `Dynamics` that is nothing, so a reference run's saves and digests are + unchanged. For the native core it is the core's own state, without which + a rewind stepped on from a keyframe would not repeat the run. + ## 0.8.1 **A claw that catches the player beside the one it swung at has caught diff --git a/packages/flutter3d_game_shooter/lib/src/simulation.dart b/packages/flutter3d_game_shooter/lib/src/simulation.dart index d15485e0c..7c8817731 100644 --- a/packages/flutter3d_game_shooter/lib/src/simulation.dart +++ b/packages/flutter3d_game_shooter/lib/src/simulation.dart @@ -175,7 +175,7 @@ final class GameSimulation { /// Crates, barrels and anything else with mass, or null for a game with /// none. - final Dynamics? dynamics; + final RigidDynamics? dynamics; /// What the level document says follows it. final String? levelNext; @@ -621,6 +621,10 @@ final class GameSimulation { 'monsterCount': _monsterCount, 'secretCount': _secretCount, 'counted': _counted, + // What the dynamics carry beyond the bodies: nothing for the reference, + // the core's own state for the native one, without which a rewind + // stepped on from this snapshot would not repeat the run. + 'dynamics': ?dynamics?.saveState(), }); void restore(Snapshot snapshot) { @@ -658,6 +662,9 @@ final class GameSimulation { // the collision world, and something has to put the two together. actors?.syncCorpses(); + // After the bodies' own restore, which it puts the core's state over. + dynamics?.restoreState(from['dynamics']); + // The broadphase is holding every body where it was before the restore. _world.afterRestore(); } diff --git a/packages/flutter3d_hardware/CHANGELOG.md b/packages/flutter3d_hardware/CHANGELOG.md index 36c03afec..7f4587da2 100644 --- a/packages/flutter3d_hardware/CHANGELOG.md +++ b/packages/flutter3d_hardware/CHANGELOG.md @@ -1,3 +1,31 @@ +## Unreleased + +- **`ShaderLibraryStack`**: any number of libraries consulted in order, the + first that answers winning — `P8`. What a game with more than one compiled + material bundle hands a renderer. + +- **`ShaderBundle.materialSection`**, with `encodeMaterialSection` and + `decodeMaterialSection`: each stage's material-language source, by name — + `P8`. The backend that compiles nothing compiles it, and a runtime reads a + material's lighting model out of it. + +- **`GraphicsDevice.supportsAlphaToCoverage` and + `PassEncoder.setAlphaToCoverage`**: a fragment's alpha as the share of a + multisampled pixel's samples it covers. WebGL2 and WebGPU answer true; + Impeller, whose flutter_gpu has no such control, and the software + rasteriser answer false and ignore the call. Traced as + `TraceSetAlphaToCoverage`; `FakeBackend` takes the answer and `FakePass` + records `RecordedAlphaToCoverage`. Every `PassEncoder` gains the method. + +- **A draw can read a window of the bound indices.** `PassEncoder.draw` takes + `firstIndex` and `indexCount`, so one buffer holding a model's every part is + bound once and drawn a part at a time. Leaving both out draws the whole + binding, as before. Every implementation of `PassEncoder` changes its + signature. A window past the end of the binding is refused with a + `RangeError` by `indexWindow`, which every backend calls, rather than left + to four drivers that answer it four different ways. Traces record the + window only when there is one, so older traces read as they were recorded. + ## 0.8.0+1 **Resolves on Flutter 3.44 and Dart 3.12.0.** The constraints asked for Dart diff --git a/packages/flutter3d_hardware/lib/src/command_encoder.dart b/packages/flutter3d_hardware/lib/src/command_encoder.dart index 2cdeeb314..86db7bd72 100644 --- a/packages/flutter3d_hardware/lib/src/command_encoder.dart +++ b/packages/flutter3d_hardware/lib/src/command_encoder.dart @@ -194,6 +194,16 @@ abstract interface class PassEncoder { /// attachment one off where attachment zero already is. void setBlend(BlendState? state, {int attachment = 0}); + /// Whether the next draws turn alpha into multisample coverage — `P7`. + /// + /// A masked surface drawn this way keeps its alpha rather than cutting at a + /// threshold, and the hardware covers that share of each pixel's samples: + /// the edge of a leaf or a fence is antialiased by the same resolve that + /// smooths a triangle's. Off by default and after every pass begins. Ask + /// `GraphicsDevice.supportsAlphaToCoverage` first: a device answering false + /// ignores this, and it does nothing in a pass of one sample anywhere. + void setAlphaToCoverage(bool enabled); + /// The constant the four constant-reading [BlendFactor]s multiply by. /// /// [BlendFactor.blendColor] and [BlendFactor.oneMinusBlendColor] read one @@ -381,7 +391,50 @@ abstract interface class PassEncoder { /// Zero draws nothing, which matters because the number usually comes from a /// live population: a particle system with nothing alive should not be a /// special case at every call site. - void draw({int instanceCount = 1}); + /// + /// [firstIndex] and [indexCount] draw a window of the bound indices rather + /// than all of them — `P7`. Null [indexCount] reads to the end of the + /// binding, so leaving both out is the whole buffer, as it always was. + /// + /// **On the draw, not on the binding, for the reason [instanceCount] is.** + /// One buffer holding a model's every part is bound once and drawn a part at + /// a time, each part with its own material; a window on the binding would + /// mean binding the same buffer again for every part to say something only + /// the draw knows. WebGPU and Metal take a first index on the draw for the + /// same reason, and WebGL2 takes a byte offset there. + /// + /// **A window past the end of the binding is refused with a [RangeError], + /// on every backend, before anything reaches the driver** — see + /// [indexWindow], which all four call. Left to the drivers it would be four + /// different answers: WebGL2 reports `INVALID_OPERATION` and draws nothing, + /// WebGPU invalidates the whole pass, Metal leaves it undefined, and the + /// software rasteriser would read past its bytes and throw from somewhere + /// that names none of them. Held by the conformance check + /// `a window of the index buffer draws that window`. + void draw({int instanceCount = 1, int firstIndex = 0, int? indexCount}); +} + +/// The window of [bound] indices a draw reads, from [firstIndex] for +/// [indexCount] of them, or to the end when [indexCount] is null. +/// +/// **For a backend's [PassEncoder.draw]**, so that all four refuse the same +/// windows in the same words rather than each reaching its driver's own +/// answer. Throws a [RangeError] naming the three numbers when the window +/// does not lie inside the binding. +({int first, int count}) indexWindow( + int bound, { + int firstIndex = 0, + int? indexCount, +}) { + final count = indexCount ?? bound - firstIndex; + if (firstIndex < 0 || count < 0 || firstIndex + count > bound) { + throw RangeError( + 'a draw of $count indices from index $firstIndex reads past the ' + '$bound the index binding holds. Draw a window inside it, or bind the ' + 'buffer whose indices the window means.', + ); + } + return (first: firstIndex, count: count); } /// A pass somebody opened, and must therefore close. diff --git a/packages/flutter3d_hardware/lib/src/graphics_device.dart b/packages/flutter3d_hardware/lib/src/graphics_device.dart index 925672c05..993fdfcff 100644 --- a/packages/flutter3d_hardware/lib/src/graphics_device.dart +++ b/packages/flutter3d_hardware/lib/src/graphics_device.dart @@ -199,6 +199,19 @@ abstract interface class GraphicsDevice implements TextureAllocator { /// backend that said true and filled the triangles passed the whole suite. bool get supportsWireframe; + /// Whether `PassEncoder.setAlphaToCoverage` turns a fragment's alpha into + /// the share of a multisampled pixel's samples it covers — `P7`. + /// + /// **Two of the four can.** WebGL2 has `SAMPLE_ALPHA_TO_COVERAGE` and WebGPU + /// a pipeline's `alphaToCoverageEnabled`; flutter_gpu exposes neither, nor a + /// sample mask a stage could write, and the software rasteriser draws one + /// sample a pixel. A device answering false ignores the call, and the + /// caller draws the hard alpha test it would have drawn without it — + /// `FrameResult.alphaToCoverageDeclined` says so. True means the coverage + /// follows alpha in a multisampled pass; a pass of one sample has no + /// coverage to spread, on any device. + bool get supportsAlphaToCoverage; + /// Whether the depth attachment this device hands out carries a stencil /// that `PassEncoder.setStencil` can test against. /// diff --git a/packages/flutter3d_hardware/lib/src/shader.dart b/packages/flutter3d_hardware/lib/src/shader.dart index eb6a595d2..fd2142cf5 100644 --- a/packages/flutter3d_hardware/lib/src/shader.dart +++ b/packages/flutter3d_hardware/lib/src/shader.dart @@ -203,3 +203,31 @@ final class LayeredShaderLibrary implements ShaderLibrary { @override ShaderHandle? operator [](String name) => first[name] ?? second[name]; } + +/// Any number of libraries consulted in order, the first that answers +/// winning — `P8`. +/// +/// What a game with more than one compiled material bundle hands the +/// renderer: each `.f3dmat` the build hook compiles is a bundle of its own, +/// and [LayeredShaderLibrary] takes two. The same rule as there — the +/// earlier library wins a clash — for the same reason. +/// +/// The list is copied, so a caller adding to its own list afterwards changes +/// nothing here; a renderer that takes libraries one at a time keeps its own +/// stack — see `Renderer.addMaterials`. +final class ShaderLibraryStack implements ShaderLibrary { + ShaderLibraryStack(Iterable libraries) + : libraries = List.unmodifiable(libraries); + + /// The libraries, searched first to last. + final List libraries; + + @override + ShaderHandle? operator [](String name) { + for (final library in libraries) { + final found = library[name]; + if (found != null) return found; + } + return null; + } +} diff --git a/packages/flutter3d_hardware/lib/src/shader_bundle.dart b/packages/flutter3d_hardware/lib/src/shader_bundle.dart index 0e10709ee..258e9b363 100644 --- a/packages/flutter3d_hardware/lib/src/shader_bundle.dart +++ b/packages/flutter3d_hardware/lib/src/shader_bundle.dart @@ -108,6 +108,18 @@ final class ShaderBundle { /// would be inventing a version to compare. static const String webgpuSection = 'webgpu'; + /// The section a backend that compiles nothing reads — `P8`: the source of + /// every stage written in the engine's material language, by stage name, + /// as JSON. See [encodeMaterialSection]. + /// + /// **The source rather than anything made from it.** The software + /// rasteriser runs Dart and evaluates the language itself, so the text is + /// all it needs; and a runtime that has to know how to bind a material — + /// which maps it samples, which lighting model stands for it — reads the + /// answer out of the same text instead of a second description that could + /// disagree with the stage beside it. + static const String materialSection = 'material'; + /// What the bundle is called, and what a refusal names. final String name; @@ -327,3 +339,45 @@ final class _Reader { return copy.buffer.asByteData(); } } + +/// The payload of [ShaderBundle.materialSection]: each stage's +/// material-language source, by stage name — `P8`. +/// +/// Versioned inside the document, as the WebGPU section is, so the payload +/// can grow without the container's version moving. +ByteData encodeMaterialSection(Map sources) { + final text = jsonEncode({ + 'version': 1, + 'stages': { + for (final name in sources.keys.toList()..sort()) name: sources[name]!, + }, + }); + return ByteData.sublistView(Uint8List.fromList(utf8.encode(text))); +} + +/// The sources [encodeMaterialSection] wrote, or empty when [bundle] has no +/// material section. Throws a [FormatException] for one that is not that +/// payload. +Map decodeMaterialSection(ShaderBundle bundle) { + final section = bundle.sections[ShaderBundle.materialSection]; + if (section == null) return const {}; + final json = jsonDecode( + utf8.decode( + section.buffer.asUint8List(section.offsetInBytes, section.lengthInBytes), + ), + ); + if (json is! Map || + json['version'] != 1 || + json['stages'] is! Map) { + throw const FormatException( + 'the material section is not version 1 of its payload', + ); + } + return { + for (final MapEntry(:key, :value) + in (json['stages']! as Map).entries) + key: value is String + ? value + : throw FormatException('the source of "$key" is not text'), + }; +} diff --git a/packages/flutter3d_hardware/lib/src/testing_fake_backend.dart b/packages/flutter3d_hardware/lib/src/testing_fake_backend.dart index 8772c7102..e21068c63 100644 --- a/packages/flutter3d_hardware/lib/src/testing_fake_backend.dart +++ b/packages/flutter3d_hardware/lib/src/testing_fake_backend.dart @@ -142,6 +142,7 @@ final class FakeBackend implements GraphicsDevice { FakeBackend({ Set missingShaders = const {}, this.supportsWireframe = true, + this.supportsAlphaToCoverage = true, this.supportsStencil = true, this.supportsRenderToMip = true, this.unsupportedFormats = const {}, @@ -224,6 +225,12 @@ final class FakeBackend implements GraphicsDevice { @override final bool supportsWireframe; + /// Settable for the reason [supportsWireframe] is: two of the four real + /// backends say no, and the engine is meant to draw the hard alpha test + /// there and report it. + @override + final bool supportsAlphaToCoverage; + /// True, because a fake has nothing to be incapable with. The real answer is /// a device property, and the two backends that have one disagree. @override diff --git a/packages/flutter3d_hardware/lib/src/testing_fake_pass.dart b/packages/flutter3d_hardware/lib/src/testing_fake_pass.dart index b4c341ffe..20a2e5587 100644 --- a/packages/flutter3d_hardware/lib/src/testing_fake_pass.dart +++ b/packages/flutter3d_hardware/lib/src/testing_fake_pass.dart @@ -54,6 +54,9 @@ final class FakePass implements CommandEncoder { // usually does not. PrimitiveType? primitiveType; PolygonMode? polygonMode; + + /// The last `setAlphaToCoverage`, or null when the pass never set it. + bool? alphaToCoverage; CullMode? cullMode; WindingOrder? windingOrder; bool? depthWrite; @@ -89,6 +92,12 @@ final class FakePass implements CommandEncoder { commands.add(RecordedPolygonMode(mode)); } + @override + void setAlphaToCoverage(bool enabled) { + alphaToCoverage = enabled; + commands.add(RecordedAlphaToCoverage(enabled)); + } + @override void setCullMode(CullMode mode) { cullMode = mode; @@ -154,12 +163,22 @@ final class FakePass implements CommandEncoder { commands.add(RecordedVertices(vertexCount, transient: true, slot: slot)); @override - void bindIndexBuffer(GeometryBuffer buffer, IndexType type, int indexCount) => - commands.add(RecordedIndices(type, indexCount, transient: false)); + void bindIndexBuffer(GeometryBuffer buffer, IndexType type, int indexCount) { + commands.add(RecordedIndices(type, indexCount, transient: false)); + _boundIndices = indexCount; + } @override - void bindIndexData(ByteData bytes, IndexType type, int indexCount) => - commands.add(RecordedIndices(type, indexCount, transient: true)); + void bindIndexData(ByteData bytes, IndexType type, int indexCount) { + commands.add(RecordedIndices(type, indexCount, transient: true)); + _boundIndices = indexCount; + } + + /// How many indices the last binding carried, for holding a draw's window + /// to it as every real backend does. Null until something is bound, and + /// then a window is recorded as it was asked for: a test that draws with + /// nothing bound is asserting on the order of calls, not on geometry. + int? _boundIndices; @override bool bindUniformBlock( @@ -202,11 +221,26 @@ final class FakePass implements CommandEncoder { void clearBindings() { commands.add(const RecordedClearBindings()); _forget(); + _boundIndices = null; } @override - void draw({int instanceCount = 1}) { - commands.add(RecordedDraw(instanceCount: instanceCount)); + void draw({int instanceCount = 1, int firstIndex = 0, int? indexCount}) { + final window = switch (_boundIndices) { + final bound? => indexWindow( + bound, + firstIndex: firstIndex, + indexCount: indexCount, + ), + null => (first: firstIndex, count: indexCount), + }; + commands.add( + RecordedDraw( + instanceCount: instanceCount, + firstIndex: window.first, + indexCount: indexCount == null ? null : window.count, + ), + ); final bindings = stageBindings; final pipeline = _pipelineName; if (bindings == null || pipeline == null) return; diff --git a/packages/flutter3d_hardware/lib/src/testing_recorded.dart b/packages/flutter3d_hardware/lib/src/testing_recorded.dart index e8dc78c7c..c26843689 100644 --- a/packages/flutter3d_hardware/lib/src/testing_recorded.dart +++ b/packages/flutter3d_hardware/lib/src/testing_recorded.dart @@ -17,12 +17,21 @@ sealed class Recorded { } final class RecordedDraw extends Recorded { - const RecordedDraw({this.instanceCount = 1}); + const RecordedDraw({ + this.instanceCount = 1, + this.firstIndex = 0, + this.indexCount, + }); /// How many instances the draw asked for. One for every draw this engine /// made before mesh particles, which is why the characterisation snapshots /// print `draw` unadorned unless it is anything else. final int instanceCount; + + /// The window of the bound indices the draw read — `P7`. Zero and null, the + /// whole binding, for every draw that did not ask for less. + final int firstIndex; + final int? indexCount; } final class RecordedPipeline extends Recorded { @@ -116,6 +125,11 @@ final class RecordedPolygonMode extends Recorded { final PolygonMode mode; } +final class RecordedAlphaToCoverage extends Recorded { + const RecordedAlphaToCoverage(this.enabled); + final bool enabled; +} + final class RecordedCullMode extends Recorded { const RecordedCullMode(this.mode); final CullMode mode; diff --git a/packages/flutter3d_hardware/lib/src/trace/recording_device.dart b/packages/flutter3d_hardware/lib/src/trace/recording_device.dart index 5e83fdbf5..1a2bf14d5 100644 --- a/packages/flutter3d_hardware/lib/src/trace/recording_device.dart +++ b/packages/flutter3d_hardware/lib/src/trace/recording_device.dart @@ -108,6 +108,9 @@ final class RecordingDevice implements GraphicsDevice { bool get supportsRenderToMip => inner.supportsRenderToMip; @override bool get supportsWireframe => inner.supportsWireframe; + + @override + bool get supportsAlphaToCoverage => inner.supportsAlphaToCoverage; @override bool get supportsStencil => inner.supportsStencil; @override @@ -475,6 +478,12 @@ final class _RecordingEncoder implements CommandEncoder { _inner.setWindingOrder(order); } + @override + void setAlphaToCoverage(bool enabled) { + _events.add(TraceSetAlphaToCoverage(_pass, enabled)); + _inner.setAlphaToCoverage(enabled); + } + @override void setDepthWrite(bool enabled) { _events.add(TraceSetDepthWrite(_pass, enabled)); @@ -618,9 +627,13 @@ final class _RecordingEncoder implements CommandEncoder { } @override - void draw({int instanceCount = 1}) { - _events.add(TraceDraw(_pass, instanceCount)); - _inner.draw(instanceCount: instanceCount); + void draw({int instanceCount = 1, int firstIndex = 0, int? indexCount}) { + _events.add(TraceDraw(_pass, instanceCount, firstIndex, indexCount)); + _inner.draw( + instanceCount: instanceCount, + firstIndex: firstIndex, + indexCount: indexCount, + ); } @override diff --git a/packages/flutter3d_hardware/lib/src/trace/trace.dart b/packages/flutter3d_hardware/lib/src/trace/trace.dart index bfe1624be..65ee93ef1 100644 --- a/packages/flutter3d_hardware/lib/src/trace/trace.dart +++ b/packages/flutter3d_hardware/lib/src/trace/trace.dart @@ -304,6 +304,8 @@ void _replayPassEvent( pass.setWindingOrder(value); case TraceSetDepthWrite(:final value): pass.setDepthWrite(value); + case TraceSetAlphaToCoverage(:final value): + pass.setAlphaToCoverage(value); case TraceSetDepthCompare(:final value): pass.setDepthCompare(value); case TraceSetStencil(:final front, :final back): @@ -335,8 +337,12 @@ void _replayPassEvent( ); case TraceClearBindings(): pass.clearBindings(); - case TraceDraw(:final instanceCount): - pass.draw(instanceCount: instanceCount); + case TraceDraw(:final instanceCount, :final firstIndex, :final indexCount): + pass.draw( + instanceCount: instanceCount, + firstIndex: firstIndex, + indexCount: indexCount, + ); case TraceSubmit(): pass.submit(); } diff --git a/packages/flutter3d_hardware/lib/src/trace/trace_event.dart b/packages/flutter3d_hardware/lib/src/trace/trace_event.dart index fad1ca03e..7fd6aca00 100644 --- a/packages/flutter3d_hardware/lib/src/trace/trace_event.dart +++ b/packages/flutter3d_hardware/lib/src/trace/trace_event.dart @@ -190,6 +190,10 @@ sealed class TraceEvent { asInt(j['pass']), j['value']! as bool, ), + 'setAlphaToCoverage' => TraceSetAlphaToCoverage( + asInt(j['pass']), + j['value']! as bool, + ), 'setDepthCompare' => TraceSetDepthCompare( asInt(j['pass']), byName(CompareFunction.values, j['value']), @@ -251,7 +255,12 @@ sealed class TraceEvent { sampler: j['sampler'] == null ? null : samplerFromJson(j['sampler']), ), 'clearBindings' => TraceClearBindings(asInt(j['pass'])), - 'draw' => TraceDraw(asInt(j['pass']), asInt(j['instanceCount'])), + 'draw' => TraceDraw( + asInt(j['pass']), + asInt(j['instanceCount']), + j['firstIndex'] == null ? 0 : asInt(j['firstIndex']), + j['indexCount'] == null ? null : asInt(j['indexCount']), + ), 'submit' => TraceSubmit(asInt(j['pass'])), 'createStorageBuffer' => TraceCreateStorageBuffer( id: asInt(j['id']), @@ -693,6 +702,20 @@ final class TraceSetWindingOrder extends TracePassEvent { }; } +/// `PassEncoder.setAlphaToCoverage` — `P7`. A trace recorded before it had +/// none, which replays as it was recorded: off. +final class TraceSetAlphaToCoverage extends TracePassEvent { + const TraceSetAlphaToCoverage(super.pass, this.value); + final bool value; + @override + String get kind => 'setAlphaToCoverage'; + @override + Map toJson(TraceBlobWriter blob) => { + 'pass': pass, + 'value': value, + }; +} + final class TraceSetDepthWrite extends TracePassEvent { const TraceSetDepthWrite(super.pass, this.value); final bool value; @@ -924,14 +947,27 @@ final class TraceClearBindings extends TracePassEvent { } final class TraceDraw extends TracePassEvent { - const TraceDraw(super.pass, [this.instanceCount = 1]); + const TraceDraw( + super.pass, [ + this.instanceCount = 1, + this.firstIndex = 0, + this.indexCount, + ]); final int instanceCount; + + /// The window of the bound indices — `P7`. Written only when it is not the + /// whole binding, so a trace recorded before windows existed reads as it + /// was recorded and needs no new format version. + final int firstIndex; + final int? indexCount; @override String get kind => 'draw'; @override Map toJson(TraceBlobWriter blob) => { 'pass': pass, 'instanceCount': instanceCount, + if (firstIndex != 0) 'firstIndex': firstIndex, + if (indexCount != null) 'indexCount': indexCount, }; } diff --git a/packages/flutter3d_hardware/test/index_window_test.dart b/packages/flutter3d_hardware/test/index_window_test.dart new file mode 100644 index 000000000..5c027abbf --- /dev/null +++ b/packages/flutter3d_hardware/test/index_window_test.dart @@ -0,0 +1,51 @@ +/// What `indexWindow` lets a draw read, and what it refuses. +/// +/// flutter test test/index_window_test.dart +/// +/// The conformance check `a window of the index buffer draws that window` asks +/// the same of a live device; this asks it of the rule the four backends share, +/// which is where a refusal is added and so where a test of one belongs. +library; + +import 'package:flutter3d_hardware/flutter3d_hardware.dart'; +import 'package:test/test.dart'; + +void main() { + test('leaving both out is the whole binding', () { + expect(indexWindow(6), (first: 0, count: 6)); + }); + + test('a start with no count reads to the end', () { + expect(indexWindow(6, firstIndex: 3), (first: 3, count: 3)); + }); + + test('a window inside the binding is the window', () { + expect(indexWindow(6, firstIndex: 3, indexCount: 3), (first: 3, count: 3)); + expect(indexWindow(6, firstIndex: 0, indexCount: 0), (first: 0, count: 0)); + }); + + test('nothing bound is an empty whole, not a refusal', () { + // The encoders return early on a count of nought, which is how a draw + // with nothing bound has always drawn nothing. + expect(indexWindow(0), (first: 0, count: 0)); + }); + + test('a window past the end is refused, naming the three numbers', () { + expect( + () => indexWindow(6, firstIndex: 4, indexCount: 3), + throwsA( + isA().having( + (e) => e.message, + 'message', + allOf(contains('3 indices'), contains('index 4'), contains('6')), + ), + ), + ); + }); + + test('a negative start or count is refused', () { + expect(() => indexWindow(6, firstIndex: -1), throwsRangeError); + expect(() => indexWindow(6, indexCount: -1), throwsRangeError); + expect(() => indexWindow(6, firstIndex: 7), throwsRangeError); + }); +} diff --git a/packages/flutter3d_hardware/test/trace_codec_test.dart b/packages/flutter3d_hardware/test/trace_codec_test.dart index 09e41a953..613334768 100644 --- a/packages/flutter3d_hardware/test/trace_codec_test.dart +++ b/packages/flutter3d_hardware/test/trace_codec_test.dart @@ -126,6 +126,7 @@ List _everyEvent() => [ const TraceSetCullMode(0, CullMode.frontFace), const TraceSetWindingOrder(0, WindingOrder.clockwise), const TraceSetDepthWrite(0, false), + const TraceSetAlphaToCoverage(0, true), const TraceSetDepthCompare(0, CompareFunction.greaterEqual), const TraceSetStencil( 0, @@ -184,7 +185,7 @@ List _everyEvent() => [ sampler: SamplerOptions.nearestClamp, ), const TraceClearBindings(0), - const TraceDraw(0, 4), + const TraceDraw(0, 4, 3, 6), const TraceSubmit(0), const TraceReadPixels(1), const TraceReadback(texture: 1, region: _rect), @@ -222,7 +223,7 @@ void main() { // round trip below never tries. final kinds = _everyEvent().map((e) => e.kind).toList(); expect(kinds.toSet(), hasLength(kinds.length)); - expect(kinds, hasLength(47)); + expect(kinds, hasLength(48)); }); test('a file read back writes the same file', () { @@ -236,6 +237,18 @@ void main() { expect(Trace(read.events, metadata: read.metadata).encode(), first); }); + test('a draw of the whole binding reads back as the whole binding', () { + // A trace recorded before windows existed has neither key, and has to + // read as the draw it was without a new format version. + final read = Trace.decode( + Trace(const [TraceDraw(0, 2)]).encode(), + ); + final draw = read.events.single as TraceDraw; + expect(draw.instanceCount, 2); + expect(draw.firstIndex, 0); + expect(draw.indexCount, isNull); + }); + test('a trace from another format version is refused by name', () { final bytes = Trace(const []).encode(); ByteData.sublistView(bytes).setUint32(8, 99, Endian.little); diff --git a/packages/flutter3d_impeller/CHANGELOG.md b/packages/flutter3d_impeller/CHANGELOG.md index 4a5e62a5b..77e95b2e6 100644 --- a/packages/flutter3d_impeller/CHANGELOG.md +++ b/packages/flutter3d_impeller/CHANGELOG.md @@ -1,3 +1,8 @@ +## Unreleased + +- **`supportsAlphaToCoverage` is false**: flutter_gpu has no + alpha-to-coverage and no sample mask. `setAlphaToCoverage` does nothing. + ## 0.8.1+1 **Resolves on Flutter 3.44 and Dart 3.12.0.** The constraints asked for Dart diff --git a/packages/flutter3d_impeller/lib/src/gpu_command_encoder.dart b/packages/flutter3d_impeller/lib/src/gpu_command_encoder.dart index 93f27d6d8..e9e3a3826 100644 --- a/packages/flutter3d_impeller/lib/src/gpu_command_encoder.dart +++ b/packages/flutter3d_impeller/lib/src/gpu_command_encoder.dart @@ -97,6 +97,10 @@ final class GpuCommandEncoder implements CommandEncoder { @override void setDepthWrite(bool enabled) => _pass.setDepthWriteEnable(enabled); + /// Nothing — `P7`: flutter_gpu has no alpha-to-coverage to set; `supportsAlphaToCoverage` is false. + @override + void setAlphaToCoverage(bool enabled) {} + @override void setDepthCompare(CompareFunction compare) => _pass.setDepthCompareOperation(compare.toGpu()); @@ -197,6 +201,7 @@ final class GpuCommandEncoder implements CommandEncoder { // its draw needs after binding the pipeline, never before. _pass.clearBindings(); _indexCount = 0; + _indexView = null; _pass.bindPipeline(pipeline.backend as gpu.RenderPipeline); } @@ -222,17 +227,33 @@ final class GpuCommandEncoder implements CommandEncoder { _pass.bindVertexBuffer(_emplace(bytes), slot: slot); @override - void bindIndexBuffer(GeometryBuffer buffer, IndexType type, int indexCount) { - _pass.bindIndexBuffer(_view(buffer), type.toGpu()); - _indexCount = indexCount; - } + void bindIndexBuffer(GeometryBuffer buffer, IndexType type, int indexCount) => + _bindIndices(_view(buffer), type, indexCount); @override - void bindIndexData(ByteData bytes, IndexType type, int indexCount) { - _pass.bindIndexBuffer(_emplace(bytes), type.toGpu()); + void bindIndexData(ByteData bytes, IndexType type, int indexCount) => + _bindIndices(_emplace(bytes), type, indexCount); + + void _bindIndices(gpu.BufferView view, IndexType type, int indexCount) { + _pass.bindIndexBuffer(view, type.toGpu()); + _indexView = view; + _indexType = type; _indexCount = indexCount; + _boundFirstIndex = 0; } + /// The view the last index bind named, kept so a draw of a window can bind + /// a narrower one — `P7`. flutter_gpu's `drawIndexed` takes a count and no + /// first index, so where the window starts is said with the view's offset. + gpu.BufferView? _indexView; + IndexType _indexType = IndexType.int32; + + /// Where the view bound on the pass right now starts, in indices from the + /// start of [_indexView]. A window draw moves it, and the next draw that + /// wants a different start binds again rather than reading from wherever + /// the last window left it. + int _boundFirstIndex = 0; + @override bool bindUniformBlock( ShaderHandle shader, @@ -336,12 +357,31 @@ final class GpuCommandEncoder implements CommandEncoder { // draw after a `clearBindings` inherit the previous mesh's index count, // which is the kind of state leak that draws a plausible wrong picture. _indexCount = 0; + _indexView = null; } @override - void draw({int instanceCount = 1}) { - if (_indexCount == 0 || instanceCount <= 0) return; - _pass.drawIndexed(_indexCount, instanceCount: instanceCount); + void draw({int instanceCount = 1, int firstIndex = 0, int? indexCount}) { + final window = indexWindow( + _indexCount, + firstIndex: firstIndex, + indexCount: indexCount, + ); + final view = _indexView; + if (window.count == 0 || instanceCount <= 0 || view == null) return; + if (window.first != _boundFirstIndex) { + final skip = window.first * (_indexType == IndexType.int16 ? 2 : 4); + _pass.bindIndexBuffer( + gpu.BufferView( + view.buffer, + offsetInBytes: view.offsetInBytes + skip, + lengthInBytes: view.lengthInBytes - skip, + ), + _indexType.toGpu(), + ); + _boundFirstIndex = window.first; + } + _pass.drawIndexed(window.count, instanceCount: instanceCount); } @override diff --git a/packages/flutter3d_impeller/lib/src/gpu_device.dart b/packages/flutter3d_impeller/lib/src/gpu_device.dart index 34bb74052..23d452a6d 100644 --- a/packages/flutter3d_impeller/lib/src/gpu_device.dart +++ b/packages/flutter3d_impeller/lib/src/gpu_device.dart @@ -252,6 +252,10 @@ final class GpuRenderBackend implements GraphicsDevice { // Impeller exposes glPolygonMode's equivalent, so the request goes through. bool get supportsWireframe => true; + /// False — `P7`: flutter_gpu exposes no alpha-to-coverage and no sample mask. + @override + bool get supportsAlphaToCoverage => false; + @override // flutter_gpu's depth-stencil format is documented never to be depth-only, // so a device that names one has a stencil beside its depth; one that diff --git a/packages/flutter3d_impeller/test/gpu_formats_test.dart b/packages/flutter3d_impeller/test/gpu_formats_test.dart index 8de20b89a..d49c1bf86 100644 --- a/packages/flutter3d_impeller/test/gpu_formats_test.dart +++ b/packages/flutter3d_impeller/test/gpu_formats_test.dart @@ -2,7 +2,7 @@ /// /// The hazard this exists for: a wrong mapping compiles, runs, and renders /// wrong only for the values a scene happens to use. The goldens cover the -/// values the seventy-eight scenes exercise and nothing else — `BlendFactor` has +/// values the ninety-five scenes exercise and nothing else — `BlendFactor` has /// fifteen members and the engine draws with two of them — so a mistake in the /// other thirteen would be found by a user rather than by us. /// diff --git a/packages/flutter3d_model_core/lib/src/panorama_sync.dart b/packages/flutter3d_model_core/lib/src/panorama_sync.dart index f1235c2ce..642a7907c 100644 --- a/packages/flutter3d_model_core/lib/src/panorama_sync.dart +++ b/packages/flutter3d_model_core/lib/src/panorama_sync.dart @@ -92,31 +92,12 @@ final class PanoramaSync { } /// A Radiance file as the RGBA8 `EnvironmentMap.fromPanorama` reads, or null -/// when it is not one. -/// -/// **Clamped rather than tone-mapped.** The cube is eight bits a channel — -/// `EnvironmentMap`'s own doc comment says why, and that this is a real -/// limitation — so a sun four hundred times brighter than the sky around it -/// becomes white either way. Rolling the highlights off first would darken -/// everything else to buy detail in a region the cube cannot hold anyway, -/// and would make the indirect light a panorama gives differ from the -/// indirect light the same picture gives as a PNG. +/// when it is not one — clamped, for the reason `EnvironmentMap.hdrToRgba8` +/// gives. ByteData? panoramaPixels(Uint8List bytes) { - final HdrImage image; try { - image = readHdr(bytes); + return EnvironmentMap.hdrToRgba8(readHdr(bytes)); } on HdrFormatException { return null; } - final ByteData out = ByteData(image.width * image.height * 4); - for (var i = 0; i < image.width * image.height; i++) { - out - ..setUint8(i * 4, _byte(image.rgb[i * 3])) - ..setUint8(i * 4 + 1, _byte(image.rgb[i * 3 + 1])) - ..setUint8(i * 4 + 2, _byte(image.rgb[i * 3 + 2])) - ..setUint8(i * 4 + 3, 255); - } - return out; } - -int _byte(double value) => (value * 255.0).round().clamp(0, 255); diff --git a/packages/flutter3d_model_mcp/lib/src/model_server.dart b/packages/flutter3d_model_mcp/lib/src/model_server.dart index 3d044230d..00ab36ca9 100644 --- a/packages/flutter3d_model_mcp/lib/src/model_server.dart +++ b/packages/flutter3d_model_mcp/lib/src/model_server.dart @@ -12,7 +12,7 @@ import 'render_tool.dart'; /// 0.1.0 while the package moved through 0.6.0 to 0.7.0, since "kept beside /// the pubspec's" was a comment and nothing checked it; /// `server_version_test.dart` does now. -const String modelMcpVersion = '0.8.0'; +const String modelMcpVersion = '0.8.1'; /// [tool]'s own [Answer], carried into a [PictureAnswer] with a null `png` — /// every tool in [modelTools] answers this way; [renderTool] (`mcp-06n`) is diff --git a/packages/flutter3d_net/CHANGELOG.md b/packages/flutter3d_net/CHANGELOG.md index f5c2d7a69..bec21056e 100644 --- a/packages/flutter3d_net/CHANGELOG.md +++ b/packages/flutter3d_net/CHANGELOG.md @@ -1,3 +1,9 @@ +## Unreleased + +- `firstDifferingPath` lives in `flutter3d_sim` now; this package exports + it from there, so imports of it keep working. + + ## 0.8.1 **Any transport here carries `flame_multiplayer`.** `NetTransportWire` makes diff --git a/packages/flutter3d_net/lib/flutter3d_net.dart b/packages/flutter3d_net/lib/flutter3d_net.dart index 5978bf876..41f7cf595 100644 --- a/packages/flutter3d_net/lib/flutter3d_net.dart +++ b/packages/flutter3d_net/lib/flutter3d_net.dart @@ -15,6 +15,8 @@ /// against a mock that cannot lie about timing. library; +export 'package:flutter3d_sim/flutter3d_sim.dart' show firstDifferingPath; + export 'src/loopback_transport.dart'; export 'src/net_session.dart'; export 'src/net_transport.dart'; diff --git a/packages/flutter3d_net/lib/src/snapshot_divergence.dart b/packages/flutter3d_net/lib/src/snapshot_divergence.dart index 73b4840b1..368d6313c 100644 --- a/packages/flutter3d_net/lib/src/snapshot_divergence.dart +++ b/packages/flutter3d_net/lib/src/snapshot_divergence.dart @@ -79,43 +79,3 @@ SnapshotDivergence? diffRuns({ found: path.b, ); } - -/// A path into JSON-shaped values ([Map], [List], or a primitive) leading -/// to the first leaf where [a] and [b] differ, or null if they are equal — -/// exported so a caller that already has two full snapshots and already -/// knows which step to compare (a golden test disagreeing on one recorded -/// step, say) can ask this question directly, without going through -/// [diffRuns]'s digest-narrowing at all. -({String path, Object? a, Object? b})? firstDifferingPath( - Object? a, - Object? b, [ - String prefix = '', -]) { - if (a is Map && b is Map) { - final keys = {...a.keys, ...b.keys}.toList() - ..sort((x, y) => x.toString().compareTo(y.toString())); - for (final key in keys) { - final childPrefix = prefix.isEmpty ? '$key' : '$prefix.$key'; - final result = firstDifferingPath(a[key], b[key], childPrefix); - if (result != null) return result; - } - return null; - } - if (a is List && b is List) { - final shorter = a.length < b.length ? a.length : b.length; - for (var i = 0; i < shorter; i++) { - final result = firstDifferingPath(a[i], b[i], '$prefix[$i]'); - if (result != null) return result; - } - if (a.length != b.length) { - return ( - path: prefix.isEmpty ? '' : '$prefix.', - a: a.length, - b: b.length, - ); - } - return null; - } - if (a != b) return (path: prefix, a: a, b: b); - return null; -} diff --git a/packages/flutter3d_particles/CHANGELOG.md b/packages/flutter3d_particles/CHANGELOG.md index 170b3f3c9..f27c30b7a 100644 --- a/packages/flutter3d_particles/CHANGELOG.md +++ b/packages/flutter3d_particles/CHANGELOG.md @@ -1,3 +1,22 @@ +## Unreleased + +* **Particles through an orthographic camera fog by depth.** Every particle + stage measured its fog from the eye's position and a mesh particle lit + its faces by how squarely they faced it; through an orthographic lens the + eye is only where the camera was put along its axis, so a particle off + the axis came out foggier and a shard dimmer than the same one on it. The + contributors now bind the view axis and the lens, and the stages measure + from the eye's plane and against the axis. + +* **Height fog, as thick as it is at the camera.** Both contributors hand + their stages `FogSettings.densityAt` the camera's height rather than the + density at the fog's base, so a height fog does not leave particles fogged + as though they stood on the ground. + +* **Particles follow a shader reload.** `ParticleContributor` and + `MeshParticleContributor` drop their pipelines when the renderer relinks, + through `flutter3d_core`'s `PassContributor.relinkShaders`. + ## 0.8.1+1 **Resolves on Flutter 3.44 and Dart 3.12.0.** The constraints asked for Dart diff --git a/packages/flutter3d_particles/lib/src/mesh_particle_contributor.dart b/packages/flutter3d_particles/lib/src/mesh_particle_contributor.dart index 5bf3064e3..50083a0ba 100644 --- a/packages/flutter3d_particles/lib/src/mesh_particle_contributor.dart +++ b/packages/flutter3d_particles/lib/src/mesh_particle_contributor.dart @@ -205,14 +205,27 @@ final class MeshParticleContributor extends PassContributor { _fog[0] = colour.x; _fog[1] = colour.y; _fog[2] = colour.z; - _fog[3] = fog.density; - view.camera.readWorldPosition(_eye); + view.camera.readViewOrigin(_eye); + // The air as thick as it is at the camera: a height fog's falloff is not + // integrated here — `FogSettings.densityAt` says what that costs. + _fog[3] = fog.densityAt(_eye.y); _eyeData[0] = _eye.x; _eyeData[1] = _eye.y; _eyeData[2] = _eye.z; + // The view axis and the lens, so the stage measures the fog from the + // eye's plane through an orthographic camera rather than in rings round a + // point the picture does not depend on — `P7`. + view.camera.readForward(_eye); + _forwardData + ..[0] = _eye.x + ..[1] = _eye.y + ..[2] = _eye.z; + _projectionData[0] = isOrthographic(viewProjection) ? 1.0 : 0.0; encoder.bindUniformBlock(fragmentShader, 'FogInfo', { 'fog': _fog, 'eye': _eyeData, + 'forward': _forwardData, + 'projection': _projectionData, }); encoder.draw(instanceCount: written); @@ -244,7 +257,12 @@ final class MeshParticleContributor extends PassContributor { final Set _missing = {}; final Float32List _fog = Float32List(4); final Float32List _eyeData = Float32List(4); + final Float32List _forwardData = Float32List(4); + final Float32List _projectionData = Float32List(4); final vm.Vector3 _eye = vm.Vector3.zero(); PipelineHandle? _pipeline; Float32List? _instances; + + @override + void relinkShaders() => _pipeline = null; } diff --git a/packages/flutter3d_particles/lib/src/particle_contributor.dart b/packages/flutter3d_particles/lib/src/particle_contributor.dart index b3f0dd582..0a7c1909f 100644 --- a/packages/flutter3d_particles/lib/src/particle_contributor.dart +++ b/packages/flutter3d_particles/lib/src/particle_contributor.dart @@ -186,14 +186,27 @@ final class ParticleContributor extends PassContributor { _fog[0] = colour.x; _fog[1] = colour.y; _fog[2] = colour.z; - _fog[3] = fog.density; - view.camera.readWorldPosition(_eye); + view.camera.readViewOrigin(_eye); + // The air as thick as it is at the camera: a height fog's falloff is not + // integrated here — `FogSettings.densityAt` says what that costs. + _fog[3] = fog.densityAt(_eye.y); _eyeData[0] = _eye.x; _eyeData[1] = _eye.y; _eyeData[2] = _eye.z; + // The view axis and the lens, so the stage measures the fog from the + // eye's plane through an orthographic camera rather than in rings round a + // point the picture does not depend on — `P7`. + view.camera.readForward(_eye); + _forwardData + ..[0] = _eye.x + ..[1] = _eye.y + ..[2] = _eye.z; + _projectionData[0] = isOrthographic(viewProjection) ? 1.0 : 0.0; encoder.bindUniformBlock(fragmentShader, 'FogInfo', { 'fog': _fog, 'eye': _eyeData, + 'forward': _forwardData, + 'projection': _projectionData, }); if (sheet != null && lights != null) { @@ -464,10 +477,22 @@ final class ParticleContributor extends PassContributor { int? _reactiveDevice; PipelineHandle? _reactivePipeline; + /// Forgets the device the pipelines were keyed on, which drops them all: + /// the next frame links them again from the reloaded stages. + @override + void relinkShaders() { + _pipelines.clear(); + _pipelineDevice = null; + _reactivePipeline = null; + _reactiveDevice = null; + } + final Set _missing = {}; final Float32List _fog = Float32List(4); final Float32List _eyeData = Float32List(4); + final Float32List _forwardData = Float32List(4); + final Float32List _projectionData = Float32List(4); final vm.Vector3 _eye = vm.Vector3.zero(); Float32List? _vertices; Uint32List? _indices; diff --git a/packages/flutter3d_particles/test/orthographic_particles_test.dart b/packages/flutter3d_particles/test/orthographic_particles_test.dart new file mode 100644 index 000000000..fb7c67ab3 --- /dev/null +++ b/packages/flutter3d_particles/test/orthographic_particles_test.dart @@ -0,0 +1,129 @@ +/// Particles through an orthographic camera — `P7`. +/// +/// dart test test/orthographic_particles_test.dart +/// +/// Every particle stage measured its fog from the eye's position, and a mesh +/// particle lit its faces by how squarely they faced it. Through an +/// orthographic lens the eye is only where the camera was put along its axis +/// and moves nothing in the picture, so a particle off the axis came out +/// foggier, and a shard's face turned towards the camera dimmer, than the +/// same particle on it. Two identical particles at the same depth, one on the +/// axis and one off it, must now draw the same. +library; + +import 'dart:math' as math; +import 'dart:typed_data'; + +import 'package:flutter3d_build/flutter3d_build.dart'; +import 'package:flutter3d_core/flutter3d_core.dart'; +import 'package:flutter3d_cpu/flutter3d_cpu.dart'; +import 'package:flutter3d_particles/flutter3d_particles.dart'; +import 'package:test/test.dart'; +import 'package:vector_math/vector_math.dart'; + +const int _width = 48; +const int _height = 24; + +enum _Stage { disc, sprite, sixWay, mesh } + +/// The brightest light in the eight columns on either side of each of the +/// two particles' centres — on the axis, and four metres to the right of it. +({double onAxis, double offAxis}) _pair(_Stage stage, {FogSettings? fog}) { + final device = CpuDevice( + width: _width, + height: _height, + shaders: CpuShaderLibrary(builtinCpuShaders()), + ); + TextureHandle upload(int width, int height, Uint8List bytes) => + device.createTextureFromPixels( + width: width, + height: height, + format: TextureFormat.r8g8b8a8UNormInt, + pixels: ByteData.sublistView(bytes), + )!; + + final effect = ParticleEffect( + count: 1, + emitter: const SphereEmitter(speed: Range.exact(0.0)), + lifetime: const Range.exact(5.0), + size: const Range.exact(1.5), + color: Vector4(0.8, 0.5, 0.2, 1.0), + ); + final particles = ParticleSystem(capacity: 2) + ..burst(effect, Vector3(0.0, 0.0, -5.0)) + ..burst(effect, Vector3(4.0, 0.0, -5.0)); + + // Two pixels to the metre, looking down -z from the origin. + final camera = CameraNode() + ..projection = const OrthographicProjection(height: 12.0); + final scene = Scene()..add(camera); + + final white = Uint8List(4 * 4 * 4)..fillRange(0, 64, 255); + final sheet = stage == _Stage.sixWay + ? bakeSixWay(density: smokePuff(), frames: 1, columns: 1, cell: 16) + : null; + final PassContributor contributor = stage == _Stage.mesh + ? MeshParticleContributor( + particles, + mesh: DeviceMesh.upload( + device, + CuboidShape(size: Vector3.all(1.0)).build(), + ), + ) + : ParticleContributor( + particles, + texture: stage == _Stage.sprite ? upload(4, 4, white) : null, + sixWay: sheet == null + ? null + : SixWayMaterial( + positive: upload(sheet.width, sheet.height, sheet.positive), + negative: upload(sheet.width, sheet.height, sheet.negative), + ambient: Vector3(1.0, 1.0, 1.0), + ), + ); + final renderer = Renderer.create(device: device)..addContributor(contributor); + final result = renderer.render( + width: _width, + height: _height, + scene: scene, + views: [ + RenderView(camera: camera, clearColor: Vector4(0.0, 0.0, 0.0, 1.0)), + ], + settings: RenderSettings( + tonemap: false, + bloom: const BloomSettings(enabled: false), + fog: fog ?? const FogSettings(), + ), + ); + final frame = device.readHdrPixels(result.frame); + double brightest(int centre) => [ + for (var x = centre - 4; x < centre + 4; x++) + for (var y = _height ~/ 2 - 2; y < _height ~/ 2 + 2; y++) + frame[(y * _width + x) * 4], + ].reduce(math.max); + return (onAxis: brightest(_width ~/ 2), offAxis: brightest(_width ~/ 2 + 8)); +} + +void main() { + for (final stage in _Stage.values) { + test('a ${stage.name} particle off the axis fogs as one on it', () { + // Mutation: measure `FogDistance` in `cpu_shaders_particles.dart` from + // the eye's position — the one off the axis is 6.4 metres away rather + // than 5, and a fifth of a metre's density leaves it a quarter darker. + final it = _pair( + stage, + fog: FogSettings(color: Vector3.zero(), density: 0.2), + ); + expect(it.onAxis, greaterThan(0.02), reason: 'no particle on the axis'); + expect(it.offAxis, closeTo(it.onAxis, it.onAxis * 0.02)); + }); + } + + test('a mesh particle off the axis is lit as one on it', () { + // Mutation: measure `TowardsEyeFrom` from the eye's position — the face + // of the shard off the axis turns thirty-nine degrees from it and dims. + final it = _pair(_Stage.mesh); + expect(it.onAxis, greaterThan(0.02), reason: 'no particle on the axis'); + expect(it.offAxis, closeTo(it.onAxis, it.onAxis * 0.02)); + }); +} diff --git a/packages/flutter3d_physics/CHANGELOG.md b/packages/flutter3d_physics/CHANGELOG.md index 7a4e76f4e..e6a3de370 100644 --- a/packages/flutter3d_physics/CHANGELOG.md +++ b/packages/flutter3d_physics/CHANGELOG.md @@ -1,3 +1,59 @@ +## Unreleased + +- **Bodies are stepped through `RigidDynamics`.** `WorldStep`, the games, + the crates and the Flame bridge hold the interface, and `Dynamics` + implements it unchanged. `flutter3d_physics_native`'s `NativeDynamics` + is the other implementation. A character's push is `Pusher`, the same + code `Dynamics.push` ran, now shared, so a crate is shoved the same + whichever steps it. `CollisionWorld.movers` lists the moving colliders, + as `statics` lists the still ones. `saveState()` and `restoreState()` + carry what a backend holds beyond its bodies through a save: nothing + for `Dynamics`. + +- **Drops let go on top of each other spread instead of flying apart.** + A stream lets go of its drops at one point step after step, so + `ParticleFluid` laid each lump exactly on the last. A coincident pair + counts whole in each other's density, but the kernel has no gradient at + nought, so the constraint read heavily compressed with nothing to part + along and λ = −C / |∇C|² ran away: the chemistry bench's overflowing flask + threw thirty thousand particles eleven metres up. Coincident particles now + part along a direction set by the pair, and each substep first takes the + compression it starts with out of the positions alone (pre-stabilisation), + so overlap is never turned into speed. `velocities` reads each particle's. + +- **No wave stands steeper than Stokes' limit.** `FreeSurface` breaks a + mode higher than a fourteenth of its wavelength: the linear modes knew no + bound, and a vessel turned sixty-seven degrees in a step laid its old + level out as a thirty-four millimetre wave that threw most of it over the + lip. + +- **An upright, overfull vessel runs over its edge, outwards.** Over every + part of a level lip at once, the outward pulls cancelled: the spill left + from the middle of the mouth with no speed and fell back in, round and + round. It leaves over the stretch passing most, no wider than the mouth. + +- **A body can turn, if it is built to.** `RigidBody` has an `orientation`, + an `angularVelocity`, an inertia tensor in its own axes + (`inertiaLocal`, `inverseInertiaLocal`) and one in the world's + (`inverseInertiaWorld`), plus `applyImpulseAt` and `applyTorqueImpulse`. + `inertiaFor(shape, mass)` gives the principal moments of a box, a sphere + and a capsule; a wedge and a heightfield are taken as their bounding box. + Only a body built with `canRotate: true` turns, because every shipped + level was tuned against crates that do not tip. The others have an + inverse inertia of zero and step exactly as before. A turning body + keeps its angular momentum rather than its angular velocity, which is + the gyroscopic term taken implicitly: a box spun off its principal axes + for ten seconds holds L to within 1e-4. `angularDamping` is zero by + default. No contact turns a body yet, and the collider stays + axis-aligned. `save()` writes the orientation and spin only for a body + that can turn, so saved levels and their digests do not change. + `readQuaternion` reads one back. +- **The step has trigonometry it may call.** `src/portable_math.dart` + holds `sin`, `cos`, `sinCos`, `atan`, `atan2`, `asin` and `acos` built + from fdlibm's kernels out of IEEE arithmetic alone, for the hinge + angles and cone limits that come next. It is not exported, because + `flutter3d_sim` re-exports this package beside its own `Portable`. + ## 0.8.2+1 **Resolves on Flutter 3.44 and Dart 3.12.0.** The constraints asked for Dart diff --git a/packages/flutter3d_physics/lib/flutter3d_physics.dart b/packages/flutter3d_physics/lib/flutter3d_physics.dart index 2a3198247..cb1d8ea1f 100644 --- a/packages/flutter3d_physics/lib/flutter3d_physics.dart +++ b/packages/flutter3d_physics/lib/flutter3d_physics.dart @@ -1,4 +1,4 @@ -/// Collision, character movement and cloth, with nothing above them. +/// Collision, character movement, cloth and liquids, with nothing above them. /// /// Shapes that overlap exactly, a broadphase over a uniform grid, sweeps and /// rays that do not tunnel, and a controller that walks, jumps, climbs a step @@ -39,7 +39,23 @@ export 'src/collision_shape.dart'; export 'src/collision_world.dart'; export 'src/contact.dart'; export 'src/dynamics.dart'; +export 'src/fluid/buoyancy.dart'; +export 'src/fluid/capillary.dart'; +export 'src/fluid/fluid_medium.dart'; +export 'src/fluid/fluid_world.dart'; +export 'src/fluid/free_surface.dart'; +export 'src/fluid/jet.dart'; +export 'src/fluid/liquid_body.dart'; +export 'src/fluid/liquid_layer.dart'; +export 'src/fluid/outflow.dart'; +export 'src/fluid/particle_fluid.dart'; +export 'src/fluid/pipe.dart'; +export 'src/fluid/vessel_shape.dart'; +export 'src/inertia.dart'; +export 'src/portable_math.dart'; +export 'src/push.dart'; export 'src/rigid_body.dart'; +export 'src/rigid_dynamics.dart'; export 'src/snapshot.dart'; export 'src/spatial_grid.dart'; export 'src/tolerances.dart'; diff --git a/packages/flutter3d_physics/lib/src/collision_world.dart b/packages/flutter3d_physics/lib/src/collision_world.dart index 7f5aa93f4..48856f140 100644 --- a/packages/flutter3d_physics/lib/src/collision_world.dart +++ b/packages/flutter3d_physics/lib/src/collision_world.dart @@ -72,6 +72,11 @@ final class CollisionWorld { /// which keep the grid's indices true. Iterable get statics => _statics; + /// The moving colliders — bodies, characters, doors and lifts — for a + /// caller that mirrors the world elsewhere, as the native dynamics does. + /// Read-only, as [statics] is. + Iterable get movers => _movers; + /// How many moving colliders are in the world. /// /// [colliderCount] is what the engine's own checks watch, because the total is diff --git a/packages/flutter3d_physics/lib/src/dynamics.dart b/packages/flutter3d_physics/lib/src/dynamics.dart index 08cc97851..c16dfd52f 100644 --- a/packages/flutter3d_physics/lib/src/dynamics.dart +++ b/packages/flutter3d_physics/lib/src/dynamics.dart @@ -22,7 +22,10 @@ /// /// ## What it does not do /// -/// No rotation, no joints, no continuous collision. A body moving fast enough +/// No contact that turns a body — a body built with `canRotate` spins from the +/// impulses a game gives it and keeps its angular momentum, but the solver +/// below pushes only along the normal, through the centre. No joints, no +/// continuous collision. A body moving fast enough /// to cross a wall between two steps will cross it; the sweep that stops that /// happening for the character controller is not applied here, and stage two is /// where it belongs. Stated rather than discovered, and the numbers are in @@ -37,7 +40,9 @@ import 'collider.dart'; import 'collision_shape.dart'; import 'collision_world.dart'; import 'contact.dart'; +import 'push.dart'; import 'rigid_body.dart'; +import 'rigid_dynamics.dart'; import 'tolerances.dart'; /// What a contact needed last step. @@ -85,15 +90,18 @@ final class _Pair { double bounce = 0.0; } -final class Dynamics { +/// The reference [RigidDynamics]: sequential impulses in Dart doubles. +final class Dynamics implements RigidDynamics { Dynamics({required this.world, Vector3? gravity, this.iterations = 20}) : gravity = gravity ?? Vector3(0.0, -22.0, 0.0); + @override final CollisionWorld world; /// Metres per second squared. The default matches the character controller's, /// because a crate that falls slower than the player who dropped it reads as /// a bug in the crate. + @override final Vector3 gravity; /// How many times the velocity solver goes round. @@ -107,6 +115,7 @@ final class Dynamics { /// and throws the pile apart. int positionIterations = 6; + @override final List bodies = []; /// Below this speed for [sleepAfter] seconds, a body stops being simulated. @@ -148,12 +157,14 @@ final class Dynamics { final Contact _contact = Contact(); final Vector3 _scratch = Vector3.zero(); + @override RigidBody add(RigidBody body) { bodies.add(body); _byCollider[body.collider] = body; return body; } + @override void remove(RigidBody body) { bodies.remove(body); _byCollider.remove(body.collider); @@ -167,6 +178,7 @@ final class Dynamics { /// broadphase loop, where a linear scan makes the cost of a step quadratic in /// the number of bodies. The platformer's shipped level has thirty-four /// crates in it, which is where that stopped being theoretical. + @override RigidBody? bodyOf(Collider collider) { _lookups++; return _byCollider[collider]; @@ -184,6 +196,7 @@ final class Dynamics { final Map _byCollider = {}; /// One fixed step. + @override void step(double dt) { if (dt <= 0.0) return; _lookupsLastStep = _lookups; @@ -213,6 +226,9 @@ final class Dynamics { ..setFrom(body.velocity) ..scale(dt); body.collider.moveTo(body.position + _scratch); + // After the solve, like the position: the spin this step ends with is + // the one the body turns by. Nothing for a body that cannot turn. + body.integrateOrientation(dt); } _separate(); @@ -228,65 +244,21 @@ final class Dynamics { world.reindex(); } - /// Shoves whatever [by] is walking into. - /// - /// ## Why a character does not push a crate on its own - /// - /// A `CharacterController` is kinematic: it sweeps, it slides, and it is - /// never moved by anything. That is what makes a first-person game feel - /// solid, and it also means walking into a crate does exactly nothing to the - /// crate — the sweep stops the player and no momentum goes the other way. - /// - /// So the transfer is explicit, and it is a *speed* rather than a force. A - /// crate is given just enough velocity to move away at the speed the walker - /// is approaching it, and no more: that is what makes pushing feel like - /// pushing rather than like a bat. A force proportional to mass would let a - /// player launch a light crate across the room by brushing it. - /// - /// Horizontal only. Walking into a crate must not press it into the floor, - /// and standing on one must not drive it downwards. - void push(Collider by, Vector3 velocity, {double strength = 1.0}) { - final speed = math.sqrt(velocity.x * velocity.x + velocity.z * velocity.z); - if (speed < Nearly.moving) return; + /// Shoves whatever [by] is walking into, as [Pusher] says. + @override + void push(Collider by, Vector3 velocity, {double strength = 1.0}) => + _pusher.push(by, velocity, strength: strength, margin: margin); - world.overlap( - _inflated(by.shape), - by.position, - _nearby, - ignore: by, - includeTriggers: false, - ); - for (final other in _nearby) { - final body = bodyOf(other); - if (body == null || !body.isMovable) continue; + late final Pusher _pusher = Pusher(this); - contactBetween( - body.collider.shape, - body.position, - by.shape, - by.position, - _contact, - margin: margin, - ); - if (!_contact.touching) continue; + /// Nothing beyond the bodies: the warm starts held between steps were + /// never part of a save, and the runs' digests are what they are without + /// them. + @override + Object? saveState() => null; - // Which way the crate would go, flattened: the normal points out of the - // walker, which is exactly the direction to shove. - _scratch.setValues(_contact.normal.x, 0.0, _contact.normal.z); - final flat = _scratch.length; - if (flat < Nearly.moving) continue; - _scratch.scale(1.0 / flat); - - // Only if the walker is actually heading into it. - final into = velocity.x * _scratch.x + velocity.z * _scratch.z; - if (into <= 0.0) continue; - - final already = body.velocity.dot(_scratch); - final wanted = into * strength; - if (already >= wanted) continue; - body.applyImpulse(_scratch * ((wanted - already) / body.inverseMass)); - } - } + @override + void restoreState(Object? saved) {} /// Everything overlapping, as pairs the solver can work on. void _collect() { diff --git a/packages/flutter3d_physics/lib/src/fluid/buoyancy.dart b/packages/flutter3d_physics/lib/src/fluid/buoyancy.dart new file mode 100644 index 000000000..0c5d4891e --- /dev/null +++ b/packages/flutter3d_physics/lib/src/fluid/buoyancy.dart @@ -0,0 +1,213 @@ +import 'dart:math' as math; + +import 'package:vector_math/vector_math.dart'; + +import '../collision_shape.dart'; +import '../portable_math.dart'; +import '../rigid_body.dart'; +import 'fluid_medium.dart'; +import 'liquid_body.dart'; +import 'vessel_shape.dart'; + +/// How much of [shape], centred at [centre], is below the plane +/// `up · p = height` (all in one frame): exactly, for the shapes a body can +/// have that hold a volume — a sphere by its cap, π h²(3R − h)/3; a box by +/// cutting it as a closed mesh; an upright capsule as a surface of +/// revolution. Nought for shapes with no inside: a heightfield, a wedge +/// ramp. +double submergedVolume( + CollisionShape shape, + Vector3 centre, + Vector3 up, + double height, +) { + final depth = height - up.dot(centre); + switch (shape) { + case CollisionSphere(:final radius): + final h = (radius + depth).clamp(0.0, 2.0 * radius); + return math.pi * h * h * (3.0 * radius - h) / 3.0; + case CollisionBox(:final halfExtents): + return _box(halfExtents).volumeBelow(up, depth); + case CollisionCapsule(:final radius, :final halfHeight): + return _capsule(radius, halfHeight).volumeBelow(up, depth); + default: + return 0.0; + } +} + +/// A box about the origin as a closed mesh. +MeshVessel _box(Vector3 e) { + final p = [ + for (final y in [-e.y, e.y]) + for (final (x, z) in [(-e.x, -e.z), (e.x, -e.z), (e.x, e.z), (-e.x, e.z)]) + Vector3(x, y, z), + ]; + const quads = >[ + [0, 1, 2, 3], + [4, 7, 6, 5], + [0, 4, 5, 1], + [1, 5, 6, 2], + [2, 6, 7, 3], + [3, 7, 4, 0], + ]; + return MeshVessel( + positions: p, + indices: [ + for (final q in quads) ...[q[0], q[1], q[2], q[0], q[2], q[3]], + ], + ); +} + +/// An upright capsule about the origin as a lathe: a hemisphere, the +/// cylinder, a hemisphere. +RevolvedVessel _capsule(double r, double half) => RevolvedVessel([ + for (var i = 0; i <= 12; i++) + Vector2( + r * Portable.sin(i / 12 * math.pi / 2), + -half - r * Portable.cos(i / 12 * math.pi / 2), + ), + for (var i = 1; i <= 12; i++) + Vector2( + r * Portable.cos(i / 12 * math.pi / 2), + half + r * Portable.sin(i / 12 * math.pi / 2), + ), + Vector2(0, half + r), +]); + +/// The drag coefficient of a sphere at Reynolds number [re]: White's +/// correlation, 24/Re + 6/(1 + √Re) + 0.4, which is Stokes's law when the +/// flow is slow and the turbulent plateau when it is quick. +double sphereDrag(double re) { + if (re <= 0.0) return 0.0; + return 24.0 / re + 6.0 / (1.0 + math.sqrt(re)) + 0.4; +} + +/// A rigid body in a vessel's liquid: the forces on it, applied as impulses +/// each step, and the liquid it pushes aside. +/// +/// **Archimedes**: the liquid pushes up with the weight of what the body +/// displaces, ρgV along the gravity the liquid feels. **Drag**: against its +/// motion through the liquid, ½ρC_dA|v|v with White's C_d, for the part of +/// it that is under — a sphere's own coefficient, and a box's or capsule's +/// as the sphere of the same frontal area, which is an estimate. And the +/// liquid it pushes aside raises the level in the vessel by as much +/// ([LiquidBody.displaced]). +final class FloatingBody { + FloatingBody(this.body); + + final RigidBody body; + + /// The volume of it under the surface of [liquid] as it is now. + double submergedIn(LiquidBody liquid) { + final turnBack = liquid.rotation.clone()..transpose(); + final centre = turnBack.transformed(body.position - liquid.position); + if (!liquid.shape.contains(centre) && + !_overlaps(liquid.shape, centre, body.collider.shape)) { + return 0.0; + } + return submergedVolume( + body.collider.shape, + centre, + liquid.up, + liquid.height, + ); + } + + bool _overlaps(VesselShape shape, Vector3 centre, CollisionShape s) { + final e = s.boundsHalfExtents; + for (final dx in [-e.x, e.x]) { + for (final dy in [-e.y, e.y]) { + for (final dz in [-e.z, e.z]) { + if (shape.contains(centre + Vector3(dx, dy, dz))) return true; + } + } + } + return false; + } + + /// Pushes the body for [dt] with the buoyancy and drag of [liquid] under + /// [gravity], and returns the volume it displaces there. + double push(LiquidBody liquid, double dt, Vector3 gravity) { + final under = submergedIn(liquid); + if (under <= 0.0 || !body.isMovable) return under; + final medium = liquid.medium; + final g = gravity.length; + if (g <= 0.0) return under; + final lift = -gravity * (medium.density * under); + body + ..wake() + ..applyImpulse(lift * dt); + // Drag, implicitly: the velocity scaled by 1 / (1 + kΔt) for a drag + // acceleration −kv, which slows a body however thick the liquid and + // never turns it round. + final v = body.velocity; + final speed = v.length; + if (speed > 1e-9) { + final shape = body.collider.shape; + final area = _frontalArea(shape, v / speed); + final share = (under / _wholeVolume(shape)).clamp(0.0, 1.0); + final diameter = math.sqrt(4.0 * area / math.pi); + final re = medium.density * speed * diameter / medium.viscosity; + final k = + 0.5 * + medium.density * + sphereDrag(re) * + area * + share * + speed * + body.inverseMass; + // Gravity is added after this, by the body's own step; taken into the + // implicit solve here and back out, so the speed the body settles at + // is (g − b)/k and not that plus the one step of gravity that would + // otherwise land on top of it — g·Δt, half again Stokes's speed for a + // small ball in glycerol at a two-thousandth of a second. + v + ..addScaled(gravity, dt) + ..scale(1.0 / (1.0 + k * dt)) + ..addScaled(gravity, -dt); + } + return under; + } + + /// The area [s] shows to a flow along [dir]: a sphere's disc, a box's + /// projection, an upright capsule's — its cylinder's rectangle and its + /// caps' disc, foreshortened by how steeply it is met. + static double _frontalArea(CollisionShape s, Vector3 dir) => switch (s) { + CollisionSphere(:final radius) => math.pi * radius * radius, + CollisionBox(:final halfExtents) => + 4.0 * + (halfExtents.y * halfExtents.z * dir.x.abs() + + halfExtents.x * halfExtents.z * dir.y.abs() + + halfExtents.x * halfExtents.y * dir.z.abs()), + CollisionCapsule(:final radius, :final halfHeight) => + math.pi * radius * radius + + 4.0 * radius * halfHeight * math.sqrt(1.0 - dir.y * dir.y), + _ => 0.0, + }; + + static double _wholeVolume(CollisionShape s) => switch (s) { + CollisionSphere(:final radius) => + 4.0 / 3.0 * math.pi * radius * radius * radius, + CollisionBox(:final halfExtents) => + 8.0 * halfExtents.x * halfExtents.y * halfExtents.z, + CollisionCapsule(:final radius, :final halfHeight) => + math.pi * radius * radius * (2.0 * halfHeight + 4.0 / 3.0 * radius), + _ => 1.0, + }; +} + +/// Stokes's settling speed of a sphere of [radius] and [density] in [medium] +/// under [g]: 2(ρ_s − ρ)gR² / 9μ, for slow flow. What [FloatingBody]'s drag +/// comes to for a small heavy ball in a thick liquid. +double stokesSpeed( + FluidMedium medium, + double radius, + double density, + double g, +) => + 2.0 * + (density - medium.density) * + g * + radius * + radius / + (9.0 * medium.viscosity); diff --git a/packages/flutter3d_physics/lib/src/fluid/capillary.dart b/packages/flutter3d_physics/lib/src/fluid/capillary.dart new file mode 100644 index 000000000..aa95d7290 --- /dev/null +++ b/packages/flutter3d_physics/lib/src/fluid/capillary.dart @@ -0,0 +1,220 @@ +import 'dart:math' as math; + +import '../portable_math.dart'; +import 'fluid_medium.dart'; + +/// Surface tension at a wall: how high liquid climbs it, and the shape of +/// the surface it leaves. +/// +/// Across a curved surface the pressure jumps by σ times its curvature +/// (Young and Laplace), and under gravity the pressure in the liquid grows +/// as ρg per metre down; a surface at rest is one where the two balance at +/// every point, and where it meets the wall it meets it at the contact +/// angle. Everything here is that one balance, solved for a shape. + +/// Jurin's height: how far liquid rises in a narrow tube of [radius] dipped +/// in a wide bath, under gravity [g] — or sinks, for a liquid that does not +/// wet the wall. The narrow-tube limit of [TubeMeniscus.rise]. +double jurinHeight(FluidMedium medium, double radius, double g) => + 2.0 * + medium.surfaceTension * + Portable.cos(medium.contactAngle) / + (medium.density * g * radius); + +/// The meniscus in a round tube of [radius]: the axisymmetric +/// Young–Laplace surface, solved exactly rather than taken for a sphere. +/// +/// From the middle of the surface outwards, along its own length s, the +/// surface turns as dφ/ds = (ρg/σ)z + b − sin φ / r, where z is the height +/// above the middle and b the curvature there (twice the curvature of each +/// principal direction, which are equal on the axis). The b that makes it +/// meet the wall at the contact angle is found by halving. In a tube much +/// narrower than the capillary length that is a spherical cap; in a wide one +/// it is flat across the middle and climbs only near the wall — which is the +/// shape a liquid in a test tube actually has. +final class TubeMeniscus { + TubeMeniscus({ + required this.medium, + required this.radius, + required this.g, + this.samples = 64, + }) { + _solve(); + } + + final FluidMedium medium; + final double radius; + final double g; + final int samples; + + /// The curvature at the middle, per metre. + late final double apexCurvature; + + /// How far the surface stands at the wall above its middle: positive for a + /// liquid that wets the wall, negative for one that does not. + late final double wallRise; + + /// How far the middle of the surface stands above the level of a wide bath + /// the tube is dipped in: the pressure under it, σb, worth ρg per metre. + /// Jurin's height for a narrow tube, and less for a wide one, whose middle + /// is nearly flat. + double get rise => + medium.surfaceTension * apexCurvature / (medium.density * g); + + late final List _r; + late final List _z; + + /// The height of the surface above its middle at distance [r] from the + /// axis. + double heightAt(double r) { + final x = r.abs().clamp(0.0, radius); + for (var i = 1; i < _r.length; i++) { + if (_r[i] >= x) { + final span = _r[i] - _r[i - 1]; + final t = span > 0.0 ? (x - _r[i - 1]) / span : 0.0; + return _z[i - 1] + (_z[i] - _z[i - 1]) * t; + } + } + return _z.last; + } + + /// The mean height of the surface above its middle, weighed by area: what + /// the meniscus takes out of a flat surface's level, so the volume stays. + late final double meanHeight = _meanHeight(); + + /// The highest the meniscus stands above its mean: at the wall when the + /// liquid wets it, in the middle when it does not. + late final double peak = math.max( + heightAt(radius) - meanHeight, + heightAt(0.0) - meanHeight, + ); + + double _meanHeight() { + var sum = 0.0; + var area = 0.0; + for (var i = 1; i < _r.length; i++) { + final r0 = _r[i - 1], r1 = _r[i]; + final ring = math.pi * (r1 * r1 - r0 * r0); + sum += 0.5 * (_z[i - 1] + _z[i]) * ring; + area += ring; + } + return area > 0.0 ? sum / area : 0.0; + } + + void _solve() { + final target = math.pi / 2 - medium.contactAngle; + final bond = medium.density * g / medium.surfaceTension; + var lo = -40.0 / radius; + var hi = 40.0 / radius; + for (var i = 0; i < 60; i++) { + final mid = 0.5 * (lo + hi); + if (_shoot(mid, bond).angle < target) { + lo = mid; + } else { + hi = mid; + } + } + apexCurvature = 0.5 * (lo + hi); + final path = _shoot(apexCurvature, bond, keep: true); + _r = path.r; + _z = path.z; + wallRise = _z.last; + } + + /// The surface from the axis to the wall for an apex curvature [b]: the + /// angle it reaches the wall at, and its points. + ({double angle, List r, List z}) _shoot( + double b, + double bond, { + bool keep = false, + }) { + // Near the axis sin φ / r → b/2, so start a hair off it on the cap. + final s0 = radius * 1e-4; + var r = s0; + var z = 0.5 * b * 0.5 * s0 * s0; + var phi = 0.5 * b * s0; + final rs = [0.0]; + final zs = [0.0]; + double turn(double r, double z, double phi) => + bond * z + b - Portable.sin(phi) / math.max(r, 1e-12); + final ds = radius / (samples * 8); + var guard = 0; + while (r < radius && guard++ < samples * 400) { + // Runge–Kutta in arc length. + final k1r = Portable.cos(phi), k1z = Portable.sin(phi); + final k1p = turn(r, z, phi); + final k2r = Portable.cos(phi + 0.5 * ds * k1p); + final k2z = Portable.sin(phi + 0.5 * ds * k1p); + final k2p = turn( + r + 0.5 * ds * k1r, + z + 0.5 * ds * k1z, + phi + 0.5 * ds * k1p, + ); + final k3r = Portable.cos(phi + 0.5 * ds * k2p); + final k3z = Portable.sin(phi + 0.5 * ds * k2p); + final k3p = turn( + r + 0.5 * ds * k2r, + z + 0.5 * ds * k2z, + phi + 0.5 * ds * k2p, + ); + final k4r = Portable.cos(phi + ds * k3p); + final k4z = Portable.sin(phi + ds * k3p); + final k4p = turn(r + ds * k3r, z + ds * k3z, phi + ds * k3p); + final nr = r + ds / 6 * (k1r + 2 * k2r + 2 * k3r + k4r); + final nz = z + ds / 6 * (k1z + 2 * k2z + 2 * k3z + k4z); + final np = phi + ds / 6 * (k1p + 2 * k2p + 2 * k3p + k4p); + // A surface that turns back on itself before the wall has overshot. + if (nr <= r || np.abs() > math.pi) { + return (angle: np.sign * math.pi, r: rs, z: zs); + } + if (nr >= radius) { + final t = (radius - r) / (nr - r); + z += (nz - z) * t; + phi += (np - phi) * t; + r = radius; + } else { + r = nr; + z = nz; + phi = np; + } + if (keep) { + rs.add(r); + zs.add(z); + } + } + return (angle: phi, r: rs, z: zs); + } +} + +/// The height of the surface at distance [distance] from a flat wall in a +/// wide vessel, above the level far from it: the planar Young–Laplace +/// solution. The surface climbs the wall to l·√(2(1 − sin θ)), l the +/// capillary length, and falls away from it within a few l; for a liquid +/// that does not wet the wall the same shape is turned down. +double wallMeniscus(FluidMedium medium, double distance, double g) { + final l = medium.capillaryLength(g); + final theta = medium.contactAngle; + final h0 = l * math.sqrt(2.0 * (1.0 - Portable.sin(theta))); + if (h0 <= 0.0) return 0.0; + // x(z) = l[acosh(2l/z) − √(4 − z²/l²)], measured from where z = h0. + double x(double z) { + final a = 2.0 * l / z; + final acosh = Portable.log(a + math.sqrt(a * a - 1.0)); + return l * (acosh - math.sqrt(math.max(4.0 - z * z / (l * l), 0.0))); + } + + final x0 = x(h0); + final d = math.max(distance, 0.0); + var lo = 1e-12 * l; + var hi = h0; + for (var i = 0; i < 60; i++) { + final mid = 0.5 * (lo + hi); + if (x(mid) - x0 > d) { + lo = mid; + } else { + hi = mid; + } + } + final z = 0.5 * (lo + hi); + return theta > math.pi / 2 ? -z : z; +} diff --git a/packages/flutter3d_physics/lib/src/fluid/fluid_medium.dart b/packages/flutter3d_physics/lib/src/fluid/fluid_medium.dart new file mode 100644 index 000000000..aa253bb7b --- /dev/null +++ b/packages/flutter3d_physics/lib/src/fluid/fluid_medium.dart @@ -0,0 +1,104 @@ +import 'dart:math' as math; + +/// A liquid, by the properties that decide how it moves: how heavy it is, +/// how thick, how strongly its surface pulls, and how it meets a wall. +/// +/// All in SI. The presets are at 20 °C and against clean glass, from the +/// usual handbook values; a liquid on another wall material differs only in +/// [contactAngle], which is a property of the pair, not of the liquid, and +/// so is a field a caller can change with [copyWith]. +final class FluidMedium { + const FluidMedium({ + required this.name, + required this.density, + required this.viscosity, + required this.surfaceTension, + this.contactAngle = 0.0, + }); + + /// Water: 998 kg/m³, 1.0 mPa·s, 72.8 mN/m, wetting glass at about 20°. + static const FluidMedium water = FluidMedium( + name: 'water', + density: 998.2, + viscosity: 1.002e-3, + surfaceTension: 0.0728, + contactAngle: 0.35, + ); + + /// Glycerol: heavy and fourteen hundred times as viscous as water. + static const FluidMedium glycerol = FluidMedium( + name: 'glycerol', + density: 1261.0, + viscosity: 1.412, + surfaceTension: 0.0634, + contactAngle: 0.45, + ); + + /// Olive oil: lighter than water, which it floats on. + static const FluidMedium oil = FluidMedium( + name: 'oil', + density: 911.0, + viscosity: 0.084, + surfaceTension: 0.032, + contactAngle: 0.2, + ); + + /// Ethanol: wets glass completely and pulls weakly. + static const FluidMedium ethanol = FluidMedium( + name: 'ethanol', + density: 789.0, + viscosity: 1.2e-3, + surfaceTension: 0.0223, + ); + + /// Mercury: does not wet glass, so its meniscus bulges and it sinks in a + /// capillary rather than climbing it. + static const FluidMedium mercury = FluidMedium( + name: 'mercury', + density: 13534.0, + viscosity: 1.526e-3, + surfaceTension: 0.485, + contactAngle: 2.44, + ); + + final String name; + + /// Kilograms per cubic metre. + final double density; + + /// Dynamic viscosity, pascal seconds. + final double viscosity; + + /// Newtons per metre. + final double surfaceTension; + + /// The angle the surface meets the wall at, through the liquid, radians: + /// nought wets completely, past a quarter turn does not wet. + final double contactAngle; + + /// Kinematic viscosity, square metres per second: what the damping of + /// waves and the thickness of a boundary layer go by, which + /// [FreeSurface] reads. + double get kinematicViscosity => viscosity / density; + + /// The capillary length √(σ / ρg) under gravity [g]: the size below which + /// surface tension outweighs gravity — about 2.7 mm for water on Earth. + double capillaryLength(double g) => math.sqrt(surfaceTension / (density * g)); + + FluidMedium copyWith({ + String? name, + double? density, + double? viscosity, + double? surfaceTension, + double? contactAngle, + }) => FluidMedium( + name: name ?? this.name, + density: density ?? this.density, + viscosity: viscosity ?? this.viscosity, + surfaceTension: surfaceTension ?? this.surfaceTension, + contactAngle: contactAngle ?? this.contactAngle, + ); + + @override + String toString() => 'FluidMedium($name)'; +} diff --git a/packages/flutter3d_physics/lib/src/fluid/fluid_world.dart b/packages/flutter3d_physics/lib/src/fluid/fluid_world.dart new file mode 100644 index 000000000..d97a561e2 --- /dev/null +++ b/packages/flutter3d_physics/lib/src/fluid/fluid_world.dart @@ -0,0 +1,168 @@ +import 'package:vector_math/vector_math.dart'; + +import '../rigid_body.dart'; +import 'buoyancy.dart'; +import 'jet.dart'; +import 'liquid_body.dart'; +import 'particle_fluid.dart'; +import 'pipe.dart'; + +/// Liquids in a world: the vessels, the pipes between them, and the streams +/// running from one to another, stepped together at a fixed step. +/// +/// **The world's gravity, not its own.** [gravity] is a vector the world +/// shares — pass the one a `Dynamics` uses, and a crate and a glass of water +/// fall the same way; set it to the Moon's and every slosh, spill and stream +/// on it is the Moon's. +/// +/// **A fixed step**, like the rest of this package: [advance] takes however +/// long a frame was and runs as many whole steps as fit, carrying the rest +/// over, so a run is the same run on any frame rate. + +final class FluidWorld { + FluidWorld({ + required this.gravity, + this.step = 1.0 / 240.0, + this.particleSpacing = 0.002, + this.floor, + }); + + /// Metres per second squared, shared with whoever else falls. + final Vector3 gravity; + + /// Seconds per step. + final double step; + + final List bodies = []; + + /// Rigid bodies the liquids push on: buoyed up, slowed, and taking up + /// room in whatever vessel they are in. + final List floating = []; + + /// Adds [body] to [floating]. + void float(RigidBody body) => floating.add(FloatingBody(body)); + final List pipes = []; + + /// The stream from each vessel's lip, while there is one. + final Map jets = {}; + + /// How far apart the particles drops and splashes become sit at rest. + final double particleSpacing; + + /// What spilt liquid lands on, if anything: a bench, a floor. + final List? floor; + + /// Liquid out of every vessel and stream, as particles, one fluid per + /// medium: what streams broke into, what splashed, what lies spilt. + final Map particles = {}; + + /// Every cubic metre the world holds: in vessels, in the air, as + /// particles. + double get volume => + bodies.fold(0.0, (s, b) => s + b.volume) + + jets.values.fold(0.0, (s, j) => s + j.inFlight) + + particles.values.fold(0.0, (s, p) => s + p.volume); + + double _carried = 0.0; + int _ran = 0; + + /// Seconds handed to [advance] so far, the part not yet stepped included: + /// the time of whatever the caller places for the frame it is on, as + /// `LiquidBody.place`'s `time` wants it. Bodies added at the start share + /// this clock. + double get time => _ran * step + _carried; + + /// Runs as many whole steps as [elapsed] seconds hold, plus what was left + /// over from the last call; returns how many it ran. + int advance(double elapsed) { + _carried += elapsed; + var ran = 0; + while (_carried >= step) { + _carried -= step; + _stepOnce(step); + ran++; + _ran++; + } + return ran; + } + + void _stepOnce(double dt) { + for (final liquid in bodies) { + var displaced = 0.0; + for (final f in floating) { + displaced += f.push(liquid, dt, gravity); + } + liquid.displaced = displaced; + } + for (final pipe in pipes) { + pipe.step(dt, gravity: gravity); + } + for (final body in bodies) { + final spill = body.step(dt, gravity: gravity); + final jet = jets[body]; + if (spill.flow > 0.0 || jet != null) { + final stream = jets.putIfAbsent(body, () => Jet(medium: body.medium)); + final across = gravity.cross(spill.velocity); + stream.emit( + flow: spill.flow, + dt: dt, + point: spill.point, + velocity: spill.velocity, + width: spill.width, + across: across.length2 > 0.0 ? across : Vector3(1, 0, 0), + medium: spill.medium, + concentrations: spill.concentrations, + ); + } + } + final finished = []; + jets.forEach((source, jet) { + // Every glass's inside but the one it is leaving, which it is + // already over the edge of; every outside, its own included, which + // a slow pour runs down. + final walls = [ + for (final body in bodies) + if (!identical(body, source)) InsideWalls(body), + for (final body in bodies) + if (body.wallThickness > 0.0) + OutsideWalls(body, thickness: body.wallThickness), + ]; + for (final drop in jet.step( + dt, + gravity: gravity, + obstacles: walls, + receivers: bodies, + )) { + particles + .putIfAbsent( + drop.medium.name, + () => + ParticleFluid(medium: drop.medium, spacing: particleSpacing), + ) + .inject( + drop.volume, + drop.position, + drop.velocity, + concentrations: drop.concentrations, + ); + } + if (!jet.flowing) finished.add(source); + }); + finished.forEach(jets.remove); + final everywhere = [ + for (final body in bodies) InsideWalls(body), + for (final body in bodies) + if (body.wallThickness > 0.0) + OutsideWalls(body, thickness: body.wallThickness), + ...?floor, + ]; + for (final fluid in particles.values) { + fluid.step( + dt, + gravity: gravity, + obstacles: everywhere, + receivers: bodies, + ); + } + } +} diff --git a/packages/flutter3d_physics/lib/src/fluid/free_surface.dart b/packages/flutter3d_physics/lib/src/fluid/free_surface.dart new file mode 100644 index 000000000..705c0f299 --- /dev/null +++ b/packages/flutter3d_physics/lib/src/fluid/free_surface.dart @@ -0,0 +1,832 @@ +import 'dart:math' as math; +import 'dart:typed_data'; + +import 'package:vector_math/vector_math.dart'; + +import '../portable_math.dart'; +import 'fluid_medium.dart'; +import 'vessel_shape.dart'; + +/// The waves on a liquid's free surface in a vessel of any shape: how far +/// the surface stands off the plane it settles to, and how that moves. +/// +/// **The modes of the vessel, worked out rather than assumed.** Small waves +/// on a liquid of depth h are sums of shapes φ that satisfy ∇²φ = −k²φ over +/// the surface and meet the wall square (∂φ/∂n = 0), each ringing at its own +/// frequency, ω² = (gk + σk³/ρ)·tanh(kh): gravity and surface tension pulling +/// it back, the depth holding it. In a round vessel those shapes are Bessel +/// functions and the k are known zeros; in any other they are whatever the +/// outline makes them. So the surface is laid out as a grid over the plane's +/// cut through the vessel, its Laplacian with the wall's condition is built +/// on the cells inside, and its lowest [modeCount] shapes and k² are found by +/// Lanczos. A round vessel gets its Bessel modes back to a few per cent, +/// which is the test that this is the same physics. +/// +/// **Volume stays by construction.** Every shape but the flat one averages +/// to nought over the surface, and the flat one is never kept, so no wave +/// adds or takes away liquid. +/// +/// **Viscosity damps them**, twice over. Most of it is lost in the thin +/// layer where the liquid slides past the wall, and for the first mode of a +/// round vessel the measured rate is Stephens and Dodge's, +/// ζ = 0.83·√(ν / √(gR³))·[1 + 0.318 / sinh(1.84h/R)·(1 + (1 − h/R) / +/// cosh(1.84h/R))]; a boundary layer is √(2ν/ω) thick, so a mode n's decay +/// rate goes as the root of its frequency. The rest is lost in the bulk, +/// 2νk², which is what quickly kills short ripples. A vessel that is not +/// round is given the radius of the round one with its surface's area. +final class FreeSurface { + FreeSurface({required this.medium, this.cells = 40, this.modeCount = 12}); + + /// The liquid at the surface: the top one, when there are layers. + FluidMedium medium; + + /// How many cells the grid has across its wider side. + final int cells; + + /// How many of the lowest shapes are kept. + final int modeCount; + + /// How far the plane may turn, radians, before the modes are found again. + static const double reuseTurn = 0.05; + + _Grid? _grid; + List _modes = const []; + Float64List _k2 = Float64List(0); + Float64List _amplitude = Float64List(0); + Float64List _rate = Float64List(0); + Float64List? _field; + + /// The plane the surface settles to, in the vessel's frame: its normal and + /// height along it, as last laid out. + Vector3 get up => _grid?.up ?? Vector3(0, 1, 0); + double get height => _grid?.height ?? 0.0; + + /// The surface's area, square metres. + double get area => _grid == null ? 0.0 : _grid!.count * _grid!.cell2; + + /// How much liquid the waves hold above the plane, cubic metres: nought, + /// since no mode but the flat one has any, and that one is never kept. + /// For a test to hold the surface to. + double get displacedVolume { + final grid = _grid; + if (grid == null || _modes.isEmpty) return 0.0; + return _heights().fold(0.0, (sum, h) => sum + h) * grid.cell2; + } + + /// The wavenumbers of the modes kept, lowest first. + List get wavenumbers => [for (final k2 in _k2) math.sqrt(k2)]; + + /// Lays the surface out over the plane `up · p = height` through [shape], + /// keeping the liquid where it was: the waves there are carried over, and + /// a plane that has turned or moved leaves them that much further from it. + /// + /// A plane that has turned by less than [reuseTurn] and moved by less than + /// half a cell keeps the modes it has: the cut has hardly changed shape, + /// and the modes are what cost time to find. Only the liquid left behind + /// by the move is added to the waves. + void layOut(VesselShape shape, Vector3 up, double height) { + // What landed on the old grid is laid on its modes before they go. + if (_modes.isNotEmpty) _applyLandings(); + final old = _grid; + if (old != null && + _modes.isNotEmpty && + 1.0 - old.up.dot(up.normalized()) < 0.5 * reuseTurn * reuseTurn && + (height - old.height).abs() < 0.5 * old.cell) { + final moved = old.movedTo(up, height); + final behind = Float64List(moved.count); + for (var i = 0; i < moved.count; i++) { + final p = moved.point(i); + behind[i] = -(old.up.dot(p) - old.height); + } + _grid = moved; + final added = _project(behind); + for (var n = 0; n < added.length; n++) { + _amplitude[n] += added[n]; + } + _field = null; + return; + } + final oldHeights = old == null ? null : _heights(); + final oldRates = old == null ? null : _sumModes(_rate); + final grid = _Grid.cut(shape, up, height, cells); + _grid = grid; + _field = null; + if (grid.count < 6) { + _modes = const []; + _k2 = Float64List(0); + _amplitude = Float64List(0); + _rate = Float64List(0); + return; + } + final (shapes, k2) = _lowestModes(grid, modeCount); + _modes = shapes; + _k2 = k2; + // The liquid stays: what stood off the old plane stands off the new one + // by that, less how far the new plane is above the old one there. + final heights = Float64List(grid.count); + final rates = Float64List(grid.count); + for (var i = 0; i < grid.count; i++) { + final p = grid.point(i); + if (old != null) { + heights[i] = old.sample(oldHeights!, p) - (old.up.dot(p) - old.height); + rates[i] = old.sample(oldRates!, p); + } + } + _amplitude = _project(heights); + _rate = _project(rates); + } + + /// Moves the waves on by [dt] seconds under gravity [g], over a liquid + /// [depth] deep on average: each mode exactly, as the damped oscillator it + /// is, so any step is stable. + void step(double dt, {required double g, required double depth}) { + _depth = depth; + if (_modes.isEmpty) return; + _applyLandings(); + // Still water stays still: nothing to ring. + var still = true; + for (var n = 0; n < _amplitude.length && still; n++) { + still = _amplitude[n] == 0.0 && _rate[n] == 0.0; + } + if (still) return; + final nu = medium.kinematicViscosity; + final tension = medium.surfaceTension / medium.density; + final h = math.max(depth, 1e-6); + double omega(double k) => + math.sqrt(math.max((g * k + tension * k * k * k) * _tanh(k * h), 0.0)); + final k1 = math.sqrt(_k2[0]); + final w1 = omega(k1); + final radius = math.sqrt(area / math.pi); + final boundary = _stephens(nu, g, radius, h) * w1; + _omega2 = Float64List(_modes.length); + _decay = Float64List(_modes.length); + for (var n = 0; n < _modes.length; n++) { + final k = math.sqrt(_k2[n]); + final w = omega(k); + final decay = + boundary * math.sqrt(w / math.max(w1, 1e-9)) + 2.0 * nu * k * k; + _omega2[n] = w * w; + _decay[n] = decay; + final wd = math.sqrt(math.max(w * w - decay * decay, 1e-12)); + final c = Portable.cos(wd * dt); + final s = Portable.sin(wd * dt); + final e = Portable.exp(-decay * dt); + final a = _amplitude[n]; + final v = _rate[n]; + final b = (v + decay * a) / wd; + _amplitude[n] = e * (a * c + b * s); + _rate[n] = e * (v * c - (decay * b + a * wd) * s); + } + _breakSteep(); + _field = null; + } + + /// Breaks every mode steeper than Stokes' limit: a wave whose height is + /// more than about a seventh of its length cannot stand, and spills its + /// crest. So a mode stands no higher than a fourteenth of its wavelength + /// anywhere, and what was more is lost to the break, its motion with it. + /// + /// **A linear model's own bound, imposed.** The modes are small waves on + /// a plane, and nothing in them stops one growing past what water does: a + /// flask turned sixty-seven degrees in one frame was laid out with the old + /// level as a thirty-four millimetre wave, which no liquid makes, and it + /// threw four fifths of what the flask held over the lip in ten frames. + void _breakSteep() { + for (var n = 0; n < _amplitude.length; n++) { + final k = math.sqrt(_k2[n]); + final peak = _amplitude[n].abs() * _norm(n); + final most = 2.0 * math.pi / (14.0 * math.max(k, 1e-9)); + if (peak <= most) continue; + final keep = most / peak; + _amplitude[n] *= keep; + _rate[n] *= keep; + } + } + + Float64List _omega2 = Float64List(0); + Float64List _decay = Float64List(0); + + /// The force the waves put on the vessel, newtons, in its frame, for a + /// liquid of [density]: the liquid's centre of mass moves as the surface + /// rocks, and what moves it pushes back on the glass. Each mode shifts the + /// centre by its first moment over the surface times its amplitude, so the + /// force is −ρ Σ Mₙ äₙ, with äₙ = −ωₙ²aₙ − 2σₙȧₙ as the mode rings. + Vector3 force(double density) { + final grid = _grid; + final out = Vector3.zero(); + if (grid == null || _omega2.length != _modes.length) return out; + for (var n = 0; n < _modes.length; n++) { + final moment = Vector3.zero(); + final shape = _modes[n]; + for (var i = 0; i < grid.count; i++) { + moment.addScaled(grid.e1, shape[i] * grid.cellsU[i]); + moment.addScaled(grid.e2, shape[i] * grid.cellsV[i]); + } + moment.scale(grid.cell2); + final accel = -_omega2[n] * _amplitude[n] - 2.0 * _decay[n] * _rate[n]; + out.addScaled(moment, -density * accel); + } + return out; + } + + /// How wide a [knock] spreads, metres: the patch's standard deviation. + double get knockSpread => 2.0 * (_grid?.cell ?? 0.0); + + /// The liquid's depth on the last step, for [strike]. + double _depth = 0.0; + + /// Liquid landing at [point] (vessel frame): [heap], its volume over the + /// patch [knock] spreads it on, in metres; and [flux], that volume times + /// the speed it comes down at over the same patch, in m²/s. + /// + /// **The strike.** An impulse that sudden is an impulsive pressure, which + /// leaves the liquid under it with a velocity potential φ = −P/ρ and + /// nothing else yet; on a mode of wavenumber k the surface then moves at + /// k·tanh(kh) times that mode's share of φ (linear waves, η_t = φ_z). A + /// stream landing in one place keeps a dip under it; drops arriving one + /// by one set it ringing. + /// + /// **Gathered and laid on the modes once a step.** A pour lands a thousand + /// drops a second, and projecting each onto the modes as it came was most + /// of what the pour cost; the waves cannot tell, since they move only when + /// stepped. + void land(Vector3 point, {double heap = 0.0, double flux = 0.0}) { + disturbances++; + final grid = _grid; + if (grid == null || _modes.isEmpty) return; + if (heap != 0.0) { + _gather(_pendingHeap ??= Float64List(grid.count), point, heap); + } + if (flux != 0.0) { + _gather(_pendingPhi ??= Float64List(grid.count), point, -flux); + } + } + + Float64List? _pendingHeap; + Float64List? _pendingPhi; + + /// Adds a bump [strength] high at [point], [knockSpread] wide, into + /// [field]: only where it is more than a ten-thousandth of its height. + void _gather(Float64List field, Vector3 point, double strength) { + final grid = _grid!; + final spread = knockSpread; + final reach2 = 18.4 * spread * spread; // e^(−r²/2s²) > 1e-4 + for (var i = 0; i < grid.count; i++) { + final d = grid.point(i) - point; + final along = grid.up.dot(d); + final r2 = d.length2 - along * along; + if (r2 > reach2) continue; + field[i] += strength * Portable.exp(-r2 / (2.0 * spread * spread)); + } + } + + /// Lays what [land] gathered onto the modes. + void _applyLandings() { + final heap = _pendingHeap; + final phi = _pendingPhi; + _pendingHeap = null; + _pendingPhi = null; + if (heap != null) { + final added = _project(heap); + for (var n = 0; n < added.length; n++) { + _amplitude[n] += added[n]; + } + } + if (phi != null) { + final share = _project(phi); + final h = math.max(_depth, 1e-6); + for (var n = 0; n < share.length; n++) { + final k = math.sqrt(_k2[n]); + _rate[n] += k * _tanh(k * h) * share[n]; + } + } + if (heap != null || phi != null) _field = null; + } + + /// Knocks the surface at [point] (vessel frame) [strength] metres high, + /// over a patch two cells across: what is not a wave — the patch's mean — + /// is not kept, so no liquid is added. + void knock(Vector3 point, double strength) { + disturbances++; + final grid = _grid; + if (grid == null || _modes.isEmpty) return; + final spread = knockSpread; + final bump = Float64List(grid.count); + for (var i = 0; i < grid.count; i++) { + final d = grid.point(i) - point; + final along = grid.up.dot(d); + final r2 = d.length2 - along * along; + if (r2 > 18.4 * spread * spread) continue; + bump[i] = strength * Portable.exp(-r2 / (2.0 * spread * spread)); + } + final added = _project(bump); + for (var n = 0; n < added.length; n++) { + _amplitude[n] += added[n]; + } + _field = null; + } + + /// How far the surface stands off its plane, along [up], over [point] + /// (vessel frame); nought outside it. + double displacement(Vector3 point) { + final grid = _grid; + if (grid == null || _modes.isEmpty) return 0.0; + return grid.sample(_heights(), point); + } + + /// Whether nothing moves that the eye would see: every mode under a tenth + /// of a millimetre, and slower than that a radian. + bool get settled { + for (var n = 0; n < _amplitude.length; n++) { + if (_amplitude[n].abs() * _norm(n) > 1e-4) return false; + if (_rate[n].abs() * _norm(n) > 1e-3) return false; + } + return true; + } + + /// How many times the surface has been knocked or landed on: a body that + /// sleeps while nothing happens to it wakes when this moves. + int disturbances = 0; + + /// Whether the waves are too small to see or to matter: no mode stands a + /// tenth of a micrometre off the plane or moves a micrometre a second, + /// and nothing waits to land. + bool get quiet { + if (_pendingHeap != null || _pendingPhi != null) return false; + for (var n = 0; n < _amplitude.length; n++) { + if (_amplitude[n].abs() * _norm(n) > 1e-7) return false; + if (_rate[n].abs() * _norm(n) > 1e-6) return false; + } + return true; + } + + /// Takes every wave away. + void calm() { + _amplitude = Float64List(_amplitude.length); + _rate = Float64List(_rate.length); + _field = null; + } + + /// The surface's displacement at each cell. + Float64List _heights() => _field ??= _sumModes(_amplitude); + + Float64List _sumModes(Float64List weights) { + final grid = _grid!; + final out = Float64List(grid.count); + for (var n = 0; n < _modes.length && n < weights.length; n++) { + final w = weights[n]; + if (w == 0.0) continue; + final shape = _modes[n]; + for (var i = 0; i < out.length; i++) { + out[i] += w * shape[i]; + } + } + return out; + } + + /// The largest a mode stands anywhere, per unit amplitude. + /// The largest a mode gets anywhere on the surface, once a layout. + double _norm(int n) { + if (!identical(_normsOf, _modes)) { + _normsOf = _modes; + _norms = [ + for (final mode in _modes) + mode.fold(0.0, (most, x) => math.max(most, x.abs())), + ]; + } + return _norms[n]; + } + + List? _normsOf; + List _norms = const []; + + /// The most the waves can stand off the plane anywhere just now: no more + /// than every mode at its peak at once. + double get reach { + var sum = 0.0; + for (var n = 0; n < _amplitude.length; n++) { + sum += _amplitude[n].abs() * _norm(n); + } + return sum; + } + + /// [values] over the cells as amplitudes of the kept modes: the modes are + /// orthonormal under the area, so each is the area-weighted dot product. + Float64List _project(Float64List values) { + final cell2 = _grid!.cell2; + final out = Float64List(_modes.length); + for (var n = 0; n < _modes.length; n++) { + var sum = 0.0; + final shape = _modes[n]; + for (var i = 0; i < values.length; i++) { + sum += values[i] * shape[i]; + } + out[n] = sum * cell2; + } + return out; + } + + static double _tanh(double x) { + if (x > 20.0) return 1.0; + final e = Portable.exp(-2.0 * x); + return (1.0 - e) / (1.0 + e); + } + + /// Stephens and Dodge's damping ratio for the first mode of a round + /// vessel of [radius], liquid [depth] deep. + static double _stephens(double nu, double g, double radius, double depth) { + final r = math.max(radius, 1e-6); + final ratio = 1.84 * depth / r; + final sinh = 0.5 * (Portable.exp(ratio) - Portable.exp(-ratio)); + final cosh = 0.5 * (Portable.exp(ratio) + Portable.exp(-ratio)); + final shallow = ratio > 30.0 + ? 0.0 + : 0.318 / math.max(sinh, 1e-9) * (1.0 + (1.0 - depth / r) / cosh); + return 0.83 * math.sqrt(nu / math.sqrt(g * r * r * r)) * (1.0 + shallow); + } + + /// The lowest [count] non-flat modes of [grid]'s Laplacian with the wall's + /// condition, orthonormal under the area, and their k². + static (List, Float64List) _lowestModes(_Grid grid, int count) { + final n = grid.count; + final inv = 1.0 / grid.cell2; + // The graph Laplacian: each cell against the neighbours it has inside. + // A neighbour outside is the wall, and leaving it out is ∂φ/∂n = 0. + Float64List apply(Float64List x) { + final y = Float64List(n); + for (var i = 0; i < n; i++) { + var sum = 0.0; + for (final j in grid.neighbours[i]) { + sum += x[i] - x[j]; + } + y[i] = sum * inv; + } + return y; + } + + // The lowest of L are the highest of L⁻¹, and well apart there, where + // near nought they crowd together and Lanczos on L itself would take + // hundreds of steps to tell them apart. L⁻¹ is applied by conjugate + // gradients, on the flat-free part where L can be inverted. + Float64List solve(Float64List b) { + final x = Float64List(n); + final r = Float64List.fromList(b); + _removeMean(r); + final p = Float64List.fromList(r); + var rr = _dot(r, r); + final stop = 1e-24 * math.max(rr, 1e-300); + for (var it = 0; it < 4 * n && rr > stop; it++) { + final ap = apply(p); + final step = rr / _dot(p, ap); + for (var i = 0; i < n; i++) { + x[i] += step * p[i]; + r[i] -= step * ap[i]; + } + final next = _dot(r, r); + final ratio = next / rr; + rr = next; + for (var i = 0; i < n; i++) { + p[i] = r[i] + ratio * p[i]; + } + } + _removeMean(x); + return x; + } + + final steps = math.min(n - 1, 3 * count + 12); + final basis = []; + final alpha = []; + final beta = []; + // A start that is not flat, with the flat part taken out: the Krylov + // space then never holds the flat mode. + var q = Float64List(n); + for (var i = 0; i < n; i++) { + q[i] = Portable.sin(1.0 + 0.7 * i) + 0.3 * Portable.cos(0.31 * i * i); + } + _removeMean(q); + _normalise(q); + var previous = Float64List(n); + var b = 0.0; + for (var m = 0; m < steps; m++) { + basis.add(q); + final w = solve(q); + for (var i = 0; i < n; i++) { + w[i] -= b * previous[i]; + } + final a = _dot(w, q); + for (var i = 0; i < n; i++) { + w[i] -= a * q[i]; + } + // Full reorthogonalisation, twice: Lanczos alone loses it and finds + // the same mode again. + for (var pass = 0; pass < 2; pass++) { + for (final v in basis) { + final c = _dot(w, v); + for (var i = 0; i < n; i++) { + w[i] -= c * v[i]; + } + } + _removeMean(w); + } + alpha.add(a); + b = math.sqrt(_dot(w, w)); + if (b < 1e-12 * a.abs() || m == steps - 1) break; + beta.add(b); + previous = q; + q = Float64List(n); + for (var i = 0; i < n; i++) { + q[i] = w[i] / b; + } + } + final size = alpha.length; + final (values, vectors) = _tridiagonalEigen(alpha, beta, size); + // Highest of L⁻¹ first, which is lowest of L. + final order = List.generate(size, (i) => i) + ..sort((x, y) => values[y].compareTo(values[x])); + final modes = []; + final k2 = []; + final scale = 1.0 / math.sqrt(grid.cell2); + for (final r in order) { + if (values[r] <= 0.0) continue; + final lambda = 1.0 / values[r]; + final shape = Float64List(n); + for (var j = 0; j < size; j++) { + final weight = vectors[j * size + r]; + final v = basis[j]; + for (var i = 0; i < n; i++) { + shape[i] += weight * v[i]; + } + } + _normalise(shape); + for (var i = 0; i < n; i++) { + shape[i] *= scale; + } + modes.add(shape); + k2.add(lambda); + if (modes.length == count) break; + } + return (modes, Float64List.fromList(k2)); + } + + static double _dot(Float64List a, Float64List b) { + var s = 0.0; + for (var i = 0; i < a.length; i++) { + s += a[i] * b[i]; + } + return s; + } + + static void _normalise(Float64List a) { + final l = math.sqrt(_dot(a, a)); + if (l == 0.0) return; + for (var i = 0; i < a.length; i++) { + a[i] /= l; + } + } + + static void _removeMean(Float64List a) { + var mean = 0.0; + for (final x in a) { + mean += x; + } + mean /= a.length; + for (var i = 0; i < a.length; i++) { + a[i] -= mean; + } + } + + /// The eigenvalues and eigenvectors (column r of the row-major matrix) of + /// the symmetric tridiagonal matrix with [diagonal] and [off] — the + /// implicit QL method with Wilkinson shifts. + static (Float64List, Float64List) _tridiagonalEigen( + List diagonal, + List off, + int n, + ) { + final d = Float64List.fromList(diagonal); + final e = Float64List(n); + for (var i = 0; i < n - 1 && i < off.length; i++) { + e[i] = off[i]; + } + final z = Float64List(n * n); + for (var i = 0; i < n; i++) { + z[i * n + i] = 1.0; + } + for (var l = 0; l < n; l++) { + var iterations = 0; + while (true) { + var m = l; + for (; m < n - 1; m++) { + final dd = d[m].abs() + d[m + 1].abs(); + if (e[m].abs() <= 1e-15 * dd) break; + } + if (m == l) break; + if (++iterations > 60) break; + var g = (d[l + 1] - d[l]) / (2.0 * e[l]); + var r = _hypot(g, 1.0); + g = d[m] - d[l] + e[l] / (g + (g >= 0 ? r.abs() : -r.abs())); + var s = 1.0; + var c = 1.0; + var p = 0.0; + var i = m - 1; + for (; i >= l; i--) { + var f = s * e[i]; + final b = c * e[i]; + r = _hypot(f, g); + e[i + 1] = r; + if (r == 0.0) { + d[i + 1] -= p; + e[m] = 0.0; + break; + } + s = f / r; + c = g / r; + g = d[i + 1] - p; + r = (d[i] - g) * s + 2.0 * c * b; + p = s * r; + d[i + 1] = g + p; + g = c * r - b; + for (var k = 0; k < n; k++) { + f = z[k * n + i + 1]; + z[k * n + i + 1] = s * z[k * n + i] + c * f; + z[k * n + i] = c * z[k * n + i] - s * f; + } + } + if (r == 0.0 && i >= l) continue; + d[l] -= p; + e[l] = g; + e[m] = 0.0; + } + } + return (d, z); + } + + static double _hypot(double a, double b) => math.sqrt(a * a + b * b); +} + +/// A square grid laid over a plane's cut through a vessel: the cells whose +/// middles are inside, and which of their four neighbours are too. +final class _Grid { + _Grid._({ + required this.up, + required this.height, + required this.origin, + required this.e1, + required this.e2, + required this.cell, + required this.u0, + required this.v0, + required this.columns, + required this.rows, + required this.index, + required this.cellsU, + required this.cellsV, + required this.neighbours, + }); + + factory _Grid.cut(VesselShape shape, Vector3 up, double height, int cells) { + final n = up.normalized(); + final helper = n.x.abs() < 0.9 ? Vector3(1, 0, 0) : Vector3(0, 0, 1); + final e1 = (helper - n * n.dot(helper))..normalize(); + final e2 = n.cross(e1); + final origin = n * height; + final s1 = shape.span(e1); + final s2 = shape.span(e2); + final cell = math.max(s1.high - s1.low, s2.high - s2.low) / cells; + final columns = math.max(1, ((s1.high - s1.low) / cell).ceil()); + final rows = math.max(1, ((s2.high - s2.low) / cell).ceil()); + final index = Int32List(columns * rows)..fillRange(0, columns * rows, -1); + final us = []; + final vs = []; + for (var r = 0; r < rows; r++) { + for (var c = 0; c < columns; c++) { + final u = s1.low + (c + 0.5) * cell; + final v = s2.low + (r + 0.5) * cell; + if (shape.contains(origin + e1 * u + e2 * v)) { + index[r * columns + c] = us.length; + us.add(u); + vs.add(v); + } + } + } + final neighbours = >[]; + for (var r = 0; r < rows; r++) { + for (var c = 0; c < columns; c++) { + if (index[r * columns + c] < 0) continue; + neighbours.add([ + for (final (dc, dr) in const [(1, 0), (-1, 0), (0, 1), (0, -1)]) + if (c + dc >= 0 && + c + dc < columns && + r + dr >= 0 && + r + dr < rows && + index[(r + dr) * columns + c + dc] >= 0) + index[(r + dr) * columns + c + dc], + ]); + } + } + return _Grid._( + up: n, + height: height, + origin: origin, + e1: e1, + e2: e2, + cell: cell, + u0: s1.low, + v0: s2.low, + columns: columns, + rows: rows, + index: index, + cellsU: Float64List.fromList(us), + cellsV: Float64List.fromList(vs), + neighbours: neighbours, + ); + } + + final Vector3 up; + final double height; + final Vector3 origin; + final Vector3 e1; + final Vector3 e2; + final double cell; + final double u0; + final double v0; + final int columns; + final int rows; + final Int32List index; + final Float64List cellsU; + final Float64List cellsV; + final List> neighbours; + + int get count => cellsU.length; + + /// The same cells on the plane `up · p = height`: a plane that has hardly + /// moved cuts the vessel in hardly another outline. + _Grid movedTo(Vector3 up, double height) { + final n = up.normalized(); + final helper = n.x.abs() < 0.9 ? Vector3(1, 0, 0) : Vector3(0, 0, 1); + final f1 = (helper - n * n.dot(helper))..normalize(); + return _Grid._( + up: n, + height: height, + origin: n * height, + e1: f1, + e2: n.cross(f1), + cell: cell, + u0: u0, + v0: v0, + columns: columns, + rows: rows, + index: index, + cellsU: cellsU, + cellsV: cellsV, + neighbours: neighbours, + ); + } + + double get cell2 => cell * cell; + + /// The middle of cell [i], on the plane. + Vector3 point(int i) => origin + e1 * cellsU[i] + e2 * cellsV[i]; + + /// [values] at [point] seen square to the plane: bilinear between the + /// cells round it that are inside, the nearest such when none are. + double sample(Float64List values, Vector3 point) { + final d = point - origin; + final u = (e1.dot(d) - u0) / cell - 0.5; + final v = (e2.dot(d) - v0) / cell - 0.5; + final c0 = u.floor(); + final r0 = v.floor(); + final fu = u - c0; + final fv = v - r0; + var sum = 0.0; + var weight = 0.0; + for (final (dc, dr, w) in [ + (0, 0, (1 - fu) * (1 - fv)), + (1, 0, fu * (1 - fv)), + (0, 1, (1 - fu) * fv), + (1, 1, fu * fv), + ]) { + final c = c0 + dc; + final r = r0 + dr; + if (c < 0 || c >= columns || r < 0 || r >= rows) continue; + final i = index[r * columns + c]; + if (i < 0) continue; + sum += values[i] * w; + weight += w; + } + if (weight > 1e-9) return sum / weight; + // Outside every cell: the nearest one inside, as the wall's own value. + var best = -1; + var bestD = double.infinity; + for (var i = 0; i < count; i++) { + final du = cellsU[i] - (u + 0.5) * cell - u0; + final dv = cellsV[i] - (v + 0.5) * cell - v0; + final dd = du * du + dv * dv; + if (dd < bestD) { + bestD = dd; + best = i; + } + } + return best < 0 ? 0.0 : values[best]; + } +} diff --git a/packages/flutter3d_physics/lib/src/fluid/jet.dart b/packages/flutter3d_physics/lib/src/fluid/jet.dart new file mode 100644 index 000000000..7b3df9a54 --- /dev/null +++ b/packages/flutter3d_physics/lib/src/fluid/jet.dart @@ -0,0 +1,461 @@ +import 'dart:math' as math; + +import 'package:vector_math/vector_math.dart'; + +import '../portable_math.dart'; +import 'fluid_medium.dart'; + +/// When something thrown at [velocity] from [height] reaches [floor] under +/// gravity [g] pointing down: the later root of height + v_y·t − g t²/2 = +/// floor. For aiming a stream: how far it is carried while it falls. +double fallTime( + Vector3 velocity, + double height, + double floor, { + double g = 9.81, +}) { + final drop = math.max(height - floor, 0.0); + return (velocity.y + math.sqrt(velocity.y * velocity.y + 2.0 * g * drop)) / g; +} + +/// Something a stream can land in — a vessel's liquid. +abstract interface class JetReceiver { + /// Whether a parcel of [radius] at [point] (world) has reached this + /// receiver's surface: whether its underside has. + bool catches(Vector3 point, double radius); + + /// Takes [volume] cubic metres of [medium] carrying [concentrations], + /// arriving at [point] moving at [velocity]. + void receive( + double volume, + Vector3 point, + Vector3 velocity, + FluidMedium medium, + Map concentrations, + ); +} + +/// Something a stream can run into and along — a wall. +abstract interface class JetObstacle { + /// Where a parcel of [radius] at [point] (world) is into this obstacle: + /// the way out and how far, or null when it is clear of it. + ({Vector3 normal, double depth})? touch(Vector3 point, double radius); + + /// Whether anything within [distance] of [centre] (world) could touch + /// this obstacle: false only when it certainly cannot. Asked once a step + /// for everything in flight together, so the walls far from it are not + /// asked again for each parcel, piece and pass. + bool reaches(Vector3 centre, double distance); +} + +/// [obstacles] that something within [distance] of [centre] could touch. +List obstaclesNear( + List obstacles, + Vector3 centre, + double distance, +) => [ + for (final o in obstacles) + if (o.reaches(centre, distance)) o, +]; + +/// A drop a stream broke into, leaving it: where, how fast, how much. +typedef JetDrop = ({ + Vector3 position, + Vector3 velocity, + double volume, + FluidMedium medium, + Map concentrations, +}); + +/// One point of a stream as a renderer draws it: where, which way it moves, +/// and its section — an ellipse [wide] across [across] and [thick] the other +/// way. +typedef JetSample = ({ + Vector3 position, + Vector3 direction, + Vector3 across, + double wide, + double thick, +}); + +/// A stream of liquid, as the parcels of it that are in the air. +/// +/// **Each parcel is what left the lip in one step**, Q·dt of it, thrown at +/// the speed it left with and falling freely under the world's gravity from +/// there; so a stream from a lip that has moved on keeps the path it was +/// thrown on, and when nothing more leaves, its tail comes away and falls. +/// The continuity equation is then not imposed but follows: a parcel's +/// section is its volume over the distance it covers in a step, which is the +/// flow it left with over its speed now. It leaves the lip as a flat sheet as +/// wide as the wetted edge, and its edges draw in at the Taylor–Culick speed, +/// √(2σ / ρe), until it is round. +/// +/// **Water wets glass**, so a parcel that meets a wall stays on it: it loses +/// the part of its speed that went into the wall and runs along it, held +/// there while it is slower than [clingSpeed] — down the inside of a glass +/// it is poured into, and, poured slowly, down the outside of the one it is +/// poured from. +/// +/// **A thin stream breaks into drops** (Plateau and Rayleigh): ripples on it +/// grow at the rate one over √(ρD³/σ) + 3μD/σ — Weber's, the first term +/// inertia against surface tension and the second viscosity — and it parts +/// where the growth reaches e¹², the factor between a stream's first +/// disturbances and its radius that Grant and Middleman measured. In still +/// air, that is the intact length L/D = 12(√We + 3We/Re). A parted parcel +/// leaves as drops of about 1.89 times the stream's diameter, handed back by +/// [step]. +/// +/// **Every cubic metre is somewhere**: emitted is the sum of what is in the +/// air, what receivers have taken, and what left as drops. +final class Jet { + Jet({required this.medium, this.breakupGrowth = 12.0}); + + /// The liquid a parcel is when [emit] is not told otherwise, and the one + /// [clingSpeed] answers for. + final FluidMedium medium; + + /// How many e-foldings of growth part the stream. + final double breakupGrowth; + + final List<_Parcel> _parcels = []; + + /// The fastest a film or rivulet [thickness] thick runs along a wall it + /// wets and stays on it, for this [medium]. + /// + /// Surface tension holds it to the wall with σ(1 + cos θ) per unit of + /// wetted area, the work it would take to pull the liquid off; its + /// inertia, ρv²e per unit of width, is what would carry it straight on. + /// It clings while the second is the smaller — slower than + /// √(σ(1 + cos θ) / ρe) — which is why a slow pour runs down the outside of + /// a spout and a quick one leaves it cleanly. An estimate of the criterion + /// Duez and colleagues measured (2010), whose full form also weighs how + /// sharply the edge curves. + double clingSpeed(double thickness) => math.sqrt( + medium.surfaceTension * + (1.0 + Portable.cos(medium.contactAngle)) / + (medium.density * math.max(thickness, 1e-9)), + ); + + /// The share of a parcel that splashes back out where it lands. + /// + /// Mundo and colleagues' criterion: a drop of diameter D striking at v + /// splashes when K = Oh·Re^1.25 passes 57.7, Oh = μ/√(ρσD) weighing + /// viscosity against surface tension and Re = ρvD/μ inertia against + /// viscosity. A stream of water three millimetres across at a metre a + /// second is at 46 and goes in quietly. How much is thrown out past the + /// threshold is less settled; this takes a tenth of the excess, up to + /// half — an estimate of the secondary-drop fractions measured for crown + /// splashes, not a fit to one. + static double _splashShare(_Parcel p) { + final m = p.medium; + final d = 2.0 * math.sqrt(p.section / math.pi); + final v = p.velocity.length; + if (d <= 0.0 || v <= 0.0) return 0.0; + final oh = m.viscosity / math.sqrt(m.density * m.surfaceTension * d); + final re = m.density * v * d / m.viscosity; + final k = oh * re * math.sqrt(math.sqrt(re)); + return k <= 57.7 ? 0.0 : math.min(0.5, 0.1 * (k / 57.7 - 1.0)); + } + + /// How much has left the lip, and how much of it receivers have taken. + double get emitted => _emitted; + double _emitted = 0.0; + double get landed => _landed; + double _landed = 0.0; + + /// How much has left as drops. + double get dropped => _dropped; + double _dropped = 0.0; + + /// How much is in the air. + double get inFlight => _parcels.fold(0.0, (s, p) => s + p.volume); + + /// Whether any of it is in the air. + bool get flowing => _parcels.isNotEmpty; + + /// Liquid leaving a lip at [point] at [velocity] for [dt] seconds at + /// [flow] cubic metres a second, as a sheet [width] wide across [across]. + void emit({ + required double flow, + required double dt, + required Vector3 point, + required Vector3 velocity, + required double width, + required Vector3 across, + FluidMedium? medium, + Map concentrations = const {}, + }) { + final volume = flow * dt; + if (volume <= 0.0) { + // A gap: what follows is a new stream, not this one carried on. + if (_parcels.isNotEmpty) _parcels.last.endsRun = true; + return; + } + _emitted += volume; + final speed = math.max(velocity.length, 1e-6); + final w = math.max(width, 1e-6); + _parcels.add( + _Parcel( + position: point.clone(), + velocity: velocity.clone(), + volume: volume, + dt: dt, + width: w, + sheet: flow / (speed * w), + across: across.clone()..normalize(), + medium: medium ?? this.medium, + concentrations: concentrations, + ), + ); + } + + /// Moves every parcel on by [dt] under [gravity], against [obstacles], + /// into [receivers]; returns the drops it broke into. + List step( + double dt, { + required Vector3 gravity, + List obstacles = const [], + List receivers = const [], + }) { + final drops = []; + var walls = obstacles; + if (_parcels.isNotEmpty) { + // Every parcel, and as far as any moves this step, in one ball. + final low = _parcels.first.position.clone(); + final high = low.clone(); + var travel = 0.0; + for (final p in _parcels) { + Vector3.min(low, p.position, low); + Vector3.max(high, p.position, high); + final speed = p.velocity.length + gravity.length * dt; + travel = math.max(travel, speed * dt + math.sqrt(p.section / math.pi)); + } + walls = obstaclesNear( + obstacles, + (low + high) * 0.5, + (high - low).length * 0.5 + travel + 0.01, + ); + } + for (var i = 0; i < _parcels.length; i++) { + final p = _parcels[i]; + final rho = p.medium.density; + final sigma = p.medium.surfaceTension; + final mu = p.medium.viscosity; + p.velocity.addScaled(gravity, dt); + p.previous.setFrom(p.position); + p.age += dt; + final section = p.section; + final radius = math.sqrt(section / math.pi); + var touched = false; + // **In pieces no longer than its own radius**, held by the walls after + // each. A stream falls five millimetres a step into a test tube whose + // glass is half a millimetre: moved in one go, a parcel above the + // bottom of an empty tube was under its glass a step later, and broke + // into drops beneath the tube. + final pieces = (p.velocity.length * dt / radius).ceil().clamp(1, 16); + for (var k = 0; k < pieces; k++) { + p.position.addScaled(p.velocity, dt / pieces); + for (final wall in walls) { + final hit = wall.touch(p.position, radius); + if (hit == null) continue; + p.position.addScaled(hit.normal, hit.depth); + final into = p.velocity.dot(hit.normal); + if (into < 0.0) p.velocity.addScaled(hit.normal, -into); + p.onWall = true; + touched = true; + } + } + // Off the wall this step: held to it while surface tension outweighs + // its inertia, let go when it does not. + if (p.onWall && !touched) { + // Still touching, sliding along: on the wall at any speed. Drawn a + // little off it: pulled back while slow enough to cling. + final contact = 0.1 * radius; + final reach = 2.0 * radius; + var held = false; + for (final wall in walls) { + final near = wall.touch(p.position, radius + contact); + if (near != null) { + held = true; + break; + } + } + if (!held && p.velocity.length < clingSpeed(2.0 * radius)) { + for (final wall in walls) { + final hit = wall.touch(p.position, radius + reach); + if (hit == null) continue; + p.position.addScaled(hit.normal, hit.depth - reach); + final away = p.velocity.dot(hit.normal); + if (away > 0.0) p.velocity.addScaled(hit.normal, -away); + held = true; + break; + } + } + p.onWall = held; + } + // Weber's growth of the ripples on it, at its own diameter. + if (!p.onWall) { + final d = 2.0 * radius; + final tau = math.sqrt(rho * d * d * d / sigma) + 3.0 * mu * d / sigma; + p.growth += dt / tau; + } + } + // Landed, broken: out of the stream, each by its own account. + final kept = <_Parcel>[]; + for (final p in _parcels) { + JetReceiver? into; + // Anywhere along the way it came this step, a radius at a time: what + // passes the surface of a liquid between one step and the next has + // landed in it. + final radius = math.sqrt(p.section / math.pi); + final way = p.position - p.previous; + final looks = (way.length / radius).ceil().clamp(1, 16); + search: + for (var k = 1; k <= looks; k++) { + final at = p.previous + way * (k / looks); + for (final r in receivers) { + if (r.catches(at, radius)) { + into = r; + break search; + } + } + } + if (into != null) { + // Struck hard enough, part of it splashes back out (Mundo). + final splash = _splashShare(p); + final stays = p.volume * (1.0 - splash); + if (splash > 0.0) { + final up = gravity.length2 > 0.0 + ? -gravity.normalized() + : Vector3(0, 1, 0); + final side = up.cross( + up.x.abs() < 0.9 ? Vector3(1, 0, 0) : Vector3(0, 0, 1), + )..normalize(); + final other = up.cross(side); + final speed = 0.3 * p.velocity.length; + const crown = 8; + for (var k = 0; k < crown; k++) { + final a = 2.0 * math.pi * (k + 0.5) / crown; + final out = + (side * Portable.cos(a) + other * Portable.sin(a)) * 0.6 + + up * 0.8; + drops.add(( + position: p.position.clone(), + velocity: out.normalized() * speed, + volume: p.volume * splash / crown, + medium: p.medium, + concentrations: p.concentrations, + )); + } + _dropped += p.volume * splash; + } + into.receive(stays, p.position, p.velocity, p.medium, p.concentrations); + _landed += stays; + if (kept.isNotEmpty) kept.last.endsRun = true; + continue; + } + // A rivulet on a wall does not part: the wall holds it. + if (!p.onWall && p.growth >= breakupGrowth) { + // Drops of 1.89 diameters, as many as its volume makes. + final d = 2.0 * math.sqrt(p.section / math.pi) * 1.89; + final one = math.pi * d * d * d / 6.0; + final count = math.max(1, (p.volume / one).round()); + for (var k = 0; k < count; k++) { + drops.add(( + position: p.position.clone(), + velocity: p.velocity.clone(), + volume: p.volume / count, + medium: p.medium, + concentrations: p.concentrations, + )); + } + _dropped += p.volume; + if (kept.isNotEmpty) kept.last.endsRun = true; + continue; + } + kept.add(p); + } + _parcels + ..clear() + ..addAll(kept); + return drops; + } + + /// The stream as runs of samples, lip first: each run is a stretch with + /// nothing missing from it, ready to be swept into a tube. + List> get runs { + final out = >[]; + var run = []; + for (var i = _parcels.length - 1; i >= 0; i--) { + final p = _parcels[i]; + // A break between this parcel and the newer ones already in the run. + if (p.endsRun && run.isNotEmpty) { + if (run.length > 1) out.add(run); + run = []; + } + final section = p.section; + final round = math.sqrt(section / math.pi); + final retract = math.sqrt( + 2.0 * + p.medium.surfaceTension / + (p.medium.density * math.max(p.sheet, 1e-6)), + ); + final wide = p.onWall + ? math.sqrt(2.0) * round + : math.max(0.5 * p.width - retract * p.age, round); + run.add(( + position: p.position.clone(), + direction: p.velocity.normalized(), + across: p.across.clone(), + wide: wide, + thick: section / (math.pi * wide), + )); + } + if (run.length > 1) out.add(run); + return out; + } +} + +final class _Parcel { + _Parcel({ + required this.position, + required this.velocity, + required this.volume, + required this.dt, + required this.width, + required this.sheet, + required this.across, + required this.medium, + required this.concentrations, + }); + + /// What it is: which liquid, and what is dissolved in it. + final FluidMedium medium; + final Map concentrations; + + final Vector3 position; + final Vector3 velocity; + final double volume; + + /// The step it left in: its length along the stream is its speed times + /// this, so its section is its volume over that. + final double dt; + + /// How wide and thick a sheet it left as, and which way across. + final double width; + final double sheet; + final Vector3 across; + + double age = 0.0; + double growth = 0.0; + bool onWall = false; + + /// Where it was at the start of the step. + final Vector3 previous = Vector3.zero(); + + /// Whether the stream breaks between this parcel and the next one to + /// have left the lip. + bool endsRun = false; + + double get section => volume / (math.max(velocity.length, 1e-6) * dt); +} diff --git a/packages/flutter3d_physics/lib/src/fluid/liquid_body.dart b/packages/flutter3d_physics/lib/src/fluid/liquid_body.dart new file mode 100644 index 000000000..561f30985 --- /dev/null +++ b/packages/flutter3d_physics/lib/src/fluid/liquid_body.dart @@ -0,0 +1,691 @@ +import 'dart:math' as math; + +import 'package:vector_math/vector_math.dart'; + +import '../portable_math.dart'; +import 'capillary.dart'; +import 'fluid_medium.dart'; +import 'free_surface.dart'; +import 'jet.dart'; +import 'liquid_layer.dart'; +import 'outflow.dart'; +import 'vessel_shape.dart'; + +/// What ran over a vessel's lip in one step, in the world. +final class Spill { + const Spill({ + required this.flow, + required this.point, + required this.velocity, + required this.width, + required this.medium, + this.concentrations = const {}, + }); + + /// No spill. + static final Spill none = Spill( + flow: 0.0, + point: Vector3.zero(), + velocity: Vector3.zero(), + width: 0.0, + medium: FluidMedium.water, + ); + + /// Which liquid ran out — the top layer's — and what was dissolved in it. + final FluidMedium medium; + final Map concentrations; + + /// Cubic metres a second. + final double flow; + + /// Where along the lip it left, weighed by how much left each part. + final Vector3 point; + + /// How it left: at the crest's speed, along the glass and outwards. + final Vector3 velocity; + + /// How wide the sheet over the lip is. + final double width; +} + +/// A liquid in a vessel: its volume, the plane its surface settles to, the +/// waves on it, and what runs over the lip. +/// +/// **The surface settles square to the gravity the liquid feels**, which is +/// the world's gravity less the vessel's own acceleration: a glass carried +/// forward and braked tips its liquid as much as one turned. The vessel's +/// acceleration is taken from where it was put on the last three steps. +/// Where the plane stands follows from the volume ([VesselVolumes]); the +/// waves are the vessel's own modes ([FreeSurface]); and liquid leaves over +/// the lip at the weir's rate wherever the surface, waves and all, stands +/// above the rim. Nothing else changes the volume: [step] returns what left, +/// and [pour] is how liquid comes in. +/// +/// **Liquids that do not mix lie in [layers]**, densest at the bottom, each +/// boundary a plane square to the same gravity at the height that holds the +/// volume below it. The waves and the meniscus are the top liquid's, what +/// runs over the lip is the top layer, and a pipe draws on the layer at its +/// opening. +final class LiquidBody implements JetReceiver { + LiquidBody({ + required this.shape, + required FluidMedium medium, + required double volume, + Map concentrations = const {}, + int surfaceCells = 40, + int modes = 12, + this.wallThickness = 0.0, + }) : surface = FreeSurface( + medium: medium, + cells: surfaceCells, + modeCount: modes, + ) { + _layers.add(LiquidLayer.at(medium, volume, concentrations)); + surface.layOut(shape, _up, shape.surfaceFor(_up, volume + displaced)); + } + + final VesselShape shape; + final FreeSurface surface; + + /// The liquids in it, bottom first. + List get layers => List.unmodifiable(_layers); + final List _layers = []; + + /// How much of the vessel solid bodies in it take up below the surface, + /// cubic metres: liquid cannot be where they are, so the surface stands + /// that much higher. Set by whoever floats bodies in it — `FluidWorld`. + double displaced = 0.0; + + /// The force the liquid puts on its vessel, newtons, in the world, for + /// [gravity]: its weight less what it takes to carry it with the vessel's + /// acceleration, the push of its waves rocking, and the reaction of what + /// ran over the lip on the last step. + Vector3 load(Vector3 gravity) { + final mass = _layers.fold(0.0, (s, l) => s + l.medium.density * l.volume); + final out = (gravity - _acceleration) * mass; + out.add(rotation.transformed(surface.force(medium.density))); + final spilt = _lastSpill; + if (spilt != null) { + out.addScaled(spilt.velocity, -spilt.medium.density * spilt.flow); + } + return out; + } + + final Vector3 _acceleration = Vector3.zero(); + Spill? _lastSpill; + + /// The liquid at the surface. + FluidMedium get medium => + _layers.isEmpty ? surface.medium : _layers.last.medium; + + /// How thick the vessel's wall is, for a stream that runs down its + /// outside; nought for none. + final double wallThickness; + + /// The seconds this liquid has been stepped through: what [place] stamps + /// a position with. + double get clock => _clock; + double _clock = 0.0; + + /// Cubic metres of liquid, every layer. + double get volume => _layers.fold(0.0, (sum, l) => sum + l.volume); + + /// The vessel's turn and place in the world, as last [place]d. + final Matrix3 rotation = Matrix3.identity(); + final Vector3 position = Vector3.zero(); + final List<({double time, Vector3 at})> _history = []; + + /// Which way is up in the vessel's frame — square to the gravity felt — + /// and the height of the surface's plane along it. + Vector3 get up => _up.clone(); + Vector3 _up = Vector3(0, 1, 0); + double get height => surface.height; + + /// Puts the vessel at [at], turned by [turn], as it is at [time] seconds + /// on the liquid's clock — by default the clock as it stands, the place + /// for the next [step]. The vessel's acceleration is read off its path, so + /// the time must be when the vessel was there: a caller placing it once a + /// frame and stepping a whole number of fixed steps after passes the + /// frame's own time (`FluidWorld.time` plus the frame), since stamped with + /// the clock instead, a frame that ran one step and the next that ran + /// three read as a jolt worth a good part of gravity, and the liquid + /// threw itself out of the glass. + void place(Matrix3 turn, Vector3 at, {double? time}) { + final stamp = time ?? _clock; + rotation.setFrom(turn); + position.setFrom(at); + // Placed again for the same moment: the newer place stands for it. + if (_history.isNotEmpty && _history.last.time >= stamp) { + _history.removeLast(); + } + _history.add((time: stamp, at: at.clone())); + if (_history.length > 3) _history.removeAt(0); + } + + /// Whether a parcel of [radius] at [point] (world) is in the vessel with + /// its underside at or below the surface. + @override + bool catches(Vector3 point, double radius) { + // Far from the glass, said at once and with nothing made: every drop + // and parcel in flight asks every vessel this every step, and building + // the frame for each was most of what a pour cost. + final q = shape is RevolvedVessel ? _near(point, radius) : _local(point); + if (q == null || !shape.contains(q)) return false; + // Empty, there is no surface to land on: what reaches the bottom wets + // it and stays, the start of a liquid. A quarter of a radius of slack, + // since what rests on glass rests a radius off it and no nearer. + final surface = volume > 0.0 ? surfaceAt(q) : shape.span(_up).low; + return _up.dot(q) - radius <= surface + 0.25 * radius; + } + + /// Liquid arriving from a stream: added, first heaped where it lands — + /// its own volume over the patch it strikes — and striking the surface + /// with the momentum it comes down with, from where both spread as waves. + @override + void receive( + double volume, + Vector3 point, + Vector3 velocity, + FluidMedium medium, + Map concentrations, + ) { + final spread = surface.knockSpread; + final patch = 2.0 * math.pi * spread * spread; + pour(volume, medium: medium, concentrations: concentrations); + if (patch <= 0.0) return; + // How fast it comes down onto the surface: along the vessel's up. + final down = -(rotation.clone()..transpose()) + .transformed(velocity) + .dot(_up); + surface.land( + _local(point), + heap: volume / patch, + flux: down > 0.0 ? volume * down / patch : 0.0, + ); + } + + /// [point] from the world into the vessel's frame. + Vector3 _local(Vector3 point) => _toLocal(point); + + /// [point] in the vessel's frame, or null when it is further from the + /// vessel than [margin] beyond its inside, sideways or along its axis: + /// what an obstacle asks first, many times a step, so no matrix is made. + Vector3? _near(Vector3 point, double margin) { + final shape = this.shape; + if (shape is! RevolvedVessel) return null; + final m = rotation.storage; + final dx = point.x - position.x; + final dy = point.y - position.y; + final dz = point.z - position.z; + // Rᵀ(p − o), column by column. + final y = m[3] * dx + m[4] * dy + m[5] * dz; + if (y < shape.floor - margin || y > shape.top + margin) return null; + final x = m[0] * dx + m[1] * dy + m[2] * dz; + final z = m[6] * dx + m[7] * dy + m[8] * dz; + final reach = shape.widest + margin; + if (x * x + z * z > reach * reach) return null; + return Vector3(x, y, z); + } + + /// Whether anything within [distance] of [centre] (world) could be at + /// this vessel's walls: by the ball round its inside. + bool _reaches(Vector3 centre, double distance) { + final shape = this.shape; + if (shape is! RevolvedVessel) return true; + final half = 0.5 * (shape.top - shape.floor); + final middle = + position + + rotation.transformed(Vector3(0, 0.5 * (shape.top + shape.floor), 0)); + final ball = + math.sqrt(shape.widest * shape.widest + half * half) + distance; + return centre.distanceToSquared(middle) < ball * ball; + } + + Vector3 _toLocal(Vector3 point) => + (rotation.clone()..transpose()).transformed(point - position); + + /// Adds [amount] cubic metres of [medium] — the top liquid's, if none is + /// named — carrying [concentrations]; landing, it knocks the surface at + /// [where] (vessel frame) by [knock] metres. + void pour( + double amount, { + FluidMedium? medium, + Map concentrations = const {}, + Vector3? where, + double knock = 0.0, + }) { + if (amount <= 0.0) return; + add(LiquidLayer.at(medium ?? this.medium, amount, concentrations)); + if (where != null && knock != 0.0) surface.knock(where, knock); + } + + /// Adds [layer]: to the layer of the same medium, which it mixes with, or + /// as a layer of its own where its density puts it. + void add(LiquidLayer layer) { + if (layer.volume <= 0.0) return; + final same = _layers.where((l) => l.medium.name == layer.medium.name); + if (same.isNotEmpty) { + same.first.add(layer); + } else { + _layers + ..add( + LiquidLayer( + medium: layer.medium, + volume: layer.volume, + amounts: layer.amounts, + ), + ) + ..sort((a, b) => b.medium.density.compareTo(a.medium.density)); + } + surface.medium = medium; + _relayIfMoved(force: true); + } + + /// Takes [amount] cubic metres away from the bottom — a drain — without a + /// spill; returns what was taken, layer by layer. + List drain(double amount) => drawAt(Vector3(0, -1e9, 0), amount); + + /// Takes [amount] cubic metres from the layer at [point] (vessel frame) — + /// a pipe's opening — and from the ones above it once that runs out. + List drawAt(Vector3 point, double amount) { + final tops = layerTops(); + final h = _up.dot(point); + var first = 0; + while (first < tops.length - 1 && tops[first] < h) { + first++; + } + final out = []; + var left = amount; + for (var i = first; i < _layers.length && left > 0.0; i++) { + final taken = _layers[i].take(left); + left -= taken.volume; + if (taken.volume > 0.0) out.add(taken); + } + _layers.removeWhere((l) => l.volume <= 1e-15); + if (_layers.isNotEmpty) surface.medium = medium; + _relayIfMoved(force: true); + return out; + } + + /// The height of each layer's top along [up], bottom first: the plane + /// that holds it and every layer under it. + List layerTops() { + var below = 0.0; + // Each from the surface's own height, which the top one is near and + // the others are under. + final guess = surface.height; + return [ + for (final l in _layers) + guess.isFinite && guess != 0.0 + ? shape.surfaceNear(_up, below += l.volume, guess) + : shape.surfaceFor(_up, below += l.volume), + ]; + } + + /// The pressure of the liquid at [point] (vessel frame) above the air's: + /// every layer above it, each its own density times the gravity felt + /// times how much of it stands over the point. + double pressureAt(Vector3 point) { + final h = _up.dot(point); + final tops = layerTops(); + var pressure = 0.0; + var bottom = -double.infinity; + for (var i = 0; i < _layers.length; i++) { + final over = tops[i] - math.max(bottom, h); + if (over > 0.0) pressure += _layers[i].medium.density * _g * over; + bottom = tops[i]; + } + return pressure; + } + + /// Moves the liquid on by [dt] under the world's [gravity], and returns + /// what ran over the lip in that time, already taken out of [volume]. + Spill step(double dt, {required Vector3 gravity}) { + // **Asleep while nothing happens to it.** A glass standing on a bench, + // its surface still, has nothing to step: the same place, the same + // volume, the same gravity, nothing knocked or landed. Eight of a + // bench's nine glasses are that, and stepping them two hundred and + // forty times a second was most of what a frame cost in a browser. + if (_asleep && _sameAsAsleep(gravity)) { + _clock += dt; + return Spill.none; + } + _asleep = false; + final spill = _stepAwake(dt, gravity: gravity); + _maybeSleep(gravity, spill); + return spill; + } + + bool _asleep = false; + double _sleptVolume = double.nan; + double _sleptDisplaced = double.nan; + int _sleptDisturbances = -1; + final Vector3 _sleptAt = Vector3.zero(); + final Matrix3 _sleptTurn = Matrix3.zero(); + final Vector3 _sleptGravity = Vector3.zero(); + + bool _sameAsAsleep(Vector3 gravity) => + volume == _sleptVolume && + displaced == _sleptDisplaced && + surface.disturbances == _sleptDisturbances && + position == _sleptAt && + rotation == _sleptTurn && + gravity == _sleptGravity; + + /// Falls asleep after a step in which it was still: placed where it was + /// placed the last two times, running nothing over, its waves gone. + void _maybeSleep(Vector3 gravity, Spill spill) { + if (spill.flow > 0.0 || !surface.quiet) return; + if (_history.length < 2) return; + for (final h in _history) { + if (h.at != position) return; + } + surface.calm(); + _asleep = true; + _sleptVolume = volume; + _sleptDisplaced = displaced; + _sleptDisturbances = surface.disturbances; + _sleptAt.setFrom(position); + _sleptTurn.setFrom(rotation); + _sleptGravity.setFrom(gravity); + } + + Spill _stepAwake(double dt, {required Vector3 gravity}) { + // The gravity felt: the world's, less the vessel's acceleration. + final felt = gravity.clone(); + if (_history.length == 3) { + // The second difference over the times the places were stamped, + // however many steps lay between them. + final (time: t0, at: p0) = _history[0]; + final (time: t1, at: p1) = _history[1]; + final (time: t2, at: p2) = _history[2]; + if (t1 > t0 && t2 > t1) { + final v0 = (p1 - p0) / (t1 - t0); + final v1 = (p2 - p1) / (t2 - t1); + _acceleration.setFrom((v1 - v0) * (2.0 / (t2 - t0))); + felt.sub(_acceleration); + } + } + _clock += dt; + _g = felt.length; + final g = felt.length; + if (g > 1e-9) { + final turnBack = rotation.clone()..transpose(); + _up = turnBack.transformed(-felt / g)..normalize(); + } + _relayIfMoved(); + final depth = surface.area > 0.0 ? volume / surface.area : 0.0; + surface.step(dt, g: math.max(g, 1e-6), depth: depth); + final spill = _spill(dt, math.max(g, 1e-6)); + _lastSpill = spill.flow > 0.0 ? spill : null; + return spill; + } + + /// The surface's height along [up] over [point] (vessel frame): its plane, + /// the waves on it, and the meniscus at the wall. + double surfaceAt(Vector3 point) => + height + surface.displacement(point) + meniscusAt(point); + + /// The gravity the liquid felt on its last step, m/s². + double get gravity => _g; + double _g = 9.81; + + /// How far the meniscus stands over [point] (vessel frame) above the flat + /// surface of the same volume — up at a wall the liquid wets, down at one + /// it does not, nought on average. For a round vessel, the exact + /// Young–Laplace surface for its radius at the surface ([TubeMeniscus]); + /// for another, the flat wall's ([wallMeniscus]) by distance from it. + double meniscusAt(Vector3 point) { + final m = _meniscus(); + if (m == null) return 0.0; + final r = math.sqrt(point.x * point.x + point.z * point.z); + return m.heightAt(r) - m.meanHeight; + } + + /// The pressure the meniscus takes off the liquid under it, pascals: σ + /// times the curvature at its middle. + double get capillaryPressure { + final m = _meniscus(); + return m == null ? 0.0 : medium.surfaceTension * m.apexCurvature; + } + + /// How deep [point] (vessel frame) is under the surface. + double depthAbove(Vector3 point) => height - _up.dot(point); + + /// Menisci already solved, by the radius they were solved for, in steps of + /// a hundredth of it; and the medium and gravity they were solved under. + /// + /// **Kept, not solved again.** Each is a shooting solution of the + /// Young–Laplace equation. Kept one at a time, a surface rising through a + /// test tube's round bottom, where the radius changes with every step, + /// solved one on every question asked of the surface — each drop that + /// landed asked one — and filling the bottom of the clean tube cost three + /// times the rest of the pour. A hundredth of the radius is a fiftieth of + /// the meniscus's rise or less. + final Map _menisci = {}; + FluidMedium? _menisciMedium; + double _menisciG = double.nan; + + TubeMeniscus? _meniscus() { + final shape = this.shape; + if (shape is! RevolvedVessel || volume <= 0.0) return null; + final radius = shape.radiusAt(height.clamp(shape.floor, shape.top)); + if (radius <= 0.0) return null; + if (!identical(_menisciMedium, medium) || + !((_menisciG - _g).abs() < 1e-3 * _g)) { + _menisci.clear(); + _menisciMedium = medium; + _menisciG = _g; + } + final key = (Portable.log(radius) / 0.01).round(); + return _menisci[key] ??= TubeMeniscus( + medium: medium, + radius: Portable.exp(key * 0.01), + g: _menisciG, + ); + } + + /// The volume and up the surface's height was last found for, and the + /// height. + double _laidFor = double.nan; + final Vector3 _laidUp = Vector3.zero(); + double _laidAt = double.nan; + + /// Lays the surface out again when its plane has turned or moved by more + /// than a little, carrying the waves over. + void _relayIfMoved({bool force = false}) { + final target = volume + displaced; + // Standing still, nothing to find; moving, found from where it was. + final h = !force && target == _laidFor && _up == _laidUp + ? _laidAt + : (_laidAt.isNaN + ? shape.surfaceFor(_up, target) + : shape.surfaceNear(_up, target, _laidAt)); + _laidFor = target; + _laidUp.setFrom(_up); + _laidAt = h; + final turned = 1.0 - _up.dot(surface.up); + final moved = (h - surface.height).abs(); + if (force || turned > 2e-5 || moved > 1e-4) { + surface.layOut(shape, _up, h); + } + } + + /// What runs over the rim in [dt]: each stretch of it passes the weir's + /// rate for the depth of liquid standing over it, waves included. + Spill _spill(double dt, double g) { + final rim = shape.rim; + if (rim.isEmpty || volume <= 0.0) return Spill.none; + // Nowhere near the lip, waves at their highest and meniscus and all: + // nothing to ask the rim. + final edge = shape.lip(_up); + if (edge != null && + height + surface.reach + (_meniscus()?.peak ?? 0.0) < edge.height) { + return Spill.none; + } + var flow = 0.0; + var width = 0.0; + var crest = 0.0; + final at = Vector3.zero(); + final along = Vector3.zero(); + // The stretch passing most, and its own way out: where an overfull + // upright glass runs over, since there the weighed mean is the middle of + // the mouth. + var most = 0.0; + final mostAt = Vector3.zero(); + final mostOut = Vector3.zero(); + for (var i = 0; i < rim.length; i++) { + final a = rim[i]; + final b = rim[(i + 1) % rim.length]; + final mid = (a + b) * 0.5; + final depth = surfaceAt(mid) - _up.dot(mid); + if (depth <= 0.0) continue; + final length = (b - a).length; + final q = + sharpEdgeDischarge * + (2.0 / 3.0) * + math.sqrt(2.0 * g) * + depth * + math.sqrt(depth) * + length; + flow += q; + width += length; + crest += (2.0 / 3.0) * depth * length; + at.addScaled(mid, q); + // Out over the edge: away from the vessel's middle, in the plane. + final out = mid - _up * _up.dot(mid); + if (out.length2 > 0.0) { + along.addScaled(out.normalized(), q); + if (q > most) { + most = q; + mostAt.setFrom(mid); + mostOut.setFrom(out.normalized()); + } + } + } + if (flow <= 0.0) return Spill.none; + // Where and which way, weighed by what each stretch passed — while + // there is a way: tipped, the low side passes nearly all of it. Over + // every part of a level lip at once the stretches pull against each + // other, the mean is the middle of the mouth with no way out at all, + // and what left there fell straight back in, round and round; it goes + // over the stretch passing most instead, as an overfull glass first + // runs down the side whose rim is a hair lower. + final spread = along.length < 0.5 * flow; + final point = rotation.transformed(spread ? mostAt : at / flow); + final direction = spread + ? rotation.transformed(mostOut) + : (along.length2 > 0.0 + ? rotation.transformed(along.normalized()) + : Vector3.zero()); + final speed = flow / crest; + // The top layer runs out, and no more of it than there is: in the step + // it is used up, the one under it takes its place at the lip. + final top = _layers.last; + final out = math.min(flow * dt, top.volume); + final concentrations = top.concentrations; + final what = top.medium; + top.take(out); + if (top.volume <= 1e-15 && _layers.length > 1) _layers.removeLast(); + surface.medium = medium; + flow = dt > 0.0 ? out / dt : 0.0; + _relayIfMoved(); + return Spill( + flow: flow, + point: point + position, + velocity: direction * speed, + // Over the whole lip, the whole lip's length would be a sheet three + // times as wide as the mouth it came from: no wider than across it. + width: spread + ? math.min(width, 2.0 * (mostAt - _up * _up.dot(mostAt)).length) + : width, + medium: what, + concentrations: concentrations, + ); + } +} + +/// The inside of a [LiquidBody]'s vessel as a wall a stream runs along: for +/// a [RevolvedVessel], the side and the floor; another shape has no walls +/// yet, and a stream passes through it. +final class InsideWalls implements JetObstacle { + InsideWalls(this.body); + + final LiquidBody body; + + @override + bool reaches(Vector3 centre, double distance) => + body._reaches(centre, distance + body.wallThickness); + + @override + ({Vector3 normal, double depth})? touch(Vector3 point, double radius) { + final shape = body.shape; + if (shape is! RevolvedVessel) return null; + final q = body._near(point, body.wallThickness + radius); + if (q == null) return null; + // Under the inside's floor and beyond its edge is outside the glass, + // beside its foot, where the outside answers. Taken for the inside's, + // a drop on the bench by a beaker's foot, half a millimetre down where + // the floor inside is half a millimetre up, was pushed up into the + // glass while the bench pushed it down, and left at metres a second. + if (q.y < shape.floor && + q.x * q.x + q.z * q.z >= + shape.radiusAt(shape.floor) * shape.radiusAt(shape.floor)) { + return null; + } + final at = shape.wallDistance(q); + if (at == null) return null; + // Within a radius of the inside, or into the glass up to half its + // thickness: past that it is the outside's. + final glass = body.wallThickness > 0.0 ? 0.5 * body.wallThickness : radius; + if (at.distance <= -radius || at.distance >= glass) return null; + return ( + normal: body.rotation.transformed(-at.normal), + depth: at.distance + radius, + ); + } +} + +/// The outside of a [LiquidBody]'s vessel, [thickness] out from its inside, +/// as a wall a stream runs along: what a slow pour clings to below the lip. +/// For a [RevolvedVessel]; another shape has none yet. +final class OutsideWalls implements JetObstacle { + OutsideWalls(this.body, {required this.thickness}); + + final LiquidBody body; + final double thickness; + + @override + bool reaches(Vector3 centre, double distance) => + body._reaches(centre, distance + thickness); + + @override + ({Vector3 normal, double depth})? touch(Vector3 point, double radius) { + final shape = body.shape; + if (shape is! RevolvedVessel) return null; + final q = body._near(point, thickness + radius); + if (q == null) return null; + // Below the inside's floor the glass is its foot, standing on whatever + // the vessel stands on: pushed out sideways, never down into the bench. + if (q.y < shape.floor) { + final r = math.sqrt(q.x * q.x + q.z * q.z); + final clear = shape.radiusAt(shape.floor) + thickness + radius; + if (r >= clear || r < shape.radiusAt(shape.floor)) return null; + final out = r > 1e-12 ? Vector3(q.x / r, 0, q.z / r) : Vector3(1, 0, 0); + return (normal: body.rotation.transformed(out), depth: clear - r); + } + // The rim's top is glass too, up to its thickness above the mouth. + final at = shape.wallDistance( + q.y > shape.top && q.y <= shape.top + thickness + ? Vector3(q.x, shape.top, q.z) + : q, + ); + if (at == null) return null; + final clear = thickness + radius; + if (at.distance < 0.5 * thickness || at.distance >= clear) return null; + return ( + normal: body.rotation.transformed(at.normal), + depth: clear - at.distance, + ); + } +} diff --git a/packages/flutter3d_physics/lib/src/fluid/liquid_layer.dart b/packages/flutter3d_physics/lib/src/fluid/liquid_layer.dart new file mode 100644 index 000000000..b98a50bf3 --- /dev/null +++ b/packages/flutter3d_physics/lib/src/fluid/liquid_layer.dart @@ -0,0 +1,78 @@ +import 'fluid_medium.dart'; + +/// One liquid in a vessel: a medium, how much of it, and what is dissolved +/// in it. +/// +/// **Liquids that mix are one layer**, and what is dissolved in them is kept +/// as amounts — moles, grams, whatever the caller counts in — so a +/// concentration is an amount over the volume and pouring two together adds +/// both. The mixing is taken as instant: a layer is the same all through. +/// +/// **Liquids that do not mix are separate layers**, one per medium, lying +/// by density; see `LiquidBody.layers`. +final class LiquidLayer { + LiquidLayer({ + required this.medium, + required this.volume, + Map? amounts, + }) : amounts = {...?amounts}; + + final FluidMedium medium; + + /// Cubic metres. + double volume; + + /// What is dissolved in it, by name: amounts, not concentrations. + final Map amounts; + + /// The amount of [solute] per cubic metre. + double concentration(String solute) => + volume > 0.0 ? (amounts[solute] ?? 0.0) / volume : 0.0; + + /// Every solute's concentration. + Map get concentrations => { + for (final entry in amounts.entries) + entry.key: volume > 0.0 ? entry.value / volume : 0.0, + }; + + /// Takes [amount] cubic metres out, with its share of every solute, and + /// returns it as a layer of its own. + LiquidLayer take(double amount) { + final part = amount.clamp(0.0, volume); + final share = volume > 0.0 ? part / volume : 0.0; + final out = LiquidLayer( + medium: medium, + volume: part, + amounts: { + for (final entry in amounts.entries) entry.key: entry.value * share, + }, + ); + volume -= part; + for (final key in amounts.keys.toList()) { + amounts[key] = amounts[key]! * (1.0 - share); + } + return out; + } + + /// Adds [other], which must be the same medium. + void add(LiquidLayer other) { + volume += other.volume; + other.amounts.forEach((key, value) { + amounts[key] = (amounts[key] ?? 0.0) + value; + }); + } + + /// [volume] cubic metres of this medium at [concentrations]. + static LiquidLayer at( + FluidMedium medium, + double volume, + Map concentrations, + ) => LiquidLayer( + medium: medium, + volume: volume, + amounts: { + for (final entry in concentrations.entries) + entry.key: entry.value * volume, + }, + ); +} diff --git a/packages/flutter3d_physics/lib/src/fluid/outflow.dart b/packages/flutter3d_physics/lib/src/fluid/outflow.dart new file mode 100644 index 000000000..3bc986083 --- /dev/null +++ b/packages/flutter3d_physics/lib/src/fluid/outflow.dart @@ -0,0 +1,130 @@ +import 'dart:math' as math; + +import '../portable_math.dart'; +import 'fluid_medium.dart'; + +/// How liquid leaves a vessel, from Bernoulli's equation. +/// +/// Along a streamline of an incompressible liquid p + ½ρv² + ρgz is the +/// same everywhere, so liquid that falls a head h from a still surface to +/// open air at the same pressure arrives at √(2gh) — Torricelli's speed — +/// whatever the vessel. Real openings pass less than their area times that: +/// the stream narrows past the edge (the vena contracta) and loses a little +/// to friction, which the measured discharge coefficient C_d carries. + +/// The discharge coefficient of a sharp-edged opening or crest: the stream +/// contracts to about 0.62 of the opening past a sharp edge. +const double sharpEdgeDischarge = 0.62; + +/// What a submerged opening of [area] passes under a [head] of liquid, in +/// gravity [g]: Torricelli's speed, and that over the contracted section. +({double flow, double speed}) orificeFlow({ + required double area, + required double head, + required double g, + double discharge = sharpEdgeDischarge, +}) { + if (head <= 0.0 || area <= 0.0) return (flow: 0.0, speed: 0.0); + final speed = math.sqrt(2.0 * g * head); + return (flow: discharge * area * speed, speed: speed); +} + +/// How much passes a weir round a circular mouth of [radius] tipped [tilt] +/// from upright, with the still surface [head] over its lowest point, in +/// gravity [g]; and the section of the sheet at the crest. +/// +/// **Bernoulli over a sharp crest**: each strip of the edge passes +/// C_d·(2/3)·√(2g)·depth^(3/2) per metre, the integral of Torricelli's speed +/// over the depth above it. The mouth is a circle tipped with the glass, so +/// at an angle φ round it from the lowest point the liquid is +/// head − R(1 − cos φ)·sin(tilt) deep over the edge, where that is above +/// nought. At the crest the flow is critical, about two thirds of the head +/// deep, which is the section the sheet leaves with. Nothing passes at a +/// head of nought or less: the liquid has drawn back from the edge. +({double flow, double area}) circularWeir({ + required double head, + required double radius, + required double tilt, + required double g, + double discharge = sharpEdgeDischarge, + int steps = 360, +}) { + if (head <= 0.0) return (flow: 0.0, area: 0.0); + final dip = radius * math.max(Portable.sin(tilt).abs(), 1e-3); + var q = 0.0; + var area = 0.0; + final dPhi = 2.0 * math.pi / steps; + for (var i = 0; i < steps; i++) { + final phi = -math.pi + (i + 0.5) * dPhi; + final depth = head - dip * (1.0 - Portable.cos(phi)); + if (depth <= 0.0) continue; + final edge = radius * dPhi; + q += Portable.pow(depth, 1.5) * edge; + area += depth * edge; + } + return ( + flow: discharge * (2.0 / 3.0) * math.sqrt(2.0 * g) * q, + area: (2.0 / 3.0) * area, + ); +} + +/// The other way round: the head over a circular mouth's lip that passes +/// [flow], the speed the sheet leaves at — the flow over its crest section — +/// and the sheet's width, the chord of the wetted edge. +({double head, double speed, double width}) overCircularLip({ + required double flow, + required double radius, + required double tilt, + required double g, +}) { + final dip = radius * math.max(Portable.sin(tilt).abs(), 1e-3); + var lo = 0.0; + var hi = 2.0 * dip + radius; + for (var i = 0; i < 52; i++) { + final mid = 0.5 * (lo + hi); + final passed = circularWeir(head: mid, radius: radius, tilt: tilt, g: g); + if (passed.flow < flow) { + lo = mid; + } else { + hi = mid; + } + } + final head = 0.5 * (lo + hi); + final crest = circularWeir(head: head, radius: radius, tilt: tilt, g: g); + final reach = (1.0 - head / dip).clamp(-1.0, 1.0); + return ( + head: head, + speed: crest.area > 0.0 ? flow / crest.area : math.sqrt(g * head), + width: 2.0 * radius * Portable.sin(Portable.acos(reach)), + ); +} + +/// Flow through a straight round pipe of [length] and [radius] driven by a +/// head difference [head], for [medium] in gravity [g]. +/// +/// Two limits, and the lesser flow governs. A long thin pipe is held back by +/// viscosity: Hagen–Poiseuille, Q = πr⁴Δp / 8μL, with Δp = ρg·head. A short +/// wide one is held back by the liquid's own inertia, and passes what +/// Bernoulli lets through its section, C_d·A·√(2g·head). The sign follows +/// the head: negative flows the other way. +double pipeFlow({ + required double length, + required double radius, + required double head, + required FluidMedium medium, + required double g, +}) { + if (head == 0.0) return 0.0; + final drop = medium.density * g * head.abs(); + final viscous = + math.pi * + Portable.pow(radius, 4) * + drop / + (8.0 * medium.viscosity * math.max(length, 1e-6)); + final inertial = orificeFlow( + area: math.pi * radius * radius, + head: head.abs(), + g: g, + ).flow; + return math.min(viscous, inertial) * head.sign; +} diff --git a/packages/flutter3d_physics/lib/src/fluid/particle_fluid.dart b/packages/flutter3d_physics/lib/src/fluid/particle_fluid.dart new file mode 100644 index 000000000..6ac290288 --- /dev/null +++ b/packages/flutter3d_physics/lib/src/fluid/particle_fluid.dart @@ -0,0 +1,619 @@ +import 'dart:math' as math; + +import 'package:vector_math/vector_math.dart'; + +import '../portable_math.dart'; +import 'fluid_medium.dart'; +import 'jet.dart'; + +/// A flat surface particles rest on — a bench, a floor: the points with +/// `normal · p ≥ offset` are clear of it. +final class PlaneObstacle implements JetObstacle { + PlaneObstacle({required Vector3 normal, required this.offset}) + : normal = normal.normalized(); + + final Vector3 normal; + final double offset; + + @override + bool reaches(Vector3 centre, double distance) => + normal.dot(centre) - offset < distance; + + @override + ({Vector3 normal, double depth})? touch(Vector3 point, double radius) { + final d = normal.dot(point) - offset - radius; + return d < 0.0 ? (normal: normal, depth: -d) : null; + } +} + +/// Liquid that has left every vessel and stream — drops, splashes, a puddle +/// on the bench — as particles: Position Based Fluids (Macklin and Müller, +/// 2013). +/// +/// **Incompressibility is a constraint, not a stiff spring.** Each particle +/// carries ρ₀s³ of liquid, s the [spacing]; its density is the poly6-weighted +/// sum of its neighbours' masses, and every step the positions are moved, a +/// few passes over, until each density is the rest density: the constraint +/// ρᵢ/ρ₀ − 1 = 0, solved along its spiky-kernel gradient. A small artificial +/// pressure keeps neighbours from clumping where too few surround them. +/// +/// **Viscosity** is XSPH's: each velocity is drawn towards its neighbours' +/// by a share that grows with the medium's kinematic viscosity over s²/Δt — +/// an artificial viscosity, so honey is slow and water quick, but not to the +/// figures a viscous flow is measured by. +/// +/// **Surface tension** is Akinci and colleagues' (2013): cohesion between +/// neighbours plus a pull that flattens curvature. Their coefficient is not +/// σ; it is set so that pulling the liquid apart along a plane takes 2σ per +/// square metre, the work of cohesion, worked out once over the particle +/// lattice. A contact angle against a wall is not modelled: a puddle spreads +/// as far as cohesion lets it, whatever the wall. +/// +/// **Every cubic metre is counted.** Liquid arrives by [inject] in any +/// amount, and what is less than one particle waits in a bank until it is; +/// what reaches a receiver leaves as whole particles. So [volume] — the +/// particles and the bank — is exactly what came in less what left. +final class ParticleFluid { + ParticleFluid({ + required this.medium, + required this.spacing, + this.iterations = 4, + this.substeps = 2, + }) : h = 2.0 * spacing { + _gamma = _cohesionForWorkOfCohesion(); + // The kernel summed over the lattice at rest: what a particle's density + // estimate is divided by, so liquid at its rest spacing is at its rest + // density exactly, whatever the kernel's own normalisation makes of a + // discrete lattice. + var sum = 0.0; + var gradient2 = 0.0; + final reach = (h / spacing).ceil(); + for (var a = -reach; a <= reach; a++) { + for (var b = -reach; b <= reach; b++) { + for (var c = -reach; c <= reach; c++) { + final r2 = (a * a + b * b + c * c) * spacing * spacing; + sum += _poly6(r2); + gradient2 += _spikyGradient( + _V(a * spacing, b * spacing, c * spacing), + ).length2; + } + } + } + _latticeSum = sum; + // How strongly the constraint answers a move at rest: Σ|∇C|² on the + // lattice, in the constraint's normalisation. The artificial pressure + // is a share of a constraint's worth over this. + _restStiffness = gradient2 / (sum * sum); + } + + late final double _latticeSum; + late final double _restStiffness; + + final FluidMedium medium; + + /// The particles' spacing at rest, metres. + final double spacing; + + /// Density passes per substep, and the fewest substeps per [step]. + final int iterations; + final int substeps; + + /// The time surface tension takes to move a particle its own size, + /// √(ρs³/σ): the cohesion between neighbours is a spring this quick, and + /// a step stepped explicitly must be well inside it. A third of a + /// millisecond for millimetre water, where a fixed two substeps of a + /// 240th of a second threw drops apart at metres a second. + double get capillaryTime => medium.surfaceTension > 0.0 + ? math.sqrt( + medium.density * spacing * spacing * spacing / medium.surfaceTension, + ) + : double.infinity; + + /// The kernel's reach. + final double h; + + late final double _gamma; + + // In double precision: a particle's velocity is read off how far it moved + // in a substep, a millionth of a metre a metre from the origin, and + // single precision has only a tenth of that to spare. + final List<_V> _x = []; + final List<_V> _v = []; + + /// What is dissolved in each particle, as concentrations; and in the + /// bank, as amounts. + final List> _c = []; + final Map _bankAmounts = {}; + double _bank = 0.0; + double _received = 0.0; + + /// How much liquid one particle is. + double get particleVolume => spacing * spacing * spacing; + double get _mass => medium.density * particleVolume; + + /// The particles' places. + List get positions => [for (final p in _x) p.toVector3()]; + + /// Every particle's velocity, in the order of [positions]. + List get velocities => [for (final v in _v) v.toVector3()]; + int get count => _x.length; + + /// Cubic metres here: every particle and the bank. + double get volume => _x.length * particleVolume + _bank; + + /// Cubic metres handed to receivers so far. + double get received => _received; + + /// Adds [amount] cubic metres at [position] moving at [velocity], with + /// [concentrations] dissolved in it: as many whole particles as it and + /// the bank make, laid out round [position] at the spacing so none start + /// on top of another, each carrying what the bank holds per cubic metre. + void inject( + double amount, + Vector3 position, + Vector3 velocity, { + Map concentrations = const {}, + }) { + _bank += amount; + concentrations.forEach((key, c) { + _bankAmounts[key] = (_bankAmounts[key] ?? 0.0) + c * amount; + }); + // A hair of slack, so that halves that add up to a whole make one. + final whole = (_bank / particleVolume + 1e-9).floor(); + if (whole <= 0) return; + final carried = { + for (final e in _bankAmounts.entries) e.key: e.value / _bank, + }; + final left = math.max(_bank - whole * particleVolume, 0.0); + for (final key in _bankAmounts.keys.toList()) { + _bankAmounts[key] = carried[key]! * left; + } + _bank = left; + var side = 1; + while (side * side * side < whole) { + side++; + } + var placed = 0; + for (var k = 0; k < side && placed < whole; k++) { + for (var j = 0; j < side && placed < whole; j++) { + for (var i = 0; i < side && placed < whole; i++) { + _x.add( + _V( + position.x + (i - 0.5 * (side - 1)) * spacing, + position.y + (j - 0.5 * (side - 1)) * spacing, + position.z + (k - 0.5 * (side - 1)) * spacing, + ), + ); + _v.add(_V(velocity.x, velocity.y, velocity.z)); + _c.add(carried); + placed++; + } + } + } + } + + /// Moves the particles on by [dt] under [gravity], against [obstacles], + /// into [receivers]. + void step( + double dt, { + required Vector3 gravity, + List obstacles = const [], + List receivers = const [], + }) { + if (_x.isEmpty) return; + // A quarter of the capillary time a substep at most, and never fewer + // than asked. How far a particle goes in one is not held here: it is + // moved in pieces against the walls instead (`_advance`), which costs a + // wall test, not a density solve, per piece. + final fastest = _v.fold(0.0, (m, v) => math.max(m, v.length)); + final count = math + .max(substeps, (dt / (0.25 * capillaryTime)).ceil()) + .clamp(1, 256); + final sub = dt / count; + final g = _V(gravity.x, gravity.y, gravity.z); + // The walls anything here could reach this step, found once: every + // particle, and as far as the fastest goes, in one ball. + final low = _x.first.copy(); + final high = _x.first.copy(); + for (final p in _x) { + low + ..x = math.min(low.x, p.x) + ..y = math.min(low.y, p.y) + ..z = math.min(low.z, p.z); + high + ..x = math.max(high.x, p.x) + ..y = math.max(high.y, p.y) + ..z = math.max(high.z, p.z); + } + final walls = obstaclesNear( + obstacles, + ((low + high) * 0.5).toVector3(), + 0.5 * (high - low).length + + (fastest + gravity.length * dt) * dt + + spacing, + ); + for (var s = 0; s < count; s++) { + _substep(sub, g, walls); + } + // Into a vessel's liquid: handed over, whole. + final radius = 0.5 * spacing; + for (var i = _x.length - 1; i >= 0; i--) { + final at = _x[i].toVector3(); + for (final r in receivers) { + if (!r.catches(at, radius)) continue; + r.receive(particleVolume, at, _v[i].toVector3(), medium, _c[i]); + _received += particleVolume; + _x.removeAt(i); + _v.removeAt(i); + _c.removeAt(i); + break; + } + } + } + + void _substep(double dt, _V gravity, List obstacles) { + final n = _x.length; + // Where a particle starts inside a wall or under the floor, it is put + // out first, its velocity left alone. A drop let go near the bottom of a + // glass is laid out as a little block round where it parted, and a + // particle of the block can start under the floor; put out by the + // correction pass instead, its four millimetres became its velocity + // over a fifth of a millisecond, and it left at eighteen metres a + // second. + for (var i = 0; i < n; i++) { + _collide(_x[i], obstacles); + } + // **Pre-stabilisation** (Macklin, Müller, Chentanez and Kim, "Unified + // Particle Physics for Real-Time Applications", 2014): the compression + // there already is when the substep starts is taken out of where the + // particles are, and not out of how fast they go — the same as a + // particle started in a wall, above, for the same reason. A stream lets + // go of its drops at nearly one point step after step and lays each + // lump over the last; corrected only in the solve below, that overlap + // became velocity, and the chemistry bench's overflowing flask threw + // thirty thousand particles eleven metres up. Taken out here, the crowd + // makes room for itself and keeps the speed it had. + _holdDensity(_x, obstacles, artificialPressure: false, until: 1e-3); + final rho0 = medium.density; + final m = _mass; + final norm = 1.0 / _latticeSum; + // Forces first: gravity, cohesion and curvature, on the velocities. + final grid = _Grid(h, _x); + final neighbours = [for (var i = 0; i < n; i++) grid.near(i, _x)]; + final density = List.filled(n, 0.0); + for (var i = 0; i < n; i++) { + var w = _poly6(0.0); + for (final j in neighbours[i]) { + w += _poly6(_x[i].distance2(_x[j])); + } + density[i] = rho0 * w * norm; + } + final normal = List<_V>.generate(n, (_) => _V(0, 0, 0)); + for (var i = 0; i < n; i++) { + for (final j in neighbours[i]) { + normal[i].addScaled(_spikyGradient(_x[i] - _x[j]), h * m / density[j]); + } + } + for (var i = 0; i < n; i++) { + final a = gravity.copy(); + for (final j in neighbours[i]) { + final d = _x[i] - _x[j]; + final r = d.length; + if (r < 1e-12) continue; + final k = 2.0 * rho0 / (density[i] + density[j]); + a + ..addScaled(d, -k * _gamma * m * _cohesion(r) / r) + ..addScaled(normal[i] - normal[j], -k * _gamma); + } + _v[i].addScaled(a, dt); + } + // Predict, then hold the density to the rest density. + final p = [ + for (var i = 0; i < n; i++) _advance(_x[i], _v[i], dt, obstacles), + ]; + final near = _holdDensity(p, obstacles, artificialPressure: true); + // Velocities from the move, then XSPH's viscosity. + for (var i = 0; i < n; i++) { + _v[i] = (p[i] - _x[i]) * (1.0 / dt); + } + final share = math.min( + 0.5, + medium.kinematicViscosity * dt / (spacing * spacing) * 50.0 + 0.01, + ); + final smoothed = [for (final v in _v) v.copy()]; + for (var i = 0; i < n; i++) { + for (final j in near[i]) { + final w = _poly6(p[i].distance2(p[j])) * norm; + smoothed[i].addScaled(_v[j] - _v[i], share * w); + } + } + for (var i = 0; i < n; i++) { + _v[i] = smoothed[i]; + _x[i] = p[i]; + } + } + + /// Moves [p] until no particle is compressed past the rest density, for + /// [iterations] passes at most, or fewer once none is compressed by more + /// than [until] of it; returns each particle's neighbours, found where + /// [p] stood at the start. + /// + /// [artificialPressure] adds Macklin's term against clumping, which the + /// solve wants and pre-stabilisation does not: there it would push still + /// water apart every substep with nothing to answer it. + List> _holdDensity( + List<_V> p, + List obstacles, { + required bool artificialPressure, + double until = 0.0, + }) { + final n = p.length; + final norm = 1.0 / _latticeSum; + final lambda = List.filled(n, 0.0); + final grid = _Grid(h, p); + final near = [for (var i = 0; i < n; i++) grid.near(i, p)]; + final dq = 0.3 * h; + final wq = _poly6(dq * dq); + for (var it = 0; it < iterations; it++) { + var worst = 0.0; + for (var i = 0; i < n; i++) { + // C = ρ/ρ₀ − 1 with ρ/ρ₀ the kernel sum over the lattice's: only + // where it is compressed. Both ways, a surface particle with half + // its neighbours missing is pulled in by half a spacing a pass and + // the liquid flies apart; a stretched surface is held by cohesion + // instead. + var w = _poly6(0.0); + var sum2 = 0.0; + final gi = _V(0, 0, 0); + for (final j in near[i]) { + final d = _between(p, i, j); + w += _poly6(d.length2); + final g = _spikyGradient(d) * norm; + sum2 += g.length2; + gi.add(g); + } + sum2 += gi.length2; + final c = math.max(w * norm - 1.0, 0.0); + worst = math.max(worst, c); + lambda[i] = -c / (sum2 + 1e-6 * norm * norm / (h * h)); + } + if (worst <= until) break; + final delta = List<_V>.generate(n, (_) => _V(0, 0, 0)); + for (var i = 0; i < n; i++) { + for (final j in near[i]) { + final d = _between(p, i, j); + var corr = 0.0; + if (artificialPressure) { + final ratio = _poly6(d.length2) / wq; + // Macklin's artificial pressure, which keeps neighbours from + // clumping: a fiftieth of a constraint's worth. At his tenth it + // held still water a sixth thinner than its rest density. + corr = -0.02 * ratio * ratio * ratio * ratio / _restStiffness; + } + // Δpᵢ = Σⱼ (λᵢ + λⱼ + s_corr) ∇W(pᵢ − pⱼ), in the same + // normalisation as the constraint. + delta[i].addScaled( + _spikyGradient(d), + (lambda[i] + lambda[j] + corr) * norm, + ); + } + } + for (var i = 0; i < n; i++) { + p[i].add(delta[i]); + _collide(p[i], obstacles); + } + } + return near; + } + + /// Where [x] moving at [v] is after [dt]: moved in pieces of two fifths + /// of a spacing, put out of the walls after each. A wall holds what comes + /// within a radius of it or half its thickness into it, a band wider than + /// a piece, so nothing passes through glass thinner than a particle; a + /// drop falling a metre a second into a test tube went five millimetres a + /// step and through its bottom. + _V _advance(_V x, _V v, double dt, List obstacles) { + final p = x.copy(); + final pieces = obstacles.isEmpty + ? 1 + : (v.length * dt / (0.4 * spacing)).ceil().clamp(1, 64); + for (var k = 0; k < pieces; k++) { + p.addScaled(v, dt / pieces); + if (pieces > 1) _collide(p, obstacles); + } + return p; + } + + void _collide(_V p, List obstacles) { + final radius = 0.5 * spacing; + for (final o in obstacles) { + final hit = o.touch(p.toVector3(), radius); + if (hit != null) { + p.addScaled(_V(hit.normal.x, hit.normal.y, hit.normal.z), hit.depth); + } + } + } + + double _poly6(double r2) { + final h2 = h * h; + if (r2 >= h2) return 0.0; + final d = h2 - r2; + return 315.0 / (64.0 * math.pi * _pow9(h)) * d * d * d; + } + + /// pᵢ − pⱼ, or, where the two stand on the same point, a hair of it in a + /// direction set by the pair alone — opposite for j and i. + /// + /// **Coincident particles have no gradient to part along.** Each counts + /// whole in the other's density, and the kernel's gradient at nought is + /// nought: the constraint read heavily compressed with nothing to move it + /// by, λ = −C / |∇C|² ran away, and the particles round the pair were + /// thrown kilometres. A stream's lumps let go at the same point land + /// exactly on each other's lattice, so this is the ordinary case, not a + /// corner. + _V _between(List<_V> p, int i, int j) { + final d = p[i] - p[j]; + if (d.length2 > 1e-24 * h * h) return d; + final low = math.min(i, j); + final high = math.max(i, j); + // A point on the sphere from the pair's indices, by the golden angle. + final t = (low * 0.6180339887498949 + high * 0.4142135623730951) % 1.0; + final z = 1.0 - 2.0 * t; + final ring = math.sqrt(math.max(1.0 - z * z, 0.0)); + final phi = 2.399963229728653 * (low + 7 * high); + final away = 1e-6 * h * (i < j ? 1.0 : -1.0); + return _V( + ring * Portable.cos(phi) * away, + ring * Portable.sin(phi) * away, + z * away, + ); + } + + _V _spikyGradient(_V d) { + final r = d.length; + if (r <= 1e-12 || r >= h) return _V(0, 0, 0); + final f = -45.0 / (math.pi * _pow6(h)) * (h - r) * (h - r); + return d * (f / r); + } + + /// Akinci's cohesion spline. + double _cohesion(double r) { + if (r >= h || r <= 0.0) return 0.0; + final c = 32.0 / (math.pi * _pow9(h)); + final a = (h - r) * (h - r) * (h - r) * r * r * r; + if (2.0 * r > h) return c * a; + return c * (2.0 * a - _pow6(h) / 64.0); + } + + /// The cohesion coefficient that makes the work of pulling the lattice + /// apart along a plane 2σ per square metre: the potential of the cohesion + /// force, summed across a plane over every pair within reach, per unit of + /// area, at a coefficient of one, then scaled. + double _cohesionForWorkOfCohesion() { + final m = _mass; + double potential(double r) { + // ∫ from r to h of m²C(s) ds, by Simpson over sixteen pieces. + const pieces = 16; + final step = (h - r) / pieces; + var sum = 0.0; + for (var k = 0; k <= pieces; k++) { + final s = r + k * step; + final w = k == 0 || k == pieces ? 1.0 : (k.isOdd ? 4.0 : 2.0); + sum += w * m * m * _cohesion(s); + } + return sum * step / 3.0; + } + + final reach = (h / spacing).ceil() + 1; + var work = 0.0; + // One column of particles below the plane, at z = −(k + ½)s, against + // every particle above it. + for (var k = 0; k < reach; k++) { + final zi = -(k + 0.5) * spacing; + for (var a = -reach; a <= reach; a++) { + for (var b = -reach; b <= reach; b++) { + for (var c = 0; c < reach; c++) { + final d = Vector3( + a * spacing, + b * spacing, + (c + 0.5) * spacing - zi, + ); + final r = d.length; + if (r < h) work += potential(r); + } + } + } + } + work /= spacing * spacing; + return work > 0.0 ? 2.0 * medium.surfaceTension / work : 0.0; + } + + static double _pow6(double x) => x * x * x * x * x * x; + static double _pow9(double x) => _pow6(x) * x * x * x; +} + +/// A uniform grid of cells as wide as the kernel, for finding neighbours. +final class _Grid { + _Grid(this.cell, List<_V> points) { + for (var i = 0; i < points.length; i++) { + _cells.putIfAbsent(_key(points[i]), () => []).add(i); + } + } + + final double cell; + final Map> _cells = {}; + + int _key(_V p) => + _pack((p.x / cell).floor(), (p.y / cell).floor(), (p.z / cell).floor()); + + /// By multiplication, as `spatial_grid.dart` does, and not by shifting + /// into the high bits: on the web an int's bitwise operations are 32 bits + /// wide, `x << 42` kept almost nothing of `x`, cells far apart shared a + /// key, a particle met the same neighbour several times over, and the + /// liquid read as many times its density and flew apart. Each index is + /// held to ±2¹⁶ cells and the key under 2⁵¹, which a double holds exactly. + static int _pack(int x, int y, int z) { + const span = 1 << 17; + const half = 1 << 16; + int wrap(int v) => (v + half) % span; + return (wrap(x) * span + wrap(y)) * span + wrap(z); + } + + /// The points within [cell] of point [i], itself left out. + List near(int i, List<_V> points) { + final p = points[i]; + final cx = (p.x / cell).floor(); + final cy = (p.y / cell).floor(); + final cz = (p.z / cell).floor(); + final out = []; + final reach2 = cell * cell; + for (var dx = -1; dx <= 1; dx++) { + for (var dy = -1; dy <= 1; dy++) { + for (var dz = -1; dz <= 1; dz++) { + final list = _cells[_pack(cx + dx, cy + dy, cz + dz)]; + if (list == null) continue; + for (final j in list) { + if (j != i && points[j].distance2(p) < reach2) out.add(j); + } + } + } + } + return out; + } +} + +/// A vector in double precision, for the solver's own arithmetic. +final class _V { + _V(this.x, this.y, this.z); + + double x; + double y; + double z; + + _V copy() => _V(x, y, z); + Vector3 toVector3() => Vector3(x, y, z); + + _V operator +(_V o) => _V(x + o.x, y + o.y, z + o.z); + _V operator -(_V o) => _V(x - o.x, y - o.y, z - o.z); + _V operator *(double k) => _V(x * k, y * k, z * k); + + void add(_V o) { + x += o.x; + y += o.y; + z += o.z; + } + + void addScaled(_V o, double k) { + x += o.x * k; + y += o.y * k; + z += o.z * k; + } + + double get length2 => x * x + y * y + z * z; + double get length => math.sqrt(length2); + + double distance2(_V o) { + final dx = x - o.x, dy = y - o.y, dz = z - o.z; + return dx * dx + dy * dy + dz * dz; + } +} diff --git a/packages/flutter3d_physics/lib/src/fluid/pipe.dart b/packages/flutter3d_physics/lib/src/fluid/pipe.dart new file mode 100644 index 000000000..5740038fa --- /dev/null +++ b/packages/flutter3d_physics/lib/src/fluid/pipe.dart @@ -0,0 +1,97 @@ +import 'dart:math' as math; + +import 'package:vector_math/vector_math.dart'; + +import 'liquid_body.dart'; + +/// A round pipe joining two vessels below their surfaces: communicating +/// vessels. +/// +/// **The liquid in it has mass**, so it is not pushed through at once but +/// accelerated: the pressure difference between its ends drives the column +/// — the pipe's own length over its section, and the liquid standing in each +/// vessel above it over the vessel's — and friction holds it back, linearly +/// for a slow viscous flow (Hagen–Poiseuille, 8μL/πr⁴) and as the square of +/// the flow for the losses where it enters and leaves (K·ρQ|Q|/2a²). Two +/// vessels joined this way swing about a common level before they settle on +/// it — a U-tube's period 2π√(l/2g) for a column l long — and a thick liquid +/// creeps to it without swinging. +/// +/// **The pressure at each end** is the weight of the liquid above it in the +/// gravity that vessel feels — layer by layer, each at its own density — +/// less the pull of its surface where it is +/// curved: σ times the curvature at the middle of the meniscus, which is why +/// a narrow tube joined to a wide one stands higher by Jurin's height. +final class Pipe { + Pipe({ + required this.from, + required this.at, + required this.to, + required this.toAt, + required this.radius, + required this.length, + this.minorLoss = 1.5, + }); + + /// The two vessels, and where the pipe opens into each (vessel frame). + final LiquidBody from; + final Vector3 at; + final LiquidBody to; + final Vector3 toAt; + + /// The pipe's bore and length, metres. + final double radius; + final double length; + + /// The loss where it enters and leaves, in velocity heads: about 0.5 in + /// and 1 out for sharp ends. + final double minorLoss; + + /// Cubic metres a second from [from] to [to]; negative the other way. + double flow = 0.0; + + /// Moves the liquid in the pipe on by [dt] under [gravity]. + void step(double dt, {required Vector3 gravity}) { + final medium = from.medium; + final rho = medium.density; + final g = gravity.length; + if (g <= 0.0) return; + final a = math.pi * radius * radius; + final head0 = from.depthAbove(at); + final head1 = to.depthAbove(toAt); + // Every layer over each end, at its own density, less the meniscus's + // pull. + final p0 = from.pressureAt(at) - from.capillaryPressure; + final p1 = to.pressureAt(toAt) - to.capillaryPressure; + // Air cannot be pushed through: a side whose surface is below its end + // gives nothing. + if (head0 <= 0.0 && flow > 0.0) flow = 0.0; + if (head1 <= 0.0 && flow < 0.0) flow = 0.0; + final inertance = + rho * + (length / a + + math.max(head0, 0.0) / math.max(from.surface.area, a) + + math.max(head1, 0.0) / math.max(to.surface.area, a)); + final viscous = + 8.0 * + medium.viscosity * + length / + (math.pi * (radius * radius * radius * radius)); + final quadratic = minorLoss * rho * flow.abs() / (2.0 * a * a); + // Friction taken implicitly, so a thick liquid in a thin pipe is stable + // at any step. + var drive = p0 - p1; + if (head0 <= 0.0 && drive > 0.0) drive = 0.0; + if (head1 <= 0.0 && drive < 0.0) drive = 0.0; + flow = + (flow + dt * drive / inertance) / + (1.0 + dt * (viscous + quadratic) / inertance); + // The liquid at each opening goes through: the layer it opens into. + final moved = flow * dt; + if (moved > 0.0) { + from.drawAt(at, moved).forEach(to.add); + } else if (moved < 0.0) { + to.drawAt(toAt, -moved).forEach(from.add); + } + } +} diff --git a/packages/flutter3d_physics/lib/src/fluid/vessel_shape.dart b/packages/flutter3d_physics/lib/src/fluid/vessel_shape.dart new file mode 100644 index 000000000..89bd3081a --- /dev/null +++ b/packages/flutter3d_physics/lib/src/fluid/vessel_shape.dart @@ -0,0 +1,371 @@ +import 'dart:math' as math; + +import 'package:vector_math/vector_math.dart'; + +import '../portable_math.dart'; + +/// The inside of a vessel, in its own frame, as far as liquid in it cares: +/// how much it holds below any plane, and the edge of its mouth. +/// +/// **A liquid at rest has a flat surface, so this is all there is to it.** +/// Its surface is a plane square to the gravity it feels; where that plane +/// stands follows from the volume, since the volume under it is the volume +/// of liquid; and liquid leaves over the mouth when the plane would have to +/// stand above the mouth's lowest point. So a shape answers one question — +/// the volume below the plane `up · p = height` — and names its [rim]. +abstract interface class VesselShape { + /// The volume inside below the plane `up · p = height`; [up] is a unit + /// vector in the vessel's frame. + double volumeBelow(Vector3 up, double height); + + /// The least and greatest `up · p` over the inside: below [low] the plane + /// holds nothing, above [high] all of it. + ({double low, double high}) span(Vector3 up); + + /// The edge of the mouth, as points round it; empty for a closed vessel, + /// which nothing leaves. + List get rim; + + /// Whether [point] is inside. + bool contains(Vector3 point); +} + +/// The questions a [VesselShape] answers through its volume. +extension VesselVolumes on VesselShape { + /// The height along [up] at which the plane square to it leaves [volume] + /// under it: where the surface of that much liquid stands. + double surfaceFor(Vector3 up, double volume) { + final (:low, :high) = span(up); + var lo = low; + var hi = high; + for (var i = 0; i < 52; i++) { + final mid = 0.5 * (lo + hi); + if (volumeBelow(up, mid) < volume) { + lo = mid; + } else { + hi = mid; + } + } + return 0.5 * (lo + hi); + } + + /// [surfaceFor], started from [guess], the height it stood at a moment + /// ago: by secants on the volume, whose slope is the area of the cut and + /// changes little over a step, so three or four volumes are enough where + /// halving takes fifty-two. Halving still, if the secants stray out of + /// the vessel or do not settle to [tolerance] of its height. + double surfaceNear( + Vector3 up, + double volume, + double guess, { + double tolerance = 1e-10, + }) { + final (:low, :high) = span(up); + final size = high - low; + if (!(guess > low && guess < high) || volume <= 0.0) { + return surfaceFor(up, volume); + } + var h0 = guess; + var v0 = volumeBelow(up, h0) - volume; + var h1 = guess + 1e-4 * size * (v0 > 0.0 ? -1.0 : 1.0); + for (var i = 0; i < 8; i++) { + final v1 = volumeBelow(up, h1) - volume; + if ((h1 - h0).abs() <= tolerance * size || v1 == 0.0) return h1; + if (v1 == v0) break; + final next = h1 - v1 * (h1 - h0) / (v1 - v0); + if (!(next > low && next < high)) break; + h0 = h1; + v0 = v1; + h1 = next; + } + return surfaceFor(up, volume); + } + + /// The level [volume] stands at in the vessel upright. + double levelFor(double volume) => surfaceFor(Vector3(0, 1, 0), volume); + + /// The volume upright up to [level]. + double volumeUpTo(double level) => volumeBelow(Vector3(0, 1, 0), level); + + /// The lowest point of the [rim] along [up], and its height: where liquid + /// leaves first. Null for a closed vessel. + ({Vector3 point, double height})? lip(Vector3 up) { + if (rim.isEmpty) return null; + var best = rim.first; + var height = up.dot(best); + for (final p in rim) { + final h = up.dot(p); + if (h < height) { + height = h; + best = p; + } + } + return (point: best, height: height); + } + + /// The most the vessel holds with [up] as it is before liquid runs over + /// its lip; all of it, for a closed one. + double holds(Vector3 up) { + final edge = lip(up); + return edge == null + ? volumeBelow(up, span(up).high) + : volumeBelow(up, edge.height); + } + + /// The most the vessel holds upright. + double get capacity => holds(Vector3(0, 1, 0)); +} + +/// A vessel whose inside is a surface of revolution about its Y axis — a +/// test tube, a beaker, a flask: a [wall] profile in the (radius, height) +/// plane, from the middle of the floor up the side to the mouth. +/// +/// Cut across its axis it is a disc, and a plane cuts a disc along a chord, +/// so the volume below a plane is a sum of circular segments, slice by +/// slice. Upright it is exact: a straight piece of the profile sweeps a +/// frustum, πΔy(r₀² + r₀r₁ + r₁²)/3. +final class RevolvedVessel implements VesselShape { + RevolvedVessel(List wall, {this.slices = 240, this.rimPoints = 48}) + : wall = List.unmodifiable(wall) { + if (wall.length < 2) { + throw ArgumentError('A wall needs at least two points.'); + } + } + + final List wall; + + /// How many slices a tilted plane's volume is summed over. + final int slices; + + /// How many points the [rim] has. + final int rimPoints; + + late final double floor = wall.map((p) => p.y).reduce(math.min); + + /// The widest the inside gets. + late final double widest = wall.map((p) => p.x).reduce(math.max); + late final double top = wall.map((p) => p.y).reduce(math.max); + + /// How far [point] (vessel frame) is from the wall, across it: negative + /// inside, positive in or beyond the glass; and the wall's outward + /// normal there. Measured in the plane through the axis to the nearest + /// piece of the profile, so it holds round a rounded bottom as up a + /// straight side, where the radius at a height says nothing of how near + /// the bottom is. Null above the mouth, where there is no wall. + ({double distance, Vector3 normal})? wallDistance(Vector3 point) { + if (point.y > top) return null; + final r = math.sqrt(point.x * point.x + point.z * point.z); + final out = r > 1e-12 + ? Vector3(point.x / r, 0, point.z / r) + : Vector3(1, 0, 0); + final q = Vector2(r, point.y); + var best = double.infinity; + var normal = Vector2(0, -1); + var sign = 1.0; + for (var i = 0; i + 1 < wall.length; i++) { + final a = wall[i]; + final d = wall[i + 1] - a; + final length2 = d.length2; + if (length2 < 1e-24) continue; + final t = ((q - a).dot(d) / length2).clamp(0.0, 1.0); + final off = q - (a + d * t); + final distance = off.length; + if (distance < best) { + best = distance; + // Outward is to the right of the way the profile runs, from the + // middle of the floor up to the mouth. + final right = Vector2(d.y, -d.x)..normalize(); + sign = off.dot(right) >= 0.0 ? 1.0 : -1.0; + normal = right; + } + } + return ( + distance: sign * best, + normal: out * normal.x + Vector3(0, normal.y, 0), + ); + } + + /// The radius of the inside at [height], or nought outside its height. + double radiusAt(double height) { + for (var i = 0; i + 1 < wall.length; i++) { + final a = wall[i]; + final b = wall[i + 1]; + if ((b.y - a.y).abs() < 1e-12) continue; + final lo = math.min(a.y, b.y); + final hi = math.max(a.y, b.y); + if (height < lo || height > hi) continue; + return a.x + (b.x - a.x) * (height - a.y) / (b.y - a.y); + } + return 0.0; + } + + @override + bool contains(Vector3 point) { + if (point.y < floor || point.y > top) return false; + final r = radiusAt(point.y); + return point.x * point.x + point.z * point.z < r * r; + } + + /// The radius of the mouth: the profile's last point. + double get mouthRadius => wall.last.x; + + @override + late final List rim = List.unmodifiable([ + for (var i = 0; i < rimPoints; i++) + Vector3( + mouthRadius * Portable.cos(2.0 * math.pi * i / rimPoints), + wall.last.y, + mouthRadius * Portable.sin(2.0 * math.pi * i / rimPoints), + ), + ]); + + @override + ({double low, double high}) span(Vector3 up) { + final across = math.sqrt(up.x * up.x + up.z * up.z); + var lo = double.infinity; + var hi = -double.infinity; + for (final p in wall) { + lo = math.min(lo, up.y * p.y - across * p.x); + hi = math.max(hi, up.y * p.y + across * p.x); + } + return (low: lo, high: hi); + } + + @override + double volumeBelow(Vector3 up, double height) { + final across = math.sqrt(up.x * up.x + up.z * up.z); + if (across < 1e-9) { + return up.y > 0.0 + ? _upright(height / up.y) + : _upright(top) - _upright(height / up.y); + } + final dy = (top - floor) / slices; + var volume = 0.0; + for (var i = 0; i < slices; i++) { + final y = floor + (i + 0.5) * dy; + volume += circularSegment(radiusAt(y), (height - up.y * y) / across) * dy; + } + return volume; + } + + /// The volume up to [level] standing upright: frustum by frustum. + double _upright(double level) { + var volume = 0.0; + for (var i = 0; i + 1 < wall.length; i++) { + final a = wall[i]; + final b = wall[i + 1]; + if (b.y <= a.y) continue; + final hi = math.min(b.y, level); + if (hi <= a.y) break; + final r1 = a.x + (b.x - a.x) * (hi - a.y) / (b.y - a.y); + volume += math.pi * (hi - a.y) * (a.x * a.x + a.x * r1 + r1 * r1) / 3.0; + } + return volume; + } +} + +/// The area of the part of a disc of [radius] on the near side of a chord +/// [offset] from its centre: none at −radius, half at nought, all at +radius. +double circularSegment(double radius, double offset) { + if (radius <= 0.0) return 0.0; + if (offset >= radius) return math.pi * radius * radius; + if (offset <= -radius) return 0.0; + return radius * radius * Portable.acos(-offset / radius) + + offset * math.sqrt(radius * radius - offset * offset); +} + +/// A vessel whose inside is any closed triangle mesh: its cavity, closed +/// across the mouth, with the triangles wound counter-clockwise seen from +/// outside the cavity, and the [rim] of the mouth given as points. +/// +/// **The volume below a plane is a surface sum** (Gauss): the region below +/// is bounded by the part of the mesh below the plane and by the plane's own +/// cut through it. Summing the signed tetrahedra from a point to every face +/// of that boundary gives its volume; put the point on the plane, and every +/// tetrahedron on the cut is flat and adds nothing. So each triangle is cut +/// to the part below the plane and summed against a point on it, and the cut +/// itself — which for a vessel that is not convex may be several outlines — +/// never has to be found. +final class MeshVessel implements VesselShape { + MeshVessel({ + required List positions, + required List indices, + List rim = const [], + }) : positions = List.unmodifiable(positions), + indices = List.unmodifiable(indices), + rim = List.unmodifiable(rim) { + if (indices.length % 3 != 0) { + throw ArgumentError('Indices come in threes.'); + } + } + + final List positions; + final List indices; + + @override + final List rim; + + /// Inside when a ray from [point] crosses the surface an odd number of + /// times; along a skewed direction, so it does not run along an edge. + @override + bool contains(Vector3 point) { + final ray = Vector3(0.5773, 0.5774, 0.5772)..normalize(); + var crossings = 0; + for (var t = 0; t < indices.length; t += 3) { + final a = positions[indices[t]]; + final e1 = positions[indices[t + 1]] - a; + final e2 = positions[indices[t + 2]] - a; + final p = ray.cross(e2); + final det = e1.dot(p); + if (det.abs() < 1e-12) continue; + final inv = 1.0 / det; + final s = point - a; + final u = s.dot(p) * inv; + if (u < 0.0 || u > 1.0) continue; + final q = s.cross(e1); + final v = ray.dot(q) * inv; + if (v < 0.0 || u + v > 1.0) continue; + if (e2.dot(q) * inv > 0.0) crossings++; + } + return crossings.isOdd; + } + + @override + ({double low, double high}) span(Vector3 up) { + var lo = double.infinity; + var hi = -double.infinity; + for (final p in positions) { + final h = up.dot(p); + lo = math.min(lo, h); + hi = math.max(hi, h); + } + return (low: lo, high: hi); + } + + @override + double volumeBelow(Vector3 up, double height) { + final origin = up * height; + var six = 0.0; + final kept = []; + for (var t = 0; t < indices.length; t += 3) { + kept.clear(); + for (var k = 0; k < 3; k++) { + final a = positions[indices[t + k]]; + final b = positions[indices[t + (k + 1) % 3]]; + final sa = up.dot(a) - height; + final sb = up.dot(b) - height; + if (sa <= 0.0) kept.add(a); + if ((sa < 0.0) != (sb < 0.0)) { + kept.add(a + (b - a) * (sa / (sa - sb))); + } + } + for (var k = 1; k + 1 < kept.length; k++) { + final a = kept[0] - origin; + final b = kept[k] - origin; + final c = kept[k + 1] - origin; + six += a.dot(b.cross(c)); + } + } + return six / 6.0; + } +} diff --git a/packages/flutter3d_physics/lib/src/inertia.dart b/packages/flutter3d_physics/lib/src/inertia.dart new file mode 100644 index 000000000..9846d350a --- /dev/null +++ b/packages/flutter3d_physics/lib/src/inertia.dart @@ -0,0 +1,83 @@ +/// How hard a shape is to turn. +/// +/// ## Three numbers, not nine +/// +/// Every shape here is symmetric about its own three axes, and a solid with +/// that symmetry has a diagonal inertia tensor in those axes: the products of +/// inertia integrate an odd function over a symmetric body and vanish. So a +/// tensor in body space is three principal moments, carried as a [Vector3], +/// and a full matrix appears only once the body is turned — see +/// `RigidBody.inverseInertiaWorld`. +/// +/// ## Two of the five are approximations, and say so +/// +/// * **A wedge is treated as its bounding box.** The true solid is half the +/// box, with its centre of mass a sixth of the way towards the low side and +/// a tensor that is no longer diagonal about the box centre. A body's centre +/// is its collider's centre here, so honouring the wedge means moving one of +/// the two; until a wedge has to tumble convincingly, the box it sits in +/// turns close enough. +/// * **A heightfield is treated as its bounding box too.** It is ground, and +/// ground does not turn; the answer exists so that [inertiaFor] has one for +/// every shape the sealed class has, rather than a throw a game finds later. +library; + +import 'package:vector_math/vector_math.dart'; + +import 'collision_shape.dart'; + +/// The principal moments of inertia of [shape] with [mass] kilograms spread +/// evenly through it, about its own centre and in its own axes. +/// +/// Kilogram square metres. Zero for a [mass] of zero or less — what such a +/// body's inverse should be is `RigidBody`'s decision, not this one's. +Vector3 inertiaFor(CollisionShape shape, double mass) { + if (mass <= 0.0) return Vector3.zero(); + return switch (shape) { + CollisionBox(:final halfExtents) => _box(halfExtents, mass), + CollisionSphere(:final radius) => Vector3.all(0.4 * mass * radius * radius), + CollisionCapsule(:final radius, :final halfHeight) => _capsule( + radius, + halfHeight, + mass, + ), + CollisionWedge(:final halfExtents) => _box(halfExtents, mass), + CollisionHeightfield() => _box(shape.boundsHalfExtents, mass), + }; +} + +/// `m/12 · (w² + d²)` with the full widths, which is `m/3 · (b² + c²)` with +/// the half extents the shape keeps. +Vector3 _box(Vector3 half, double mass) { + final x2 = half.x * half.x; + final y2 = half.y * half.y; + final z2 = half.z * half.z; + final third = mass / 3.0; + return Vector3(third * (y2 + z2), third * (x2 + z2), third * (x2 + y2)); +} + +/// An upright capsule: a cylinder of half length [halfHeight] and two +/// hemispherical caps, the mass split between them by volume. +/// +/// **Not a cylinder of the total height**, which is the easy answer and puts +/// mass in the corners a capsule has rounded off. The difference is largest +/// for the short, round capsules a character stands in, and at a [halfHeight] +/// of zero this has to be a sphere — `rotation_test.dart` holds both ends. +/// +/// Each cap about the centre is `ms · (2/5 r² + h² + 3/4 h r)` for the pair: +/// a hemisphere's own `2/5 mr²` about its flat face, moved to its centroid +/// three-eighths of a radius out, and then on to the capsule's middle. +Vector3 _capsule(double r, double h, double mass) { + final r2 = r * r; + // π cancels out of the ratio: a cylinder of 2h·r² against a sphere of 4/3·r³. + final cylinderVolume = 2.0 * h * r2; + final capsVolume = 4.0 / 3.0 * r2 * r; + final cylinder = mass * cylinderVolume / (cylinderVolume + capsVolume); + final caps = mass - cylinder; + + final axial = cylinder * 0.5 * r2 + caps * 0.4 * r2; + final across = + cylinder * (0.25 * r2 + h * h / 3.0) + + caps * (0.4 * r2 + h * h + 0.75 * h * r); + return Vector3(across, axial, across); +} diff --git a/packages/flutter3d_physics/lib/src/portable_math.dart b/packages/flutter3d_physics/lib/src/portable_math.dart new file mode 100644 index 000000000..e4f1e3d21 --- /dev/null +++ b/packages/flutter3d_physics/lib/src/portable_math.dart @@ -0,0 +1,842 @@ +/// The functions a step is allowed to call, computed the same way everywhere. +/// +/// ## Why this exists +/// +/// `flutter3d_sim/test/parity_test.dart` asked twelve `dart:math` functions for +/// twenty thousand answers apiece, under the VM and under Chrome, and digested +/// each column. **One matched.** `sqrt` is the one the specification pins; +/// every transcendental — `sin`, `cos`, `tan`, `asin`, `acos`, `atan`, `atan2`, +/// `exp`, `log` — gave different bits in the two places, and so did every +/// combination of them. There is nothing portable in `dart:math` to build a +/// substitute out of, because on the VM those are the host's libm and in a +/// browser they are whatever that engine ships, and neither IEEE 754 nor the +/// Dart specification says the two agree on the last bit. +/// +/// `pow` was the tenth, and it was the interesting one: on macOS-arm64 it +/// matched in both places, which read as a second function the specification +/// had pinned. It had not. A third machine, an x86-64 Ubuntu, answered `pow` +/// differently from either — so what the first measurement saw was two runtimes +/// that happened to share one host's libm, and the conclusion drawn from it was +/// the conclusion a sample of two invites. That is why [pow] is here and why +/// `sqrt` is the only thing this file still asks for. +/// +/// `flutter3d_game_racing/test/parity_test.dart` is what that costs: the same +/// tape driving the same car diverged at twenty-three checkpoints of forty, +/// first at step 75. A character controller reached none of the disagreeing +/// arguments and matched all forty — so "a replay is the same run" was a thing +/// that happened to be true for one genre and false for another, which is the +/// worst shape a guarantee can have. +/// +/// ## What is guaranteed here, and what is not +/// +/// **Guaranteed: the same bits on every platform, by construction rather than +/// by luck.** Everything below is built out of `+`, `-`, `*`, `/`, `sqrt`, +/// comparison, and reading the bytes of a double — every one of which Dart +/// pins to IEEE 754 and none of which is a property of the machine. Two +/// platforms running this code cannot disagree, because there is nothing left +/// for them to disagree about. That holds for platforms nobody has measured and +/// for platforms that do not exist yet, which is the part a measurement could +/// never give. +/// +/// **Not guaranteed: the last bit against `dart:math`.** These are minimax +/// polynomials with argument reduction, accurate to a couple of units in the +/// last place — not correctly rounded. `portable_math_test.dart` holds that +/// bound, because a polynomial that is quietly wrong in the fourth digit is a +/// physics bug rather than a rounding one. But a step's answers will differ +/// from the host libm's in the last bit or two, which is exactly the change +/// being made on purpose: the step stops asking the machine. +/// +/// **Not a speed claim either way.** A polynomial `sin` is twenty-odd +/// arithmetic operations; a libm call is a call. Neither is the reason for +/// this. +/// +/// ## Which functions are here +/// +/// Ten: [sin], [cos], [tan], [asin], [acos], [atan], [atan2], [exp], [log] and +/// [pow], plus [sinCos] for the pair a call site usually wants together. That +/// is every name the rule in `tool/structure.dart` refuses, which is the line +/// this list is drawn on — a function the rule forbids and this library does +/// not have is a step with nowhere to go. +/// +/// They arrive in a handful of shapes: an angle turned into a direction +/// ([sinCos]), a direction turned back into an angle ([atan2]), the exponential +/// of a damping term ([exp]), the tangent in the bicycle-model steering +/// ([tan]), the arc sine in the tyre curve ([asin]), and a falloff raised to an +/// exponent ([pow]). +/// +/// ## The rule that keeps it +/// +/// `tool/structure.dart` holds a rule — *nothing a step runs asks the machine +/// for an answer* — that fails on `math.sin` and its neighbours anywhere in the +/// simulation. Presentation is exempt and named: a camera, a light that +/// flickers and the lightmap baker are not part of any run, and holding them to +/// this would be cost with nothing bought. +/// +/// ## Where the numbers come from +/// +/// The polynomial coefficients and the argument reductions are fdlibm's, which +/// is the public-domain reference every libm descends from. What is ours is +/// that they are evaluated here, in Dart, over operations that are the same +/// operations everywhere — rather than in whatever the platform linked. +library; + +import 'dart:math' as math; +import 'dart:typed_data'; + +/// The transcendental functions, computed identically on every platform. +/// +/// See the library doc for why they are not `dart:math`'s. +abstract final class Portable { + /// Sine of [radians]. + /// + /// NaN for an argument that is not finite, as `dart:math` gives. + static double sin(double radians) { + if (!radians.isFinite) return double.nan; + final reduced = _quarterTurns(radians); + return switch (reduced.quadrant) { + 0 => _sin(reduced.r, reduced.tail), + 1 => _cos(reduced.r, reduced.tail), + 2 => -_sin(reduced.r, reduced.tail), + _ => -_cos(reduced.r, reduced.tail), + }; + } + + /// Cosine of [radians]. + static double cos(double radians) { + if (!radians.isFinite) return double.nan; + final reduced = _quarterTurns(radians); + return switch (reduced.quadrant) { + 0 => _cos(reduced.r, reduced.tail), + 1 => -_sin(reduced.r, reduced.tail), + 2 => -_cos(reduced.r, reduced.tail), + _ => _sin(reduced.r, reduced.tail), + }; + } + + /// Both at once, which is the shape most call sites actually want. + /// + /// An angle becoming a direction needs the pair, and asking for them + /// separately reduces the argument twice — the expensive half of the work. + /// It is also one place rather than two for a caller to get the order wrong. + static ({double sin, double cos}) sinCos(double radians) { + if (!radians.isFinite) return (sin: double.nan, cos: double.nan); + final reduced = _quarterTurns(radians); + final s = _sin(reduced.r, reduced.tail); + final c = _cos(reduced.r, reduced.tail); + return switch (reduced.quadrant) { + 0 => (sin: s, cos: c), + 1 => (sin: c, cos: -s), + 2 => (sin: -s, cos: -c), + _ => (sin: -c, cos: s), + }; + } + + /// Tangent of [radians]. + /// + /// **[sin] over [cos] rather than its own polynomial**, and that is a choice + /// with a cost worth naming. A dedicated kernel is a little more accurate + /// near the poles, where `cos` is small and the quotient magnifies its error; + /// what this buys instead is that `tan`, `sin` and `cos` cannot disagree with + /// each other — the same reduction and the same two kernels serve all three. + /// The steering model that calls this works in a range nowhere near a pole. + static double tan(double radians) { + final both = sinCos(radians); + return both.sin / both.cos; + } + + /// Arc tangent of [x], in radians, between -π/2 and π/2. + static double atan(double x) { + if (x.isNaN) return double.nan; + final negative = x.isNegative; + final magnitude = x.abs(); + if (magnitude.isInfinite) return negative ? -_pio2 : _pio2; + + // Which of the four intervals the argument lands in, and the substitution + // that folds it into the one interval the polynomial is fitted over. The + // thresholds are fdlibm's: 7/16, 11/16, 19/16 and 39/16. + final int piece; + final double folded; + if (magnitude < 0.4375) { + piece = -1; + folded = magnitude; + } else if (magnitude < 0.6875) { + piece = 0; + folded = (2.0 * magnitude - 1.0) / (2.0 + magnitude); + } else if (magnitude < 1.1875) { + piece = 1; + folded = (magnitude - 1.0) / (magnitude + 1.0); + } else if (magnitude < 2.4375) { + piece = 2; + folded = (magnitude - 1.5) / (1.0 + 1.5 * magnitude); + } else { + piece = 3; + folded = -1.0 / magnitude; + } + + final z = folded * folded; + final w = z * z; + // Split into even and odd halves so the two chains evaluate independently, + // which is fdlibm's arrangement and is kept because the coefficients are + // fitted to it. + final odd = + z * + (_aT0 + w * (_aT2 + w * (_aT4 + w * (_aT6 + w * (_aT8 + w * _aT10))))); + final even = w * (_aT1 + w * (_aT3 + w * (_aT5 + w * (_aT7 + w * _aT9)))); + + if (piece < 0) { + final result = folded - folded * (odd + even); + return negative ? -result : result; + } + final result = + _atanHi[piece] - ((folded * (odd + even) - _atanLo[piece]) - folded); + return negative ? -result : result; + } + + /// The angle of the vector ([y], [x]), in radians, between -π and π. + /// + /// The quadrant logic is exact arithmetic and comparison, so all of it is + /// portable; only [atan] underneath had to be replaced. + static double atan2(double y, double x) { + if (x.isNaN || y.isNaN) return double.nan; + if (y == 0.0) { + // Sign of zero decides the side, as IEEE says it should: atan2(-0, -1) + // is -π and atan2(+0, -1) is π. + if (x > 0.0 || (x == 0.0 && !x.isNegative)) return y; + return y.isNegative ? -_pi : _pi; + } + if (x == 0.0) return y.isNegative ? -_pio2 : _pio2; + if (x.isInfinite) { + if (y.isInfinite) { + final quarter = x.isNegative ? 3.0 * _pio4 : _pio4; + return y.isNegative ? -quarter : quarter; + } + final flat = x.isNegative ? _pi : 0.0; + return y.isNegative ? -flat : flat; + } + if (y.isInfinite) return y.isNegative ? -_pio2 : _pio2; + + final angle = atan((y / x).abs()); + final folded = x.isNegative ? _pi - angle : angle; + return y.isNegative ? -folded : folded; + } + + /// Arc sine of [x], in radians. NaN outside -1 to 1. + /// + /// **Built on [atan2] rather than given a polynomial of its own.** The + /// identity is `asin(x) = atan2(x, sqrt((1 - x)(1 + x)))`, and the factored + /// form of `1 - x²` is what keeps it accurate as `|x|` approaches one, where + /// the unfactored version loses half its digits to cancellation. At exactly + /// ±1 the square root is zero and `atan2` answers ±π/2 without a special + /// case. It costs a couple of units in the last place against a dedicated + /// approximation, and it is a third of the code to be wrong in. + /// + /// `math.sqrt` is the one call to `dart:math` this file makes, and it is not + /// an oversight: IEEE 754 requires the square root to be correctly rounded, + /// and `parity_test.dart` measured that both platforms comply. It is one of + /// the two functions there was never anything to replace. + static double asin(double x) { + if (x.isNaN || x < -1.0 || x > 1.0) return double.nan; + return atan2(x, math.sqrt((1.0 - x) * (1.0 + x))); + } + + /// Arc cosine of [x], in radians, between 0 and π. NaN outside -1 to 1. + /// + /// The mirror of [asin] and built the same way, with the two arguments of + /// [atan2] swapped: the sine of the answer is the square root and its cosine + /// is `x`, so `atan2(sqrt((1 - x)(1 + x)), x)` is the angle. The factoring of + /// `1 - x²` earns its keep at the same place and for the same reason. + /// + /// **Not `π/2 - asin(x)`**, which is shorter and is a trap: near `x = 1` the + /// arc cosine is nearly zero while `asin` is nearly π/2, so the subtraction + /// cancels away most of the digits the answer was supposed to have. The form + /// here is measured against `dart:math` at two units in the last place across + /// the whole domain; the subtraction is not. + static double acos(double x) { + if (x.isNaN || x < -1.0 || x > 1.0) return double.nan; + return atan2(math.sqrt((1.0 - x) * (1.0 + x)), x); + } + + /// e raised to [x]. + static double exp(double x) { + if (x.isNaN) return x; + if (x > _expOverflow) return double.infinity; + if (x < _expUnderflow) return 0.0; + + // x = k·ln2 + r, with ln2 in two pieces so that k·ln2 is subtracted + // exactly. |r| ends up under half a ln2, where the polynomial is fitted. + final k = (x * _log2e).roundToDouble(); + final hi = x - k * _ln2Hi; + final lo = k * _ln2Lo; + final r = hi - lo; + final t = r * r; + final c = + r - + t * (_expP1 + t * (_expP2 + t * (_expP3 + t * (_expP4 + t * _expP5)))); + + if (k == 0.0) return 1.0 - ((x * c) / (c - 2.0) - x); + final y = 1.0 - ((lo - (r * c) / (2.0 - c)) - hi); + return _scaleByPowerOfTwo(y, k.toInt()); + } + + /// The natural logarithm of [x]. Negative infinity at zero, NaN below it. + /// + /// **The first function here that reads the bits of its argument**, and it has + /// to: the reduction is `x = 2^k · m` with `m` between √2/2 and √2, and `k` is + /// the exponent field. Taking it by repeated halving would be a loop whose + /// length depends on the argument, and taking it through a logarithm would be + /// circular. [_highWord] is how the exponent is read, and `ByteData` is what + /// makes that portable — the same eight bytes in the same order everywhere, + /// which is already the property [_twoTo] and `StateDigest` rest on. + /// + /// The mantissa is normalised into the interval by the `+ 0x95F64` trick: + /// adding that to the mantissa field carries into bit twenty exactly when the + /// mantissa is above √2, and the carry is both the test and the correction to + /// `k`. A subnormal argument is scaled up by 2^54 first and pays for it with + /// `k -= 54`, because its exponent field reads zero and says nothing. + static double log(double x) { + if (x.isNaN) return x; + if (x < 0.0) return double.nan; + if (x == 0.0) return double.negativeInfinity; + if (x.isInfinite) return x; + + var value = x; + var k = 0; + var hx = _highWord(value); + if (hx < 0x00100000) { + k -= 54; + value *= _two54; + hx = _highWord(value); + } + k += (hx >> 20) - 1023; + hx &= 0x000FFFFF; + final carry = (hx + 0x95F64) & 0x100000; + value = _withHighWord(value, hx | (carry ^ 0x3FF00000)); + k += carry >> 20; + + final f = value - 1.0; + final dk = k.toDouble(); + if ((0x000FFFFF & (2 + hx)) < 3) { + // |f| < 2^-20, where `f/(2+f)` would be all rounding error and the plain + // series is both shorter and better. + if (f == 0.0) { + if (k == 0) return 0.0; + return dk * _ln2Hi + dk * _ln2Lo; + } + final r = f * f * (0.5 - 0.33333333333333333 * f); + if (k == 0) return f - r; + return dk * _ln2Hi - ((r - dk * _ln2Lo) - f); + } + + final s = f / (2.0 + f); + final z = s * s; + final w = z * z; + final t1 = w * (_logLg2 + w * (_logLg4 + w * _logLg6)); + final t2 = z * (_logLg1 + w * (_logLg3 + w * (_logLg5 + w * _logLg7))); + final r = t2 + t1; + + // fdlibm writes this bound as the sign of two subtractions ored together; + // it is the mantissa field sitting between √2/2 and √2 rounded to twenty + // bits, and saying so directly costs nothing and cannot be read wrong. + if (hx >= 0x6147A && hx <= 0x6B851) { + final hfsq = 0.5 * f * f; + if (k == 0) return f - (hfsq - s * (hfsq + r)); + return dk * _ln2Hi - ((hfsq - (s * (hfsq + r) + dk * _ln2Lo)) - f); + } + if (k == 0) return f - s * (f - r); + return dk * _ln2Hi - ((s * (f - r) - dk * _ln2Lo) - f); + } + + /// [x] raised to [y]. + /// + /// **The long way round, and the short way was measured first.** `exp(y · + /// log x)` out of the two functions above is four lines and is wrong: the + /// logarithm's last two bits are multiplied by `y` before the exponential + /// magnifies them again, which came out at twenty-six units in the last place + /// at `y = 1.5` and seventy-nine at `y = 5`, against a bound of two. What + /// fixes it is what fdlibm does — carry the logarithm in two pieces, base + /// two, so the product with `y` is formed in something wider than a double + /// before the exponential sees it. That is the whole reason this function is + /// two hundred lines instead of four. + /// + /// The half of it that is not arithmetic is the special values, and there are + /// a lot of them because the answer to `pow` at an edge is a convention rather + /// than a limit: `0^0` is one, `1^NaN` is one, `(-1)^∞` is one, `(-1)^0.5` is + /// not a number, and a negative base is only allowed a whole-number exponent — + /// where the parity of that number decides the sign. Every one of those is a + /// branch below, in the order fdlibm takes them, because the order is + /// load-bearing: `y == 0` answers before NaN is considered, and NaN answers + /// before anything reads a bit. + static double pow(double x, double y) { + // Both before the NaN test, and deliberately. Anything to the nought is + // one, and one to anything is one, including when the anything is a NaN — + // which is what IEEE 754 and C99 say and what `dart:math` answers on the + // VM. It is *not* what fdlibm's kernel answers, nor what a browser's + // `Math.pow` does: both call `1^NaN` and `(-1)^∞` not a number. That is a + // place the two platforms part company like any other, so this file has to + // choose, and it chooses the standard's answer over the older one. + if (y == 0.0) return 1.0; + if (x == 1.0) return 1.0; + if (x.isNaN || y.isNaN) return double.nan; + + // Whether `y` is a whole number and, if it is, whether it is odd — which is + // the only thing that lets a negative base through at all. Past 2^53 the + // gap between doubles is two, so every one of them is even. + final ay = y.abs(); + final int yIsInt; + if (y.isInfinite || y != y.truncateToDouble()) { + yIsInt = 0; + } else if (ay >= _two53) { + yIsInt = 2; + } else { + yIsInt = ay % 2.0 == 1.0 ? 1 : 2; + } + + final ax = x.abs(); + + if (y.isInfinite) { + // (-1)^±∞ is one, for the reason above: `x` is exactly one in magnitude + // and no amount of exponent moves it. + if (ax == 1.0) return 1.0; + if (ax > 1.0) return y.isNegative ? 0.0 : double.infinity; + return y.isNegative ? double.infinity : 0.0; + } + if (y == 1.0) return x; + if (y == -1.0) return 1.0 / x; + if (y == 2.0) return x * x; + // A correctly rounded square root beats anything the general path would + // give. `isNegative` rather than `x >= 0`, so that a negative zero falls + // through to the special-base branch and answers +0 instead of -0. + if (y == 0.5 && !x.isNegative) return math.sqrt(x); + + if (ax == 0.0 || ax == 1.0 || ax.isInfinite) { + // ±0, ±1 and ±∞ are exact at every exponent, and the general path would + // reach them through a logarithm of zero or infinity. + var z = ax; + if (y.isNegative) z = 1.0 / z; + if (x.isNegative) { + if (ax == 1.0 && yIsInt == 0) return double.nan; + if (yIsInt == 1) z = -z; + } + return z; + } + + if (x.isNegative && yIsInt == 0) return double.nan; + // The sign is settled here and multiplied back in at the very end, so + // everything between works on a positive base. + final s = x.isNegative && yIsInt == 1 ? -1.0 : 1.0; + + final ix = _highWord(ax); + + // t1 + t2 is log2(|x|) in two pieces, t1 carrying the top twenty-odd bits + // with a clear tail so that y·t1 is formed without rounding. + final double t1; + final double t2; + + if (ay > 2147483648.0) { + // |y| past 2^31. The result overflows or underflows for any base that is + // not within 2^-20 of one, so the only case left needing real work is a + // base that close — where four terms of the series are the logarithm. + if (ay > 18446744073709551616.0) { + if (ix <= 0x3FEFFFFF) return y.isNegative ? double.infinity : 0.0; + if (ix >= 0x3FF00000) return y.isNegative ? 0.0 : double.infinity; + } + if (ix < 0x3FEFFFFF) { + return y.isNegative ? s * double.infinity : s * 0.0; + } + if (ix > 0x3FF00000) { + return y.isNegative ? s * 0.0 : s * double.infinity; + } + final t = ax - 1.0; + final w = (t * t) * (0.5 - t * (0.3333333333333333333333 - t * 0.25)); + final u = _ivln2Hi * t; + final v = t * _ivln2Lo - w * _ivln2; + t1 = _withLowWordZero(u + v); + t2 = v - (t1 - u); + } else { + var scaled = ax; + var hi = ix; + var n = 0; + if (hi < 0x00100000) { + scaled *= _two53; + n -= 53; + hi = _highWord(scaled); + } + n += (hi >> 20) - 0x3FF; + final mantissa = hi & 0x000FFFFF; + hi = mantissa | 0x3FF00000; + + // Which of the two anchors the mantissa is measured from — one, or one + // and a half. Above √3/2 of the upper anchor the exponent is bumped + // instead, so the ratio below is never far from zero. + final int piece; + if (mantissa <= 0x3988E) { + piece = 0; + } else if (mantissa < 0xBB67A) { + piece = 1; + } else { + piece = 0; + n += 1; + hi -= 0x00100000; + } + scaled = _withHighWord(scaled, hi); + + final u = scaled - _powBp[piece]; + final v = 1.0 / (scaled + _powBp[piece]); + final ss = u * v; + final sHi = _withLowWordZero(ss); + // The high half of `scaled + bp[piece]`, assembled from the exponent + // rather than added, so that the remainder below is exact. + var tHi = _withHighWord( + 0.0, + ((hi >> 1) | 0x20000000) + 0x00080000 + (piece << 18), + ); + final tLo = scaled - (tHi - _powBp[piece]); + final sLo = v * ((u - sHi * tHi) - sHi * tLo); + + var sq = ss * ss; + var r = + sq * + sq * + (_powL1 + + sq * + (_powL2 + + sq * + (_powL3 + + sq * (_powL4 + sq * (_powL5 + sq * _powL6))))); + r += sLo * (sHi + ss); + sq = sHi * sHi; + tHi = _withLowWordZero(3.0 + sq + r); + final tLo2 = r - ((tHi - 3.0) - sq); + + final u2 = sHi * tHi; + final v2 = sLo * tHi + tLo2 * ss; + final pHi = _withLowWordZero(u2 + v2); + final pLo = v2 - (pHi - u2); + final zHi = _powCpHi * pHi; + final zLo = _powCpLo * pHi + pLo * _powCp + _powDpLo[piece]; + + final e = n.toDouble(); + t1 = _withLowWordZero(((zHi + zLo) + _powDpHi[piece]) + e); + t2 = zLo - (((t1 - e) - _powDpHi[piece]) - zHi); + } + + // y·log2(|x|), again in two pieces: y is split so that yHi·t1 is exact. + final yHi = _withLowWordZero(y); + final pLo = (y - yHi) * t1 + y * t2; + var pHi = yHi * t1; + var z = pLo + pHi; + final zHiWord = _highWord(z); + final zLoWord = _lowWord(z); + if (!z.isNegative && zHiWord >= 0x40900000) { + // 2^1024 and above is not a double. The equality case is measured rather + // than assumed: exactly 1024 still rounds down to a finite number unless + // the discarded tail pushes it over. + if (zHiWord != 0x40900000 || zLoWord != 0) return s * double.infinity; + if (pLo + _powOvt > z - pHi) return s * double.infinity; + } else if ((zHiWord & 0x7FFFFFFF) >= 0x4090CC00) { + // Below -1075 not even a subnormal survives. + if (zHiWord != 0xC090CC00 || zLoWord != 0) return s * 0.0; + if (pLo <= z - pHi) return s * 0.0; + } + + // 2^(pHi + pLo): the whole part comes out as an exponent and the fraction, + // now under half, goes through the same polynomial `exp` uses. + final magnitude = zHiWord & 0x7FFFFFFF; + var k = (magnitude >> 20) - 0x3FF; + var whole = 0; + if (magnitude > 0x3FE00000) { + final rounded = zHiWord + (0x00100000 >> (k + 1)); + k = ((rounded & 0x7FFFFFFF) >> 20) - 0x3FF; + // The whole part of z, kept as a double with its fraction masked off. + // Written as a subtraction rather than as a complemented mask, because a + // complement is thirty-two bits wide in one of the two places this runs + // and sixty-four in the other. + final low = 0x000FFFFF >> k; + final t = _withHighWord(0.0, rounded - (rounded & low)); + whole = ((rounded & 0x000FFFFF) | 0x00100000) >> (20 - k); + if (z.isNegative) whole = -whole; + pHi -= t; + } + + var t = _withLowWordZero(pLo + pHi); + final u = t * _lg2Hi; + final v = (pLo - (t - pHi)) * _lg2 + t * _lg2Lo; + z = u + v; + final w = v - (z - u); + t = z * z; + final poly = + z - + t * (_expP1 + t * (_expP2 + t * (_expP3 + t * (_expP4 + t * _expP5)))); + final r = (z * poly) / (poly - 2.0) - (w + z * w); + z = 1.0 - (r - z); + + // Adding the whole part to the exponent field is a multiply by a power of + // two that cannot round. It only fails when the answer is subnormal and + // there is no exponent field left to write into — the exponent would go + // negative and land somewhere it does not mean — which is what the other + // branch is for. + final assembled = _highWord(z) + whole * 0x00100000; + if (assembled < 0x00100000) return s * _scaleByPowerOfTwo(z, whole); + return s * _withHighWord(z, assembled); + } + + // ## Argument reduction + + /// [x] as a quarter turn count and what is left over. + /// + /// `quadrant` is which multiple of π/2 was taken away, modulo four. `r` is + /// the remainder, in -π/4 to π/4, and `tail` is the part of the remainder + /// that did not fit in `r` — carried because the kernels below take it and + /// because without it `cos` loses digits near a multiple of π/2. + /// + /// **π/2 is subtracted in two pieces, and that is the whole trick.** With one + /// double for π/2 the product `n × π/2` is already rounded before the + /// subtraction, so the remainder is wrong in its low bits and the wrongness + /// grows with `n`. [_pio2Hi] has its low thirty-three bits clear, so `n × + /// pio2Hi` is exact for any `n` a simulation reaches, and the correction + /// [_pio2Lo] carries the rest. + /// + /// The quadrant is taken with a remainder on the double rather than by + /// converting to an integer and masking: an integer conversion of a large + /// double is a place the VM and a browser can part company, and `n % 4.0` is + /// exact arithmetic that cannot. + static ({int quadrant, double r, double tail}) _quarterTurns(double x) { + final n = (x * _twoOverPi).roundToDouble(); + final hi = x - n * _pio2Hi; + final lo = n * _pio2Lo; + final r = hi - lo; + return ( + quadrant: (n % 4.0).toInt(), + r: r, + // Exact, because `hi` is far larger than `lo`: what the subtraction + // rounded away is recoverable this way and no other. + tail: (hi - r) - lo, + ); + } + + /// Sine on -π/4 to π/4, given the remainder and its tail. + static double _sin(double x, double tail) { + final z = x * x; + final v = z * x; + final r = _sinS2 + z * (_sinS3 + z * (_sinS4 + z * (_sinS5 + z * _sinS6))); + if (tail == 0.0) return x + v * (_sinS1 + z * r); + return x - ((z * (0.5 * tail - v * r) - tail) - v * _sinS1); + } + + /// Cosine on -π/4 to π/4, given the remainder and its tail. + /// + /// The `w` dance is not decoration: `1 - z/2` throws away the bits that the + /// polynomial is supposed to supply, and `(1 - w) - hz` is what recovers + /// them. Written the obvious way this loses about ten bits near ±π/4. + static double _cos(double x, double tail) { + final z = x * x; + final r = + z * + (_cosC1 + + z * + (_cosC2 + + z * (_cosC3 + z * (_cosC4 + z * (_cosC5 + z * _cosC6))))); + final hz = 0.5 * z; + final w = 1.0 - hz; + return w + (((1.0 - w) - hz) + (z * r - x * tail)); + } + + // ## Bits + + /// [x] × 2^[k], for a [k] the caller has already bounded. + /// + /// A multiply by a power of two is exact, so this changes the exponent and + /// nothing else. Done in two steps when one power of two would be subnormal + /// or infinite on its own — the answer may be either, but the multiplier may + /// not, or the scaling would round twice. + static double _scaleByPowerOfTwo(double x, int k) { + if (k >= -1021 && k <= 1023) return x * _twoTo(k); + if (k > 1023) return x * _twoTo(1023) * _twoTo(k - 1023); + return x * _twoTo(-1021) * _twoTo(k + 1021); + } + + /// 2^[k] as a double, for -1021 ≤ [k] ≤ 1023. + /// + /// Assembled from the exponent field rather than by multiplying, which would + /// be a loop and would round. `ByteData` is the portable way to look at a + /// double's bytes: the same eight bytes in both places, which is the property + /// `StateDigest` leans on as well. + static double _twoTo(int k) { + _scratch.setUint32(0, (k + 1023) << 20); + _scratch.setUint32(4, 0); + return _scratch.getFloat64(0); + } + + /// The top thirty-two bits of [x] — sign, exponent and the high mantissa. + /// + /// **Unsigned, and every caller reads it that way.** fdlibm keeps this word in + /// a signed `int` and tests `hx < 0` to mean a negative argument, which is a + /// thirty-two-bit habit: one of the two platforms here has sixty-four-bit + /// integers and the other has doubles pretending to be integers, and neither + /// agrees with C about what the top bit means. Where fdlibm asks the sign of + /// the word, the ports above ask `x.isNegative` instead, which is the same + /// question asked of the number. + static int _highWord(double x) { + _scratch.setFloat64(0, x); + return _scratch.getUint32(0); + } + + /// The bottom thirty-two bits of [x]. + static int _lowWord(double x) { + _scratch.setFloat64(0, x); + return _scratch.getUint32(4); + } + + /// [x] with its top thirty-two bits replaced by [high]. + static double _withHighWord(double x, int high) { + _scratch.setFloat64(0, x); + _scratch.setUint32(0, high); + return _scratch.getFloat64(0); + } + + /// [x] with its bottom twenty-eight-odd bits of mantissa thrown away. + /// + /// The workhorse of the two-piece arithmetic in [pow]: a number whose low word + /// is zero has at most twenty-one significant bits left, so the product of two + /// of them fits in a double exactly and the tail can be recovered by + /// subtraction. Truncation rather than rounding, because what matters is that + /// the remainder is exactly representable and not that the head is nearest. + static double _withLowWordZero(double x) { + _scratch.setFloat64(0, x); + _scratch.setUint32(4, 0); + return _scratch.getFloat64(0); + } + + static final ByteData _scratch = ByteData(8); + + // ## Constants + // + // fdlibm's, which is where every libm's are from. The two-piece splits are + // the part that matters: each `Hi` has its low bits clear so that a product + // with a small whole number is exact, and each `Lo` carries what that leaves. + + static const double _pi = 3.14159265358979311600; + static const double _pio2 = 1.57079632679489655800; + static const double _pio4 = 0.78539816339744827900; + static const double _twoOverPi = 0.63661977236758134308; + + /// π/2 with the low thirty-three bits of its mantissa clear. + static const double _pio2Hi = 1.57079632673412561417; + + /// What [_pio2Hi] is missing. + static const double _pio2Lo = 6.07710050650619224932e-11; + + static const double _sinS1 = -1.66666666666666324348e-01; + static const double _sinS2 = 8.33333333332248946124e-03; + static const double _sinS3 = -1.98412698298579493134e-04; + static const double _sinS4 = 2.75573137070700676789e-06; + static const double _sinS5 = -2.50507602534068634195e-08; + static const double _sinS6 = 1.58969099521155010221e-10; + + static const double _cosC1 = 4.16666666666666019037e-02; + static const double _cosC2 = -1.38888888888741095749e-03; + static const double _cosC3 = 2.48015872894767294178e-05; + static const double _cosC4 = -2.75573143513906633035e-07; + static const double _cosC5 = 2.08757232129817482790e-09; + static const double _cosC6 = -1.13596475577881948265e-11; + + static const double _aT0 = 3.33333333333329318027e-01; + static const double _aT1 = -1.99999999998764832476e-01; + static const double _aT2 = 1.42857142725034663711e-01; + static const double _aT3 = -1.11111104054623557880e-01; + static const double _aT4 = 9.09088713343650656196e-02; + static const double _aT5 = -7.69187620504482999495e-02; + static const double _aT6 = 6.66107313738753120669e-02; + static const double _aT7 = -5.83357013379057348645e-02; + static const double _aT8 = 4.97687799461593236017e-02; + static const double _aT9 = -3.65315727442169155270e-02; + static const double _aT10 = 1.62858201153657823623e-02; + + /// atan at the middle of each folded interval, in two pieces apiece. + static const List _atanHi = [ + 4.63647609000806093515e-01, // atan(0.5) + 7.85398163397448278999e-01, // atan(1.0) + 9.82793723247329054082e-01, // atan(1.5) + 1.57079632679489655800e+00, // atan(∞) + ]; + static const List _atanLo = [ + 2.26987774529616870924e-17, + 3.06161699786838301793e-17, + 1.39033110312309984516e-17, + 6.12323399573676603587e-17, + ]; + + /// Above this the answer is not a finite double. + static const double _expOverflow = 709.782712893383973096; + + /// Below this it is zero, and no polynomial can say otherwise. + static const double _expUnderflow = -745.133219101941108420; + + static const double _log2e = 1.44269504088896338700; + static const double _ln2Hi = 6.93147180369123816490e-01; + static const double _ln2Lo = 1.90821492927058770002e-10; + + static const double _expP1 = 1.66666666666666019037e-01; + static const double _expP2 = -2.77777777770155933842e-03; + static const double _expP3 = 6.61375632143793436117e-05; + static const double _expP4 = -1.65339022054652515390e-06; + static const double _expP5 = 4.13813679705723846039e-08; + + /// 2^54, the scale that lifts a subnormal into the normal range for [log]. + static const double _two54 = 1.80143985094819840000e+16; + + /// 2^53, where consecutive doubles are two apart and every one of them is + /// even — which is how [pow] knows the parity of a huge exponent. + static const double _two53 = 9007199254740992.0; + + /// The logarithm's series in `s = f/(2 + f)`, fitted to the reduced interval. + static const double _logLg1 = 6.666666666666735130e-01; + static const double _logLg2 = 3.999999999940941908e-01; + static const double _logLg3 = 2.857142874366239149e-01; + static const double _logLg4 = 2.222219843214978396e-01; + static const double _logLg5 = 1.818357216161805012e-01; + static const double _logLg6 = 1.531383769920937332e-01; + static const double _logLg7 = 1.479819860511658591e-01; + + /// The two anchors [pow] measures a mantissa from, and log2 of each in two + /// pieces. `bp[0]` is one, so its logarithm is nothing and both halves are + /// zero; `bp[1]` is one and a half, and its logarithm is where the split + /// starts paying. + static const List _powBp = [1.0, 1.5]; + static const List _powDpHi = [ + 0.0, + 5.84962487220764160156e-01, + ]; + static const List _powDpLo = [ + 0.0, + 1.35003920212974897128e-08, + ]; + + /// (3/2)·(log(x)/log(2) - 1), which is the shape the two anchors leave. + static const double _powL1 = 5.99999999999994648725e-01; + static const double _powL2 = 4.28571428578550184252e-01; + static const double _powL3 = 3.33333329818377432918e-01; + static const double _powL4 = 2.72728123808534006489e-01; + static const double _powL5 = 2.30660745775561754067e-01; + static const double _powL6 = 2.06975017800338417784e-01; + + /// 2/(3·ln2), whole and split, so that the change of base is exact in its + /// high half and the remainder is carried rather than lost. + static const double _powCp = 9.61796693925975554329e-01; + static const double _powCpHi = 9.61796700954437255859e-01; + static const double _powCpLo = -7.02846165095275826516e-09; + + /// ln2, whole and split, for the trip back from base two. + static const double _lg2 = 6.93147180559945286227e-01; + static const double _lg2Hi = 6.93147182464599609375e-01; + static const double _lg2Lo = -1.90465429995776804525e-09; + + /// 1/ln2, whole and split, for the trip out to base two. + static const double _ivln2 = 1.44269504088896338700e+00; + static const double _ivln2Hi = 1.44269502162933349609e+00; + static const double _ivln2Lo = 1.92596299112661746887e-08; + + /// How far above 1024 the exponent has to reach before the answer is not a + /// double: -(1024 - log2(largest double + half an ulp)). + static const double _powOvt = 8.0085662595372944372e-17; +} diff --git a/packages/flutter3d_physics/lib/src/push.dart b/packages/flutter3d_physics/lib/src/push.dart new file mode 100644 index 000000000..580b7f67e --- /dev/null +++ b/packages/flutter3d_physics/lib/src/push.dart @@ -0,0 +1,99 @@ +/// How a kinematic walker shoves the bodies it walks into. +/// +/// One rule for every [RigidDynamics]: a crate a character pushes moves the +/// same whichever backend steps it, and only the step after differs. +library; + +import 'dart:math' as math; + +import 'package:vector_math/vector_math.dart'; + +import 'collider.dart'; +import 'collision_shape.dart'; +import 'contact.dart'; +import 'rigid_dynamics.dart'; +import 'tolerances.dart'; + +final class Pusher { + Pusher(this.dynamics); + + final RigidDynamics dynamics; + + final List _nearby = []; + final Contact _contact = Contact(); + final Vector3 _scratch = Vector3.zero(); + + /// Shoves whatever [by] is walking into, horizontally, at most at the speed + /// it walks into it, the contact found with [margin] of slack. + /// + /// ## Why a character does not push a crate on its own + /// + /// A `CharacterController` is kinematic: it sweeps, it slides, and it is + /// never moved by anything. That is what makes a first-person game feel + /// solid, and it also means walking into a crate does exactly nothing to the + /// crate — the sweep stops the player and no momentum goes the other way. + /// + /// So the transfer is explicit, and it is a *speed* rather than a force. A + /// crate is given just enough velocity to move away at the speed the walker + /// is approaching it, and no more: that is what makes pushing feel like + /// pushing rather than like a bat. A force proportional to mass would let a + /// player launch a light crate across the room by brushing it. + /// + /// Horizontal only. Walking into a crate must not press it into the floor, + /// and standing on one must not drive it downwards. + void push( + Collider by, + Vector3 velocity, { + double strength = 1.0, + double margin = 0.02, + }) { + final speed = math.sqrt(velocity.x * velocity.x + velocity.z * velocity.z); + if (speed < Nearly.moving) return; + + dynamics.world.overlap( + _inflated(by.shape, margin), + by.position, + _nearby, + ignore: by, + includeTriggers: false, + ); + for (final other in _nearby) { + final body = dynamics.bodyOf(other); + if (body == null || !body.isMovable) continue; + + contactBetween( + body.collider.shape, + body.position, + by.shape, + by.position, + _contact, + margin: margin, + ); + if (!_contact.touching) continue; + + // Which way the crate would go, flattened: the normal points out of the + // walker, which is exactly the direction to shove. + _scratch.setValues(_contact.normal.x, 0.0, _contact.normal.z); + final flat = _scratch.length; + if (flat < Nearly.moving) continue; + _scratch.scale(1.0 / flat); + + // Only if the walker is actually heading into it. + final into = velocity.x * _scratch.x + velocity.z * _scratch.z; + if (into <= 0.0) continue; + + final already = body.velocity.dot(_scratch); + final wanted = into * strength; + if (already >= wanted) continue; + body.applyImpulse(_scratch * ((wanted - already) / body.inverseMass)); + } + } + + /// The walker's bounds grown by the margin, for the broadphase. + CollisionShape _inflated(CollisionShape shape, double margin) { + final half = shape.boundsHalfExtents; + return CollisionBox( + Vector3(half.x + margin, half.y + margin, half.z + margin), + ); + } +} diff --git a/packages/flutter3d_physics/lib/src/rigid_body.dart b/packages/flutter3d_physics/lib/src/rigid_body.dart index f4fb219f9..651f0c9f9 100644 --- a/packages/flutter3d_physics/lib/src/rigid_body.dart +++ b/packages/flutter3d_physics/lib/src/rigid_body.dart @@ -1,23 +1,29 @@ /// Something the world pushes around. /// -/// ## No rotation, and it is not a shortcut +/// ## No rotation unless asked for, and that is not a shortcut /// -/// Stage one of the specification's two: mass, gravity, impulses, being pushed, -/// and coming to rest. A body has a velocity and no angular velocity, no -/// inertia tensor and no orientation. +/// Stage one of the specification's two was mass, gravity, impulses, being +/// pushed, and coming to rest, with no orientation at all. Its consequence is +/// what keeps it tractable: **a box that never rotates stays axis-aligned.** +/// Contact between two of them is then exact and cheap — the overlap on each +/// axis, and the normal is the axis of least overlap — where the general case +/// needs a separating-axis search and a manifold with several points. /// -/// The consequence is worth stating because it is what makes stage one -/// tractable: **a box that never rotates stays axis-aligned.** Contact between -/// two of them is then exact and cheap — the overlap on each axis, and the -/// normal is the axis of least overlap — where the general case needs a -/// separating-axis search and a manifold with several points. Nothing here -/// approximates a rotating box; there simply are none. +/// Stage two starts here, and it starts switched off. Every body now has an +/// [RigidBody.orientation], an [RigidBody.angularVelocity] and an inertia +/// tensor, but only a body built with `canRotate: true` turns; the rest have +/// an inverse inertia of zero, which is what "cannot be turned" means to the +/// arithmetic, and step exactly as they did. **Off by default because every +/// crate in every shipped level was tuned against bodies that do not tip**, +/// and a platformer's crate tumbling off the ledge it was placed on is a +/// broken level rather than a better simulation. /// -/// What that costs, stated rather than discovered: a crate you push into a -/// corner will not tip, a barrel will not roll, and a ragdoll is out of the -/// question. Those are stage two, and stage two is a separate decision — see -/// `ARCHITECTURE.md` §10, which says to port a solver rather than write one if it -/// is ever needed. +/// **What a turning body does not do yet: collide as a turned shape.** Its +/// collider is still the axis-aligned one, and no contact turns it — torque +/// comes from [RigidBody.applyImpulseAt] and [RigidBody.applyTorqueImpulse] +/// and nothing else. Contacts that tip a crate are the next piece of the same +/// work, and until they land a spinning box is drawn turning inside a +/// collision box that is not. /// /// ## It lives in the collision world everything else lives in /// @@ -28,11 +34,14 @@ /// door refuse to close on one without a line of new code anywhere. library; +import 'dart:math' as math; + import 'package:vector_math/vector_math.dart'; import 'collider.dart'; import 'collision_shape.dart'; import 'collision_world.dart'; +import 'inertia.dart'; import 'snapshot.dart'; final class RigidBody { @@ -43,10 +52,20 @@ final class RigidBody { this.mass = 1.0, this.restitution = 0.0, this.friction = 0.6, + this.canRotate = false, + Quaternion? orientation, + this.angularDamping = 0.0, int layer = 1 << 0, int mask = Layers.all, Object? userData, - }) : inverseMass = mass <= 0.0 ? 0.0 : 1.0 / mass { + }) : inverseMass = mass <= 0.0 ? 0.0 : 1.0 / mass, + inertiaLocal = inertiaFor(shape, mass), + inverseInertiaLocal = _inverted( + inertiaFor(shape, mass), + canRotate && mass > 0.0, + ) { + if (orientation != null) _setUnit(orientation); + refreshInertia(); collider = world.add( Collider( shape: shape, @@ -86,6 +105,115 @@ final class RigidBody { /// Coulomb friction, roughly. One is grippy, zero is ice. double friction; + /// Whether anything may turn this body. Fixed at construction, because the + /// inverse tensor below is. + final bool canRotate; + + /// Whether it actually turns: allowed to, and movable at all. + bool get rotates => canRotate && isMovable; + + /// How the body is turned, from its own axes into the world's. + /// + /// In the convention of `asRotationMatrix` and `Matrix4.compose`, which is + /// what a renderer draws with. `Quaternion.rotated` applies the inverse + /// turn, `q* v q`, and is the wrong way to carry a point out of this body. + /// + /// For a body that does not [rotates], whatever it was built or set with, + /// and nothing a step does changes it. **Assign + /// rather than edit in place:** the setter normalises and refreshes + /// [inverseInertiaWorld], and a quaternion changed through this getter + /// leaves that tensor describing the old turn until [refreshInertia] is + /// called. + Quaternion get orientation => _orientation; + set orientation(Quaternion value) { + _setUnit(value); + refreshInertia(); + } + + final Quaternion _orientation = Quaternion.identity(); + + /// Radians per second about each world axis. Zero, always, for a body that + /// does not [rotates]. + final Vector3 angularVelocity = Vector3.zero(); + + /// Per second, the fraction of spin taken away, as `1 / (1 + dt · this)`. + /// + /// **Zero by default, because a free body keeping its spin is the right + /// answer** and a damping that hides a solver bug is not one. It is here + /// for the game that wants a thrown object to settle sooner than friction + /// alone would make it. Rational rather than `exp(-dt · this)`, which is + /// the same to first order and asks the machine nothing. + double angularDamping; + + /// The principal moments of inertia in the body's own axes — see + /// `inertia.dart` for why three numbers are the whole tensor. + /// + /// The shape's real answer whether or not the body may turn; it is + /// [inverseInertiaLocal] that says whether it does. + final Vector3 inertiaLocal; + + /// One over each of [inertiaLocal], or zero for a body that does not + /// [rotates] — infinite inertia, which is the whole of how "does not turn" + /// reaches the arithmetic. + final Vector3 inverseInertiaLocal; + + /// The inverse tensor in world axes: `R · diag(inverseInertiaLocal) · Rᵀ`. + /// + /// What a solver multiplies a torque by. Refreshed whenever [orientation] is + /// assigned, restored or stepped, rather than computed per use: a contact + /// solver asks for it twenty times a step for every pair. Symmetric, so + /// reading it by rows or by columns is the same. + final Matrix3 inverseInertiaWorld = Matrix3.zero(); + + /// [inverseInertiaWorld] at full precision, which is what the arithmetic + /// reads. The matrix above is for a caller; `vector_math` stores it in + /// single precision. + _Symmetric _inverseWorld = _Symmetric.zero; + + /// Brings [inverseInertiaWorld] up to date with [orientation]. + /// + /// Called by the setter, by [restore] and by the step. A caller that turned + /// the quaternion in place rather than assigning it calls this. + void refreshInertia() { + _inverseWorld = _Symmetric.turned(_orientation, inverseInertiaLocal); + final m = _inverseWorld; + inverseInertiaWorld.setValues( + m.xx, + m.xy, + m.xz, + m.xy, + m.yy, + m.yz, + m.xz, + m.yz, + m.zz, + ); + } + + void _setUnit(Quaternion value) { + final length = value.length; + // A zero quaternion turns nothing into nothing; the identity is the only + // honest reading of "no rotation was given". + if (length == 0.0 || !length.isFinite) { + _orientation.setValues(0.0, 0.0, 0.0, 1.0); + return; + } + _orientation.setValues( + value.x / length, + value.y / length, + value.z / length, + value.w / length, + ); + } + + static Vector3 _inverted(Vector3 inertia, bool turns) => turns + ? Vector3( + inertia.x > 0.0 ? 1.0 / inertia.x : 0.0, + inertia.y > 0.0 ? 1.0 / inertia.y : 0.0, + inertia.z > 0.0 ? 1.0 / inertia.z : 0.0, + ) + : Vector3.zero(); + /// Whether the solver may skip this body. /// /// A stack of ten crates that has settled costs nothing to leave alone, and @@ -110,6 +238,7 @@ final class RigidBody { _asleep = true; _still = 0.0; velocity.setZero(); + angularVelocity.setZero(); } /// Changes the velocity by an impulse, in newton-seconds. @@ -124,12 +253,114 @@ final class RigidBody { velocity.addScaled(impulse, inverseMass); } + /// An impulse delivered at [worldPoint] rather than through the centre. + /// + /// The same change of velocity as [applyImpulse] — where it lands does not + /// change how far the centre is thrown — plus the spin of `r × impulse`, + /// with `r` from the centre to the point. A body that does not [rotates] + /// takes the first half only, which is what infinite inertia means. + void applyImpulseAt(Vector3 impulse, Vector3 worldPoint) { + if (!isMovable) return; + wake(); + velocity.addScaled(impulse, inverseMass); + if (!rotates) return; + final rx = worldPoint.x - position.x; + final ry = worldPoint.y - position.y; + final rz = worldPoint.z - position.z; + _spin( + ry * impulse.z - rz * impulse.y, + rz * impulse.x - rx * impulse.z, + rx * impulse.y - ry * impulse.x, + ); + } + + /// Changes the spin by an angular impulse, in newton-metre-seconds about + /// world axes. Nothing, for a body that does not [rotates]. + void applyTorqueImpulse(Vector3 impulse) { + if (!rotates) return; + wake(); + _spin(impulse.x, impulse.y, impulse.z); + } + + void _spin(double x, double y, double z) { + final (wx, wy, wz) = _inverseWorld.times(x, y, z); + angularVelocity.setValues( + angularVelocity.x + wx, + angularVelocity.y + wy, + angularVelocity.z + wz, + ); + } + + /// Turns the body through one step of its spin. Called by `Dynamics`, not + /// by a game. + /// + /// ## Angular momentum is what is kept, not angular velocity + /// + /// A free body's momentum `L = I·ω` is constant in the world; its velocity + /// is not, because `I` turns with the body. A rod spun about a tilted axis + /// wobbles, and that wobble is `ω` changing while `L` does not. So the step + /// is: take `L` with the old orientation, turn the body by `ω`, and read `ω` + /// back out of `L` with the new one. That *is* the gyroscopic term of + /// Euler's equations, taken implicitly — written out explicitly as + /// `ω × Iω` it is the part of a rigid body integrator that blows up first, + /// and here there is nothing to blow up: `L` is carried through unchanged. + /// + /// The turn itself is `q += ½·dt·(ω ⊗ q)` and a normalisation — first + /// order, and correct to that order for any `ω` because the + /// normalisation is what keeps `q` a rotation. The cost of first order is + /// a slow drift in rotational energy for a body spun off its principal + /// axes; `rotation_test.dart` measures it, and a body spun about one of + /// them keeps both exactly. + void integrateOrientation(double dt) { + if (!rotates) return; + final wx = angularVelocity.x; + final wy = angularVelocity.y; + final wz = angularVelocity.z; + if (wx == 0.0 && wy == 0.0 && wz == 0.0) return; + + final (lx, ly, lz) = _Symmetric.turned( + _orientation, + inertiaLocal, + ).times(wx, wy, wz); + + final x = _orientation.x; + final y = _orientation.y; + final z = _orientation.z; + final s = _orientation.w; + final h = 0.5 * dt; + final nx = x + h * (s * wx + wy * z - wz * y); + final ny = y + h * (s * wy + wz * x - wx * z); + final nz = z + h * (s * wz + wx * y - wy * x); + final ns = s - h * (wx * x + wy * y + wz * z); + final length = math.sqrt(nx * nx + ny * ny + nz * nz + ns * ns); + _orientation.setValues(nx / length, ny / length, nz / length, ns / length); + refreshInertia(); + + final keep = 1.0 / (1.0 + dt * angularDamping); + final (ox, oy, oz) = _inverseWorld.times(lx, ly, lz); + angularVelocity.setValues(ox * keep, oy * keep, oz * keep); + } + /// Everything a snapshot has to carry. + /// + /// **The orientation and spin only for a body that can turn.** One that + /// cannot has no spin and an orientation no step changes, and writing them + /// anyway would change the digest of every saved level that holds a crate — + /// the simulation's run digests fold these maps in — for no information. Map save() => { 'at': [position.x, position.y, position.z], 'velocity': [velocity.x, velocity.y, velocity.z], 'asleep': _asleep, 'still': _still, + if (canRotate) ...{ + 'orientation': [ + _orientation.x, + _orientation.y, + _orientation.z, + _orientation.w, + ], + 'spin': [angularVelocity.x, angularVelocity.y, angularVelocity.z], + }, }; void restore(Map from) { @@ -139,12 +370,38 @@ final class RigidBody { collider.clearDelta(); _asleep = from['asleep'] == true; _still = (from['still'] as num?)?.toDouble() ?? 0.0; + + // A body that cannot turn wrote neither key, and nothing a step does moves + // its orientation, so there is nothing to put back. + if (!canRotate) return; + + // **Absent is not the same as unreadable.** A snapshot without the keys + // was written for a body that did not turn, and what it says is "the + // identity and no spin" — so that is what is restored, rather than + // whatever this body happens to be doing. A key that is there and cannot + // be read leaves the body as it is, as `readVector` does. + if (from.containsKey('orientation')) { + readQuaternion(from['orientation'], _orientation); + } else { + _orientation.setValues(0.0, 0.0, 0.0, 1.0); + } + if (from.containsKey('spin')) { + readVector(from['spin'], angularVelocity); + } else { + angularVelocity.setZero(); + } + refreshInertia(); } /// Advances the sleep clock. Called by the world, not by a game. + /// + /// Spin counts as moving, in radians per second against the same number: + /// a body turning on the spot is not at rest, and putting it to sleep would + /// stop it dead. void updateSleep(double dt, double speedThreshold, double after) { if (_asleep) return; - if (velocity.length2 > speedThreshold * speedThreshold) { + final threshold = speedThreshold * speedThreshold; + if (velocity.length2 > threshold || angularVelocity.length2 > threshold) { _still = 0.0; return; } @@ -152,3 +409,46 @@ final class RigidBody { if (_still >= after) sleep(); } } + +/// A symmetric 3×3 matrix, in doubles: the six numbers that are not mirrors. +/// +/// What an inertia tensor is once it has been turned into world axes. Doubles +/// rather than `vector_math`'s `Matrix3`, which stores single precision — a +/// tensor rounded to seven digits between every use is a body whose angular +/// momentum leaks out through the rounding. +final class _Symmetric { + const _Symmetric(this.xx, this.yy, this.zz, this.xy, this.xz, this.yz); + + static const _Symmetric zero = _Symmetric(0.0, 0.0, 0.0, 0.0, 0.0, 0.0); + + /// `R · diag(d) · Rᵀ`, with `R` the rotation of the unit quaternion [q]. + factory _Symmetric.turned(Quaternion q, Vector3 d) { + final x = q.x, y = q.y, z = q.z, w = q.w; + final r00 = 1.0 - 2.0 * (y * y + z * z); + final r01 = 2.0 * (x * y - z * w); + final r02 = 2.0 * (x * z + y * w); + final r10 = 2.0 * (x * y + z * w); + final r11 = 1.0 - 2.0 * (x * x + z * z); + final r12 = 2.0 * (y * z - x * w); + final r20 = 2.0 * (x * z - y * w); + final r21 = 2.0 * (y * z + x * w); + final r22 = 1.0 - 2.0 * (x * x + y * y); + final dx = d.x, dy = d.y, dz = d.z; + return _Symmetric( + r00 * r00 * dx + r01 * r01 * dy + r02 * r02 * dz, + r10 * r10 * dx + r11 * r11 * dy + r12 * r12 * dz, + r20 * r20 * dx + r21 * r21 * dy + r22 * r22 * dz, + r00 * r10 * dx + r01 * r11 * dy + r02 * r12 * dz, + r00 * r20 * dx + r01 * r21 * dy + r02 * r22 * dz, + r10 * r20 * dx + r11 * r21 * dy + r12 * r22 * dz, + ); + } + + final double xx, yy, zz, xy, xz, yz; + + (double, double, double) times(double x, double y, double z) => ( + xx * x + xy * y + xz * z, + xy * x + yy * y + yz * z, + xz * x + yz * y + zz * z, + ); +} diff --git a/packages/flutter3d_physics/lib/src/rigid_dynamics.dart b/packages/flutter3d_physics/lib/src/rigid_dynamics.dart new file mode 100644 index 000000000..4402ccb04 --- /dev/null +++ b/packages/flutter3d_physics/lib/src/rigid_dynamics.dart @@ -0,0 +1,60 @@ +/// What steps a world's bodies: the seam a game and `WorldStep` hold. +/// +/// ## Two of them +/// +/// [Dynamics] in this package is the reference: plain Dart, in doubles, on +/// every platform including the browser, and the bits a run's digest folds +/// in. `flutter3d_physics_native` has the other, a C core underneath the +/// same bodies, with contacts that turn them, joints, continuous collision +/// and threads, stepping in f32 to bits of its own — the same bits on every +/// platform, but not these. A game chooses one when it builds the world and +/// holds it as this, so nothing past that line knows which. +/// +/// Either way the bodies are [RigidBody] objects on a [CollisionWorld]: their +/// colliders are what the broadphase, the sweeps and the character +/// controller see, and after [step] they are where the bodies are. +library; + +import 'package:vector_math/vector_math.dart'; + +import 'collider.dart'; +import 'collision_world.dart'; +import 'dynamics.dart'; +import 'rigid_body.dart'; + +abstract interface class RigidDynamics { + /// The world the bodies' colliders are in. + CollisionWorld get world; + + /// Metres per second squared. + Vector3 get gravity; + + /// Every body added, in the order it was. + List get bodies; + + /// Starts stepping [body], whose collider is already in [world]. + RigidBody add(RigidBody body); + + /// Stops stepping [body] and takes its collider out of [world]. + void remove(RigidBody body); + + /// The body a collider belongs to, or null for level geometry. + RigidBody? bodyOf(Collider collider); + + /// One fixed step of [dt] seconds. + void step(double dt); + + /// Shoves whatever [by] is walking into, horizontally, at most at the speed + /// it is walking into it: how a kinematic character pushes a crate. + void push(Collider by, Vector3 velocity, {double strength = 1.0}); + + /// What a save must carry beyond the bodies' own `save()` for a run + /// restored from it to step on to the same bits: null when there is + /// nothing, as for [Dynamics], whose state is its bodies'. A value JSON + /// can hold, so a simulation puts it in its snapshot as it is. + Object? saveState(); + + /// Back to what [saveState] gave, after the bodies' own `restore`; null + /// changes nothing. + void restoreState(Object? saved); +} diff --git a/packages/flutter3d_physics/lib/src/snapshot.dart b/packages/flutter3d_physics/lib/src/snapshot.dart index db4c60347..0948c6581 100644 --- a/packages/flutter3d_physics/lib/src/snapshot.dart +++ b/packages/flutter3d_physics/lib/src/snapshot.dart @@ -1,3 +1,5 @@ +import 'dart:math' as math; + import 'package:vector_math/vector_math.dart'; /// Reading a saved body back. @@ -26,6 +28,38 @@ bool readVector(Object? value, Vector3 out) { return true; } +/// Reading a saved orientation back, by the same rules as [readVector]. +/// +/// **Written as it was saved, not renormalised**, unless it is plainly not a +/// rotation. What `save()` wrote is already unit length to the precision it is +/// stored in, and normalising it again moves its last bits — which is a +/// restored run that is not the run that was saved. A hand-edited document +/// that says `[0, 0, 1, 1]` is a different matter, and is scaled to unit +/// length rather than left to stretch the body it turns. Zero length and +/// non-finite values are refused, leaving [out] as it was. +bool readQuaternion(Object? value, Quaternion out) { + if (value is! List || value.length < 4) return false; + final x = value[0], y = value[1], z = value[2], w = value[3]; + if (x is! num || y is! num || z is! num || w is! num) return false; + final (qx, qy, qz, qw) = ( + x.toDouble(), + y.toDouble(), + z.toDouble(), + w.toDouble(), + ); + final length2 = qx * qx + qy * qy + qz * qz + qw * qw; + if (!(length2 > 0.0) || !length2.isFinite) return false; + // A single-precision unit quaternion is within a few millionths of length + // one; anything further out was not written by `save()`. + if ((length2 - 1.0).abs() <= 1e-5) { + out.setValues(qx, qy, qz, qw); + } else { + final length = math.sqrt(length2); + out.setValues(qx / length, qy / length, qz / length, qw / length); + } + return true; +} + /// A number from a snapshot, or nought. /// /// Nought rather than a thrown error for the same reason: what these carry are diff --git a/packages/flutter3d_physics/test/fluid/buoyancy_test.dart b/packages/flutter3d_physics/test/fluid/buoyancy_test.dart new file mode 100644 index 000000000..a9f046e0c --- /dev/null +++ b/packages/flutter3d_physics/test/fluid/buoyancy_test.dart @@ -0,0 +1,167 @@ +import 'dart:math' as math; + +import 'package:flutter3d_physics/flutter3d_physics.dart'; +import 'package:test/test.dart'; +import 'package:vector_math/vector_math.dart'; + +RevolvedVessel _vat(double r, double h) => + RevolvedVessel([Vector2(0, 0), Vector2(r, 0), Vector2(r, h)]); + +void main() { + final up = Vector3(0, 1, 0); + + test('what is under the surface, shape by shape', () { + const r = 0.1; + final ball = CollisionSphere(r); + final full = 4 / 3 * math.pi * r * r * r; + expect( + submergedVolume(ball, Vector3.zero(), up, 0), + closeTo(full / 2, 1e-12), + ); + expect(submergedVolume(ball, Vector3.zero(), up, 1), closeTo(full, 1e-12)); + expect(submergedVolume(ball, Vector3.zero(), up, -1), 0.0); + final box = CollisionBox(Vector3(0.1, 0.2, 0.3)); + final tilted = Vector3(0.3, 1, 0.1)..normalize(); + expect( + submergedVolume( + box, + Vector3(1, 2, 3), + tilted, + tilted.dot(Vector3(1, 2, 3)), + ), + closeTo(0.024, 1e-6), + ); + final capsule = CollisionCapsule(radius: 0.05, halfHeight: 0.1); + final whole = math.pi * 0.05 * 0.05 * (0.2 + 4 / 3 * 0.05); + expect( + submergedVolume(capsule, Vector3.zero(), up, 1), + closeTo(whole, whole * 0.01), + ); + expect( + submergedVolume(capsule, Vector3.zero(), up, 0), + closeTo(whole / 2, whole * 0.01), + ); + }); + + test('a wooden block floats six tenths under', () { + final gravity = Vector3(0, -9.81, 0); + final collisions = CollisionWorld(); + final dynamics = Dynamics(world: collisions, gravity: gravity); + const half = 0.02; + final block = RigidBody( + world: collisions, + shape: CollisionBox(Vector3.all(half)), + position: Vector3(0, 0.13, 0), + mass: 600 * 8 * half * half * half, + ); + dynamics.add(block); + final world = FluidWorld(gravity: gravity); + final vat = LiquidBody( + shape: _vat(0.2, 0.3), + medium: FluidMedium.water, + volume: math.pi * 0.04 * 0.1, + modes: 2, + ); + world.bodies.add(vat); + world.float(block); + var under = 0.0; + var samples = 0; + for (var i = 0; i < 240 * 6; i++) { + vat.place(Matrix3.identity(), Vector3.zero()); + world.advance(world.step); + dynamics.step(world.step); + // Over the last second, as it bobs about its level. + if (i >= 240 * 5) { + under += FloatingBody(block).submergedIn(vat); + samples++; + } + } + // Archimedes: it sinks until the water it displaces weighs what it + // does, 600 / 998 of it. Mutation: buoy it by its whole volume, and it + // floats on top. + final whole = 8 * half * half * half; + expect(under / samples / whole, closeTo(600 / 998.2, 0.02)); + }); + + test('a small steel ball settles through glycerol at Stokes\'s speed', () { + final gravity = Vector3(0, -9.81, 0); + final collisions = CollisionWorld(); + final dynamics = Dynamics(world: collisions, gravity: gravity); + const r = 0.001; + final ball = RigidBody( + world: collisions, + shape: CollisionSphere(r), + position: Vector3(0, 0.25, 0), + mass: 7800 * 4 / 3 * math.pi * r * r * r, + ); + dynamics.add(ball); + final world = FluidWorld(gravity: gravity, step: 1 / 2000); + final vat = LiquidBody( + shape: _vat(0.05, 0.4), + medium: FluidMedium.glycerol, + volume: math.pi * 0.0025 * 0.3, + modes: 2, + ); + world.bodies.add(vat); + world.float(ball); + for (var i = 0; i < 2000; i++) { + vat.place(Matrix3.identity(), Vector3.zero()); + world.advance(world.step); + dynamics.step(world.step); + } + final stokes = stokesSpeed(FluidMedium.glycerol, r, 7800, 9.81); + expect(-ball.velocity.y, closeTo(stokes, stokes * 0.03)); + }); + + test('a body under the surface raises the level by its volume', () { + final collisions = CollisionWorld(); + final world = FluidWorld(gravity: Vector3(0, -9.81, 0)); + final vat = LiquidBody( + shape: _vat(0.1, 0.3), + medium: FluidMedium.water, + volume: math.pi * 0.01 * 0.1, + modes: 2, + ); + world.bodies.add(vat); + final before = vat.height; + // Held still: no mass to move. + world.float( + RigidBody( + world: collisions, + shape: CollisionSphere(0.03), + position: Vector3(0, 0.05, 0), + mass: 0, + ), + ); + vat.place(Matrix3.identity(), Vector3.zero()); + world.advance(world.step); + final rise = 4 / 3 * math.pi * 0.027e-3 / (math.pi * 0.01); + expect(vat.height - before, closeTo(rise, rise * 0.01)); + }); + + test('a still glass weighs what its liquid weighs; a pour pushes back', () { + final gravity = Vector3(0, -9.81, 0); + final glass = LiquidBody( + shape: _vat(0.02, 0.1), + medium: FluidMedium.water, + volume: math.pi * 4e-4 * 0.095, + modes: 2, + ); + glass + ..place(Matrix3.identity(), Vector3.zero()) + ..step(1e-3, gravity: gravity); + final weight = 998.2 * glass.volume * 9.81; + expect(glass.load(gravity).y, closeTo(-weight, weight * 1e-6)); + // Tipped, it pours, and the stream leaving pushes the glass the other + // way: −ρQv. + Spill? spill; + for (var i = 0; i < 400 && (spill == null || spill.flow == 0); i++) { + glass.place(Matrix3.rotationX(0.6), Vector3.zero()); + spill = glass.step(1e-3, gravity: gravity); + } + expect(spill!.flow, greaterThan(0)); + final sideways = glass.load(gravity)..y = 0; + final thrust = spill.velocity..y = 0; + expect(sideways.dot(thrust), lessThan(0)); + }); +} diff --git a/packages/flutter3d_physics/test/fluid/capillary_test.dart b/packages/flutter3d_physics/test/fluid/capillary_test.dart new file mode 100644 index 000000000..2011d28ae --- /dev/null +++ b/packages/flutter3d_physics/test/fluid/capillary_test.dart @@ -0,0 +1,64 @@ +import 'dart:math' as math; + +import 'package:flutter3d_physics/flutter3d_physics.dart'; +import 'package:test/test.dart'; + +void main() { + const g = 9.81; + final water = FluidMedium.water; + + test('in a narrow tube the meniscus lifts liquid by Jurin\'s height', () { + final m = TubeMeniscus(medium: water, radius: 2e-4, g: g); + expect( + m.rise, + closeTo(jurinHeight(water, 2e-4, g), jurinHeight(water, 2e-4, g) * 0.01), + ); + // And it is the spherical cap of radius R / cos θ. + final theta = water.contactAngle; + final cap = 2e-4 / math.cos(theta) * (1 - math.sin(theta)); + expect(m.wallRise, closeTo(cap, cap * 0.02)); + }); + + test('in a wide tube it is flat in the middle and climbs the wall', () { + // Twenty capillary lengths across: the wall's curve round the tube is + // too gentle to matter, which at seven it still does, by six per cent. + final m = TubeMeniscus(medium: water, radius: 0.05, g: g); + // Far less than Jurin's narrow-tube height, whose middle is not flat. + expect(m.rise, lessThan(0.2 * jurinHeight(water, 0.05, g))); + // At the wall, as high as a flat wall's: l·√(2(1 − sin θ)). + final l = water.capillaryLength(g); + final h0 = l * math.sqrt(2 * (1 - math.sin(water.contactAngle))); + expect(m.wallRise, closeTo(h0, h0 * 0.06)); + expect(m.heightAt(0.0), 0.0); + expect(m.heightAt(0.035), lessThan(0.05 * m.wallRise)); + }); + + test('mercury does not wet glass: it sinks, its meniscus bulges', () { + final mercury = FluidMedium.mercury; + final m = TubeMeniscus(medium: mercury, radius: 5e-4, g: g); + expect(m.rise, lessThan(0)); + expect(m.wallRise, lessThan(0)); + expect( + m.rise, + closeTo( + jurinHeight(mercury, 5e-4, g), + jurinHeight(mercury, 5e-4, g).abs() * 0.15, + ), + ); + }); + + test( + "a flat wall's meniscus climbs it and dies off within capillary lengths", + () { + final l = water.capillaryLength(g); + final h0 = l * math.sqrt(2 * (1 - math.sin(water.contactAngle))); + expect(wallMeniscus(water, 0, g), closeTo(h0, h0 * 1e-6)); + expect(wallMeniscus(water, 5 * l, g), lessThan(0.01 * h0)); + // On the Moon the same water climbs six times as far out: l ∝ 1/√g. + expect( + water.capillaryLength(1.62) / l, + closeTo(math.sqrt(g / 1.62), 1e-9), + ); + }, + ); +} diff --git a/packages/flutter3d_physics/test/fluid/fluid_world_test.dart b/packages/flutter3d_physics/test/fluid/fluid_world_test.dart new file mode 100644 index 000000000..d631e9a5f --- /dev/null +++ b/packages/flutter3d_physics/test/fluid/fluid_world_test.dart @@ -0,0 +1,124 @@ +import 'dart:math' as math; + +import 'package:flutter3d_physics/flutter3d_physics.dart'; +import 'package:test/test.dart'; +import 'package:vector_math/vector_math.dart'; + +RevolvedVessel _tube(double r, double h) => + RevolvedVessel([Vector2(0, 0), Vector2(r, 0), Vector2(r, h)]); + +void main() { + test('a world runs whole steps and carries the rest over', () { + final world = FluidWorld(gravity: Vector3(0, -9.81, 0), step: 0.01); + expect(world.advance(0.025), 2); + expect(world.advance(0.006), 1); + expect(world.advance(0.003), 0); + }); + + test('poured from one glass into another, no liquid is lost or made', () { + final gravity = Vector3(0, -9.81, 0); + final world = FluidWorld(gravity: gravity); + final source = LiquidBody( + shape: _tube(0.008, 0.1), + medium: FluidMedium.water, + volume: math.pi * 0.008 * 0.008 * 0.07, + modes: 4, + ); + final target = LiquidBody( + shape: _tube(0.012, 0.1), + medium: FluidMedium.water, + volume: 0.0, + modes: 4, + ); + world.bodies.addAll([source, target]); + final total = source.volume; + // The source tipped over the target's mouth, its lip over the back of + // it: what spills is thrown forward, across the mouth. + final turn = Matrix3.rotationX(1.3); + for (var i = 0; i < 480; i++) { + source.place(turn, Vector3(0, 0.1, -0.1045)); + target.place(Matrix3.identity(), Vector3.zero()); + world.advance(world.step); + } + + // Mutation: let the source's own inside catch its spill back, and + // nothing ever leaves it. + expect(target.volume, greaterThan(0)); + expect(world.volume, closeTo(total, total * 1e-9)); + }); + + test( + 'a glass carried steadily keeps still, however unevenly it is drawn', + () { + // Frames of one, two and three fixed steps and the odd part of one, as + // a browser draws them, with the glass moving at a steady 0.2 m/s. + // Placed at each frame's own time it feels no acceleration and its + // surface stays flat. Mutation: drop `time:` so each place is stamped + // with the liquid's clock, and the frames that ran one step against + // those that ran three read as a jolt, and the surface rocks by + // five centimetres in a tube sixteen millimetres across. + final world = FluidWorld(gravity: Vector3(0, -9.81, 0)); + final glass = LiquidBody( + shape: _tube(0.008, 0.1), + medium: FluidMedium.water, + volume: math.pi * 0.008 * 0.008 * 0.05, + modes: 4, + ); + world.bodies.add(glass); + const frames = [1.0, 3.0, 1.5, 2.0, 0.6, 3.2, 1.0, 2.4]; + var seconds = 0.0; + var worst = 0.0; + for (var i = 0; i < 400; i++) { + final frame = frames[i % frames.length] / 240.0; + seconds += frame; + glass.place( + Matrix3.identity(), + Vector3(0.2 * seconds, 0, 0), + time: world.time + frame, + ); + world.advance(frame); + worst = math.max(worst, glass.surface.reach); + } + expect(worst, lessThan(1e-5)); + }, + ); + + test('a glass left alone sleeps, and anything done to it wakes it', () { + // Asleep it costs nothing a step. Mutation: let a knock, a pour or a + // move leave it asleep, and a tapped glass on the bench never rings. + final glass = LiquidBody( + shape: _tube(0.008, 0.1), + medium: FluidMedium.water, + volume: math.pi * 0.008 * 0.008 * 0.05, + modes: 4, + ); + final g = Vector3(0, -9.81, 0); + void run(int steps, {Vector3? at}) { + for (var i = 0; i < steps; i++) { + glass + ..place(Matrix3.identity(), at ?? Vector3.zero()) + ..step(1 / 240, gravity: g); + } + } + + run(10); + // Knocked: it rings. + glass.surface.knock(Vector3(0.004, glass.height, 0), 0.001); + run(1); + expect(glass.surface.reach, greaterThan(1e-5)); + // Left long enough, the rings die and it sleeps flat. + run(240 * 20); + expect(glass.surface.reach, 0.0); + final volume = glass.volume; + // Poured into: wakes, and the level rises. + final height = glass.height; + glass.pour(1e-7); + run(1); + expect(glass.height, greaterThan(height)); + expect(glass.volume, closeTo(volume + 1e-7, 1e-18)); + // Moved: it feels the jolt. + run(240 * 20); + run(3, at: Vector3(0.01, 0, 0)); + expect(glass.surface.reach, greaterThan(0.0)); + }); +} diff --git a/packages/flutter3d_physics/test/fluid/free_surface_test.dart b/packages/flutter3d_physics/test/fluid/free_surface_test.dart new file mode 100644 index 000000000..099da729e --- /dev/null +++ b/packages/flutter3d_physics/test/fluid/free_surface_test.dart @@ -0,0 +1,366 @@ +import 'dart:math' as math; + +import 'package:flutter3d_physics/flutter3d_physics.dart'; +import 'package:test/test.dart'; +import 'package:vector_math/vector_math.dart'; + +RevolvedVessel _cylinder(double radius, double height) => RevolvedVessel([ + Vector2(0, 0), + Vector2(radius, 0), + Vector2(radius, height), +]); + +/// An open box: [width] along x, [depth] along z, [height] tall. +MeshVessel _box(double width, double depth, double height) { + final p = [ + for (final y in [0.0, height]) + for (final (x, z) in [ + (0.0, 0.0), + (width, 0.0), + (width, depth), + (0.0, depth), + ]) + Vector3(x, y, z), + ]; + const quads = >[ + [0, 1, 2, 3], [4, 7, 6, 5], // floor (down), lid (up) + [0, 4, 5, 1], [1, 5, 6, 2], [2, 6, 7, 3], [3, 7, 4, 0], + ]; + return MeshVessel( + positions: p, + indices: [ + for (final q in quads) ...[q[0], q[1], q[2], q[0], q[2], q[3]], + ], + rim: [p[4], p[5], p[6], p[7]], + ); +} + +/// A turn of [angle] about the x axis. +Matrix3 _tilt(double angle) => Matrix3.rotationX(angle); + +/// Steps [body] for [seconds] at [dt], recording the surface over [probe]. +List _record( + LiquidBody body, + Vector3 probe, + double seconds, { + double dt = 1 / 1000, + double g = 9.81, + Matrix3? turn, +}) { + final out = []; + final steps = (seconds / dt).round(); + for (var i = 0; i < steps; i++) { + body.place(turn ?? Matrix3.identity(), Vector3.zero()); + body.step(dt, gravity: Vector3(0, -g, 0)); + out.add(body.surface.displacement(probe)); + } + return out; +} + +/// The mean period of the oscillation in [samples] taken every [dt]. +double _period(List samples, double dt) { + final crossings = []; + for (var i = 1; i < samples.length; i++) { + if ((samples[i - 1] < 0) != (samples[i] < 0)) { + final t = samples[i - 1] / (samples[i - 1] - samples[i]); + crossings.add((i - 1 + t) * dt); + } + } + return 2 * (crossings.last - crossings[1]) / (crossings.length - 2); +} + +double _omega(double k, double h, double g, FluidMedium m) => math.sqrt( + (g * k + m.surfaceTension / m.density * k * k * k) * + (1 - 2 / (math.exp(2 * k * h) + 1)), +); + +void main() { + test("a round vessel's modes are Bessel's", () { + // ∂J_m/∂r = 0 at the wall: J1' at 1.8412 (the slosh), J2' at 3.0542, + // J0' = −J1 at 3.8317 (the first ring). Mutation: leave the wall cells + // their missing neighbours as zeros — a wall that pins the surface — and + // the first k is J1's zero, 3.8317, instead of 1.8412. + const r = 0.05; + final s = FreeSurface(medium: FluidMedium.water, cells: 40) + ..layOut(_cylinder(r, 0.2), Vector3(0, 1, 0), 0.1); + final k = s.wavenumbers.map((x) => x * r).toList(); + expect(k[0], closeTo(1.8412, 1.8412 * 0.04)); + expect(k[1], closeTo(1.8412, 1.8412 * 0.04)); + expect(k[2], closeTo(3.0542, 3.0542 * 0.04)); + expect(k.any((x) => (x - 3.8317).abs() < 3.8317 * 0.04), isTrue); + }); + + test("a box's first mode spans its length: k = π / L", () { + final s = FreeSurface(medium: FluidMedium.water, cells: 40) + ..layOut(_box(0.2, 0.08, 0.1), Vector3(0, 1, 0), 0.05); + expect(s.wavenumbers[0], closeTo(math.pi / 0.2, math.pi / 0.2 * 0.02)); + }); + + test('a sudden tilt sloshes at the first mode, in the world\'s gravity', () { + const r = 0.04, h = 0.06; + for (final g in [9.81, 1.62]) { + final body = LiquidBody( + shape: _cylinder(r, 0.2), + medium: FluidMedium.water, + volume: math.pi * r * r * h, + ); + final samples = _record( + body, + Vector3(0, h, 0.8 * r), + 3, + g: g, + turn: _tilt(0.15), + ); + final k = 1.8412 / r; + final expected = 2 * math.pi / _omega(k, h, g, FluidMedium.water); + // Mutation: drop the tanh, or take g as 9.81 whatever the world says, + // and the Moon's slosh comes out at Earth's period. + expect(_period(samples, 1 / 1000), closeTo(expected, expected * 0.04)); + } + }); + + test('viscosity damps the slosh as Stephens and Dodge measured', () { + const r = 0.04, h = 0.06; + double decay(FluidMedium m) { + final body = LiquidBody( + shape: _cylinder(r, 0.2), + medium: m, + volume: math.pi * r * r * h, + modes: 2, + ); + final s = _record(body, Vector3(0, h, 0.8 * r), 4, turn: _tilt(0.1)); + // Peaks of |η| over the first and last second. + double peak(int from, int to) => + s.sublist(from, to).map((x) => x.abs()).reduce(math.max); + return math.log(peak(0, 1000) / peak(3000, 4000)) / 3.0; + } + + final water = decay(FluidMedium.water); + final k = 1.8412 / r; + final w = _omega(k, h, 9.81, FluidMedium.water); + final nu = FluidMedium.water.kinematicViscosity; + final ratio = 1.84 * h / r; + final zeta = + 0.83 * + math.sqrt(nu / math.sqrt(9.81 * r * r * r)) * + (1 + + 0.318 / + ((math.exp(ratio) - math.exp(-ratio)) / 2) * + (1 + (1 - h / r) / ((math.exp(ratio) + math.exp(-ratio)) / 2))); + final expected = zeta * w + 2 * nu * k * k; + expect(water, closeTo(expected, expected * 0.25)); + // Glycerol, fourteen hundred times as viscous, is still in a moment. + expect(decay(FluidMedium.glycerol), greaterThan(10 * water)); + }); + + test('a carried glass tips its liquid as gravity and braking say', () { + // Accelerating at a along x, the surface settles at atan(a / g). + const r = 0.04; + final body = LiquidBody( + shape: _cylinder(r, 0.2), + medium: FluidMedium.glycerol, + volume: math.pi * r * r * 0.08, + modes: 4, + ); + const dt = 1 / 500, a = 3.0; + for (var i = 0; i < 500; i++) { + final t = i * dt; + body.place(Matrix3.identity(), Vector3(0.5 * a * t * t, 0, 0)); + body.step(dt, gravity: Vector3(0, -9.81, 0)); + } + final angle = math.acos(body.up.y); + expect(angle, closeTo(math.atan(a / 9.81), 1e-3)); + // Liquid left behind piles up at the back, so the surface's normal leans + // forward. Mutation: add the vessel's acceleration instead of taking it + // away, and it leans back. + expect(body.up.x, greaterThan(0)); + }); + + test('waves move no liquid: the volume stays, the surface averages flat', () { + const r = 0.04; + final body = LiquidBody( + shape: _cylinder(r, 0.3), + medium: FluidMedium.water, + volume: math.pi * r * r * 0.1, + ); + final start = body.volume; + final random = math.Random(3); + for (var i = 0; i < 4000; i++) { + final tilt = 0.3 * math.sin(i / 300.0); + body.place(_tilt(tilt), Vector3.zero()); + if (i % 500 == 0) { + body.surface.knock( + Vector3(random.nextDouble() * r - r / 2, 0.1, 0), + 0.004, + ); + } + body.step(1 / 1000, gravity: Vector3(0, -9.81, 0)); + } + expect(body.volume, start); + // Waves hold no liquid above the plane, to a part in ten thousand of a + // millimetre over the surface. Mutation: keep the flat mode, and a knock + // adds liquid. + expect( + body.surface.displacedVolume.abs(), + lessThan(1e-7 * body.surface.area), + ); + }); + + test('tipped past its lip a full vessel spills, outwards, then stops', () { + const r = 0.02; + final body = LiquidBody( + shape: _cylinder(r, 0.1), + medium: FluidMedium.water, + volume: math.pi * r * r * 0.095, + modes: 4, + ); + final start = body.volume; + var spilled = 0.0; + Spill? first; + var last = 0.0; + for (var i = 0; i < 6000; i++) { + body.place(_tilt(0.5), Vector3.zero()); + final spill = body.step(1 / 1000, gravity: Vector3(0, -9.81, 0)); + spilled += spill.flow / 1000; + if (spill.flow > 0) first ??= spill; + last = spill.flow; + } + // And it has stopped. + expect(last, 0.0); + expect(spilled, greaterThan(0)); + expect(body.volume + spilled, closeTo(start, start * 1e-9)); + // It left on the low side. The tilt set the liquid rocking, which threw + // a little more over the lip than the still surface would have let go, + // so it ends at or a little under what the tipped vessel holds. + expect(first!.point.z, greaterThan(0)); + final holds = body.shape.holds(body.up); + expect(body.volume, lessThanOrEqualTo(holds * 1.001)); + expect(body.volume, greaterThan(holds * 0.9)); + }); + + test('no wave stands steeper than Stokes\' limit: past it, it breaks', () { + // A wave higher than a seventh of its length spills its crest. The + // modes are linear and knew no such bound: turned sixty-seven degrees + // in a frame, the chemistry bench's flask laid its old level out as a + // thirty-four millimetre wave and threw most of what it held over the + // lip. + // + // Mutation: drop `_breakSteep` from `FreeSurface.step`. + const r = 0.02; + final body = LiquidBody( + shape: _cylinder(r, 0.1), + medium: FluidMedium.water, + volume: math.pi * r * r * 0.05, + modes: 4, + )..place(_tilt(0.0), Vector3.zero()); + body.step(1 / 1000, gravity: Vector3(0, -9.81, 0)); + // Turned a radian in one step: the old level, laid out on the new + // plane, is a wave far past anything that stands. + body + ..place(_tilt(1.0), Vector3.zero()) + ..step(1 / 1000, gravity: Vector3(0, -9.81, 0)); + final bound = body.surface.wavenumbers.fold( + 0.0, + (double sum, double k) => sum + 2 * math.pi / (14 * k), + ); + expect(body.surface.reach, lessThanOrEqualTo(bound * 1.0001)); + // And small waves are left alone: a knock of a tenth of a millimetre + // rings as it always did. + final calm = LiquidBody( + shape: _cylinder(r, 0.1), + medium: FluidMedium.water, + volume: math.pi * r * r * 0.05, + modes: 4, + )..place(_tilt(0.0), Vector3.zero()); + calm.step(1 / 1000, gravity: Vector3(0, -9.81, 0)); + calm.surface.knock(Vector3(0.5 * r, calm.height, 0), 1e-4); + final before = calm.surface.reach; + calm.step(1e-9, gravity: Vector3(0, -9.81, 0)); + expect(calm.surface.reach, closeTo(before, before * 1e-3)); + }); + + test('upright and overfull, it runs over the edge, out, not in', () { + // Over every part of a level lip at once, the outward pull of each + // stretch cancels the one across from it: the spill left from the middle + // of the mouth with no speed at all, and fell back into the glass it came + // from — round and round, every step. Overfilling the chemistry bench's + // tube did that. It leaves over the stretch that passes most instead, + // and outwards. + // + // Mutation: go back to the weighed mean in `_spill`. + const r = 0.02; + final body = LiquidBody( + shape: _cylinder(r, 0.1), + medium: FluidMedium.water, + volume: math.pi * r * r * 0.1, + modes: 4, + ); + body.pour(math.pi * r * r * 0.005); + Spill? first; + for (var i = 0; i < 200 && first == null; i++) { + body.place(_tilt(0.0), Vector3.zero()); + final spill = body.step(1 / 1000, gravity: Vector3(0, -9.81, 0)); + if (spill.flow > 0) first = spill; + } + expect(first, isNotNull, reason: 'an overfull glass spills'); + final out = Vector3(first!.point.x, 0.0, first.point.z); + // At the lip, not the middle of the mouth. + expect(out.length, closeTo(r, r * 0.05)); + // Moving outwards, away from the axis. + expect(first.velocity.length, greaterThan(0.0)); + expect(first.velocity.dot(out.normalized()), greaterThan(0.0)); + // A sheet no wider than the mouth. + expect(first.width, lessThanOrEqualTo(2 * r * 1.001)); + }); + + test('what lands hard sets the surface moving, and dips it', () { + // A drop landing at a metre a second strikes: an impulsive pressure, + // whose potential sets every mode moving at k·tanh(kh) times its share. + // The same drop laid down still only heaps. Mutation: drop the strike, + // and a stream poured into a glass leaves its surface as flat as a lid. + LiquidBody glass() => LiquidBody( + shape: _cylinder(0.008, 0.09), + medium: FluidMedium.water, + volume: math.pi * 0.008 * 0.008 * 0.03, + modes: 8, + )..place(Matrix3.identity(), Vector3.zero()); + double ring(double speed) { + final body = glass()..step(1 / 240, gravity: Vector3(0, -9.81, 0)); + for (var i = 0; i < 40; i++) { + body + ..receive( + 5e-9, + Vector3(0.003, 0.03, 0), + Vector3(0, -speed, 0), + FluidMedium.water, + const {}, + ) + ..place(Matrix3.identity(), Vector3.zero()) + ..step(1 / 240, gravity: Vector3(0, -9.81, 0)); + } + return body.surface.reach; + } + + final still = ring(0.0); + final struck = ring(1.0); + expect(struck, greaterThan(3.0 * still)); + // And under where it lands the surface is lower than elsewhere. + final body = glass()..step(1 / 240, gravity: Vector3(0, -9.81, 0)); + for (var i = 0; i < 12; i++) { + body + ..receive( + 5e-9, + Vector3(0.004, 0.03, 0), + Vector3(0, -1.0, 0), + FluidMedium.water, + const {}, + ) + ..place(Matrix3.identity(), Vector3.zero()) + ..step(1 / 240, gravity: Vector3(0, -9.81, 0)); + } + expect( + body.surface.displacement(Vector3(0.004, body.height, 0)), + lessThan(body.surface.displacement(Vector3(-0.004, body.height, 0))), + ); + }); +} diff --git a/packages/flutter3d_physics/test/fluid/jet_test.dart b/packages/flutter3d_physics/test/fluid/jet_test.dart new file mode 100644 index 000000000..54f676083 --- /dev/null +++ b/packages/flutter3d_physics/test/fluid/jet_test.dart @@ -0,0 +1,267 @@ +import 'dart:math' as math; + +import 'package:flutter3d_physics/flutter3d_physics.dart'; +import 'package:test/test.dart'; +import 'package:vector_math/vector_math.dart'; + +/// Runs a steady stream of [flow] from [lip] at [velocity] for [seconds]. +Jet _steady({ + required double flow, + required Vector3 lip, + required Vector3 velocity, + required double seconds, + required Vector3 gravity, + double width = 0.0, + double dt = 1 / 1000, + List obstacles = const [], + List receivers = const [], + void Function(List)? onDrops, +}) { + final jet = Jet(medium: FluidMedium.water); + final round = 2 * math.sqrt(flow / (math.pi * velocity.length)); + for (var t = 0.0; t < seconds; t += dt) { + jet.emit( + flow: flow, + dt: dt, + point: lip, + velocity: velocity, + width: width > 0 ? width : round, + across: Vector3(0, 0, 1), + ); + final drops = jet.step( + dt, + gravity: gravity, + obstacles: obstacles, + receivers: receivers, + ); + onDrops?.call(drops); + } + return jet; +} + +void main() { + test('a stream carries its flow past every height: Q = v·A', () { + const q = 2e-6; + final g = Vector3(0, -9.81, 0); + final jet = _steady( + flow: q, + lip: Vector3(0, 1, 0), + velocity: Vector3(0.3, 0, 0), + seconds: 0.3, + gravity: g, + ); + for (final s in jet.runs.first.skip(5)) { + // The speed falling gives it, from how far below the lip it is. + final v = math.sqrt(0.09 + 2 * 9.81 * (1 - s.position.y)); + final passed = math.pi * s.wide * s.thick * v; + expect(passed, closeTo(q, q * 0.02)); + } + }); + + test('a parcel falls as the world falls, whatever its gravity', () { + final moon = Vector3(0.2, -1.62, 0); + const dt = 1e-4; + // Kept whole: a stream this thin would part into drops on the way. + final jet = Jet(medium: FluidMedium.water, breakupGrowth: 1e9); + for (var k = 0; k < 2; k++) { + jet.emit( + flow: 1e-6, + dt: dt, + point: Vector3.zero(), + velocity: Vector3(1, 0.5, 0), + width: 1e-3, + across: Vector3(0, 0, 1), + ); + jet.step(dt, gravity: moon); + } + for (var i = 0; i < 4998; i++) { + jet.step(dt, gravity: moon); + } + // The first parcel has fallen half a second. + const t = 0.5; + final expected = Vector3(1, 0.5, 0) * t + moon * (0.5 * t * t); + final oldest = jet.runs.first.last.position; + expect((oldest - expected).length, lessThan(1e-3)); + }); + + test('every drop of it is somewhere', () { + final body = LiquidBody( + shape: RevolvedVessel([ + Vector2(0, 0), + Vector2(0.02, 0), + Vector2(0.02, 0.1), + ]), + medium: FluidMedium.water, + volume: 1e-5, + modes: 4, + ); + final before = body.volume; + final jet = _steady( + flow: 3e-6, + lip: Vector3(0, 0.1, 0), + velocity: Vector3(0, -0.2, 0), + seconds: 0.4, + gravity: Vector3(0, -9.81, 0), + receivers: [body], + ); + expect(jet.landed, greaterThan(0)); + // Mutation: drop a landed parcel without handing it over, and the + // vessel is short by what landed. + expect( + jet.inFlight + jet.landed + jet.dropped, + closeTo(jet.emitted, jet.emitted * 1e-12), + ); + expect(body.volume - before, closeTo(jet.landed, jet.landed * 1e-9)); + }); + + test('a stream that meets the inside of a glass runs down it', () { + const r = 0.02; + final body = LiquidBody( + shape: RevolvedVessel([Vector2(0, 0), Vector2(r, 0), Vector2(r, 0.2)]), + medium: FluidMedium.water, + volume: 1e-6, + modes: 4, + ); + var outside = 0; + final jet = Jet(medium: FluidMedium.water); + for (var i = 0; i < 400; i++) { + // Thick enough to reach the wall whole: a millimetre stream at this + // speed parts into drops in five centimetres. + jet.emit( + flow: 1e-5, + dt: 1 / 1000, + point: Vector3(-0.015, 0.19, 0), + velocity: Vector3(0.6, 0, 0), + width: 1e-3, + across: Vector3(0, 0, 1), + ); + jet.step( + 1 / 1000, + gravity: Vector3(0, -9.81, 0), + obstacles: [InsideWalls(body)], + receivers: [body], + ); + for (final run in jet.runs) { + for (final s in run) { + final p = s.position; + if (math.sqrt(p.x * p.x + p.z * p.z) > r + 1e-9) outside++; + } + } + } + // Mutation: let it bounce or pass the wall, and it leaves the glass. + expect(outside, 0); + expect(jet.landed, greaterThan(0)); + }); + + test('a stream in still air breaks at L/D = 12(√We + 3We/Re)', () { + // Weightless, so its speed and width stay what they left with. + const d = 1e-3, v = 1.0; + final water = FluidMedium.water; + final q = math.pi * d * d / 4 * v; + double? first; + _steady( + flow: q, + lip: Vector3.zero(), + velocity: Vector3(v, 0, 0), + seconds: 1.0, + gravity: Vector3.zero(), + dt: 1 / 4000, + onDrops: (drops) { + if (drops.isNotEmpty) first ??= drops.first.position.x; + }, + ); + final we = water.density * v * v * d / water.surfaceTension; + final re = water.density * v * d / water.viscosity; + final length = 12 * (math.sqrt(we) + 3 * we / re) * d; + expect(first, closeTo(length, length * 0.02)); + }); + + test('poured slowly it runs down the outside; quickly, it leaves clean', () { + const r = 0.02, wall = 0.002; + final source = LiquidBody( + shape: RevolvedVessel([Vector2(0, 0), Vector2(r, 0), Vector2(r, 0.1)]), + medium: FluidMedium.water, + volume: 1e-6, + modes: 2, + ); + double furthest(double speed) { + final jet = Jet(medium: FluidMedium.water, breakupGrowth: 1e9); + var most = 0.0; + for (var i = 0; i < 200; i++) { + jet.emit( + flow: 2e-7, + dt: 1 / 1000, + point: Vector3(r + wall + 0.0004, 0.1, 0), + velocity: Vector3(speed, 0, 0), + width: 1e-3, + across: Vector3(0, 0, 1), + ); + jet.step( + 1 / 1000, + gravity: Vector3(0, -9.81, 0), + obstacles: [OutsideWalls(source, thickness: wall)], + ); + } + for (final run in jet.runs) { + for (final s in run) { + most = math.max(most, s.position.x); + } + } + return most; + } + + final jet = Jet(medium: FluidMedium.water); + // A slow trickle is held to the glass below the lip: it stays within a + // couple of millimetres of the outside. Mutation: let go of a parcel the + // step it leaves the wall, and it falls away. + expect(furthest(0.03), lessThan(r + wall + 0.003)); + expect(jet.clingSpeed(1e-3), greaterThan(0.03)); + // A quick pour leaves it. + expect(furthest(1.0), greaterThan(r + wall + 0.05)); + }); + + test('a hard stream splashes, a gentle one goes in quietly (Mundo)', () { + ({double dropped, double landed, double emitted}) pour(double d, double v) { + final body = LiquidBody( + shape: RevolvedVessel([ + Vector2(0, 0), + Vector2(0.05, 0), + Vector2(0.05, 0.1), + ]), + medium: FluidMedium.water, + volume: 1e-4, + modes: 2, + ); + final jet = Jet(medium: FluidMedium.water, breakupGrowth: 1e9); + final q = math.pi * d * d / 4 * v; + for (var i = 0; i < 200; i++) { + jet.emit( + flow: q, + dt: 1e-4, + point: Vector3(0, 0.02, 0), + velocity: Vector3(0, -v, 0), + width: d, + across: Vector3(0, 0, 1), + ); + jet.step(1e-4, gravity: Vector3(0, -9.81, 0), receivers: [body]); + } + return (dropped: jet.dropped, landed: jet.landed, emitted: jet.emitted); + } + + final gentle = pour(0.003, 1.0); + expect(gentle.dropped, 0.0); + final hard = pour(0.005, 3.0); + expect(hard.dropped, greaterThan(0.0)); + expect(hard.dropped, lessThanOrEqualTo(0.5 * hard.landed + hard.dropped)); + }); + + test('a throw falls as long as the parabola says', () { + // Dropped from rest: √(2h/g). Thrown up first: longer, by the climb + // and the fall back. Mutation: take the earlier root, and a throw + // upwards lands before it is thrown. + expect(fallTime(Vector3.zero(), 0.2, 0.0), closeTo(0.2019, 1e-4)); + final up = fallTime(Vector3(0, 1, 0), 0.2, 0.0); + expect(up, closeTo((1 + math.sqrt(1 + 2 * 9.81 * 0.2)) / 9.81, 1e-12)); + expect(fallTime(Vector3.zero(), 0.2, 0.0, g: 1.62), greaterThan(0.49)); + }); +} diff --git a/packages/flutter3d_physics/test/fluid/mixture_test.dart b/packages/flutter3d_physics/test/fluid/mixture_test.dart new file mode 100644 index 000000000..8170a2f74 --- /dev/null +++ b/packages/flutter3d_physics/test/fluid/mixture_test.dart @@ -0,0 +1,118 @@ +import 'dart:math' as math; + +import 'package:flutter3d_physics/flutter3d_physics.dart'; +import 'package:test/test.dart'; +import 'package:vector_math/vector_math.dart'; + +LiquidBody _tube( + FluidMedium m, + double volume, { + Map c = const {}, +}) => LiquidBody( + shape: RevolvedVessel([Vector2(0, 0), Vector2(0.01, 0), Vector2(0.01, 0.2)]), + medium: m, + volume: volume, + concentrations: c, + modes: 2, +); + +void main() { + test('two solutions poured together keep every solute', () { + final a = _tube(FluidMedium.water, 2e-6, c: {'dye': 3.0}); + a.pour(1e-6, concentrations: {'dye': 0.0, 'salt': 6.0}); + final layer = a.layers.single; + // 2·3 + 1·0 of dye and 1·6 of salt, over 3. + expect(layer.concentration('dye'), closeTo(2.0, 1e-9)); + expect(layer.concentration('salt'), closeTo(2.0, 1e-9)); + expect(a.volume, closeTo(3e-6, 1e-18)); + }); + + test('oil floats on water, each its own layer, densest at the bottom', () { + final body = _tube(FluidMedium.oil, 1e-6); + body.pour(2e-6, medium: FluidMedium.water); + expect(body.layers.map((l) => l.medium.name), ['water', 'oil']); + expect(body.medium.name, 'oil'); + final tops = body.layerTops(); + final area = math.pi * 0.01 * 0.01; + expect(tops[0], closeTo(2e-6 / area, 1e-6)); + expect(tops[1], closeTo(3e-6 / area, 1e-6)); + // The pressure at the floor is each layer's weight: ρ g h, added. + body + ..place(Matrix3.identity(), Vector3.zero()) + ..step(1e-3, gravity: Vector3(0, -9.81, 0)); + final expected = 9.81 * (998.2 * 2e-6 / area + 911 * 1e-6 / area); + expect(body.pressureAt(Vector3.zero()), closeTo(expected, expected * 1e-3)); + }); + + test('a tipped glass pours its top layer first', () { + final body = _tube(FluidMedium.water, 5e-5); + body.pour(2e-6, medium: FluidMedium.oil); + var oil = 0.0, water = 0.0; + for (var i = 0; i < 3000; i++) { + // Tipped far enough that the still surface lets more than the oil + // go: at 1.2 rad it was a sloshing wave larger than any liquid makes + // that threw the rest over, and that wave now breaks. + body.place(Matrix3.rotationX(1.45), Vector3.zero()); + final spill = body.step(1e-3, gravity: Vector3(0, -9.81, 0)); + if (spill.medium.name == 'oil') oil += spill.flow * 1e-3; + if (spill.medium.name == 'water') water += spill.flow * 1e-3; + // Mutation: spill from the bottom, and water leaves while oil is left. + if (water > 0) { + expect(body.layers.any((l) => l.medium.name == 'oil'), isFalse); + } + } + expect(oil, closeTo(2e-6, 2e-6 * 1e-6)); + expect(water, greaterThan(0)); + }); + + test('a pipe at the floor draws the bottom layer', () { + final a = _tube(FluidMedium.water, 1e-5); + a.pour(5e-6, medium: FluidMedium.oil); + final b = _tube(FluidMedium.water, 1e-6); + final pipe = Pipe( + from: a, + at: Vector3(0, 0.0005, 0), + to: b, + toAt: Vector3(0, 0.0005, 0), + radius: 0.002, + length: 0.05, + ); + for (var i = 0; i < 2000; i++) { + for (final v in [a, b]) { + v.place(Matrix3.identity(), Vector3.zero()); + } + pipe.step(1e-3, gravity: Vector3(0, -9.81, 0)); + a.step(1e-3, gravity: Vector3(0, -9.81, 0)); + b.step(1e-3, gravity: Vector3(0, -9.81, 0)); + } + // Water went through; the oil stayed on top of the first glass. + expect(b.layers.map((l) => l.medium.name), ['water']); + expect(a.layers.last.medium.name, 'oil'); + expect(a.layers.last.volume, closeTo(5e-6, 1e-12)); + }); + + test('the meniscus is the top liquid\'s', () { + // Mutation: keep the water's meniscus once oil is poured over it, and + // the wall rise stays water's, a third higher. + final body = _tube(FluidMedium.water, 8e-6); + body + ..place(Matrix3.identity(), Vector3.zero()) + ..step(1e-4, gravity: Vector3(0, -9.81, 0)); + final water = body.meniscusAt(Vector3(0.01, 0, 0)); + body + ..pour(4e-6, medium: FluidMedium.oil) + ..place(Matrix3.identity(), Vector3.zero()) + ..step(1e-4, gravity: Vector3(0, -9.81, 0)); + final oil = body.meniscusAt(Vector3(0.01, 0, 0)); + final expected = TubeMeniscus( + medium: FluidMedium.oil, + radius: 0.01, + g: 9.81, + ); + // Within a percent: menisci are kept by the radius in steps of a + // hundredth of it, so this one was solved a hair off a centimetre. + final rise = expected.wallRise - expected.meanHeight; + expect(oil, closeTo(rise, 0.01 * rise)); + expect(oil, lessThan(water)); + }); +} diff --git a/packages/flutter3d_physics/test/fluid/outflow_test.dart b/packages/flutter3d_physics/test/fluid/outflow_test.dart new file mode 100644 index 000000000..9a8ac0846 --- /dev/null +++ b/packages/flutter3d_physics/test/fluid/outflow_test.dart @@ -0,0 +1,72 @@ +import 'dart:math' as math; + +import 'package:flutter3d_physics/flutter3d_physics.dart'; +import 'package:test/test.dart'; + +void main() { + test("an opening passes Torricelli's speed, in the world's gravity", () { + final earth = orificeFlow(area: 1e-4, head: 0.2, g: 9.81); + expect(earth.speed, closeTo(math.sqrt(2 * 9.81 * 0.2), 1e-12)); + expect(earth.flow, closeTo(0.62 * 1e-4 * earth.speed, 1e-15)); + // On the Moon the same head passes 0.40 as much: √(1.62 / 9.81). + final moon = orificeFlow(area: 1e-4, head: 0.2, g: 1.62); + expect(moon.flow / earth.flow, closeTo(math.sqrt(1.62 / 9.81), 1e-12)); + expect(orificeFlow(area: 1e-4, head: -1, g: 9.81).flow, 0.0); + }); + + test('a weir passes the flow its head gives, and back', () { + final over = overCircularLip(flow: 2e-6, radius: 0.008, tilt: 1.6, g: 9.81); + final again = circularWeir( + head: over.head, + radius: 0.008, + tilt: 1.6, + g: 9.81, + ); + expect(again.flow, closeTo(2e-6, 2e-6 * 1e-6)); + // Critical flow at the crest: a speed of about √(g · 2h/3). + expect(over.speed, closeTo(math.sqrt(9.81 * over.head * 2 / 3), 0.05)); + expect(circularWeir(head: 0, radius: 0.008, tilt: 1.6, g: 9.81).flow, 0); + }); + + test('a long thin pipe is held back by viscosity, as Poiseuille says', () { + const r = 0.5e-3, length = 1.0, head = 0.1; + final q = pipeFlow( + length: length, + radius: r, + head: head, + medium: FluidMedium.water, + g: 9.81, + ); + final poiseuille = + math.pi * + math.pow(r, 4) * + FluidMedium.water.density * + 9.81 * + head / + (8 * FluidMedium.water.viscosity * length); + expect(q, closeTo(poiseuille, poiseuille * 1e-12)); + // Glycerol, fourteen hundred times as viscous, is that much slower. + final thick = pipeFlow( + length: length, + radius: r, + head: head, + medium: FluidMedium.glycerol, + g: 9.81, + ); + expect(q / thick, greaterThan(1000)); + expect( + pipeFlow( + length: length, + radius: r, + head: -head, + medium: FluidMedium.water, + g: 9.81, + ), + closeTo(-q, 1e-18), + ); + }); + + test("water's capillary length is about 2.7 millimetres", () { + expect(FluidMedium.water.capillaryLength(9.81), closeTo(2.73e-3, 0.02e-3)); + }); +} diff --git a/packages/flutter3d_physics/test/fluid/particle_fluid_test.dart b/packages/flutter3d_physics/test/fluid/particle_fluid_test.dart new file mode 100644 index 000000000..380b5c2f5 --- /dev/null +++ b/packages/flutter3d_physics/test/fluid/particle_fluid_test.dart @@ -0,0 +1,212 @@ +import 'dart:math' as math; + +import 'package:flutter3d_physics/flutter3d_physics.dart'; +import 'package:test/test.dart'; +import 'package:vector_math/vector_math.dart'; + +/// A closed box of planes from (0, 0, 0) to [size]. +List _box(double size) => [ + PlaneObstacle(normal: Vector3(0, 1, 0), offset: 0), + PlaneObstacle(normal: Vector3(1, 0, 0), offset: 0), + PlaneObstacle(normal: Vector3(-1, 0, 0), offset: -size), + PlaneObstacle(normal: Vector3(0, 0, 1), offset: 0), + PlaneObstacle(normal: Vector3(0, 0, -1), offset: -size), +]; + +void main() { + test('lumps let go on top of each other spread rather than fly apart', () { + // A stream breaking up lets go of its drops at nearly the same point + // step after step, so each lump is laid out exactly on the last: the + // chemistry bench's overflowing flask threw thirty thousand particles + // eleven metres up. + // + // Mutations: in `_between`, return pᵢ − pⱼ even where it is nought — + // coincident pairs read compressed with no gradient, and the drops fly + // kilometres; or drop the pre-stabilising `_holdDensity` in `_substep` + // — the overlap is taken out by the solve, becomes velocity, and they + // leap a metre. + final fluid = ParticleFluid(medium: FluidMedium.water, spacing: 0.002); + for (var i = 0; i < 8; i++) { + fluid.inject( + 64 * fluid.particleVolume, + Vector3(0.01, 0.02, 0.01), + Vector3.zero(), + ); + } + final box = _box(0.02); + var highest = 0.0; + var fastest = 0.0; + for (var i = 0; i < 120; i++) { + fluid.step(1 / 240, gravity: Vector3(0, -9.81, 0), obstacles: box); + for (final p in fluid.positions) { + highest = math.max(highest, p.y); + } + } + fastest = fluid.velocities.fold( + 0.0, + (double m, Vector3 v) => math.max(m, v.length), + ); + // Let go from rest two centimetres up, eight lumps deep in a box two + // centimetres across: the crowd has to make room for itself, and rises + // a few centimetres doing it — not metres, which is what it did. + expect(highest, lessThan(0.02 + 0.05)); + expect(fastest, lessThan(math.sqrt(2 * 9.81 * 0.05))); + }); + + test('a single drop falls as the world falls', () { + final fluid = ParticleFluid(medium: FluidMedium.water, spacing: 0.002); + fluid.inject(fluid.particleVolume, Vector3(0, 1, 0), Vector3.zero()); + for (var i = 0; i < 500; i++) { + fluid.step(1 / 1000, gravity: Vector3(0, -1.62, 0)); + } + final y = fluid.positions.single.y; + expect(1 - y, closeTo(0.5 * 1.62 * 0.25, 0.5 * 1.62 * 0.25 * 0.01)); + }); + + test('what is less than a particle waits in the bank, and is counted', () { + final fluid = ParticleFluid(medium: FluidMedium.water, spacing: 0.002); + fluid.inject(2.5 * fluid.particleVolume, Vector3.zero(), Vector3.zero()); + expect(fluid.count, 2); + expect(fluid.volume, closeTo(2.5 * fluid.particleVolume, 1e-18)); + fluid.inject(0.5 * fluid.particleVolume, Vector3.zero(), Vector3.zero()); + expect(fluid.count, 3); + }); + + test( + 'a block of liquid settles at its rest density and stays in its box', + () { + const s = 0.005; + final fluid = ParticleFluid(medium: FluidMedium.water, spacing: s); + for (var i = 0; i < 6; i++) { + for (var j = 0; j < 6; j++) { + for (var k = 0; k < 6; k++) { + fluid.inject( + fluid.particleVolume, + Vector3((i + 0.5) * s, (j + 0.5) * s, (k + 0.5) * s), + Vector3.zero(), + ); + } + } + } + final start = fluid.volume; + final walls = _box(6 * s); + for (var t = 0; t < 600; t++) { + fluid.step(1 / 600, gravity: Vector3(0, -9.81, 0), obstacles: walls); + } + expect(fluid.volume, start); + // Nothing through the walls, and the bulk of it about as tall as it + // was: it neither blew apart nor squashed. A surface particle or two + // may ride a little higher. + final heights = []; + for (final p in fluid.positions) { + expect(p.x, inInclusiveRange(-1e-6, 6 * s + 1e-6)); + expect(p.z, inInclusiveRange(-1e-6, 6 * s + 1e-6)); + expect(p.y, greaterThanOrEqualTo(-1e-6)); + heights.add(p.y); + } + // Its mean height is three spacings — the block's — to five per cent: + // at its rest density, neither squeezed by its weight nor spread out. + // Mutation: Macklin's artificial pressure at his tenth rather than a + // fiftieth, and it settles at 3.6, a sixth too thin. + final mean = heights.reduce((a, b) => a + b) / heights.length; + expect(mean, closeTo(3.0 * s, 0.15 * s)); + heights.sort(); + expect(heights[(heights.length * 0.95).floor()], lessThan(6.2 * s)); + }, + ); + + test('drops that fall into a vessel are its liquid', () { + final body = LiquidBody( + shape: RevolvedVessel([ + Vector2(0, 0), + Vector2(0.02, 0), + Vector2(0.02, 0.1), + ]), + medium: FluidMedium.water, + volume: 1e-5, + modes: 2, + ); + final fluid = ParticleFluid(medium: FluidMedium.water, spacing: 0.002); + fluid.inject( + 20 * fluid.particleVolume, + Vector3(0, 0.05, 0), + Vector3.zero(), + ); + final total = body.volume + fluid.volume; + for (var i = 0; i < 400; i++) { + body.place(Matrix3.identity(), Vector3.zero()); + body.step(1 / 1000, gravity: Vector3(0, -9.81, 0)); + fluid.step(1 / 1000, gravity: Vector3(0, -9.81, 0), receivers: [body]); + } + expect(fluid.count, 0); + expect(body.volume + fluid.volume, closeTo(total, total * 1e-12)); + }); + + group('a test tube, a millimetre a particle', () { + // Sixteen millimetres across, half a millimetre of glass, a round + // bottom: a lathe profile from the middle of the floor up the side. + List inside() => [ + Vector2(0, 0.0005), + for (var i = 1; i <= 8; i++) + Vector2( + 0.0075 * math.sin(i / 8 * math.pi / 2), + 0.0005 + 0.0075 * (1 - math.cos(i / 8 * math.pi / 2)), + ), + Vector2(0.0075, 0.09), + ]; + + LiquidBody tube({double volume = 0.0}) => LiquidBody( + shape: RevolvedVessel(inside()), + medium: FluidMedium.water, + volume: volume, + modes: 2, + wallThickness: 0.0005, + )..place(Matrix3.identity(), Vector3.zero()); + + test('drops this small keep together', () { + // Surface tension moves a millimetre particle its own size in a third + // of a millisecond. Mutation: step it twice a 240th of a second, as + // a two-millimetre one, and four particles fly metres apart. + final fluid = ParticleFluid(medium: FluidMedium.water, spacing: 0.001) + ..inject(4e-9, Vector3(0, 1, 0), Vector3.zero()); + for (var i = 0; i < 12; i++) { + fluid.step(1 / 240, gravity: Vector3.zero()); + } + for (final p in fluid.positions) { + expect((p - Vector3(0, 1, 0)).length, lessThan(0.003)); + } + }); + + test('drops falling in land in it, round bottom and all', () { + // Mutation: hold particles by the radius at their height, which is + // nearly nought round the bottom, and they fall through the glass. + final body = tube(); + final fluid = ParticleFluid(medium: FluidMedium.water, spacing: 0.001); + final walls = [ + InsideWalls(body), + OutsideWalls(body, thickness: 0.0005), + ]; + for (var s = 0; s < 240; s++) { + if (s < 60 && s % 3 == 0) { + fluid.inject( + 4e-9, + Vector3(0.002, 0.04, 0.001), + Vector3(0, -1, 0), + concentrations: {'dye': 2.0}, + ); + } + fluid.step( + 1 / 240, + gravity: Vector3(0, -9.81, 0), + obstacles: walls, + receivers: [body], + ); + } + expect(fluid.count, 0); + expect(body.volume, closeTo(80e-9, 1e-15)); + // What was dissolved in the drops came with them. Mutation: hand + // them over with nothing in them, and a poured dye loses its colour. + expect(body.layers.single.concentration('dye'), closeTo(2.0, 1e-9)); + }); + }); +} diff --git a/packages/flutter3d_physics/test/fluid/pipe_test.dart b/packages/flutter3d_physics/test/fluid/pipe_test.dart new file mode 100644 index 000000000..7c5d66117 --- /dev/null +++ b/packages/flutter3d_physics/test/fluid/pipe_test.dart @@ -0,0 +1,99 @@ +import 'dart:math' as math; + +import 'package:flutter3d_physics/flutter3d_physics.dart'; +import 'package:test/test.dart'; +import 'package:vector_math/vector_math.dart'; + +LiquidBody _tube(double radius, double level, FluidMedium m) => LiquidBody( + shape: RevolvedVessel([ + Vector2(0, 0), + Vector2(radius, 0), + Vector2(radius, 0.5), + ]), + medium: m, + volume: math.pi * radius * radius * level, + modes: 2, +); + +/// Joins [a] and [b] at their floors and steps them for [seconds]. +List _run( + LiquidBody a, + LiquidBody b, + double seconds, { + double radius = 0.01, + double length = 0.1, + double dt = 1 / 2000, +}) { + final pipe = Pipe( + from: a, + at: Vector3(0, 0.0005, 0), + to: b, + toAt: Vector3(0, 0.0005, 0), + radius: radius, + length: length, + minorLoss: 0, + ); + final gravity = Vector3(0, -9.81, 0); + final levels = []; + for (var t = 0.0; t < seconds; t += dt) { + for (final body in [a, b]) { + body.place(Matrix3.identity(), Vector3.zero()); + } + pipe.step(dt, gravity: gravity); + a.step(dt, gravity: gravity); + b.step(dt, gravity: gravity); + levels.add(a.height - b.height); + } + return levels; +} + +void main() { + test("a U-tube swings at 2π√(l / 2g)", () { + // Wide enough that surface tension is nothing beside gravity. + final a = _tube(0.03, 0.12, FluidMedium.water); + final b = _tube(0.03, 0.08, FluidMedium.water); + final total = a.volume + b.volume; + final d = _run(a, b, 3, radius: 0.03, length: 0.1); + final crossings = []; + for (var i = 1; i < d.length; i++) { + if ((d[i - 1] < 0) != (d[i] < 0)) crossings.add(i / 2000); + } + final period = 2 * (crossings[2] - crossings[0]) / 2; + // The column: the pipe and the liquid standing in each tube. + const l = 0.1 + 0.1 + 0.1; + expect( + period, + closeTo(2 * math.pi * math.sqrt(l / (2 * 9.81)), 0.03 * 0.78), + ); + expect(a.volume + b.volume, closeTo(total, total * 1e-12)); + }); + + test('a thick liquid creeps to its level without swinging past it', () { + final a = _tube(0.02, 0.12, FluidMedium.glycerol); + final b = _tube(0.02, 0.08, FluidMedium.glycerol); + final d = _run(a, b, 4, radius: 0.002, length: 0.1); + // Mutation: drop Poiseuille's friction, and it overshoots. + expect(d.every((x) => x >= -1e-6), isTrue); + expect(d.last, lessThan(d.first)); + }); + + test( + 'a narrow tube joined to a wide one stands higher by the capillary rise', + () { + final water = FluidMedium.water; + final narrow = _tube(0.0015, 0.05, water); + final wide = _tube(0.02, 0.05, water); + final d = _run(narrow, wide, 6, radius: 0.0015, length: 0.05); + final expected = + TubeMeniscus(medium: water, radius: 0.0015, g: 9.81).rise - + TubeMeniscus(medium: water, radius: 0.02, g: 9.81).rise; + // The surfaces' flat levels differ by that, less the meniscus each takes + // out of its own level. + final settled = + d.last + + TubeMeniscus(medium: water, radius: 0.0015, g: 9.81).meanHeight - + TubeMeniscus(medium: water, radius: 0.02, g: 9.81).meanHeight; + expect(settled, closeTo(expected, expected * 0.05)); + }, + ); +} diff --git a/packages/flutter3d_physics/test/fluid/vessel_shape_test.dart b/packages/flutter3d_physics/test/fluid/vessel_shape_test.dart new file mode 100644 index 000000000..d3099ddeb --- /dev/null +++ b/packages/flutter3d_physics/test/fluid/vessel_shape_test.dart @@ -0,0 +1,152 @@ +import 'dart:math' as math; + +import 'package:flutter3d_physics/flutter3d_physics.dart'; +import 'package:test/test.dart'; +import 'package:vector_math/vector_math.dart'; + +/// A closed box from (0, 0, 0) to [size], wound outwards. +MeshVessel _box(Vector3 size, {bool rim = true}) { + final x = size.x, y = size.y, z = size.z; + final p = [ + Vector3(0, 0, 0), + Vector3(x, 0, 0), + Vector3(x, y, 0), + Vector3(0, y, 0), + Vector3(0, 0, z), + Vector3(x, 0, z), + Vector3(x, y, z), + Vector3(0, y, z), + ]; + const quads = >[ + [0, 3, 2, 1], [4, 5, 6, 7], // back, front + [0, 1, 5, 4], [3, 7, 6, 2], // bottom, top + [0, 4, 7, 3], [1, 2, 6, 5], // left, right + ]; + return MeshVessel( + positions: p, + indices: [ + for (final q in quads) ...[q[0], q[1], q[2], q[0], q[2], q[3]], + ], + rim: rim ? [p[3], p[2], p[6], p[7]] : const [], + ); +} + +/// A cylinder of [radius] and [height] as a lathe profile. +RevolvedVessel _cylinder(double radius, double height) => RevolvedVessel([ + Vector2(0, 0), + Vector2(radius, 0), + Vector2(radius, height), +]); + +/// The same cylinder as a closed mesh of [segments] sides. +MeshVessel _cylinderMesh(double radius, double height, int segments) { + final p = [Vector3(0, 0, 0), Vector3(0, height, 0)]; + for (var i = 0; i < segments; i++) { + final a = 2 * math.pi * i / segments; + p + ..add(Vector3(radius * math.cos(a), 0, radius * math.sin(a))) + ..add(Vector3(radius * math.cos(a), height, radius * math.sin(a))); + } + final idx = []; + for (var i = 0; i < segments; i++) { + final b0 = 2 + 2 * i, t0 = b0 + 1; + final b1 = 2 + 2 * ((i + 1) % segments), t1 = b1 + 1; + idx + ..addAll([0, b0, b1]) // floor, facing down + ..addAll([1, t1, t0]) // lid, facing up + ..addAll([b0, t0, t1, b0, t1, b1]); // side, facing out + } + return MeshVessel(positions: p, indices: idx); +} + +void main() { + group('a box', () { + final box = _box(Vector3(1, 2, 3)); + test('holds its whole volume below its top', () { + expect(box.volumeBelow(Vector3(0, 1, 0), 10), closeTo(6.0, 1e-12)); + expect(box.capacity, closeTo(6.0, 1e-12)); + }); + test('holds half below its middle, whichever way it is cut', () { + // Mutation: sum against the origin instead of a point on the plane, + // and the cut's own face is missing from the volume. + final diagonal = Vector3(1, 2, 3)..normalize(); + final middle = diagonal.dot(Vector3(0.5, 1, 1.5)); + // Positions are single precision (vector_math), so to a millionth. + expect(box.volumeBelow(diagonal, middle), closeTo(3.0, 1e-6)); + expect(box.volumeBelow(Vector3(0, 1, 0), 0.5), closeTo(1.5, 1e-12)); + }); + test('finds the surface that leaves a volume under it', () { + final up = Vector3(0.3, 1, 0.2)..normalize(); + final h = box.surfaceFor(up, 2.0); + expect(box.volumeBelow(up, h), closeTo(2.0, 1e-6)); + }); + }); + + test('a cylinder upright is π r² h, exactly', () { + final c = _cylinder(0.5, 2); + expect(c.volumeUpTo(1.2), closeTo(math.pi * 0.25 * 1.2, 1e-12)); + expect(c.levelFor(math.pi * 0.25), closeTo(1.0, 1e-9)); + }); + + test('a tilted cylinder keeps its volume under a plane through its axis', () { + // A plane through the axis at mid height, not reaching floor or rim, + // leaves the same volume as the level surface did: wedge for wedge. + final c = _cylinder(0.5, 4); + final up = Vector3(0, math.cos(0.4), -math.sin(0.4)); + final v = c.volumeBelow(up, up.dot(Vector3(0, 2, 0))); + expect(v, closeTo(math.pi * 0.25 * 2, math.pi * 0.25 * 2 * 2e-3)); + }); + + test('a lathe and a mesh of the same cylinder agree', () { + final lathe = _cylinder(0.3, 1); + final mesh = _cylinderMesh(0.3, 1, 256); + for (final tilt in [0.0, 0.5, 1.2]) { + final up = Vector3(math.sin(tilt), math.cos(tilt), 0); + final h = up.dot(Vector3(0, 0.45, 0)); + expect( + mesh.volumeBelow(up, h), + closeTo(lathe.volumeBelow(up, h), lathe.volumeUpTo(1) * 3e-3), + reason: 'tilt $tilt', + ); + } + }); + + test('liquid leaves over the lowest point of the rim', () { + final c = _cylinder(0.5, 2); + final up = Vector3(math.sin(0.3), math.cos(0.3), 0); + final edge = c.lip(up)!; + expect(edge.point.x, closeTo(-0.5, 1e-9)); + // Tipped, it holds less than upright. + expect(c.holds(up), lessThan(c.capacity)); + expect(_box(Vector3(1, 1, 1), rim: false).lip(up), isNull); + }); + + test('the wall is as near as the nearest of it, round the bottom too', () { + // A round-bottomed tube, a centimetre across. + final tube = RevolvedVessel([ + for (var i = 0; i <= 16; i++) + Vector2( + 0.005 * math.sin(i / 16 * math.pi / 2), + 0.005 * (1 - math.cos(i / 16 * math.pi / 2)), + ), + Vector2(0.005, 0.05), + ]); + // Up the side: by the radius. + final side = tube.wallDistance(Vector3(0.004, 0.03, 0))!; + expect(side.distance, closeTo(-0.001, 1e-9)); + expect(side.normal.x, closeTo(1, 1e-9)); + // Near the bottom, off the axis: by the distance to the bowl, which is + // what is left of its radius round the centre of the bowl. Mutation: + // measure by the radius at this height, a millimetre and a half, and + // a point inside reads as in the glass. + final low = Vector3(0.003, 0.002, 0); + final bowl = tube.wallDistance(low)!; + final fromCentre = (low - Vector3(0, 0.005, 0)).length; + expect(bowl.distance, closeTo(fromCentre - 0.005, 2e-5)); + expect(bowl.normal.y, lessThan(0)); + // Below the glass, outside. + expect(tube.wallDistance(Vector3(0, -0.001, 0))!.distance, greaterThan(0)); + // Above the mouth, no wall. + expect(tube.wallDistance(Vector3(0, 0.06, 0)), isNull); + }); +} diff --git a/packages/flutter3d_physics/test/portable_math_test.dart b/packages/flutter3d_physics/test/portable_math_test.dart new file mode 100644 index 000000000..6b4d04d8c --- /dev/null +++ b/packages/flutter3d_physics/test/portable_math_test.dart @@ -0,0 +1,221 @@ +/// The trigonometry a turning body is allowed, asked two questions. +/// +/// dart test test/portable_math_test.dart +/// +/// 1. **The same bits every time and everywhere.** One recorded digest per +/// function over a fixed sweep of arguments. A second answer appearing on +/// some platform is the failure the library exists to rule out. +/// 2. **The right answer.** The same sweep against `dart:math`, in units in +/// the last place. Both platforms agreeing on a wrong number would pass +/// every digest. +library; + +import 'dart:math' as math; +import 'dart:typed_data'; + +import 'package:flutter3d_physics/src/portable_math.dart'; +import 'package:test/test.dart'; + +void main() { + group('the same answer everywhere', () { + // Recorded on macOS-arm64 under the VM, 2026-10-01, where the same sweep + // through `flutter3d_sim`'s `Portable` answered the same bits for every + // one of the 20000 arguments of every function. + // + // Mutation: a coefficient in either kernel changed in its last digit, or + // `_quarterTurns` taking π/2 in one piece, changes every one of these. + for (final row in <(String, int, double Function(int))>[ + ('sin', 1047661232, (int i) => Portable.sin(_a[i])), + ('cos', 19596382, (int i) => Portable.cos(_a[i])), + ('atan', 3618538760, (int i) => Portable.atan(_a[i])), + ('atan2', 273388763, (int i) => Portable.atan2(_a[i], _b[i])), + ('asin', 1248226849, (int i) => Portable.asin(_a[i].abs() % 1.0)), + ('acos', 730462323, (int i) => Portable.acos(_a[i].abs() % 2.0 - 1.0)), + ]) { + final (name, expected, at) = row; + test('$name gives one answer, not one per platform', () { + expect( + _digest([for (var i = 0; i < _sweep; i++) at(i)]), + expected, + reason: + 'Portable.$name answered differently here than where this was ' + 'recorded. Everything in it is IEEE arithmetic over the same ' + 'bits, so that is an edit that changed its answers or a platform ' + 'whose `*` is not the specification\'s. Either is news.', + ); + }); + } + + test('sinCos agrees with sin and cos taken apart', () { + // Mutation: a quadrant table in `sinCos` with two rows swapped passes + // every digest above and turns an axis the wrong way a quarter of the + // time. + for (var i = 0; i < _sweep; i++) { + final both = Portable.sinCos(_a[i]); + expect(both.sin, Portable.sin(_a[i])); + expect(both.cos, Portable.cos(_a[i])); + } + }); + }); + + group('and the right answer', () { + // Mutation: any coefficient typed wrong in its fourth digit — which no + // digest notices, because it is wrong the same way everywhere. + for (final row + in <(String, double Function(double), double Function(double))>[ + ('sin', Portable.sin, math.sin), + ('cos', Portable.cos, math.cos), + ('atan', Portable.atan, math.atan), + ]) { + final (name, ours, theirs) = row; + test('$name is within two ulp of dart:math', () { + final (worst, at) = _worst( + _sweep, + (int i) => (ours(_a[i]), theirs(_a[i]), _a[i]), + ); + expect(worst, lessThanOrEqualTo(2.0), reason: '$name at $at'); + }); + } + + test('atan2 is within two ulp of dart:math, in all four quadrants', () { + final (worst, at) = _worst( + _sweep, + (int i) => + (Portable.atan2(_a[i], _b[i]), math.atan2(_a[i], _b[i]), _a[i]), + ); + expect(worst, lessThanOrEqualTo(2.0), reason: 'atan2 at $at'); + // The signed zeros and infinities, which are conventions rather than + // limits and are where a hand port usually slips. + for (final (y, x) in <(double, double)>[ + (0.0, -1.0), + (-0.0, -1.0), + (0.0, 1.0), + (1.0, 0.0), + (-1.0, 0.0), + (double.infinity, double.infinity), + (double.infinity, double.negativeInfinity), + (1.0, double.negativeInfinity), + ]) { + expect(Portable.atan2(y, x), math.atan2(y, x), reason: 'atan2($y, $x)'); + } + }); + + test('asin is within four ulp of dart:math, including at the ends', () { + final (worst, at) = _worst(_sweep + 4, (int i) { + final x = i < _sweep + ? _a[i].abs() % 1.0 + : const [1.0, -1.0, 0.9999999999, -0.9999999999][i - + _sweep]; + return (Portable.asin(x), math.asin(x), x); + }); + expect(worst, lessThanOrEqualTo(4.0), reason: 'asin at $at'); + }); + + test('acos is within two ulp of dart:math, including near one', () { + // Near one is where a joint at rest sits, and where `π/2 - asin(x)` + // would cancel away nearly every digit. + // + // Mutation: `acos` written as `π/2 - asin(x)` is out by thousands of + // ulp at 0.9999999999. + const ends = [1.0, -1.0, 0.9999999999, -0.9999999999, 0.0]; + final (worst, at) = _worst(_sweep + ends.length, (int i) { + final x = i < _sweep ? _a[i].abs() % 2.0 - 1.0 : ends[i - _sweep]; + return (Portable.acos(x), math.acos(x), x); + }); + expect(worst, lessThanOrEqualTo(2.0), reason: 'acos at $at'); + }); + + test('and an argument it cannot answer is NaN, not a throw', () { + expect(Portable.sin(double.infinity), isNaN); + expect(Portable.cos(double.nan), isNaN); + expect(Portable.acos(1.5), isNaN); + expect(Portable.asin(-1.5), isNaN); + expect(Portable.sinCos(double.infinity).sin, isNaN); + }); + }); +} + +/// The largest distance in ulp across [count] cases, and the argument it was +/// found at. +(double, double) _worst(int count, (double, double, double) Function(int) at) { + return Iterable.generate(count).map(at).fold<(double, double)>( + (0.0, 0.0), + (worst, row) { + final (ours, theirs, x) = row; + final error = _ulpsApart(ours, theirs); + return error > worst.$1 ? (error, x) : worst; + }, + ); +} + +/// How far apart [a] and [b] are, in units of the last place at [b]. +/// +/// A distance over an ulp rather than a difference of bit patterns: those are +/// near 2^62 and their difference taken in a double rounds to a multiple of +/// 1024, which is what an instrument measuring itself looks like. +double _ulpsApart(double a, double b) { + if (a == b) return 0.0; + if (a.isNaN && b.isNaN) return 0.0; + if (a.isNaN || b.isNaN || a.isInfinite || b.isInfinite) { + return double.infinity; + } + return (a - b).abs() / _ulpSize(b); +} + +/// The gap between [x] and the next double away from zero, found by adding one +/// to the mantissa — through `ByteData` in halves, because a browser's integers +/// stop being exact at 2^53. +double _ulpSize(double x) { + final magnitude = x.abs(); + if (magnitude == 0.0 || !magnitude.isFinite) return double.minPositive; + _bits.setFloat64(0, magnitude); + final low = _bits.getUint32(4); + if (low == 0xFFFFFFFF) { + _bits.setUint32(0, _bits.getUint32(0) + 1); + _bits.setUint32(4, 0); + } else { + _bits.setUint32(4, low + 1); + } + return _bits.getFloat64(0) - magnitude; +} + +final ByteData _bits = ByteData(8); + +/// FNV-1a over the eight bytes of every double, as an unsigned 32-bit number. +/// +/// The multiply by the FNV prime is split as `2^24 + 403` so no product passes +/// 2^53: on the web an integer is a double, and a product past that rounds — +/// the digest would then be a property of the platform it was checking. +int _digest(List values) { + final bytes = ByteData(8); + return values.fold(0x811C9DC5, (hash, value) { + bytes.setFloat64(0, value); + return Iterable.generate(8).fold(hash, (h, i) { + final mixed = h ^ bytes.getUint8(i); + return ((mixed % 256) * 16777216 + mixed * 403) % 4294967296; + }); + }); +} + +const int _sweep = 20000; + +/// Arguments in four bands — a unit interval, two turns either way, a thousand +/// radians, and a ten-thousandth — drawn from a golden-ratio sequence. +/// +/// **Built from double arithmetic alone**, so the arguments are the same bits +/// on every platform before any function is asked about them: `%` on doubles +/// is exact, and so is a product rounded once. +List _sweepArguments(double offset) => [ + for (var i = 0; i < _sweep; i++) + switch (((i + offset) * 0.6180339887498949) % 1.0 * 2.0 - 1.0) { + final u => switch (i % 4) { + 0 => u, + 1 => u * math.pi * 2.0, + 2 => u * 1000.0, + _ => u * 1e-4, + }, + }, +]; + +final List _a = _sweepArguments(0.5); +final List _b = _sweepArguments(0.25); diff --git a/packages/flutter3d_physics/test/rotation_test.dart b/packages/flutter3d_physics/test/rotation_test.dart new file mode 100644 index 000000000..04f2ca186 --- /dev/null +++ b/packages/flutter3d_physics/test/rotation_test.dart @@ -0,0 +1,387 @@ +/// Bodies that turn: inertia per shape, spin from an off-centre impulse, a +/// free body keeping its angular momentum, and a snapshot that resumes the +/// same run. +/// +/// dart test test/rotation_test.dart +/// +/// The first piece of stage two. No contact turns a body yet, so every scene +/// here is a body in free flight; what is being held is the integrator, not +/// the solver. +library; + +import 'dart:convert'; + +import 'package:flutter3d_physics/flutter3d_physics.dart'; +import 'package:test/test.dart'; +import 'package:vector_math/vector_math.dart'; + +const double _dt = 1.0 / 60.0; + +void main() { + group('inertia', () { + test('a unit cube of six kilograms is one about every axis', () { + // m·s²/6 for a cube of side s. Mutation: the box formula with full + // widths where it wants half extents is four times this. + final i = inertiaFor(CollisionBox(Vector3.all(0.5)), 6.0); + expect(i.x, closeTo(1.0, 1e-6)); + expect(i.y, closeTo(1.0, 1e-6)); + expect(i.z, closeTo(1.0, 1e-6)); + }); + + test('a box is heaviest about the axis its two long sides are across', () { + // Mutation: any two of the three moments swapped. + final i = inertiaFor(CollisionBox(Vector3(0.5, 1.0, 1.5)), 3.0); + expect(i.x, closeTo(1.0 * (1.0 + 2.25), 1e-6)); + expect(i.y, closeTo(1.0 * (0.25 + 2.25), 1e-6)); + expect(i.z, closeTo(1.0 * (0.25 + 1.0), 1e-6)); + }); + + test('a sphere is two fifths of m r²', () { + final i = inertiaFor(CollisionSphere(2.0), 5.0); + expect(i, Vector3.all(8.0)); + }); + + test('a capsule with no cylinder is the sphere it is', () { + // Mutation: the caps' parallel-axis term with `h` where it wants `h²` + // still passes here, which is why the next test exists. + final capsule = inertiaFor( + CollisionCapsule(radius: 0.4, halfHeight: 0.0), + 2.0, + ); + final sphere = inertiaFor(CollisionSphere(0.4), 2.0); + expect(capsule.x, closeTo(sphere.x, 1e-6)); + expect(capsule.y, closeTo(sphere.y, 1e-6)); + }); + + test('a long capsule agrees with its volume summed point by point', () { + // A grid of points through the bounding box, kept where they fall + // inside the capsule, each carrying an equal share of the mass. Half a + // percent at this resolution; the closed form is exact. + // + // Mutation: the capsule treated as a cylinder of its whole height is + // several percent out on both axes. + const r = 0.3, h = 0.6, mass = 4.0, n = 80; + final shape = CollisionCapsule(radius: r, halfHeight: h); + final step = 2.0 * (h + r) / n; + var (count, across, axial) = (0, 0.0, 0.0); + for (var ix = 0; ix < n; ix++) { + for (var iy = 0; iy < n; iy++) { + for (var iz = 0; iz < n; iz++) { + final x = -(h + r) + (ix + 0.5) * step; + final y = -(h + r) + (iy + 0.5) * step; + final z = -(h + r) + (iz + 0.5) * step; + final dy = y.abs() > h ? y.abs() - h : 0.0; + if (x * x + z * z + dy * dy > r * r) continue; + count++; + across += y * y + z * z; + axial += x * x + z * z; + } + } + } + final i = inertiaFor(shape, mass); + expect(i.x, closeTo(mass * across / count, i.x * 0.005)); + expect(i.y, closeTo(mass * axial / count, i.y * 0.005)); + expect(i.z, i.x); + }); + + test('a wedge is its bounding box, as the library says it is', () { + final wedge = inertiaFor(CollisionWedge(Vector3(1.0, 0.5, 2.0)), 3.0); + expect(wedge, inertiaFor(CollisionBox(Vector3(1.0, 0.5, 2.0)), 3.0)); + }); + + test('and nothing for no mass', () { + expect(inertiaFor(CollisionSphere(1.0), 0.0), Vector3.zero()); + }); + }); + + group('a body that was not asked to turn', () { + test('does not, whatever it is given', () { + // The promise every shipped level rests on. Mutation: `canRotate` + // defaulting to true, or `inverseInertiaLocal` taken from the shape + // regardless of it. + final (dynamics, body) = _floating(canRotate: false); + body + ..applyTorqueImpulse(Vector3(5.0, 5.0, 5.0)) + ..applyImpulseAt( + Vector3(0.0, 0.0, 10.0), + body.position + Vector3(1.0, 0.0, 0.0), + ); + for (var i = 0; i < 60; i++) { + dynamics.step(_dt); + } + expect(body.angularVelocity, Vector3.zero()); + expect(body.orientation, Quaternion.identity()); + expect(body.inverseInertiaLocal, Vector3.zero()); + }); + + test('and its snapshot has the keys it always had', () { + // Mutation: writing the orientation for every body changes the digest + // of every saved level holding a crate. + final (_, body) = _floating(canRotate: false); + expect(body.save().keys, ['at', 'velocity', 'asleep', 'still']); + }); + + test('and an off-centre impulse still moves it as a central one would', () { + final (_, a) = _floating(canRotate: false); + final (_, b) = _floating(canRotate: false); + a.applyImpulse(Vector3(0.0, 0.0, 6.0)); + b.applyImpulseAt( + Vector3(0.0, 0.0, 6.0), + b.position + Vector3(1.0, 0.0, 0.0), + ); + expect(b.velocity, a.velocity); + }); + }); + + group('a body that turns', () { + test('spins by r × J through the inverse tensor', () { + // A unit cube of six kilograms has I = 1, so the spin is the angular + // impulse itself: a push along +z one metre out along +x is a turn + // about -y. Mutation: the cross product's operands swapped turns it + // the other way. + final (_, body) = _floating(); + body.applyImpulseAt( + Vector3(0.0, 0.0, 2.0), + body.position + Vector3(1.0, 0.0, 0.0), + ); + expect(body.velocity.z, closeTo(2.0 / 6.0, 1e-6)); + expect(body.angularVelocity.x, closeTo(0.0, 1e-6)); + expect(body.angularVelocity.y, closeTo(-2.0, 1e-6)); + expect(body.angularVelocity.z, closeTo(0.0, 1e-6)); + }); + + test('turns the way a matrix drawn from it turns, by the angle spun', () { + // A quarter turn about +y in one second takes +x to -z. Held through + // `asRotationMatrix`, the matrix `Matrix4.compose` builds and a renderer + // draws with, rather than `Quaternion.rotated`: that one computes + // `q* v q`, the inverse turn, and would put this body's +x at +z. + // + // Mutation: `ω ⊗ q` written as `q ⊗ ω` turns about the body's axes + // rather than the world's, which agrees here and nowhere else — so the + // second half starts the body already turned. The sign of `ω` flipped in + // the update fails the first half. + final (dynamics, body) = _floating(); + body.applyTorqueImpulse(Vector3(0.0, Portable.atan(1.0) * 2.0, 0.0)); + for (var i = 0; i < 60; i++) { + dynamics.step(_dt); + } + final x = body.orientation.asRotationMatrix().transformed( + Vector3(1.0, 0.0, 0.0), + ); + expect(x.x, closeTo(0.0, 1e-3)); + expect(x.z, closeTo(-1.0, 1e-3)); + + final (turnedDynamics, turned) = _floating( + orientation: Quaternion( + 0.0, + 0.0, + 0.7071067811865476, + 0.7071067811865476, + ), + ); + turned.applyTorqueImpulse(Vector3(0.0, Portable.atan(1.0) * 2.0, 0.0)); + for (var i = 0; i < 60; i++) { + turnedDynamics.step(_dt); + } + // Body +x was world +y before; a world-axis turn about +y leaves it there. + final up = turned.orientation.asRotationMatrix().transformed( + Vector3(1.0, 0.0, 0.0), + ); + expect(up.y, closeTo(1.0, 1e-3)); + }); + + test('has a world tensor that is R · I⁻¹ · Rᵀ', () { + final (_, body) = _floating( + shape: CollisionBox(Vector3(0.2, 0.5, 1.0)), + orientation: Quaternion(0.3, -0.2, 0.5, 0.8), + ); + final expected = _turned(body.orientation, body.inverseInertiaLocal); + for (var i = 0; i < 9; i++) { + expect( + body.inverseInertiaWorld.storage[i], + closeTo(expected.storage[i], 1e-4), + ); + } + }); + + test( + 'keeps its angular momentum, and nearly its energy, spinning free', + () { + // Spun about no principal axis, so ω wobbles while L stays put. Ten + // seconds at sixty steps a second. + // + // Mutation: `integrateOrientation` keeping ω and dropping the turn of + // the tensor — no gyroscopic term — lets L wander by tens of percent + // within the first second. + final (dynamics, body) = _floating( + shape: CollisionBox(Vector3(0.2, 0.5, 1.0)), + ); + body.applyTorqueImpulse(Vector3(0.4, 0.3, 0.2)); + final l0 = _momentum(body); + final e0 = 0.5 * body.angularVelocity.dot(l0); + var worstL = 0.0; + var worstE = 0.0; + for (var i = 0; i < 600; i++) { + dynamics.step(_dt); + final l = _momentum(body); + final e = 0.5 * body.angularVelocity.dot(l); + final dl = (l - l0).length / l0.length; + final de = (e - e0).abs() / e0; + if (dl > worstL) worstL = dl; + if (de > worstE) worstE = de; + } + expect(worstL, lessThan(1e-4), reason: 'angular momentum drifted'); + expect(worstE, lessThan(0.02), reason: 'rotational energy drifted'); + expect(body.isAsleep, isFalse); + }, + ); + + test('is not put to sleep for standing still while it spins', () { + // Mutation: `updateSleep` reading only the linear velocity stops this + // dead half a second in. + final (dynamics, body) = _floating(); + body.applyTorqueImpulse(Vector3(0.0, 3.0, 0.0)); + for (var i = 0; i < 120; i++) { + dynamics.step(_dt); + } + expect(body.isAsleep, isFalse); + expect(body.angularVelocity.y, closeTo(3.0, 1e-5)); + }); + + test( + 'loses spin to damping as one over one plus dt times it, per step', + () { + final (dynamics, body) = _floating(angularDamping: 2.0); + body.applyTorqueImpulse(Vector3(0.0, 3.0, 0.0)); + for (var i = 0; i < 60; i++) { + dynamics.step(_dt); + } + var expected = 3.0; + for (var i = 0; i < 60; i++) { + expected /= 1.0 + _dt * 2.0; + } + expect(body.angularVelocity.y, closeTo(expected, 1e-4)); + }, + ); + }); + + group('a snapshot', () { + test('resumes the same run, bit for bit', () { + // Through JSON, as a save on disk is. No contacts in the scene: the + // warm-start impulses `Dynamics` carries between steps are not part of + // a body's snapshot, and a contact would make this a test of that. + // + // Mutation: `restore` renormalising the orientation it reads, or + // forgetting to refresh the world tensor, changes the last bits. + final (straight, through) = (_tumbling(), _tumbling()); + for (var i = 0; i < 100; i++) { + straight.$1.step(_dt); + through.$1.step(_dt); + } + final saved = + jsonDecode(jsonEncode(through.$2.save())) as Map; + + final resumed = _tumbling(); + resumed.$2.restore(saved); + for (var i = 0; i < 100; i++) { + straight.$1.step(_dt); + resumed.$1.step(_dt); + } + final a = straight.$2, b = resumed.$2; + expect(b.position.storage, a.position.storage); + expect(b.velocity.storage, a.velocity.storage); + expect(b.orientation.storage, a.orientation.storage); + expect(b.angularVelocity.storage, a.angularVelocity.storage); + }); + + test('without the new keys is a body that is not turning', () { + // A snapshot written for a body that could not turn says "the identity + // and no spin" by leaving them out, and that is what it restores to. + final (_, body) = _floating(); + body + ..orientation = Quaternion(0.0, 0.0, 1.0, 1.0) + ..applyTorqueImpulse(Vector3(1.0, 2.0, 3.0)); + body.restore({ + 'at': [0.0, 10.0, 0.0], + 'velocity': [0.0, 0.0, 0.0], + }); + expect(body.orientation, Quaternion.identity()); + expect(body.angularVelocity, Vector3.zero()); + }); + + test('with a key it cannot read leaves the body as it was', () { + final (_, body) = _floating(); + body.orientation = Quaternion(0.0, 0.0, 1.0, 1.0); + final before = body.orientation.clone(); + body.restore({ + 'at': [0.0, 10.0, 0.0], + 'orientation': [0.0, 'up', 0.0, 1.0], + 'spin': [0.0, 1.0, 0.0], + }); + expect(body.orientation, before); + expect(body.angularVelocity.y, 1.0); + }); + + test('and an orientation edited by hand is made a rotation', () { + final out = Quaternion.identity(); + expect(readQuaternion([0.0, 0.0, 1.0, 1.0], out), isTrue); + expect(out.length, closeTo(1.0, 1e-6)); + expect(readQuaternion([0.0, 0.0, 0.0, 0.0], out), isFalse); + }); + }); +} + +/// A six-kilogram unit cube, or [shape], hanging in a world with no gravity. +(Dynamics, RigidBody) _floating({ + bool canRotate = true, + CollisionShape? shape, + Quaternion? orientation, + double angularDamping = 0.0, +}) { + final dynamics = Dynamics(world: CollisionWorld(), gravity: Vector3.zero()); + final body = dynamics.add( + RigidBody( + world: dynamics.world, + shape: shape ?? CollisionBox(Vector3.all(0.5)), + position: Vector3(0.0, 10.0, 0.0), + mass: 6.0, + canRotate: canRotate, + orientation: orientation, + angularDamping: angularDamping, + ), + ); + return (dynamics, body); +} + +/// A long box thrown upwards and tumbling under gravity, far from anything. +(Dynamics, RigidBody) _tumbling() { + final dynamics = Dynamics(world: CollisionWorld()); + final body = dynamics.add( + RigidBody( + world: dynamics.world, + shape: CollisionBox(Vector3(0.2, 0.5, 1.0)), + position: Vector3(0.0, 100.0, 0.0), + mass: 3.0, + canRotate: true, + angularDamping: 0.1, + ), + ); + body.applyImpulseAt( + Vector3(0.5, 30.0, 0.2), + body.position + Vector3(0.3, 0.0, 0.8), + ); + return (dynamics, body); +} + +/// `I_world · ω`, worked out the long way, independently of the body. +Vector3 _momentum(RigidBody body) => _turned( + body.orientation, + body.inertiaLocal, +).transformed(body.angularVelocity); + +/// `R · diag(d) · Rᵀ` through `vector_math`'s own rotation matrix, so the +/// body's hand-written one is checked against something it did not write. +Matrix3 _turned(Quaternion q, Vector3 d) { + final r = q.asRotationMatrix(); + return r.multiplied(Matrix3.zero()..setDiagonal(d))..multiply(r.transposed()); +} diff --git a/packages/flutter3d_physics_native/CHANGELOG.md b/packages/flutter3d_physics_native/CHANGELOG.md new file mode 100644 index 000000000..0b74d3f3e --- /dev/null +++ b/packages/flutter3d_physics_native/CHANGELOG.md @@ -0,0 +1,453 @@ +## Unreleased + +- **The platformer runs on the core.** A rewind or a demo restored from a + save made mid-level now steps on to the same bits as the run it was + taken from. `NativeDynamics.saveState()` is the core's snapshot as + text, and its restore puts right what came and went since: a body added + since is made again, a collider standing since stands again, and a body + or wall since gone is taken out. Generational handles tell a slot used + again since apart from the old one. A save restored while a barge was + elsewhere also read as the barge having jumped there in one step, and + the mirror gave it that speed. Comparing every step's save after the + restore with the live run caught it. The final states had agreed, + since a crate stopped by friction ends in the same place whenever it + falls asleep. A collider that stands in the core now resumes at its own + position after a restore. + +- **The core under `flutter3d_physics`' bodies.** `NativeDynamics` is a + `RigidDynamics`, so a game builds one where it built a `Dynamics`, and + its crates, sweeps and character controller stay as they were. The core + holds the bodies. Before each step, whatever a game did to a body (an + impulse, a push, a teleport, its own `restore`) is found by comparing it + with where the mirror left it, and written in. After the step, every + body is written back: its collider moved, its velocity, spin and + orientation, asleep or awake. Level geometry, characters, doors and + lifts stand in the core as fixed bodies, made, moved and taken out as + their colliders are. A box, sphere or capsule becomes the same shape, a + wedge becomes a hull, and a height field becomes a mesh split the way + the field splits its quads. A moving collider is given the velocity it + moved at, or a lift would leave the crate on it behind: 1.89 m instead + of 2 after a metre's climb. The same scenes through both backends land + crates where the reference does, stack and sleep them, push them, and + rest them on a height field. Only the core rolls a ball down a wedge, + because the reference meets a wedge as its box, and only the core tips + a crate off an edge. `snapshot()` and `restore()` carry the warm starts + and sleep for a rollback, and a restored world steps on to the same + bits. In Chrome, under dart2js and dart2wasm, a scene of crates, a ball + down a wedge, a lift and a push steps through `NativeDynamics` to the + same hash as natively. +- **A fresh checkout no longer reads "Build must be rerun".** The hook + unpacked wgpu-native's headers dated now, and since the GPU library is + compiled against them, they counted as files modified during the build. + They are now dated 2000. + +- **The same bits on every platform, and a test that says so.** + `csrc/tests/test_digest.c` steps seven scenes and fails unless each + hashes to the number written in it: a world of every shape, the same + world in the fast mode on three threads, the ragdoll down its stairs, + debris, cloth, water and particles, every real of them. The numbers were + taken on macOS arm64. They come out the same, in both precisions, on + Linux arm64, x86-64 and 32-bit ARM with gcc and with clang, on the iOS + simulator, and on Android arm64 in the emulator. + `tool/digest_platforms.sh` runs all of these from a Mac. + `test/c_unit_test.dart` now builds the C tests with MSVC on Windows too, + and a CI job runs the whole package there with the library the hook + builds. 32-bit x86 was the exception: gcc does its arithmetic on the x87 + there, in wide registers, and all seven scenes landed elsewhere. In SSE2 they + match, under gcc and clang alike. The hook now asks for SSE2 on ia32, + and the core refuses to build on the x87. + +- **Ragdolls — the closing check of the native core.** A ball joint now + takes a cone (`f3d_joint_set_cone`, `setJointCone`), which holds the + swing of its second body's axis within an angle of the first's. Its + twist about that axis takes the same limits as a hinge's angle, and it + takes friction (`f3d_joint_set_friction`, `setJointFriction`): torque up + to a set bound resisting the turn between its bodies. Without friction + a ragdoll on the floor never sleeps, because a head rolls and a leg + turns about its own length with nothing to slow them. `jointSwing` reads + the swing, and `jointValue` now reads a ball joint's twist. The twist + limit pushes along the axes' sum over one plus their cosine, which is + how the twist actually changes. Pushed about the first axis alone, a + shoulder swung a radian and a half went nearly half a radian past its + limit on a flight of stairs; along the sum it goes 0.04 past. A ragdoll + of eleven bodies on ten joints falls to a floor and sleeps within two + seconds. A bullet of thirty grams at 300 m/s knocks it along, and it + sleeps again. It tumbles down eight stairs to their foot. Throughout, + the joints hold their points within 2.5 cm and their limits within five + degrees, and at rest within a fraction of a millimetre. It steps to the + same bits on four threads as on one, in either mode. It wants eight + substeps: at four, an arm struck on a stair edge swings a quarter of a + radian past its cone for a step. ABI 18, snapshot version 8. + +- **Threads in the browser — phase 12 done.** A second module, + `web/f3d_physics_threads.wasm`, is the core built with atomics on a + shared memory its host makes. `loadPhysicsCore(threads: n)` on a page + that can share memory — served cross-origin isolated, with COOP and + COEP — loads it and starts n − 1 Web Workers from `web/f3d_worker.js`, + each an instance of the module on that memory with a stack of its own, + waiting in the core's `f3d_worker_main`; `physicsCoreThreads` says how + many a world can then ask for. Elsewhere it loads the single-threaded + module, and a world asked for more threads says no. The module cannot + start a thread itself, so its pool takes workers its host started; they + wait with `memory.atomic.wait`, and the page, which may not block, spins. + The module's allocator is held by a spin lock, since workers allocate in + the middle of a step. On four threads — four of node's workers, and in + Chrome four Web Workers — the module steps a scene to the native + library's bytes. Memory is read through a JavaScript DataView, which + takes a SharedArrayBuffer as an ArrayBuffer. + +- **The core runs in the browser.** `loadPhysicsCore()` fetches the + WebAssembly module — shipped in the package as `web/f3d_physics.wasm`, + an asset a Flutter web app serves — and the same `NativeWorld`, debris, + cloth, water and particles run on it through `dart:js_interop`, compiled + with dart2js or dart2wasm. A 64-bit handle crosses into the module as + two 32-bit halves, through a shim `tool/gen_core.dart` writes beside the + calls. In Chrome, a scene of boxes, balls, capsules, a hull, a hinge, + wood and wind steps to the snapshot the native library steps it to, + hash for hash; and `test/wasm_test.dart` holds the shipped module to the + native library byte for byte, so a core changed and not rebuilt fails. + A body's slot and generation are now taken from its handle by division: + under dart2js Dart's shifts see 32 bits, and every generation read as + nought. + +- **One API over the core, natively and in the browser — phase 12.** The + wrappers no longer hold `dart:ffi` pointers: they reach the core through + `lib/src/core/`, where a call is a function and every pointer an + address — natively a pointer, in the browser an offset into the + WebAssembly module's memory — and blocks of the core's memory are read + and written by element (`F32s`, `U64s` and their kin) and the structs it + fills by offsets. The calls, both ways, and the structs' layouts are + written by `tool/gen_core.dart` from `f3d_physics.h`, the one place a + signature lives; a test runs it in check mode and holds every layout + against the C compiler's own `offsetof`. The GPU passes stay native + (`gpu_native.dart`); in the browser `NativeGpu.open()` is null and the + CPU systems stand in. The GPU's settings are written as the core's, + since they are laid out alike. `loadPhysicsCore()` fetches the module in + the browser and does nothing natively. A world dropped without + `dispose()` is freed by a `Finalizer`. Every native test passes as it + did; the package compiles with dart2js and dart2wasm. `package:ffi` is + no longer a dependency. Bodies' handles are 64-bit, and under dart2js an + int holds 53 bits exact: a slot reused more than two million times + would come out wrong there. + +- **The fast mode solves contacts four at a time — phase 11 done.** A + colour's contacts go in batches of four, a contact a lane of a vector + (GCC's and Clang's vector types, NEON or SSE beneath; MSVC the same + operations a lane at a time), every lane doing what one contact solved + alone does, operation for operation: what a contact decides with a + branch, a lane decides with a mask, and a lane with fewer points or a + body that does not move is masked out, not skipped. So the fast mode + lands on the bits it landed on before, with vectors or without, and a + single contact still on the deterministic mode's. A colour's contacts + are batched by how many points they have, which changes nothing. The + contacts now read the bodies from a tight array of velocity, spin and + how far each has moved and turned since the step began — the turn + worked out once a body instead of once a contact — gathered from the + slots before each stage and scattered back after; every bit of both + modes is unchanged. On the heap of four thousand, the fast mode's solver + on one thread went from 7.7 ms to 4.5. On several threads the numbers + swung from 3.3 ms to 20 with the machine at load 8 to 49: a step meets + at some 130 barriers, and a thread the system has set aside holds the + rest there. One thread stays the default. + +- **The pairs of overlapping leaves are kept from step to step.** Every + awake body used to ask the tree what was near it every step; now the + world keeps the pairs whose leaves overlap, drops those whose leaves + parted and asks the tree only for the leaves that moved, and finds the + step's pairs among them by the same tests the queries made — the boxes + swept through the step overlapping. A leaf holds its body's swept box, + so two swept boxes can only meet where two leaves do, and the pairs are + the same: every body of a test scene moves to the same bits as before. + Swept boxes are worked out once a step, not once a query. On the heap + of four thousand the collision stage fell from 13.3 ms to 2.2 ms on one + thread, and to 1.2 ms on six. + +- **The fast mode.** `f3d_world_set_fast` (Dart: `NativeWorld.fast`) + colours the contacts each step — greedily, in contact order, the lowest + of 64 colours neither of its moving bodies has, the rest to an overflow + solved on one thread — and solves a colour's contacts at once on every + thread, the stages of a step met at a spinning barrier inside one pass + of the pool, joints on one thread between them. Solved in another order, + it lands on other bits than the deterministic mode, but on the same bits + on any number of threads; a single contact, one colour, solves to the + deterministic mode's bits exactly. Bodies that do not move are no longer + written with nothing by a contact, in either mode, which changes no bit. + A stack of ten walks 5.4 cm aside as it settles (1.4 cm deterministic) + and sleeps upright. Kept in a snapshot (version 7). On a heap of four + thousand, eight colours and some 124 barriers a step; the solver went + from 8.4 ms to 4.9 ms on six threads with the machine at load 29, where + a barrier waits on threads the system has put aside. ABI 17. + +- **A world steps on threads, to the same bits — phase 11.** + `f3d_world_set_threads` (Dart: `NativeWorld.threads`) gives a world a + pool of up to 64 threads, POSIX or Windows', the caller one of them; the + WebAssembly build has none and steps on the caller. The tree's queries + and the narrow phase are shared out: each worker gathers its pairs in a + lane of its own and the lanes are sorted together after, and each + pair's manifold is made in a slot of its own and the slots closed up in + key order, so a world of meshes, hulls, boxes, balls, capsules, + cylinders and a hinged chain snapshots to the same bytes, every step, + on one thread, two, three or eight. On four thousand bodies in a heap + the collision stage fell from 13.3 ms to 3.8 ms on six threads; the + solver, 8 ms, is still on one. The joined pairs' table is now built + before the queries, not lazily inside one. ABI 16. + +- **Water, phase 10.** `f3d_fluid` steps water as particles, + position-based (PBF): each held near the density a lattice of its + spacing has, a kernel twice the spacing wide, in a tank; only ever + pushed apart, so nothing clumps (and there is no surface tension), + with XSPH viscosity. A tank wall counts as the water going on past it: + layers of still particles beyond it add to a particle's density and push + it straight off. Without them the bottom layer had no neighbours below + and the water pressed it flat: 325 particles where a layer holds 200, + and the water too shallow; with them, 210, and a dam of a thousand + settles with its centre of mass at half its 25 cm depth. The GPU runs + the same passes, read a frame late; it agrees particle for particle + until the dam splashes, then holds the same water. + +- **A GPU pass that does not compile is refused, not fatal.** Kernels are + built inside a validation error scope, so a WGSL module wgpu will not + take makes the system's constructor throw instead of the device's + default handler ending the process. Three tests passed a tolerance + already scaled by its value, so they checked a kernel's rest density to + 8%, a pendulum's speed to 22% and a rolling ball to 2.2%; they now check + to a part in 10⁵, 5% and 2%. ABI 15, GPU ABI 4. + +- **Cloth, phase 10.** `f3d_cloth` holds points at their distances by + constraints, each with its own compliance — XPBD in small steps, one + pass a substep, so a compliance α is a spring of stiffness 1/α and a + kilogram hangs α m g lower. Points fall on balls and a floor, drift in + the wind and can be pinned and carried. The constraints are coloured + when the cloth is made, no two of a colour sharing a point, and solved + colour by colour; the GPU gets them from the core already coloured and + solves a colour a dispatch, the colour picked by a uniform bound at an + offset, so nothing is summed in an order it chooses. Where nothing folds + the two agree point for point; a sheet hanging from two corners agrees + to 2·10⁻⁵ m for half a second, until its wrinkles go their own ways, + and then hangs as low to a hundredth of a millimetre. ABI 14, GPU ABI 3. + +- **Debris, the visual bodies, read a frame late — phase 10.** Balls of + any size and mass that fall, knock into each other, roll and settle on + still planes and boxes: rubble and splinters nobody steers and the game + never reads. `f3d_debris` in the core finds contacts through a hashed + grid with a list per cell and solves them all at once from the same + velocities, each body splitting its mass among its contacts; without the + split a ball landing in a shallow groove is stopped twice over and hops + two centimetres. A pair's impulse is always worked out from its lower + slot, so both bodies get it to the bit and a free cluster keeps its + momentum. The GPU runs the same passes in WGSL, building the grid with + `atomicExchange`; a read without waiting gives the last step whose copy + has come back, a frame behind the one queued. Until bodies meet the GPU + agrees with the CPU to the bit; a poured heap of a thousand then goes + its own way body by body, and settles to the same mean height within a + part in a thousand. The GPU library is split into a file per pass, with + the device, kernels and readback shared. ABI 13, GPU ABI 2. + +- **Particles, and the first GPU pass, phase 10.** `f3d_particles` in the + core steps sparks, spray and dust: gravity, a drift towards the wind, + a floor they bounce off and slide on, a life that runs out, slots filled + round and round with the oldest going first. The same step runs as a + WGSL compute shader in `f3d_gpu`, a second library linked with + wgpu-native; the build hook downloads a pinned release (v29.0.1.1) for + the target, checks its sha256, and keeps it cached. Two seconds of a + thousand bouncing particles on an M3 Pro come out within 3·10⁻⁵ m of the + CPU's. No release, no network or a bad checksum builds the core alone, + and `NativeGpu.open()` returns null; particles are visual, so the CPU + ones stand in. ABI 12. + +- **A body turns no more than an eighth of a turn a substep**, as Box2D v3 + holds it: π/4, 188 rad/s at four substeps of a sixtieth. A rod two metres + by two centimetres struck at its end spins about its length — five + thousand times easier to turn — at thousands of radians a second; turned + tens of radians in a substep, the first-order turn read its momentum back + through that tiny inertia and the rod flew off at hundreds of millions. + Bounded before the turn and after it, it is thrown back and spins, and + nothing blows up. + +- **Queries and a character, phase 9.** Rays (`f3d_world_ray_cast`, the + nearest; `f3d_world_ray_cast_all`, every one in order) walk the tree + nearest box first and cut it at each hit; a ball and an unrounded box are + met in closed form, a mesh triangle by triangle from its front, every + other shape by conservative advancement of a point. Shape overlaps and + shape casts ask the narrow phase, a cast by conservative advancement; a + cast starting overlapping meets at nought. Every query takes a layer mask + and a body to ignore, and none sees a shape its ray starts inside. A ray + meets what `flutter3d_physics`' does, at the same distance. + `f3d_world_move_character` moves a kinematic capsule by casting and + sliding: ground no steeper than its slope is stood on, steeper is slid + along and not climbed, a step up to its height is climbed by lifting, + moving and setting down, and walking down a slope it keeps to the + ground. A ball's ray is found by the ray's nearest distance to its + centre, not b² − c, which cost two millimetres at thirty metres in + floats. + +- **A bullet's turn is swept too.** The path a bullet took through the + step is recorded substep by substep, and the sweep follows it, place and + turn, bounding how fast its turn closes a gap by its fastest point; so a + bar two metres long spun at three hundred radians a second — five a step, + which the step's first and last turns would read the short way round, + backwards — stops at a wall beside it and is thrown back, instead of + turning through. A turn of more than half a revolution in one substep is + past what it follows. + +- **Continuous collision, phase 8.** Soft, by default: a body's leaf in the + tree covers where it will be after the step, and a pair's contact reaches + as far as the two can close in it by their speeds and spins, so a ball at + three hundred metres a second stops at a wall a centimetre thick instead + of being past it before any contact sees it — and one going by close to a + box at a hundred is not slowed. Hard, for a body made a bullet + (`f3d_body_set_bullet`): after the solve it is swept along its path by + conservative advancement against every body but another bullet and put + back at its first impact; a bullet touching something where the step + began, or moving away from it, is the contact solver's and not held. + `f3d_world_set_speculative` turns the soft kind off. +- **A rolling ball stays on the floor.** The solver measured a contact's + gap by carrying the point it touched round with the body; a ball rolling + at ten metres a second turns a radian and a half a step, read a gap where + it rested and sank two centimetres. The point's motion is taken to first + order now, the body's turn crossed with its lever. + +- **Joints, phase 7.** Fixed, spherical, revolute (a hinge), prismatic (a + slider) and distance joints, in their own arena of generational handles, + solved in the same soft substeps as the contacts and warm-started from + step to step. A joint's locked directions — its point, its turns, its + slide across the axis — are solved together as one block by Cholesky's + factoring, so a light ball on a long lever holds its hinge's plane, which + solving them one after another did not. Hinges and sliders take limits, a + motor with a most force, and a spring; a distance joint is a rod, a spring + between a least and a most length, or with no spring force a rope that is + slack until it is taut. Joined bodies do not collide unless told to, and a + joint joins its bodies' islands. A pendulum swings at its period, a spring + at its, a rod holds a weight with m g; the core has its own arctangent for + a hinge's angle. Joints are in snapshots. A distance joint changed from + rod to rope or spring starts its impulses afresh, or what it held as a rod + would hold on. + +- **Triangle meshes, phase 6.** Level geometry, terrain and walls as + indexed triangle meshes the world keeps (`f3d_world_create_mesh`) for + fixed bodies (`f3d_body_set_mesh`), one sided, each with a tree of its + triangles. A ball or capsule meets a triangle by its closest points — a + capsule lying down rests on both ends — and every other shape by GJK and + EPA against the triangle; the contacts of every triangle a body touches + are merged into one manifold. An edge two triangles share across a flat or + hollow fold is internal, and a contact on it takes the face's normal, so a + ball rolling down a mesh of a thousand triangles rolls at 5/7 g sin θ + instead of catching on their seams; a ridge keeps its edge. Meshes are in + snapshots; their trees are built again. + +- **Convex shapes, phase 5.** Cylinders, cones and convex hulls, and any + shape rounded by a radius (`f3d_body_set_rounding`). A hull is built from + points in the order given (`f3d_world_create_hull`), weighed as a solid + from its tetrahedra and moved so its centre of mass is the body's origin; + the world keeps its hulls in snapshots. Inertia is a full tensor now, so a + hull's products of inertia turn with it and a hull spun off its axes keeps + its angular momentum. Every pair without a closed form goes through GJK + between the shapes' cores and EPA where the cores overlap, touching + judged by the difference's own size, and its manifold comes from the + features facing each other: a cylinder on its end rests on four points of + its rim and on its side along its length, a cone on its base or its + slant, a hull on its face, a rounded box on the flat of its face. Depth is + measured from the reference face's own plane, so a slightly tilted normal + over a wide floor does not invent five centimetres of overlap. A cylinder + rolls down a slope at 2/3 g sin θ. + +- **The broadphase tree, phase 4.** A dynamic tree of boxes, as Box2D's: + each body with a shape has a leaf holding its box grown by ten + centimetres, moved only when the body leaves it; a leaf goes in beside the + node that costs least by surface area, and rotations keep every node's two + sides within one level of each other, so a thousand boxes laid in a row + are a tree twenty deep and not a list. Only awake bodies ask it what is + near them; two bodies that cannot move keep last step's contact, and one + moved by hand wakes what slept against it, so a crate on a plank lifted + away falls. The tree is not in a snapshot — it is built again — and the + restored world steps to the same bytes, since the contacts come out in + slot order whatever shape the tree has. `f3d_world_query_box` finds the + bodies a box overlaps, in slot order. + +- **The solver, phase 3: bodies stand on what they touch.** Soft contacts + solved in substeps (four by default, `f3d_world_set_substeps`): each + substep integrates the velocities, warm-starts every contact from what its + points pushed with last step — matched by the features that made them — + solves with a soft bias, moves the bodies and solves again without the + bias, so pushing overlap out does not leave it as speed; restitution after + the last. The contact is a heavily damped spring at a quarter of the + substep rate, at most thirty hertz, pushing overlap out no faster than + three metres a second and leaving five millimetres of it alone. Friction + inside Coulomb's circle on the geometric mean of the pair's coefficients, + restitution the larger of the two, nothing bouncing below a metre a + second (`f3d_body_set_friction`, `f3d_body_set_restitution`). A crate comes + to rest and sleeps where `flutter3d_physics` rests it; ten stand stacked; + a ball bounces back with its restitution's share; a crate slides down a + slope at g(sin θ − μ cos θ) and holds when μ > tan θ; a ball rolls down it + at 5/7 g sin θ with its spin matching its speed; collisions keep their + momentum; a crate stood on its edge falls flat. +- **The step's order changed:** contacts where the bodies stand first, then + the solver, then heat, so a body placed between steps is in the contacts + the solver uses. Sleep is decided there too, a step after the sleep time + is up. Touching now means within the solver's five millimetres, which is + as close as it holds a body: a speculative contact that stopped a ball at + the surface is a contact that began. + +- **Contacts, phase 2.** Every pair of sphere, box and capsule, turned + however they are turned, answers with a manifold: one normal and up to four + points halfway between the surfaces, each with its depth and the features + that made it. Two boxes are parted along the least of fifteen axes, a + resting box keeps its whole face — the incident face clipped to the + reference face and, when that leaves eight points, the four that span the + most — a box on its edge keeps the edge, crossed edges meet at a point, and + a capsule lying on a box or along another capsule keeps both ends. A sort + and sweep finds the pairs, exactly those trying every pair finds, and + collision layers and masks filter them. +- **What began and ended touching**, as events naming both bodies. +- **Islands.** Bodies joined by their contacts sleep when all of them have + been still and wake together when one moves, so a crate knocked off a + stack wakes the crate under it; two sleeping bodies keep their contact. +- **Heat across a contact**, by Holm's constriction over the contact's area — + a face's polygon, or Hertz's circle where a curve presses in — taken + implicitly for each pair. A fixed body of no thermal mass is a reservoir: a + hot plate stays hot. Materials have a conductivity. Wood is an insulator, + so a burning block warms the one it touches but does not light it; fire + crossing by its flames waits for the smoke grid. +- **Nothing pushes touching bodies apart yet**: the solver is phase 3. The + WebAssembly module and the native library agree to the byte on a scene + that falls through a floor, contacts, events and heat included. + +- **The world's core, phase 1.** Bodies turn: a sphere, box or capsule + gives a body its inertia, surface and drag, and the orientation steps as + `flutter3d_physics` steps it, the angular momentum carried through the + turn, so a box spun off its axes wobbles the way the reference's does. + Impulses through the centre or at a point, forces and torques held over a + step, damping, a lock on rotation, and sleep with its events. +- **Wind, heat and fire are the world's.** The air has a temperature and a + density, and a wind, uniform and from a grid read trilinearly. A body in + it feels ½ ρ C A |u| u, taken implicitly, so a leaf in a gale is stable at + any step and a ball falls at exactly its terminal speed. Every body has a + material and a temperature; it loses heat by convection that grows with + the wind past it and by radiation, in one implicit step that cannot + overshoot. Wood, paper and rubber catch at their ignition point, burn + their fuel at a rate per square metre, keep a share of the heat and give + the rest off as hot gas, which `f3d_world_read_fires` hands to a smoke + grid; a burning body loses mass and inertia. Water on a body lands at the + air's temperature, holds the body at its boiling point until it has + boiled off, and so puts a fire out. Heat, water, forces and torques reach + a body through the same bus. +- **An origin in doubles.** Positions are relative to it, and + `f3d_world_shift_origin` moves it to the play without moving anything in + the world, so a body ten kilometres out keeps f32's precision. +- **Snapshots to the byte.** A world restored from one steps to the same + bytes as the world it came from, and the WebAssembly module and the + native library now agree on the whole snapshot after six hundred steps of + turning, burning bodies in a wind grid, not only on their transforms. +- **`f3d_real`.** The core builds with doubles throughout under + `F3D_REAL_DOUBLE`, and its C tests run in both precisions. + +- **The physics core in C, phase 0** — P9. A world of rigid bodies, + dynamic or fixed, each named by a generational handle: the slot in the + low 32 bits and the slot's generation in the high 32, so a handle kept past + its body's destruction is refused rather than answered with whoever took + the slot next. Gravity and a semi-implicit Euler step in the order + `flutter3d_physics` steps, and every body's transform read back in one + flat buffer with its handle. Built by the package's hook with the target's + own C compiler, and as a WebAssembly module with no imports by + `tool/build_wasm.dart`, from the same sources and flags. The two step the + same scenario to the same bits. diff --git a/packages/flutter3d_physics_native/LICENSE b/packages/flutter3d_physics_native/LICENSE new file mode 100644 index 000000000..c602d8d40 --- /dev/null +++ b/packages/flutter3d_physics_native/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Dmitrii Zolotov + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/flutter3d_physics_native/README.md b/packages/flutter3d_physics_native/README.md new file mode 100644 index 000000000..cda9f4b4e --- /dev/null +++ b/packages/flutter3d_physics_native/README.md @@ -0,0 +1,95 @@ +# flutter3d_physics_native + +The physics core of flutter3d, written in C11 and reached through `dart:ffi`. In the browser it runs as a WebAssembly module built from the same sources. `flutter3d_physics`, the pure-Dart physics, is its reference and its fallback. + +It is being built in phases (P9 in the 0.9 plan). What is here now: + +- rigid bodies named by generational handles, so a handle kept past its body is refused; +- contacts between every pair of shapes, turned, with closed forms where there are some and GJK and EPA where there are not: manifolds of up to four points, events when a contact begins and ends, collision filters, and islands that sleep and wake together; +- a solver that keeps bodies apart: soft contacts in substeps with warm starting, friction and restitution, so crates rest, stack, slide, roll and bounce; +- a dynamic tree of boxes for the broadphase, which also answers box queries; +- triangle meshes for level geometry and terrain, one sided, that bodies slide across without catching on the seams; +- joints: fixed, spherical, hinge, slider and distance (rod, spring, rope), with limits, motors and springs, and the ball joint's cone, twist limits and friction a ragdoll is built from (give one eight substeps); +- continuous collision: contacts that reach as far as a body moves in a step, and bullets swept to their time of impact, turn and all; +- rays, shape overlaps and shape casts, and a kinematic character controller that slides, climbs steps and keeps to slopes; +- shapes (sphere, box, capsule, cylinder, cone, convex hull, and any of them rounded) as inertia, surface and drag; the orientation stepped with the angular momentum kept, impulses, forces, torques, damping and sleep; +- the air and its wind, uniform or from a grid, and the drag it puts on a body; +- heat on every body, by convection, radiation and across its contacts, and fire: wood, paper and rubber catch, burn their fuel, lose mass and give off hot gas, and water puts them out; +- an origin held in doubles that the world can move to where the play is; +- events, and snapshots a world restores from to the byte; +- particles that fall, drift in the wind, bounce off a floor and die, on the CPU and, through wgpu-native, on the GPU. +- debris, the visual bodies: thousands of balls that tumble, heap and settle on still planes and boxes, on the CPU and on the GPU, where they are read a frame late. +- cloth: sheets and ropes held by constraints of any stiffness, draped over balls and blown by the wind, on the CPU and on the GPU. +- water as particles in a tank that slumps, splashes and settles, on the CPU and on the GPU. + +## Under CollisionWorld + +`NativeDynamics` is a `RigidDynamics` from `flutter3d_physics`. Build it where a game built a `Dynamics`: + +```dart +final dynamics = NativeDynamics(world: collisionWorld); // was Dynamics(world: collisionWorld) +final crate = dynamics.add(RigidBody(world: collisionWorld, shape: CollisionBox(Vector3.all(0.5)), position: at)); +``` + +The bodies stay `RigidBody` objects on the `CollisionWorld`, so sweeps, queries and the character controller see them where the core put them. The rest of the world (level boxes, wedges, height fields, characters, doors, lifts) is mirrored into the core as fixed bodies each step. The core turns bodies built with `canRotate`, and `native` reaches its joints, bullets and air. It steps in f32 to bits of its own, the same on every platform, so a run's digests differ from the reference's. For rollback, use `snapshot()` and `restore()`: unlike a body's own `save()`, they carry the warm starts and sleep. + +Multibody chains follow, with their tests. + +## Building + +There is nothing to build by hand. The package's hook compiles `csrc/` with the C compiler the target already uses: Xcode's clang on Apple platforms, the NDK's on Android, MSVC or clang on Windows, the system's on Linux. A game that depends on this package needs no other toolchain. + +The GPU passes are a second library, `f3d_gpu`, built from `csrc/gpu/` and linked with wgpu-native. The hook downloads a pinned wgpu-native release for the target from GitHub, checks the archive against the sha256 written in `hook/wgpu_native.dart`, and keeps it in the hooks' shared output, so it is fetched once. Only the static library is used, so nothing extra has to be found at run time. If there is no release for the target, no network, or the checksum is wrong, the hook says so and builds the core alone; `NativeGpu.open()` then returns null and particles run on the CPU. + +The Dart side reaches the core the same way natively and in the browser: through `lib/src/core/`, where every call is a function and every pointer an address. The calls and the layouts of the structs the Dart side fills are generated from `csrc/include/f3d_physics.h`; after changing the header, run + +```sh +dart run tool/gen_core.dart +``` + +and `test/bindings_test.dart` fails until you do. In the browser there are no GPU passes: `NativeGpu.open()` returns null there, and the CPU systems run instead. Call `await loadPhysicsCore()` before making the first world; natively it does nothing. With `threads:` above one, on a page served cross-origin isolated (COOP and COEP headers), it loads `web/f3d_physics_threads.wasm` instead and starts that many Web Workers less one from `web/f3d_worker.js`; `physicsCoreThreads` then says how many threads a world can ask for. In the browser it fetches `web/f3d_physics.wasm`, which this package ships as an asset; after changing the core, rebuild it with `dart run tool/build_wasm.dart`, or `test/wasm_test.dart` fails. + +The WebAssembly module is built separately, because hooks build for native targets only: + +```sh +dart run tool/build_wasm.dart build/f3d_physics.wasm +``` + +That needs a clang with the wasm32 target, which Apple's has, and a wasm linker. Either `wasm-ld` on the path works, or the `rust-lld` that a Rust toolchain ships. + +## Determinism + +The deterministic mode uses the same bits on every platform. Every number is an f32, and the build turns off fused multiply-add contraction and fast math. The core also avoids the C library's transcendental functions; the square root it does use is rounded exactly by IEEE 754 on every target. `test/wasm_test.dart` holds that promise to the byte: the WebAssembly module and the native library step the same scenario, and their snapshots of the whole world afterwards have to be identical. + +`csrc/tests/test_digest.c` holds the same promise across machines: it steps seven scenes and fails unless they hash to the numbers written in it. Those numbers were taken on macOS arm64, and the same numbers come out on Linux (arm64, x86-64 and 32-bit ARM, with gcc and with clang), on the iOS simulator and on Android arm64. CI runs the test on Windows with MSVC. `tool/digest_platforms.sh` repeats the Linux, Android and iOS runs from a Mac. 32-bit x86 qualifies only with its arithmetic in SSE2. There, gcc and clang both match. On the x87, which gcc (and Debian's clang) use there by default, the results come out different. So the core refuses to build that way, and the hook passes `-msse2 -mfpmath=sse`. + +Built with `-DF3D_REAL_DOUBLE`, the core uses doubles throughout instead. That build is for C callers who want the precision and do not need the browser to agree; the Dart bindings refuse it. + +## Tests + +```sh +dart test +``` + +The suite does four things: + +- builds the C unit tests in `csrc/tests/` with the address and undefined-behaviour sanitisers, in both precisions, and runs them; +- checks the hand-written bindings against the header; +- drives the world through `dart:ffi` and compares a free fall, a box spun off its axes and an impulse off the centre with `flutter3d_physics`. Those comparisons use a tolerance, because the reference works in doubles; +- burns, wets and blows on bodies through the binding, and holds unturned contacts against `flutter3d_physics`; +- drops a crate on a floor and holds where it comes to rest against `flutter3d_physics`; +- steps a thousand particles on the GPU and on the CPU and compares them. A GPU rounds its own way, so this is a tolerance too. The test is skipped where there is no adapter; +- pours debris on both and compares it: body for body where nothing chaotic happens, and as a heap where it does; +- hangs cloth on both and compares it the same way: point for point until it wrinkles; +- breaks a dam of water on both, particle for particle until it splashes; +- builds the WebAssembly module and runs it in node, so the byte-for-byte comparison above actually happens. This test is skipped where node, clang or a wasm linker is missing. + +The browser has its own test, run in Chrome with both web compilers: + +```sh +dart test -p chrome test/web_core_test.dart +dart test -p chrome -c dart2wasm test/web_core_test.dart +CHROME_EXECUTABLE=tool/chrome_sab.sh dart test -p chrome test/web_threads_test.dart +``` + +The last runs on Chrome asked for SharedArrayBuffer outright by `tool/chrome_sab.sh`, since the test runner's pages are not isolated; without it the test is skipped. diff --git a/packages/flutter3d_physics_native/analysis_options.yaml b/packages/flutter3d_physics_native/analysis_options.yaml new file mode 100644 index 000000000..0fdfb4706 --- /dev/null +++ b/packages/flutter3d_physics_native/analysis_options.yaml @@ -0,0 +1,11 @@ +include: ../../analysis_options.yaml + +analyzer: + exclude: + - build/** + - android/** + - ios/** + - web/** + - windows/** + - macos/** + - linux/** diff --git a/packages/flutter3d_physics_native/csrc/compile_flags.txt b/packages/flutter3d_physics_native/csrc/compile_flags.txt new file mode 100644 index 000000000..71c1e1253 --- /dev/null +++ b/packages/flutter3d_physics_native/csrc/compile_flags.txt @@ -0,0 +1,5 @@ +-std=c11 +-Iinclude +-Isrc +-Wall +-Wextra diff --git a/packages/flutter3d_physics_native/csrc/gpu/f3d_gpu.c b/packages/flutter3d_physics_native/csrc/gpu/f3d_gpu.c new file mode 100644 index 000000000..658760b36 --- /dev/null +++ b/packages/flutter3d_physics_native/csrc/gpu/f3d_gpu.c @@ -0,0 +1,398 @@ +/* + * The GPU passes — P9, phase 10: a wgpu device, and what every pass shares + * (f3d_gpu_internal.h). The passes are in their own files: particles, + * debris. + * + * Written against wgpu-native's webgpu.h of v29: string views, callbacks + * given as info structs and fired from wgpuInstanceProcessEvents, and + * wgpuDevicePoll to wait for the queue. + */ +#include +#include + +#include "f3d_gpu_internal.h" + +WGPUStringView f3d_gpu_view(const char *s) { + WGPUStringView v; + v.data = s; + v.length = WGPU_STRLEN; + return v; +} + +uint32_t f3d_gpu_abi_version(void) { return F3D_GPU_ABI_VERSION; } + +typedef struct Waiting { + int done; + void *result; +} Waiting; + +static void got_adapter(WGPURequestAdapterStatus status, WGPUAdapter adapter, + WGPUStringView message, void *userdata1, void *userdata2) { + (void)message; + (void)userdata2; + Waiting *w = (Waiting *)userdata1; + w->result = status == WGPURequestAdapterStatus_Success ? (void *)adapter : NULL; + w->done = 1; +} + +static void got_device(WGPURequestDeviceStatus status, WGPUDevice device, + WGPUStringView message, void *userdata1, void *userdata2) { + (void)message; + (void)userdata2; + Waiting *w = (Waiting *)userdata1; + w->result = status == WGPURequestDeviceStatus_Success ? (void *)device : NULL; + w->done = 1; +} + +/* Turns the instance's events over until [done] is set, or gives up. */ +static void wait_for(WGPUInstance instance, const int *done) { + for (int i = 0; i < 100000 && !*done; i++) wgpuInstanceProcessEvents(instance); +} + +F3dGpu *f3d_gpu_create(void) { + F3dGpu *gpu = (F3dGpu *)calloc(1, sizeof(F3dGpu)); + if (gpu == NULL) return NULL; + gpu->instance = wgpuCreateInstance(NULL); + if (gpu->instance == NULL) { + free(gpu); + return NULL; + } + WGPURequestAdapterOptions options; + memset(&options, 0, sizeof options); + options.powerPreference = WGPUPowerPreference_HighPerformance; + WGPURequestAdapterCallbackInfo adapter_info; + memset(&adapter_info, 0, sizeof adapter_info); + Waiting wa = {0, NULL}; + adapter_info.mode = WGPUCallbackMode_AllowProcessEvents; + adapter_info.callback = got_adapter; + adapter_info.userdata1 = &wa; + wgpuInstanceRequestAdapter(gpu->instance, &options, adapter_info); + wait_for(gpu->instance, &wa.done); + gpu->adapter = (WGPUAdapter)wa.result; + if (gpu->adapter == NULL) { + f3d_gpu_destroy(gpu); + return NULL; + } + WGPURequestDeviceCallbackInfo device_info; + memset(&device_info, 0, sizeof device_info); + Waiting wd = {0, NULL}; + device_info.mode = WGPUCallbackMode_AllowProcessEvents; + device_info.callback = got_device; + device_info.userdata1 = &wd; + wgpuAdapterRequestDevice(gpu->adapter, NULL, device_info); + wait_for(gpu->instance, &wd.done); + gpu->device = (WGPUDevice)wd.result; + if (gpu->device == NULL) { + f3d_gpu_destroy(gpu); + return NULL; + } + gpu->queue = wgpuDeviceGetQueue(gpu->device); + WGPUAdapterInfo info; + memset(&info, 0, sizeof info); + if (wgpuAdapterGetInfo(gpu->adapter, &info) == WGPUStatus_Success) { + if (info.device.data != NULL) { + size_t n = info.device.length == WGPU_STRLEN ? strlen(info.device.data) + : info.device.length; + if (n >= sizeof gpu->name) n = sizeof gpu->name - 1u; + memcpy(gpu->name, info.device.data, n); + gpu->name[n] = '\0'; + } + wgpuAdapterInfoFreeMembers(info); + } + return gpu; +} + +void f3d_gpu_destroy(F3dGpu *gpu) { + if (gpu == NULL) return; + if (gpu->queue != NULL) wgpuQueueRelease(gpu->queue); + if (gpu->device != NULL) wgpuDeviceRelease(gpu->device); + if (gpu->adapter != NULL) wgpuAdapterRelease(gpu->adapter); + if (gpu->instance != NULL) wgpuInstanceRelease(gpu->instance); + free(gpu); +} + +uint32_t f3d_gpu_adapter_name(const F3dGpu *gpu, char *out, uint32_t capacity) { + const size_t n = strlen(gpu->name); + if (capacity > 0) { + const size_t k = n < capacity - 1u ? n : capacity - 1u; + memcpy(out, gpu->name, k); + out[k] = '\0'; + } + return (uint32_t)n; +} + +WGPUBuffer f3d_gpu_buffer(const F3dGpu *gpu, WGPUBufferUsage usage, uint64_t size) { + WGPUBufferDescriptor d; + memset(&d, 0, sizeof d); + d.usage = usage; + /* wgpu refuses a binding of nought bytes, and copies in fours. */ + d.size = size < 16u ? 16u : (size + 3u) & ~(uint64_t)3u; + return wgpuDeviceCreateBuffer(gpu->device, &d); +} + +void f3d_gpu_finish(const F3dGpu *gpu) { wgpuDevicePoll(gpu->device, 1, NULL); } + +typedef struct Scope { + int done; + int failed; +} Scope; + +static void scope_popped(WGPUPopErrorScopeStatus status, WGPUErrorType type, + WGPUStringView message, void *userdata1, void *userdata2) { + (void)message; + (void)userdata2; + Scope *scope = (Scope *)userdata1; + scope->failed = status != WGPUPopErrorScopeStatus_Success || type != WGPUErrorType_NoError; + scope->done = 1; +} + +/* Whether anything since the matching push failed validation: so a WGSL + * module that does not compile makes a kernel set that is not, instead of + * a device error that ends the process. */ +static int scope_failed(const F3dGpu *gpu) { + Scope scope = {0, 0}; + WGPUPopErrorScopeCallbackInfo info; + memset(&info, 0, sizeof info); + info.mode = WGPUCallbackMode_AllowProcessEvents; + info.callback = scope_popped; + info.userdata1 = &scope; + wgpuDevicePopErrorScope(gpu->device, info); + wait_for(gpu->instance, &scope.done); + return !scope.done || scope.failed; +} + +static int kernels_build(F3dGpuKernels *k, const F3dGpu *gpu, const char *source, + const char *const *entries, uint32_t entry_count, + const F3dGpuBinding *kinds, const WGPUBuffer *buffers, + const uint64_t *sizes, uint32_t binding_count); + +int f3d_gpu_kernels_create(F3dGpuKernels *k, const F3dGpu *gpu, const char *source, + const char *const *entries, uint32_t entry_count, + const F3dGpuBinding *kinds, const WGPUBuffer *buffers, + const uint64_t *sizes, uint32_t binding_count) { + wgpuDevicePushErrorScope(gpu->device, WGPUErrorFilter_Validation); + const int built = + kernels_build(k, gpu, source, entries, entry_count, kinds, buffers, sizes, binding_count); + const int failed = scope_failed(gpu); + if (built && failed) f3d_gpu_kernels_release(k); + return built && !failed; +} + +static int kernels_build(F3dGpuKernels *k, const F3dGpu *gpu, const char *source, + const char *const *entries, uint32_t entry_count, + const F3dGpuBinding *kinds, const WGPUBuffer *buffers, + const uint64_t *sizes, uint32_t binding_count) { + memset(k, 0, sizeof *k); + if (entry_count > F3D_GPU_MAX_KERNELS || binding_count > 16u) return 0; + WGPUShaderSourceWGSL wgsl; + memset(&wgsl, 0, sizeof wgsl); + wgsl.chain.sType = WGPUSType_ShaderSourceWGSL; + wgsl.code = f3d_gpu_view(source); + WGPUShaderModuleDescriptor md; + memset(&md, 0, sizeof md); + md.nextInChain = &wgsl.chain; + k->module = wgpuDeviceCreateShaderModule(gpu->device, &md); + WGPUBindGroupLayoutEntry layout_entries[16]; + WGPUBindGroupEntry bind_entries[16]; + memset(layout_entries, 0, sizeof layout_entries); + memset(bind_entries, 0, sizeof bind_entries); + for (uint32_t i = 0; i < binding_count; i++) { + layout_entries[i].binding = i; + layout_entries[i].visibility = WGPUShaderStage_Compute; + const int at = kinds[i] == F3D_GPU_UNIFORM_AT; + layout_entries[i].buffer.type = kinds[i] == F3D_GPU_UNIFORM || at ? WGPUBufferBindingType_Uniform + : kinds[i] == F3D_GPU_STORAGE ? WGPUBufferBindingType_Storage + : WGPUBufferBindingType_ReadOnlyStorage; + layout_entries[i].buffer.hasDynamicOffset = at; + if (at) k->bound_at = 1; + bind_entries[i].binding = i; + bind_entries[i].buffer = buffers[i]; + bind_entries[i].size = sizes[i] < 16u ? 16u : (sizes[i] + 3u) & ~(uint64_t)3u; + } + WGPUBindGroupLayoutDescriptor ld; + memset(&ld, 0, sizeof ld); + ld.entryCount = binding_count; + ld.entries = layout_entries; + k->layout = wgpuDeviceCreateBindGroupLayout(gpu->device, &ld); + WGPUPipelineLayoutDescriptor pld; + memset(&pld, 0, sizeof pld); + pld.bindGroupLayoutCount = 1; + pld.bindGroupLayouts = &k->layout; + k->pipeline_layout = wgpuDeviceCreatePipelineLayout(gpu->device, &pld); + if (k->module == NULL || k->layout == NULL || k->pipeline_layout == NULL) { + f3d_gpu_kernels_release(k); + return 0; + } + for (uint32_t i = 0; i < entry_count; i++) { + WGPUComputePipelineDescriptor pd; + memset(&pd, 0, sizeof pd); + pd.layout = k->pipeline_layout; + pd.compute.module = k->module; + pd.compute.entryPoint = f3d_gpu_view(entries[i]); + k->pipelines[i] = wgpuDeviceCreateComputePipeline(gpu->device, &pd); + k->count = i + 1u; + if (k->pipelines[i] == NULL) { + f3d_gpu_kernels_release(k); + return 0; + } + } + WGPUBindGroupDescriptor bd; + memset(&bd, 0, sizeof bd); + bd.layout = k->layout; + bd.entryCount = binding_count; + bd.entries = bind_entries; + k->bind = wgpuDeviceCreateBindGroup(gpu->device, &bd); + if (k->bind == NULL) { + f3d_gpu_kernels_release(k); + return 0; + } + return 1; +} + +void f3d_gpu_kernels_release(F3dGpuKernels *k) { + if (k->bind != NULL) wgpuBindGroupRelease(k->bind); + for (uint32_t i = 0; i < k->count; i++) { + if (k->pipelines[i] != NULL) wgpuComputePipelineRelease(k->pipelines[i]); + } + if (k->pipeline_layout != NULL) wgpuPipelineLayoutRelease(k->pipeline_layout); + if (k->layout != NULL) wgpuBindGroupLayoutRelease(k->layout); + if (k->module != NULL) wgpuShaderModuleRelease(k->module); + memset(k, 0, sizeof *k); +} + +void f3d_gpu_dispatch(WGPUComputePassEncoder pass, const F3dGpuKernels *k, uint32_t entry, + uint32_t threads) { + const uint32_t nought = 0; + wgpuComputePassEncoderSetPipeline(pass, k->pipelines[entry]); + wgpuComputePassEncoderSetBindGroup(pass, 0, k->bind, k->bound_at ? 1u : 0u, + k->bound_at ? &nought : NULL); + wgpuComputePassEncoderDispatchWorkgroups(pass, (threads + 63u) / 64u, 1, 1); +} + +void f3d_gpu_dispatch_at(WGPUComputePassEncoder pass, const F3dGpuKernels *k, uint32_t entry, + uint32_t threads, uint32_t offset) { + if (threads == 0) return; + wgpuComputePassEncoderSetPipeline(pass, k->pipelines[entry]); + wgpuComputePassEncoderSetBindGroup(pass, 0, k->bind, 1, &offset); + wgpuComputePassEncoderDispatchWorkgroups(pass, (threads + 63u) / 64u, 1, 1); +} + +int f3d_gpu_readback_create(F3dGpuReadback *r, const F3dGpu *gpu, uint64_t bytes) { + memset(r, 0, sizeof *r); + r->bytes = bytes; + for (int i = 0; i < 2; i++) { + r->slots[i].owner = r; + r->slots[i].staging = + f3d_gpu_buffer(gpu, WGPUBufferUsage_MapRead | WGPUBufferUsage_CopyDst, bytes); + if (r->slots[i].staging == NULL) { + f3d_gpu_readback_release(r); + return 0; + } + } + return 1; +} + +void f3d_gpu_readback_release(F3dGpuReadback *r) { + for (int i = 0; i < 2; i++) { + if (r->slots[i].staging != NULL) wgpuBufferRelease(r->slots[i].staging); + r->slots[i].staging = NULL; + } +} + +static void mapped(WGPUMapAsyncStatus status, WGPUStringView message, void *userdata1, + void *userdata2) { + (void)message; + (void)userdata2; + F3dGpuReadbackSlot *slot = (F3dGpuReadbackSlot *)userdata1; + /* A map that failed leaves the slot free, its frame never returned. */ + slot->state = status == WGPUMapAsyncStatus_Success ? 2 : 0; +} + +void f3d_gpu_readback_request(F3dGpuReadback *r, const F3dGpu *gpu, WGPUBuffer source, + uint64_t frame) { + int pick = -1; + for (int i = 0; i < 2 && pick < 0; i++) { + if (r->slots[i].state == 0) pick = i; + } + if (pick < 0) { + /* Both taken: give up the older one that has come back. */ + for (int i = 0; i < 2; i++) { + if (r->slots[i].state == 2 && (pick < 0 || r->slots[i].frame < r->slots[pick].frame)) { + pick = i; + } + } + if (pick < 0) return; + wgpuBufferUnmap(r->slots[pick].staging); + r->slots[pick].state = 0; + } + F3dGpuReadbackSlot *slot = &r->slots[pick]; + WGPUCommandEncoder encoder = wgpuDeviceCreateCommandEncoder(gpu->device, NULL); + wgpuCommandEncoderCopyBufferToBuffer(encoder, source, 0, slot->staging, 0, r->bytes); + WGPUCommandBuffer commands = wgpuCommandEncoderFinish(encoder, NULL); + wgpuQueueSubmit(gpu->queue, 1, &commands); + wgpuCommandBufferRelease(commands); + wgpuCommandEncoderRelease(encoder); + slot->state = 1; + slot->frame = frame; + WGPUBufferMapCallbackInfo info; + memset(&info, 0, sizeof info); + info.mode = WGPUCallbackMode_AllowProcessEvents; + info.callback = mapped; + info.userdata1 = slot; + wgpuBufferMapAsync(slot->staging, WGPUMapMode_Read, 0, (size_t)r->bytes, info); +} + +static void pump(const F3dGpu *gpu, int wait) { + wgpuDevicePoll(gpu->device, wait, NULL); + wgpuInstanceProcessEvents(gpu->instance); +} + +uint64_t f3d_gpu_readback_take(F3dGpuReadback *r, const F3dGpu *gpu, WGPUBuffer source, + uint64_t frame, int wait, + void (*copy)(const void *mapped, void *out, uint32_t n), + void *out, uint32_t n) { + pump(gpu, 0); + if (wait && frame > r->taken) { + for (int tries = 0; tries < 1000; tries++) { + int asked = 0; + for (int i = 0; i < 2; i++) asked |= r->slots[i].state != 0 && r->slots[i].frame == frame; + if (asked) break; + f3d_gpu_readback_request(r, gpu, source, frame); + /* Both still on their way: let one come back first. */ + pump(gpu, 1); + } + for (int tries = 0; tries < 1000; tries++) { + int back = 0; + for (int i = 0; i < 2; i++) back |= r->slots[i].state == 2 && r->slots[i].frame == frame; + if (back) break; + pump(gpu, 1); + } + } + int best = -1; + for (int i = 0; i < 2; i++) { + F3dGpuReadbackSlot *slot = &r->slots[i]; + if (slot->state != 2) continue; + if (slot->frame <= r->taken) { + wgpuBufferUnmap(slot->staging); + slot->state = 0; + continue; + } + if (best < 0 || slot->frame > r->slots[best].frame) best = i; + } + if (best < 0) return 0; + F3dGpuReadbackSlot *slot = &r->slots[best]; + const void *data = wgpuBufferGetConstMappedRange(slot->staging, 0, (size_t)r->bytes); + if (data == NULL) return 0; + copy(data, out, n); + wgpuBufferUnmap(slot->staging); + slot->state = 0; + r->taken = slot->frame; + /* The other, older and come back, is past. */ + for (int i = 0; i < 2; i++) { + if (r->slots[i].state == 2 && r->slots[i].frame <= r->taken) { + wgpuBufferUnmap(r->slots[i].staging); + r->slots[i].state = 0; + } + } + return r->taken; +} diff --git a/packages/flutter3d_physics_native/csrc/gpu/f3d_gpu.h b/packages/flutter3d_physics_native/csrc/gpu/f3d_gpu.h new file mode 100644 index 000000000..c5c654a8c --- /dev/null +++ b/packages/flutter3d_physics_native/csrc/gpu/f3d_gpu.h @@ -0,0 +1,189 @@ +/* + * The physics core's GPU passes, through wgpu-native — P9, phase 10. + * + * A library of its own, beside the core: the core has no dependencies and + * builds for the browser as it is, and this one links wgpu-native, which a + * build hook fetches for the target. What runs here is visual — particles + * now, cloth and smoke and the bodies a frame late as they come — and not + * the game's state, which stays on the CPU and the same bits everywhere: a + * GPU's arithmetic is its own. + * + * Every function is synchronous to the caller. A step is queued and + * submitted; a read waits for the GPU and copies the answer back. + */ +#ifndef F3D_GPU_H_ +#define F3D_GPU_H_ + +#include + +#if defined(_WIN32) +#define F3D_GPU_API __declspec(dllexport) +#else +#define F3D_GPU_API __attribute__((visibility("default"))) +#endif + +#ifdef __cplusplus +extern "C" { +#endif + +/* Bumped whenever a function's meaning or signature changes. */ +#define F3D_GPU_ABI_VERSION 4u + +typedef struct F3dGpu F3dGpu; +typedef struct F3dGpuParticles F3dGpuParticles; +typedef struct F3dGpuDebris F3dGpuDebris; +typedef struct F3dGpuCloth F3dGpuCloth; +typedef struct F3dGpuFluid F3dGpuFluid; + +/* How particles move, as f3d_particles_step reads F3dParticleForces. */ +typedef struct F3dGpuParticleForces { + float gravity[3]; + float wind[3]; + float drag; + float floor_y; + float restitution; + float friction; +} F3dGpuParticleForces; + +F3D_GPU_API uint32_t f3d_gpu_abi_version(void); + +/* A device on the best adapter there is, headless; null where there is no + * adapter — a machine with no GPU, or a driver wgpu cannot use. */ +F3D_GPU_API F3dGpu *f3d_gpu_create(void); +F3D_GPU_API void f3d_gpu_destroy(F3dGpu *gpu); + +/* The adapter's name, null-terminated, into [out]: up to [capacity] bytes; + * returns its whole length. */ +F3D_GPU_API uint32_t f3d_gpu_adapter_name(const F3dGpu *gpu, char *out, + uint32_t capacity); + +/* [capacity] particle slots on [gpu], all dead; null for none or no + * memory. Freed before their GPU. */ +F3D_GPU_API F3dGpuParticles *f3d_gpu_particles_create(F3dGpu *gpu, + uint32_t capacity); +F3D_GPU_API void f3d_gpu_particles_destroy(F3dGpuParticles *particles); + +/* As f3d_particles_emit: [count] particles of seven floats into the next + * slots, round and round; returns the slot the first went into. */ +F3D_GPU_API uint32_t f3d_gpu_particles_emit(F3dGpuParticles *particles, + const float *data, uint32_t count); + +/* [steps] steps of [dt], as f3d_particles_step takes them, queued in one + * submission. */ +F3D_GPU_API void f3d_gpu_particles_step(F3dGpuParticles *particles, + const F3dGpuParticleForces *forces, + float dt, uint32_t steps); + +/* Every slot, four floats apiece as f3d_particles_read writes them, into + * [out], waiting for the GPU; returns how many, nought when the read + * failed. */ +F3D_GPU_API uint32_t f3d_gpu_particles_read(F3dGpuParticles *particles, + float *out, uint32_t capacity); + +/* Debris, as f3d_debris steps it: F3dDebrisSettings, in floats. */ +typedef struct F3dGpuDebrisSettings { + float gravity[3]; + float friction; + float restitution; + float linear_damping; + float angular_damping; + float max_speed; + uint32_t substeps; + uint32_t iterations; +} F3dGpuDebrisSettings; + +/* [capacity] empty debris slots on [gpu]; null for none or no memory. */ +F3D_GPU_API F3dGpuDebris *f3d_gpu_debris_create(F3dGpu *gpu, uint32_t capacity); +F3D_GPU_API void f3d_gpu_debris_destroy(F3dGpuDebris *debris); + +/* As f3d_debris_add and f3d_debris_set_statics. */ +F3D_GPU_API uint32_t f3d_gpu_debris_add(F3dGpuDebris *debris, const float *data, + uint32_t count); +F3D_GPU_API int f3d_gpu_debris_set_statics(F3dGpuDebris *debris, const float *data, + uint32_t count); + +/* Queues a step of [dt] and, after it, the bodies' copy back. */ +F3D_GPU_API void f3d_gpu_debris_step(F3dGpuDebris *debris, + const F3dGpuDebrisSettings *settings, float dt); + +/* The bodies as f3d_debris_read writes them, into [out], from the latest + * step whose copy has come back and was not read yet: returns that step, + * counted from one, and nought, leaving [out] alone, when there is none. + * A frame late, without [wait]: the step queued last is still on the GPU. + * With [wait], the last step's, waited for. */ +F3D_GPU_API uint64_t f3d_gpu_debris_read(F3dGpuDebris *debris, float *out, + uint32_t capacity, int wait); + +/* Cloth, as f3d_cloth steps it: F3dClothSettings, in floats. */ +typedef struct F3dGpuClothSettings { + float gravity[3]; + float wind[3]; + float drag; + float damping; + float floor_y; + float thickness; + float friction; + uint32_t substeps; +} F3dGpuClothSettings; + +/* A cloth of [point_count] points, four floats apiece as f3d_cloth_create + * takes them, held by the constraints f3d_cloth_edges gives for the same + * cloth: [pairs], [rest], [compliance], [edge_count] of them, coloured as + * [colour_start] says, [colour_count] colours. Null for no points, a pair + * out of range, colours that do not end at the edges' count, or no + * memory. */ +F3D_GPU_API F3dGpuCloth *f3d_gpu_cloth_create(F3dGpu *gpu, const float *points, + uint32_t point_count, const uint32_t *pairs, + const float *rest, const float *compliance, + uint32_t edge_count, const uint32_t *colour_start, + uint32_t colour_count); +F3D_GPU_API void f3d_gpu_cloth_destroy(F3dGpuCloth *cloth); + +/* As f3d_cloth_set_balls and f3d_cloth_move_point. */ +F3D_GPU_API int f3d_gpu_cloth_set_balls(F3dGpuCloth *cloth, const float *balls, uint32_t count); +F3D_GPU_API void f3d_gpu_cloth_move_point(F3dGpuCloth *cloth, uint32_t index, float x, float y, + float z); + +/* Queues a step of [dt] and, after it, the points' copy back. */ +F3D_GPU_API void f3d_gpu_cloth_step(F3dGpuCloth *cloth, const F3dGpuClothSettings *settings, + float dt); + +/* The points as f3d_cloth_read writes them, read as f3d_gpu_debris_read + * reads: the step they are from, nought for none new. */ +F3D_GPU_API uint64_t f3d_gpu_cloth_read(F3dGpuCloth *cloth, float *out, uint32_t capacity, + int wait); + +/* Fluid, as f3d_fluid steps it: F3dFluidSettings, in floats. */ +typedef struct F3dGpuFluidSettings { + float gravity[3]; + float tank_min[3]; + float tank_max[3]; + float viscosity; + float relaxation; + uint32_t substeps; + uint32_t iterations; +} F3dGpuFluidSettings; + +/* As f3d_fluid_create: room for [capacity] particles [spacing] apart at + * rest, and the same density at rest, worked out the same way. */ +F3D_GPU_API F3dGpuFluid *f3d_gpu_fluid_create(F3dGpu *gpu, uint32_t capacity, float spacing); +F3D_GPU_API void f3d_gpu_fluid_destroy(F3dGpuFluid *fluid); +F3D_GPU_API float f3d_gpu_fluid_rest_density(const F3dGpuFluid *fluid); + +/* As f3d_fluid_add. */ +F3D_GPU_API uint32_t f3d_gpu_fluid_add(F3dGpuFluid *fluid, const float *data, uint32_t count); + +/* Queues a step of [dt] and, after it, the particles' copy back. */ +F3D_GPU_API void f3d_gpu_fluid_step(F3dGpuFluid *fluid, const F3dGpuFluidSettings *settings, + float dt); + +/* The particles as f3d_fluid_read writes them, read as f3d_gpu_debris_read + * reads: the step they are from, nought for none new. */ +F3D_GPU_API uint64_t f3d_gpu_fluid_read(F3dGpuFluid *fluid, float *out, uint32_t capacity, + int wait); + +#ifdef __cplusplus +} +#endif + +#endif /* F3D_GPU_H_ */ diff --git a/packages/flutter3d_physics_native/csrc/gpu/f3d_gpu_cloth.c b/packages/flutter3d_physics_native/csrc/gpu/f3d_gpu_cloth.c new file mode 100644 index 000000000..ef62d04e8 --- /dev/null +++ b/packages/flutter3d_physics_native/csrc/gpu/f3d_gpu_cloth.c @@ -0,0 +1,295 @@ +/* + * Cloth on the GPU — P9, phase 10: what f3d_cloth.c does, a dispatch a + * colour. The constraints come coloured from the core (f3d_cloth_edges), + * so both solve the same ones in the same order of colours; inside a + * colour no two share a point, so nothing is summed in an order the GPU + * chooses, and the two agree to the GPU's rounding. Which colour a + * dispatch solves is a uniform bound at an offset of its own. + */ +#include +#include + +#include "f3d_gpu_internal.h" + +#define POINT_BYTES 16u +#define EDGE_BYTES 16u +#define PARAMS_BYTES 80u +#define MAX_BALLS 16u +#define BALLS_BYTES (MAX_BALLS * 16u) +/* A colour's range, at offsets a uniform's dynamic binding allows. */ +#define RANGE_STRIDE 256u + +struct F3dGpuCloth { + F3dGpu *gpu; + uint32_t point_count; + uint32_t colour_count; + uint32_t ball_count; + uint32_t *colour_size; + float *inv_mass; + uint64_t frame; + WGPUBuffer params; + WGPUBuffer balls; + WGPUBuffer ranges; + WGPUBuffer position; + WGPUBuffer previous; + WGPUBuffer velocity; + WGPUBuffer edges; + F3dGpuKernels kernels; + F3dGpuReadback readback; +}; + +enum { PREDICT, SOLVE, COLLIDE, KERNELS }; + +static const char *const kEntries[KERNELS] = {"predict", "solve", "collide"}; + +static const char kClothShader[] = + "struct Params {\n" + " gravity_h : vec4, // gravity, the substep\n" + " wind_drift : vec4, // the wind, what drag keeps of the difference\n" + " misc : vec4, // what damping keeps, 1/h², the floor, the thickness\n" + " extra : vec4, // friction\n" + " counts : vec4, // points, balls\n" + "};\n" + "struct Balls { b : array, 16> };\n" + "struct Range { start : u32, count : u32, pad0 : u32, pad1 : u32 };\n" + "struct Edge { a : u32, b : u32, rest : f32, compliance : f32 };\n" + "@group(0) @binding(0) var P : Params;\n" + "@group(0) @binding(1) var B : Balls;\n" + "@group(0) @binding(2) var R : Range;\n" + "@group(0) @binding(3) var position : array>;\n" + "@group(0) @binding(4) var previous : array>;\n" + "@group(0) @binding(5) var velocity : array>;\n" + "@group(0) @binding(6) var edges : array;\n" + "@compute @workgroup_size(64)\n" + "fn predict(@builtin(global_invocation_id) id : vec3) {\n" + " let i = id.x;\n" + " if (i >= P.counts.x) { return; }\n" + " let p = position[i];\n" + " previous[i] = p;\n" + " if (p.w == 0.0) { return; }\n" + " var v = velocity[i].xyz + P.gravity_h.xyz * P.gravity_h.w;\n" + " v = P.wind_drift.xyz + (v - P.wind_drift.xyz) * P.wind_drift.w;\n" + " v = v * P.misc.x;\n" + " velocity[i] = vec4(v, 0.0);\n" + " position[i] = vec4(p.xyz + v * P.gravity_h.w, p.w);\n" + "}\n" + "@compute @workgroup_size(64)\n" + "fn solve(@builtin(global_invocation_id) id : vec3) {\n" + " if (id.x >= R.count) { return; }\n" + " let e = edges[R.start + id.x];\n" + " let pa = position[e.a];\n" + " let pb = position[e.b];\n" + " let w = pa.w + pb.w + e.compliance * P.misc.y;\n" + " if (!(w > 0.0)) { return; }\n" + " let d = pa.xyz - pb.xyz;\n" + " let len = sqrt(dot(d, d));\n" + " if (!(len > 0.0)) { return; }\n" + " let lambda = (e.rest - len) / w;\n" + " let n = d * (1.0 / len);\n" + " position[e.a] = vec4(pa.xyz + n * (pa.w * lambda), pa.w);\n" + " position[e.b] = vec4(pb.xyz + n * (-pb.w * lambda), pb.w);\n" + "}\n" + "fn rub(x : vec3, was : vec3, n : vec3) -> vec3 {\n" + " let moved = x - was;\n" + " let slide = moved + n * -dot(moved, n);\n" + " return x + slide * -P.extra.x;\n" + "}\n" + "@compute @workgroup_size(64)\n" + "fn collide(@builtin(global_invocation_id) id : vec3) {\n" + " let i = id.x;\n" + " if (i >= P.counts.x) { return; }\n" + " let p = position[i];\n" + " if (p.w == 0.0) {\n" + " velocity[i] = vec4(0.0);\n" + " return;\n" + " }\n" + " var x = p.xyz;\n" + " let was = previous[i].xyz;\n" + " for (var k = 0u; k < P.counts.y; k = k + 1u) {\n" + " let ball = B.b[k];\n" + " let d = x - ball.xyz;\n" + " let len = sqrt(dot(d, d));\n" + " let reach = ball.w + P.misc.w;\n" + " if (!(len < reach)) { continue; }\n" + " var n = vec3(0.0, 1.0, 0.0);\n" + " if (len > 0.0) { n = d * (1.0 / len); }\n" + " x = ball.xyz + n * reach;\n" + " x = rub(x, was, n);\n" + " }\n" + " if (x.y < P.misc.z) {\n" + " x.y = P.misc.z;\n" + " x = rub(x, was, vec3(0.0, 1.0, 0.0));\n" + " }\n" + " position[i] = vec4(x, p.w);\n" + " velocity[i] = vec4((x - was) * (1.0 / P.gravity_h.w), 0.0);\n" + "}\n"; + +F3dGpuCloth *f3d_gpu_cloth_create(F3dGpu *gpu, const float *points, uint32_t point_count, + const uint32_t *pairs, const float *rest, + const float *compliance, uint32_t edge_count, + const uint32_t *colour_start, uint32_t colour_count) { + if (gpu == NULL || point_count == 0 || point_count > (1u << 22) || colour_count > 256u || + edge_count > (1u << 24) || (colour_count > 0 && colour_start[colour_count] != edge_count)) { + return NULL; + } + for (uint32_t e = 0; e < edge_count; e++) { + if (pairs[e * 2u] >= point_count || pairs[e * 2u + 1u] >= point_count) return NULL; + } + F3dGpuCloth *c = (F3dGpuCloth *)calloc(1, sizeof(F3dGpuCloth)); + if (c == NULL) return NULL; + c->gpu = gpu; + c->point_count = point_count; + c->colour_count = colour_count; + c->colour_size = (uint32_t *)calloc(colour_count > 0 ? colour_count : 1u, sizeof(uint32_t)); + c->inv_mass = (float *)calloc(point_count, sizeof(float)); + const uint64_t point_bytes = (uint64_t)point_count * POINT_BYTES; + const uint64_t edge_bytes = (uint64_t)(edge_count > 0 ? edge_count : 1u) * EDGE_BYTES; + const uint64_t range_bytes = (uint64_t)(colour_count > 0 ? colour_count : 1u) * RANGE_STRIDE; + const WGPUBufferUsage uniform = WGPUBufferUsage_Uniform | WGPUBufferUsage_CopyDst; + const WGPUBufferUsage storage = WGPUBufferUsage_Storage | WGPUBufferUsage_CopyDst; + c->params = f3d_gpu_buffer(gpu, uniform, PARAMS_BYTES); + c->balls = f3d_gpu_buffer(gpu, uniform, BALLS_BYTES); + c->ranges = f3d_gpu_buffer(gpu, uniform, range_bytes); + c->position = f3d_gpu_buffer(gpu, storage | WGPUBufferUsage_CopySrc, point_bytes); + c->previous = f3d_gpu_buffer(gpu, storage, point_bytes); + c->velocity = f3d_gpu_buffer(gpu, storage, point_bytes); + c->edges = f3d_gpu_buffer(gpu, storage, edge_bytes); + uint8_t *ranges = (uint8_t *)calloc(1, (size_t)range_bytes); + float *start = (float *)calloc(point_count, POINT_BYTES); + uint8_t *edge_data = (uint8_t *)calloc(1, (size_t)edge_bytes); + void *zero = calloc(1, BALLS_BYTES > point_bytes ? BALLS_BYTES : (size_t)point_bytes); + if (c->colour_size == NULL || c->inv_mass == NULL || c->params == NULL || c->balls == NULL || + c->ranges == NULL || c->position == NULL || c->previous == NULL || c->velocity == NULL || + c->edges == NULL || ranges == NULL || start == NULL || edge_data == NULL || zero == NULL || + !f3d_gpu_readback_create(&c->readback, gpu, point_bytes)) { + free(ranges); + free(start); + free(edge_data); + free(zero); + f3d_gpu_cloth_destroy(c); + return NULL; + } + for (uint32_t i = 0; i < point_count; i++) { + for (int k = 0; k < 3; k++) start[i * 4u + (uint32_t)k] = points[i * 4u + (uint32_t)k]; + c->inv_mass[i] = points[i * 4u + 3u] > 0.0f ? points[i * 4u + 3u] : 0.0f; + start[i * 4u + 3u] = c->inv_mass[i]; + } + for (uint32_t e = 0; e < edge_count; e++) { + const float comp = compliance[e] > 0.0f ? compliance[e] : 0.0f; + memcpy(edge_data + e * EDGE_BYTES, &pairs[e * 2u], 8u); + memcpy(edge_data + e * EDGE_BYTES + 8u, &rest[e], 4u); + memcpy(edge_data + e * EDGE_BYTES + 12u, &comp, 4u); + } + for (uint32_t k = 0; k < colour_count; k++) { + const uint32_t range[2] = {colour_start[k], colour_start[k + 1u] - colour_start[k]}; + c->colour_size[k] = range[1]; + memcpy(ranges + (size_t)k * RANGE_STRIDE, range, sizeof range); + } + wgpuQueueWriteBuffer(gpu->queue, c->position, 0, start, (size_t)point_bytes); + wgpuQueueWriteBuffer(gpu->queue, c->previous, 0, start, (size_t)point_bytes); + wgpuQueueWriteBuffer(gpu->queue, c->velocity, 0, zero, (size_t)point_bytes); + wgpuQueueWriteBuffer(gpu->queue, c->balls, 0, zero, BALLS_BYTES); + wgpuQueueWriteBuffer(gpu->queue, c->edges, 0, edge_data, (size_t)edge_bytes); + wgpuQueueWriteBuffer(gpu->queue, c->ranges, 0, ranges, (size_t)range_bytes); + free(ranges); + free(start); + free(edge_data); + free(zero); + const F3dGpuBinding kinds[] = {F3D_GPU_UNIFORM, F3D_GPU_UNIFORM, F3D_GPU_UNIFORM_AT, + F3D_GPU_STORAGE, F3D_GPU_STORAGE, F3D_GPU_STORAGE, + F3D_GPU_READ_ONLY}; + const WGPUBuffer buffers[] = {c->params, c->balls, c->ranges, c->position, + c->previous, c->velocity, c->edges}; + const uint64_t sizes[] = {PARAMS_BYTES, BALLS_BYTES, 16u, point_bytes, + point_bytes, point_bytes, edge_bytes}; + if (!f3d_gpu_kernels_create(&c->kernels, gpu, kClothShader, kEntries, KERNELS, kinds, buffers, + sizes, 7)) { + f3d_gpu_cloth_destroy(c); + return NULL; + } + return c; +} + +void f3d_gpu_cloth_destroy(F3dGpuCloth *c) { + if (c == NULL) return; + f3d_gpu_kernels_release(&c->kernels); + f3d_gpu_readback_release(&c->readback); + const WGPUBuffer buffers[] = {c->params, c->balls, c->ranges, c->position, + c->previous, c->velocity, c->edges}; + for (size_t i = 0; i < sizeof buffers / sizeof buffers[0]; i++) { + if (buffers[i] != NULL) wgpuBufferRelease(buffers[i]); + } + free(c->colour_size); + free(c->inv_mass); + free(c); +} + +int f3d_gpu_cloth_set_balls(F3dGpuCloth *c, const float *balls, uint32_t count) { + if (count > MAX_BALLS) return 0; + float all[MAX_BALLS * 4u]; + memset(all, 0, sizeof all); + if (count > 0) memcpy(all, balls, (size_t)count * 4u * sizeof(float)); + wgpuQueueWriteBuffer(c->gpu->queue, c->balls, 0, all, sizeof all); + c->ball_count = count; + return 1; +} + +void f3d_gpu_cloth_move_point(F3dGpuCloth *c, uint32_t index, float x, float y, float z) { + if (index >= c->point_count) return; + const float p[4] = {x, y, z, c->inv_mass[index]}; + const float still[4] = {0, 0, 0, 0}; + const uint64_t at = (uint64_t)index * POINT_BYTES; + wgpuQueueWriteBuffer(c->gpu->queue, c->position, at, p, sizeof p); + wgpuQueueWriteBuffer(c->gpu->queue, c->velocity, at, still, sizeof still); +} + +void f3d_gpu_cloth_step(F3dGpuCloth *c, const F3dGpuClothSettings *s, float dt) { + if (!(dt > 0.0f)) return; + c->frame++; + const uint32_t substeps = s->substeps > 0 ? s->substeps : 1u; + const float h = dt / (float)substeps; + float params[20]; + params[0] = s->gravity[0]; + params[1] = s->gravity[1]; + params[2] = s->gravity[2]; + params[3] = h; + params[4] = s->wind[0]; + params[5] = s->wind[1]; + params[6] = s->wind[2]; + params[7] = 1.0f / (1.0f + s->drag * h); + params[8] = 1.0f / (1.0f + s->damping * h); + params[9] = 1.0f / (h * h); + params[10] = s->floor_y + s->thickness; + params[11] = s->thickness; + params[12] = s->friction; + params[13] = params[14] = params[15] = 0.0f; + const uint32_t counts[4] = {c->point_count, c->ball_count, 0, 0}; + memcpy(¶ms[16], counts, sizeof counts); + wgpuQueueWriteBuffer(c->gpu->queue, c->params, 0, params, sizeof params); + WGPUCommandEncoder encoder = wgpuDeviceCreateCommandEncoder(c->gpu->device, NULL); + WGPUComputePassEncoder pass = wgpuCommandEncoderBeginComputePass(encoder, NULL); + for (uint32_t sub = 0; sub < substeps; sub++) { + f3d_gpu_dispatch(pass, &c->kernels, PREDICT, c->point_count); + for (uint32_t k = 0; k < c->colour_count; k++) { + f3d_gpu_dispatch_at(pass, &c->kernels, SOLVE, c->colour_size[k], k * RANGE_STRIDE); + } + f3d_gpu_dispatch(pass, &c->kernels, COLLIDE, c->point_count); + } + wgpuComputePassEncoderEnd(pass); + wgpuComputePassEncoderRelease(pass); + WGPUCommandBuffer commands = wgpuCommandEncoderFinish(encoder, NULL); + wgpuQueueSubmit(c->gpu->queue, 1, &commands); + wgpuCommandBufferRelease(commands); + wgpuCommandEncoderRelease(encoder); + f3d_gpu_readback_request(&c->readback, c->gpu, c->position, c->frame); +} + +static void copy_points(const void *mapped, void *out, uint32_t n) { + memcpy(out, mapped, (size_t)n * POINT_BYTES); +} + +uint64_t f3d_gpu_cloth_read(F3dGpuCloth *c, float *out, uint32_t capacity, int wait) { + const uint32_t n = capacity < c->point_count ? capacity : c->point_count; + return f3d_gpu_readback_take(&c->readback, c->gpu, c->position, c->frame, wait, copy_points, + out, n); +} diff --git a/packages/flutter3d_physics_native/csrc/gpu/f3d_gpu_debris.c b/packages/flutter3d_physics_native/csrc/gpu/f3d_gpu_debris.c new file mode 100644 index 000000000..d90ad3f38 --- /dev/null +++ b/packages/flutter3d_physics_native/csrc/gpu/f3d_gpu_debris.c @@ -0,0 +1,422 @@ +/* + * Debris on the GPU — P9, phase 10: the visual bodies, read a frame late. + * The WGSL does what f3d_debris.c does, pass for pass and line for line; + * see there for why. The grid's lists are built with atomicExchange, so a + * cell's bodies come in whatever order the GPU inserted them, and a body's + * impulses are summed in that order: the same as the CPU's to the GPU's + * rounding, not to the bit. + */ +#include +#include + +#include "f3d_gpu_internal.h" + +/* Sixty-four bytes a body, as the shader's Body lays them out. */ +#define BODY_BYTES 64u +#define MOTION_BYTES 32u +#define PARAMS_BYTES 64u +#define MAX_STATICS 64u +#define STATICS_BYTES (MAX_STATICS * 32u) + +struct F3dGpuDebris { + F3dGpu *gpu; + uint32_t capacity; + uint32_t cells; + uint32_t next; + uint32_t static_count; + uint64_t frame; + float max_radius; + WGPUBuffer params; + WGPUBuffer statics; + WGPUBuffer bodies; + WGPUBuffer motion; + WGPUBuffer contacts; + WGPUBuffer head; + WGPUBuffer next_in_cell; + F3dGpuKernels kernels; + F3dGpuReadback readback; +}; + +enum { CLEAR_CELLS, GRAVITY_GRID, COUNT_CONTACTS, SOLVE, ADOPT, INTEGRATE, KERNELS }; + +static const char *const kEntries[KERNELS] = { + "clear_cells", "gravity_grid", "count_contacts", "solve", "adopt", "integrate", +}; + +static const char kDebrisShader[] = + "struct Body { pos_r : vec4, vel_m : vec4, spin_i : vec4, turn : vec4 };\n" + "struct Motion { v : vec4, w : vec4 };\n" + "struct Still { a : vec4, b : vec4 };\n" + "struct Params {\n" + " gravity_h : vec4, // gravity, the substep\n" + " material : vec4, // friction, restitution, what damping keeps of v and w\n" + " limits : vec4, // the speed limit, one over the cell\n" + " counts : vec4, // slots, cells, still shapes\n" + "};\n" + "struct Statics { s : array };\n" + "@group(0) @binding(0) var P : Params;\n" + "@group(0) @binding(1) var S : Statics;\n" + "@group(0) @binding(2) var bodies : array;\n" + "@group(0) @binding(3) var motion : array;\n" + "@group(0) @binding(4) var contacts : array;\n" + "@group(0) @binding(5) var head : array>;\n" + "@group(0) @binding(6) var next_in_cell : array;\n" + "const NONE : u32 = 0xFFFFFFFFu;\n" + "const SLOP : f32 = 0.0005;\n" + "const PUSH : f32 = 0.8;\n" + "const MAX_PUSH : f32 = 3.0;\n" + "const BOUNCE_SPEED : f32 = 1.0;\n" + "fn coord(x : f32) -> i32 { return i32(floor(clamp(x * P.limits.y, -1e9, 1e9))); }\n" + "fn cell_of(p : vec3) -> vec3 { return vec3(coord(p.x), coord(p.y), coord(p.z)); }\n" + "fn hash(c : vec3) -> u32 {\n" + " return ((bitcast(c.x) * 73856093u) ^ (bitcast(c.y) * 19349663u) ^\n" + " (bitcast(c.z) * 83492791u)) & (P.counts.y - 1u);\n" + "}\n" + "struct Touch { n : vec3, gap : f32 };\n" + "fn touch_pair(lo : Body, hi : Body) -> Touch {\n" + " let d = lo.pos_r.xyz - hi.pos_r.xyz;\n" + " let len = sqrt(dot(d, d));\n" + " var t : Touch;\n" + " t.n = vec3(0.0, 1.0, 0.0);\n" + " if (len > 0.0) { t.n = d * (1.0 / len); }\n" + " t.gap = len - lo.pos_r.w - hi.pos_r.w;\n" + " return t;\n" + "}\n" + "fn touch_still(b : Body, s : Still) -> Touch {\n" + " var t : Touch;\n" + " let p = b.pos_r.xyz;\n" + " if (s.b.w == 0.0) {\n" + " t.n = s.a.xyz;\n" + " t.gap = dot(p, t.n) - s.a.w - b.pos_r.w;\n" + " return t;\n" + " }\n" + " let c = s.a.xyz;\n" + " let d = p - clamp(p, c - s.b.xyz, c + s.b.xyz);\n" + " let len = sqrt(dot(d, d));\n" + " if (len > 0.0) {\n" + " t.n = d * (1.0 / len);\n" + " t.gap = len - b.pos_r.w;\n" + " return t;\n" + " }\n" + " let depth = s.b.xyz - abs(p - c);\n" + " var axis = 0u;\n" + " var best = depth.x;\n" + " if (depth.y < best) { best = depth.y; axis = 1u; }\n" + " if (depth.z < best) { best = depth.z; axis = 2u; }\n" + " var n = vec3(0.0);\n" + " n[axis] = select(1.0, -1.0, p[axis] < c[axis]);\n" + " t.n = n;\n" + " t.gap = -best - b.pos_r.w;\n" + " return t;\n" + "}\n" + "fn surface(b : Body, n : vec3) -> vec3 {\n" + " return b.vel_m.xyz + cross(b.spin_i.xyz, n * -b.pos_r.w);\n" + "}\n" + "fn impulse(lo : Body, n_lo : u32, hi : Body, n_hi : u32, still : bool, t : Touch) -> vec3 {\n" + " let reach = select(0.25 * (lo.pos_r.w + hi.pos_r.w), lo.pos_r.w, still);\n" + " if (!(t.gap <= reach)) { return vec3(0.0); }\n" + " var rel = surface(lo, t.n);\n" + " if (!still) { rel = rel - surface(hi, -t.n); }\n" + " let vn = dot(rel, t.n);\n" + " let h = P.gravity_h.w;\n" + " var goal = min(max(-t.gap - SLOP, 0.0) * PUSH / h, MAX_PUSH);\n" + " if (t.gap > 0.0) { goal = -t.gap / h; }\n" + " if (vn < -BOUNCE_SPEED && t.gap <= SLOP) { goal = max(goal, -P.material.y * vn); }\n" + " let dvn = goal - vn;\n" + " if (!(dvn > 0.0)) { return vec3(0.0); }\n" + " let w_lo = lo.vel_m.w * f32(n_lo);\n" + " let w_hi = select(hi.vel_m.w * f32(n_hi), 0.0, still);\n" + " let pn = dvn / (w_lo + w_hi);\n" + " var p = t.n * pn;\n" + " let vt = rel - t.n * vn;\n" + " let slide = sqrt(dot(vt, vt));\n" + " if (slide > 0.0) {\n" + " var k = (lo.vel_m.w + lo.pos_r.w * lo.pos_r.w * lo.spin_i.w) * f32(n_lo);\n" + " if (!still) { k = k + (hi.vel_m.w + hi.pos_r.w * hi.pos_r.w * hi.spin_i.w) * f32(n_hi); }\n" + " let pt = min(slide / k, P.material.x * pn);\n" + " p = p - vt * (pt / slide);\n" + " }\n" + " return p;\n" + "}\n" + "@compute @workgroup_size(64)\n" + "fn clear_cells(@builtin(global_invocation_id) id : vec3) {\n" + " if (id.x >= P.counts.y) { return; }\n" + " atomicStore(&head[id.x], NONE);\n" + "}\n" + "@compute @workgroup_size(64)\n" + "fn gravity_grid(@builtin(global_invocation_id) id : vec3) {\n" + " let i = id.x;\n" + " if (i >= P.counts.x) { return; }\n" + " let b = bodies[i];\n" + " if (!(b.pos_r.w > 0.0)) { return; }\n" + " var v = b.vel_m.xyz + P.gravity_h.xyz * P.gravity_h.w;\n" + " let speed2 = dot(v, v);\n" + " if (speed2 > P.limits.x * P.limits.x) { v = v * (P.limits.x / sqrt(speed2)); }\n" + " bodies[i].vel_m = vec4(v, b.vel_m.w);\n" + " next_in_cell[i] = atomicExchange(&head[hash(cell_of(b.pos_r.xyz))], i);\n" + "}\n" + "fn gather(i : u32, counting : bool) {\n" + " let b = bodies[i];\n" + " if (!(b.pos_r.w > 0.0)) { return; }\n" + " var count = 0u;\n" + " var dv = b.vel_m.xyz;\n" + " var dw = b.spin_i.xyz;\n" + " let c = cell_of(b.pos_r.xyz);\n" + " for (var x = c.x - 1; x <= c.x + 1; x = x + 1) {\n" + " for (var y = c.y - 1; y <= c.y + 1; y = y + 1) {\n" + " for (var z = c.z - 1; z <= c.z + 1; z = z + 1) {\n" + " var j = atomicLoad(&head[hash(vec3(x, y, z))]);\n" + " loop {\n" + " if (j == NONE) { break; }\n" + " let after = next_in_cell[j];\n" + " let o = bodies[j];\n" + " // Another cell sharing this one's hash: met there.\n" + " if (j != i && all(cell_of(o.pos_r.xyz) == vec3(x, y, z))) {\n" + " let lower = i < j;\n" + " var lo = o;\n" + " var hi = b;\n" + " if (lower) { lo = b; hi = o; }\n" + " let t = touch_pair(lo, hi);\n" + " if (counting) {\n" + " if (t.gap <= 0.25 * (lo.pos_r.w + hi.pos_r.w)) { count = count + 1u; }\n" + " } else {\n" + " let n_lo = contacts[select(j, i, lower)];\n" + " let n_hi = contacts[select(i, j, lower)];\n" + " let p = impulse(lo, n_lo, hi, n_hi, false, t);\n" + " let sign = select(-1.0, 1.0, lower);\n" + " let mine = p * sign;\n" + " dv = dv + mine * b.vel_m.w;\n" + " dw = dw + cross(t.n * sign * -b.pos_r.w, mine) * b.spin_i.w;\n" + " }\n" + " }\n" + " j = after;\n" + " }\n" + " }\n" + " }\n" + " }\n" + " for (var k = 0u; k < P.counts.z; k = k + 1u) {\n" + " let t = touch_still(b, S.s[k]);\n" + " if (counting) {\n" + " if (t.gap <= b.pos_r.w) { count = count + 1u; }\n" + " } else {\n" + " let p = impulse(b, contacts[i], b, 0u, true, t);\n" + " dv = dv + p * b.vel_m.w;\n" + " dw = dw + cross(t.n * -b.pos_r.w, p) * b.spin_i.w;\n" + " }\n" + " }\n" + " if (counting) {\n" + " contacts[i] = count;\n" + " } else {\n" + " motion[i] = Motion(vec4(dv, 0.0), vec4(dw, 0.0));\n" + " }\n" + "}\n" + "@compute @workgroup_size(64)\n" + "fn count_contacts(@builtin(global_invocation_id) id : vec3) {\n" + " if (id.x < P.counts.x) { gather(id.x, true); }\n" + "}\n" + "@compute @workgroup_size(64)\n" + "fn solve(@builtin(global_invocation_id) id : vec3) {\n" + " if (id.x < P.counts.x) { gather(id.x, false); }\n" + "}\n" + "@compute @workgroup_size(64)\n" + "fn adopt(@builtin(global_invocation_id) id : vec3) {\n" + " let i = id.x;\n" + " if (i >= P.counts.x || !(bodies[i].pos_r.w > 0.0)) { return; }\n" + " bodies[i].vel_m = vec4(motion[i].v.xyz, bodies[i].vel_m.w);\n" + " bodies[i].spin_i = vec4(motion[i].w.xyz, bodies[i].spin_i.w);\n" + "}\n" + "@compute @workgroup_size(64)\n" + "fn integrate(@builtin(global_invocation_id) id : vec3) {\n" + " let i = id.x;\n" + " if (i >= P.counts.x) { return; }\n" + " let b = bodies[i];\n" + " if (!(b.pos_r.w > 0.0)) { return; }\n" + " let h = P.gravity_h.w;\n" + " let v = motion[i].v.xyz * P.material.z;\n" + " let w = motion[i].w.xyz * P.material.w;\n" + " let q = b.turn;\n" + " let k = 0.5 * h;\n" + " var r = vec4(q.x + k * (w.x * q.w + w.y * q.z - w.z * q.y),\n" + " q.y + k * (w.y * q.w + w.z * q.x - w.x * q.z),\n" + " q.z + k * (w.z * q.w + w.x * q.y - w.y * q.x),\n" + " q.w - k * (w.x * q.x + w.y * q.y + w.z * q.z));\n" + " r = r / sqrt(dot(r, r));\n" + " bodies[i] = Body(vec4(b.pos_r.xyz + v * h, b.pos_r.w), vec4(v, b.vel_m.w),\n" + " vec4(w, b.spin_i.w), r);\n" + "}\n"; + +F3dGpuDebris *f3d_gpu_debris_create(F3dGpu *gpu, uint32_t capacity) { + if (gpu == NULL || capacity == 0 || capacity > (1u << 20)) return NULL; + F3dGpuDebris *d = (F3dGpuDebris *)calloc(1, sizeof(F3dGpuDebris)); + if (d == NULL) return NULL; + d->gpu = gpu; + d->capacity = capacity; + uint32_t cells = 1; + while (cells < capacity * 2u) cells <<= 1; + d->cells = cells; + const WGPUBufferUsage storage = WGPUBufferUsage_Storage | WGPUBufferUsage_CopyDst; + const uint64_t body_bytes = (uint64_t)capacity * BODY_BYTES; + d->params = f3d_gpu_buffer(gpu, WGPUBufferUsage_Uniform | WGPUBufferUsage_CopyDst, PARAMS_BYTES); + d->statics = f3d_gpu_buffer(gpu, WGPUBufferUsage_Uniform | WGPUBufferUsage_CopyDst, STATICS_BYTES); + d->bodies = f3d_gpu_buffer(gpu, storage | WGPUBufferUsage_CopySrc, body_bytes); + d->motion = f3d_gpu_buffer(gpu, storage, (uint64_t)capacity * MOTION_BYTES); + d->contacts = f3d_gpu_buffer(gpu, storage, (uint64_t)capacity * 4u); + d->head = f3d_gpu_buffer(gpu, storage, (uint64_t)cells * 4u); + d->next_in_cell = f3d_gpu_buffer(gpu, storage, (uint64_t)capacity * 4u); + if (d->params == NULL || d->statics == NULL || d->bodies == NULL || d->motion == NULL || + d->contacts == NULL || d->head == NULL || d->next_in_cell == NULL || + !f3d_gpu_readback_create(&d->readback, gpu, body_bytes)) { + f3d_gpu_debris_destroy(d); + return NULL; + } + /* Every slot empty: radius nought, unturned. */ + float *empty = (float *)calloc(capacity, BODY_BYTES); + void *zero = calloc(1, STATICS_BYTES); + if (empty == NULL || zero == NULL) { + free(empty); + free(zero); + f3d_gpu_debris_destroy(d); + return NULL; + } + for (uint32_t i = 0; i < capacity; i++) empty[i * 16u + 15u] = 1.0f; + wgpuQueueWriteBuffer(gpu->queue, d->bodies, 0, empty, (size_t)body_bytes); + wgpuQueueWriteBuffer(gpu->queue, d->statics, 0, zero, STATICS_BYTES); + free(empty); + free(zero); + const F3dGpuBinding kinds[] = {F3D_GPU_UNIFORM, F3D_GPU_UNIFORM, F3D_GPU_STORAGE, + F3D_GPU_STORAGE, F3D_GPU_STORAGE, F3D_GPU_STORAGE, + F3D_GPU_STORAGE}; + const WGPUBuffer buffers[] = {d->params, d->statics, d->bodies, d->motion, + d->contacts, d->head, d->next_in_cell}; + const uint64_t sizes[] = {PARAMS_BYTES, + STATICS_BYTES, + body_bytes, + (uint64_t)capacity * MOTION_BYTES, + (uint64_t)capacity * 4u, + (uint64_t)cells * 4u, + (uint64_t)capacity * 4u}; + if (!f3d_gpu_kernels_create(&d->kernels, gpu, kDebrisShader, kEntries, KERNELS, kinds, + buffers, sizes, 7)) { + f3d_gpu_debris_destroy(d); + return NULL; + } + return d; +} + +void f3d_gpu_debris_destroy(F3dGpuDebris *d) { + if (d == NULL) return; + f3d_gpu_kernels_release(&d->kernels); + f3d_gpu_readback_release(&d->readback); + const WGPUBuffer buffers[] = {d->params, d->statics, d->bodies, d->motion, + d->contacts, d->head, d->next_in_cell}; + for (size_t i = 0; i < sizeof buffers / sizeof buffers[0]; i++) { + if (buffers[i] != NULL) wgpuBufferRelease(buffers[i]); + } + free(d); +} + +uint32_t f3d_gpu_debris_add(F3dGpuDebris *d, const float *data, uint32_t count) { + const uint32_t first = d->next; + float body[16]; + for (uint32_t i = 0; i < count; i++) { + const float *in = data + (size_t)i * 8u; + memset(body, 0, sizeof body); + body[15] = 1.0f; + const float r = in[6], m = in[7]; + if (r - r == 0.0f && m - m == 0.0f && r > 0.0f && m > 0.0f) { + body[0] = in[0]; + body[1] = in[1]; + body[2] = in[2]; + body[3] = r; + body[4] = in[3]; + body[5] = in[4]; + body[6] = in[5]; + body[7] = 1.0f / m; + body[11] = 2.5f / (m * r * r); + if (r > d->max_radius) d->max_radius = r; + } + wgpuQueueWriteBuffer(d->gpu->queue, d->bodies, (uint64_t)d->next * BODY_BYTES, body, + sizeof body); + d->next = (d->next + 1u) % d->capacity; + } + return first; +} + +int f3d_gpu_debris_set_statics(F3dGpuDebris *d, const float *data, uint32_t count) { + if (count > MAX_STATICS) return 0; + float all[MAX_STATICS * 8u]; + memset(all, 0, sizeof all); + if (count > 0) memcpy(all, data, (size_t)count * 8u * sizeof(float)); + wgpuQueueWriteBuffer(d->gpu->queue, d->statics, 0, all, sizeof all); + d->static_count = count; + return 1; +} + +void f3d_gpu_debris_step(F3dGpuDebris *d, const F3dGpuDebrisSettings *s, float dt) { + if (!(dt > 0.0f)) return; + d->frame++; + const float cell = d->max_radius * 3.0f; + if (cell > 0.0f) { + const uint32_t substeps = s->substeps > 0 ? s->substeps : 1u; + const uint32_t iterations = s->iterations > 0 ? s->iterations : 1u; + const float h = dt / (float)substeps; + float params[16]; + params[0] = s->gravity[0]; + params[1] = s->gravity[1]; + params[2] = s->gravity[2]; + params[3] = h; + params[4] = s->friction; + params[5] = s->restitution; + params[6] = 1.0f / (1.0f + s->linear_damping * h); + params[7] = 1.0f / (1.0f + s->angular_damping * h); + params[8] = s->max_speed; + params[9] = 1.0f / cell; + params[10] = 0.0f; + params[11] = 0.0f; + const uint32_t counts[4] = {d->capacity, d->cells, d->static_count, 0}; + memcpy(¶ms[12], counts, sizeof counts); + wgpuQueueWriteBuffer(d->gpu->queue, d->params, 0, params, sizeof params); + WGPUCommandEncoder encoder = wgpuDeviceCreateCommandEncoder(d->gpu->device, NULL); + WGPUComputePassEncoder pass = wgpuCommandEncoderBeginComputePass(encoder, NULL); + for (uint32_t sub = 0; sub < substeps; sub++) { + f3d_gpu_dispatch(pass, &d->kernels, CLEAR_CELLS, d->cells); + f3d_gpu_dispatch(pass, &d->kernels, GRAVITY_GRID, d->capacity); + f3d_gpu_dispatch(pass, &d->kernels, COUNT_CONTACTS, d->capacity); + for (uint32_t it = 0; it < iterations; it++) { + if (it > 0) f3d_gpu_dispatch(pass, &d->kernels, ADOPT, d->capacity); + f3d_gpu_dispatch(pass, &d->kernels, SOLVE, d->capacity); + } + f3d_gpu_dispatch(pass, &d->kernels, INTEGRATE, d->capacity); + } + wgpuComputePassEncoderEnd(pass); + wgpuComputePassEncoderRelease(pass); + WGPUCommandBuffer commands = wgpuCommandEncoderFinish(encoder, NULL); + wgpuQueueSubmit(d->gpu->queue, 1, &commands); + wgpuCommandBufferRelease(commands); + wgpuCommandEncoderRelease(encoder); + } + f3d_gpu_readback_request(&d->readback, d->gpu, d->bodies, d->frame); +} + +static void copy_bodies(const void *mapped, void *out, uint32_t n) { + const float *b = (const float *)mapped; + float *o = (float *)out; + for (uint32_t i = 0; i < n; i++) { + const float *body = b + (size_t)i * 16u; + float *to = o + (size_t)i * 8u; + to[0] = body[0]; + to[1] = body[1]; + to[2] = body[2]; + to[3] = body[12]; + to[4] = body[13]; + to[5] = body[14]; + to[6] = body[15]; + to[7] = body[3]; + } +} + +uint64_t f3d_gpu_debris_read(F3dGpuDebris *d, float *out, uint32_t capacity, int wait) { + const uint32_t n = capacity < d->capacity ? capacity : d->capacity; + return f3d_gpu_readback_take(&d->readback, d->gpu, d->bodies, d->frame, wait, copy_bodies, + out, n); +} diff --git a/packages/flutter3d_physics_native/csrc/gpu/f3d_gpu_fluid.c b/packages/flutter3d_physics_native/csrc/gpu/f3d_gpu_fluid.c new file mode 100644 index 000000000..36e5c5a38 --- /dev/null +++ b/packages/flutter3d_physics_native/csrc/gpu/f3d_gpu_fluid.c @@ -0,0 +1,394 @@ +/* + * Fluid on the GPU — P9, phase 10: what f3d_fluid.c does, pass for pass + * and in the same order of arithmetic, read a frame late. The grid's lists + * are built with atomicExchange, so neighbours are summed in whatever + * order the GPU inserted them: the CPU's to the GPU's rounding. + */ +#include +#include + +#include "f3d_gpu_internal.h" + +#define PARTICLE_BYTES 16u +#define PARAMS_BYTES 128u + +struct F3dGpuFluid { + F3dGpu *gpu; + uint32_t capacity; + uint32_t cells; + uint32_t count; + uint32_t next_slot; + uint64_t frame; + float spacing; + float h; + float poly6; + float spiky; + float rest_density; + WGPUBuffer params; + WGPUBuffer position; + WGPUBuffer predicted; + WGPUBuffer velocity; + WGPUBuffer scratch; + WGPUBuffer lambda; + WGPUBuffer head; + WGPUBuffer link; + F3dGpuKernels kernels; + F3dGpuReadback readback; +}; + +enum { + CLEAR_CELLS, + PREDICT, + DENSITY, + SPREAD, + ADOPT_SPREAD, + VELOCITY, + SMOOTH, + FINISH, + KERNELS +}; + +static const char *const kEntries[KERNELS] = { + "clear_cells", "predict", "density", "spread", "adopt_spread", "take_velocity", "smoothen", "finish", +}; + +static const char kFluidShader[] = + "struct Params {\n" + " gravity_h : vec4, // gravity, the substep\n" + " kernel : vec4, // its width, poly6's and spiky's constants, rest density\n" + " tank_lo : vec4, // the tank's low corner, one over the cell\n" + " tank_hi : vec4, // the tank's high corner, viscosity\n" + " misc : vec4, // relaxation, spacing\n" + " counts : vec4, // particles, cells\n" + " wall_lo : vec4, // the tank's walls\n" + " wall_hi : vec4,\n" + "};\n" + "@group(0) @binding(0) var P : Params;\n" + "@group(0) @binding(1) var position : array>;\n" + "@group(0) @binding(2) var predicted : array>;\n" + "@group(0) @binding(3) var velocity : array>;\n" + "@group(0) @binding(4) var scratch : array>;\n" + "@group(0) @binding(5) var lam : array>;\n" + "@group(0) @binding(6) var head : array>;\n" + "@group(0) @binding(7) var link : array>;\n" + "const NONE : u32 = 0xFFFFFFFFu;\n" + "fn coord(x : f32) -> i32 { return i32(floor(clamp(x * P.tank_lo.w, -1e9, 1e9))); }\n" + "fn hash(c : vec3) -> u32 {\n" + " return ((bitcast(c.x) * 73856093u) ^ (bitcast(c.y) * 19349663u) ^\n" + " (bitcast(c.z) * 83492791u)) & (P.counts.y - 1u);\n" + "}\n" + "fn poly6(r2 : f32) -> f32 {\n" + " let d = P.kernel.x * P.kernel.x - r2;\n" + " if (d > 0.0) { return P.kernel.y * d * d * d; }\n" + " return 0.0;\n" + "}\n" + "fn spiky(d : vec3, r : f32) -> vec3 {\n" + " if (!(r > 0.0) || !(r < P.kernel.x)) { return vec3(0.0); }\n" + " let k = P.kernel.x - r;\n" + " return d * (P.kernel.z * k * k / r);\n" + "}\n" + "@compute @workgroup_size(64)\n" + "fn clear_cells(@builtin(global_invocation_id) id : vec3) {\n" + " if (id.x < P.counts.y) { atomicStore(&head[id.x], NONE); }\n" + "}\n" + "@compute @workgroup_size(64)\n" + "fn predict(@builtin(global_invocation_id) id : vec3) {\n" + " let i = id.x;\n" + " if (i >= P.counts.x) { return; }\n" + " let v = velocity[i].xyz + P.gravity_h.xyz * P.gravity_h.w;\n" + " let p = position[i].xyz + v * P.gravity_h.w;\n" + " velocity[i] = vec4(v, 0.0);\n" + " predicted[i] = vec4(p, 0.0);\n" + " let c = vec3(coord(p.x), coord(p.y), coord(p.z));\n" + " let after = atomicExchange(&head[hash(c)], i);\n" + " link[i] = vec4(bitcast(after), c);\n" + "}\n" + "struct Walls { density : f32, push : vec3 };\n" + "// As f3d_fluid.c's walls(): still layers past each wall within reach.\n" + "fn walls(p : vec3) -> Walls {\n" + " var w : Walls;\n" + " w.density = 0.0;\n" + " w.push = vec3(0.0);\n" + " let s = P.misc.y;\n" + " let h = P.kernel.x;\n" + " for (var axis = 0u; axis < 3u; axis = axis + 1u) {\n" + " for (var side = 0u; side < 2u; side = side + 1u) {\n" + " var off = P.wall_hi[axis] - p[axis];\n" + " if (side == 0u) { off = p[axis] - P.wall_lo[axis]; }\n" + " var normal = 0.0;\n" + " for (var z = off + s * 0.5; z < h; z = z + s) {\n" + " for (var i = -2; i <= 2; i = i + 1) {\n" + " for (var j = -2; j <= 2; j = j + 1) {\n" + " let r2 = f32(i * i + j * j) * s * s + z * z;\n" + " if (!(r2 < h * h)) { continue; }\n" + " w.density = w.density + poly6(r2);\n" + " let r = sqrt(r2);\n" + " let k = h - r;\n" + " normal = normal + P.kernel.z * k * k / r * z;\n" + " }\n" + " }\n" + " }\n" + " var dir = vec3(0.0);\n" + " dir[axis] = select(-1.0, 1.0, side == 0u);\n" + " w.push = w.push + dir * normal;\n" + " }\n" + " }\n" + " return w;\n" + "}\n" + "struct Sum { density : f32, gradient : vec3, gradient2 : f32, total : vec3 };\n" + "// What a pass sums over a particle's neighbours: 0 density, 1 spread, 2 smooth.\n" + "fn neighbours(i : u32, visit : u32) -> Sum {\n" + " var sum : Sum;\n" + " sum.density = 0.0;\n" + " sum.gradient = vec3(0.0);\n" + " sum.gradient2 = 0.0;\n" + " sum.total = vec3(0.0);\n" + " let p = predicted[i].xyz;\n" + " let c = link[i].yzw;\n" + " let rest = P.kernel.w;\n" + " for (var x = c.x - 1; x <= c.x + 1; x = x + 1) {\n" + " for (var y = c.y - 1; y <= c.y + 1; y = y + 1) {\n" + " for (var z = c.z - 1; z <= c.z + 1; z = z + 1) {\n" + " var j = atomicLoad(&head[hash(vec3(x, y, z))]);\n" + " loop {\n" + " if (j == NONE) { break; }\n" + " let lj = link[j];\n" + " let after = bitcast(lj.x);\n" + " if (all(lj.yzw == vec3(x, y, z))) {\n" + " let d = p - predicted[j].xyz;\n" + " let r2 = dot(d, d);\n" + " if (r2 < P.kernel.x * P.kernel.x) {\n" + " let r = sqrt(r2);\n" + " if (visit == 0u) {\n" + " sum.density = sum.density + poly6(r2);\n" + " if (j != i) {\n" + " let g = spiky(d, r) * (1.0 / rest);\n" + " sum.gradient = sum.gradient + g;\n" + " sum.gradient2 = sum.gradient2 + dot(g, g);\n" + " }\n" + " } else if (visit == 1u) {\n" + " if (j != i) { sum.total = sum.total + spiky(d, r) * (lam[i].x + lam[j].x); }\n" + " } else if (j != i) {\n" + " sum.total = sum.total + (velocity[j].xyz - velocity[i].xyz) * (poly6(r2) / rest);\n" + " }\n" + " }\n" + " }\n" + " j = after;\n" + " }\n" + " }\n" + " }\n" + " }\n" + " if (visit == 0u) {\n" + " let w = walls(p);\n" + " sum.density = sum.density + w.density;\n" + " sum.gradient = sum.gradient + w.push * (1.0 / rest);\n" + " } else if (visit == 1u) {\n" + " let w = walls(p);\n" + " sum.total = sum.total + w.push * (lam[i].x + lam[i].x);\n" + " }\n" + " return sum;\n" + "}\n" + "@compute @workgroup_size(64)\n" + "fn density(@builtin(global_invocation_id) id : vec3) {\n" + " let i = id.x;\n" + " if (i >= P.counts.x) { return; }\n" + " let sum = neighbours(i, 0u);\n" + " let crowd = max(sum.density / P.kernel.w - 1.0, 0.0);\n" + " let lambda = -crowd / (sum.gradient2 + dot(sum.gradient, sum.gradient) + P.misc.x);\n" + " lam[i] = vec4(lambda, sum.density, 0.0, 0.0);\n" + "}\n" + "@compute @workgroup_size(64)\n" + "fn spread(@builtin(global_invocation_id) id : vec3) {\n" + " let i = id.x;\n" + " if (i >= P.counts.x) { return; }\n" + " let sum = neighbours(i, 1u);\n" + " let p = predicted[i].xyz + sum.total * (1.0 / P.kernel.w);\n" + " scratch[i] = vec4(clamp(p, P.tank_lo.xyz, P.tank_hi.xyz), 0.0);\n" + "}\n" + "@compute @workgroup_size(64)\n" + "fn adopt_spread(@builtin(global_invocation_id) id : vec3) {\n" + " if (id.x < P.counts.x) { predicted[id.x] = scratch[id.x]; }\n" + "}\n" + "@compute @workgroup_size(64)\n" + "fn take_velocity(@builtin(global_invocation_id) id : vec3) {\n" + " let i = id.x;\n" + " if (i >= P.counts.x) { return; }\n" + " velocity[i] = vec4((predicted[i].xyz - position[i].xyz) * (1.0 / P.gravity_h.w), 0.0);\n" + "}\n" + "@compute @workgroup_size(64)\n" + "fn smoothen(@builtin(global_invocation_id) id : vec3) {\n" + " let i = id.x;\n" + " if (i >= P.counts.x) { return; }\n" + " let sum = neighbours(i, 2u);\n" + " scratch[i] = vec4(velocity[i].xyz + sum.total * P.tank_hi.w, 0.0);\n" + "}\n" + "@compute @workgroup_size(64)\n" + "fn finish(@builtin(global_invocation_id) id : vec3) {\n" + " let i = id.x;\n" + " if (i >= P.counts.x) { return; }\n" + " velocity[i] = scratch[i];\n" + " position[i] = vec4(predicted[i].xyz, lam[i].y / P.kernel.w);\n" + "}\n"; + +/* π, a number for the kernels' constants. */ +#define PI 3.14159265358979323846f + +F3dGpuFluid *f3d_gpu_fluid_create(F3dGpu *gpu, uint32_t capacity, float spacing) { + if (gpu == NULL || capacity == 0 || capacity > (1u << 22) || !(spacing > 0.0f) || + spacing - spacing != 0.0f) { + return NULL; + } + F3dGpuFluid *f = (F3dGpuFluid *)calloc(1, sizeof(F3dGpuFluid)); + if (f == NULL) return NULL; + f->gpu = gpu; + f->capacity = capacity; + uint32_t cells = 1; + while (cells < capacity * 2u) cells <<= 1; + f->cells = cells; + /* The kernels, as f3d_fluid_create works them out. */ + f->spacing = spacing; + f->h = spacing * 2.0f; + const float h2 = f->h * f->h; + const float h3 = h2 * f->h; + f->poly6 = 315.0f / (64.0f * PI * h3 * h3 * h3); + f->spiky = -45.0f / (PI * h3 * h3); + float rest = 0; + for (int x = -2; x <= 2; x++) { + for (int y = -2; y <= 2; y++) { + for (int z = -2; z <= 2; z++) { + const float d = f->h * f->h - (float)(x * x + y * y + z * z) * spacing * spacing; + rest += d > 0.0f ? f->poly6 * d * d * d : 0.0f; + } + } + } + f->rest_density = rest; + const uint64_t bytes = (uint64_t)capacity * PARTICLE_BYTES; + const WGPUBufferUsage storage = WGPUBufferUsage_Storage | WGPUBufferUsage_CopyDst; + f->params = f3d_gpu_buffer(gpu, WGPUBufferUsage_Uniform | WGPUBufferUsage_CopyDst, PARAMS_BYTES); + f->position = f3d_gpu_buffer(gpu, storage | WGPUBufferUsage_CopySrc, bytes); + f->predicted = f3d_gpu_buffer(gpu, storage, bytes); + f->velocity = f3d_gpu_buffer(gpu, storage, bytes); + f->scratch = f3d_gpu_buffer(gpu, storage, bytes); + f->lambda = f3d_gpu_buffer(gpu, storage, bytes); + f->head = f3d_gpu_buffer(gpu, storage, (uint64_t)cells * 4u); + f->link = f3d_gpu_buffer(gpu, storage, bytes); + void *zero = calloc(1, (size_t)bytes); + if (f->params == NULL || f->position == NULL || f->predicted == NULL || f->velocity == NULL || + f->scratch == NULL || f->lambda == NULL || f->head == NULL || f->link == NULL || + zero == NULL || !f3d_gpu_readback_create(&f->readback, gpu, bytes)) { + free(zero); + f3d_gpu_fluid_destroy(f); + return NULL; + } + wgpuQueueWriteBuffer(gpu->queue, f->lambda, 0, zero, (size_t)bytes); + free(zero); + const F3dGpuBinding kinds[] = {F3D_GPU_UNIFORM, F3D_GPU_STORAGE, F3D_GPU_STORAGE, + F3D_GPU_STORAGE, F3D_GPU_STORAGE, F3D_GPU_STORAGE, + F3D_GPU_STORAGE, F3D_GPU_STORAGE}; + const WGPUBuffer buffers[] = {f->params, f->position, f->predicted, f->velocity, + f->scratch, f->lambda, f->head, f->link}; + const uint64_t sizes[] = {PARAMS_BYTES, bytes, bytes, bytes, bytes, + bytes, (uint64_t)cells * 4u, bytes}; + if (!f3d_gpu_kernels_create(&f->kernels, gpu, kFluidShader, kEntries, KERNELS, kinds, buffers, + sizes, 8)) { + f3d_gpu_fluid_destroy(f); + return NULL; + } + return f; +} + +void f3d_gpu_fluid_destroy(F3dGpuFluid *f) { + if (f == NULL) return; + f3d_gpu_kernels_release(&f->kernels); + f3d_gpu_readback_release(&f->readback); + const WGPUBuffer buffers[] = {f->params, f->position, f->predicted, f->velocity, + f->scratch, f->lambda, f->head, f->link}; + for (size_t i = 0; i < sizeof buffers / sizeof buffers[0]; i++) { + if (buffers[i] != NULL) wgpuBufferRelease(buffers[i]); + } + free(f); +} + +float f3d_gpu_fluid_rest_density(const F3dGpuFluid *f) { return f->rest_density; } + +uint32_t f3d_gpu_fluid_add(F3dGpuFluid *f, const float *data, uint32_t count) { + const uint32_t first = f->next_slot; + for (uint32_t i = 0; i < count; i++) { + const float *in = data + (size_t)i * 6u; + const float p[4] = {in[0], in[1], in[2], 0.0f}; + const float v[4] = {in[3], in[4], in[5], 0.0f}; + const uint64_t at = (uint64_t)f->next_slot * PARTICLE_BYTES; + wgpuQueueWriteBuffer(f->gpu->queue, f->position, at, p, sizeof p); + wgpuQueueWriteBuffer(f->gpu->queue, f->velocity, at, v, sizeof v); + f->next_slot = (f->next_slot + 1u) % f->capacity; + if (f->count < f->capacity) f->count++; + } + return first; +} + +void f3d_gpu_fluid_step(F3dGpuFluid *f, const F3dGpuFluidSettings *s, float dt) { + if (!(dt > 0.0f) || f->count == 0) return; + f->frame++; + const uint32_t substeps = s->substeps > 0 ? s->substeps : 1u; + const uint32_t iterations = s->iterations > 0 ? s->iterations : 1u; + const float h = dt / (float)substeps; + const float margin = f->spacing * 0.5f; + float params[32]; + params[0] = s->gravity[0]; + params[1] = s->gravity[1]; + params[2] = s->gravity[2]; + params[3] = h; + params[4] = f->h; + params[5] = f->poly6; + params[6] = f->spiky; + params[7] = f->rest_density; + for (int k = 0; k < 3; k++) { + params[8 + k] = s->tank_min[k] + margin; + params[12 + k] = s->tank_max[k] - margin; + } + params[11] = 1.0f / f->h; + params[15] = s->viscosity; + params[16] = s->relaxation; + params[17] = f->spacing; + params[18] = params[19] = 0.0f; + const uint32_t counts[4] = {f->count, f->cells, 0, 0}; + memcpy(¶ms[20], counts, sizeof counts); + for (int k = 0; k < 3; k++) { + params[24 + k] = s->tank_min[k]; + params[28 + k] = s->tank_max[k]; + } + params[27] = params[31] = 0.0f; + wgpuQueueWriteBuffer(f->gpu->queue, f->params, 0, params, sizeof params); + WGPUCommandEncoder encoder = wgpuDeviceCreateCommandEncoder(f->gpu->device, NULL); + WGPUComputePassEncoder pass = wgpuCommandEncoderBeginComputePass(encoder, NULL); + const uint32_t n = f->count; + for (uint32_t sub = 0; sub < substeps; sub++) { + f3d_gpu_dispatch(pass, &f->kernels, CLEAR_CELLS, f->cells); + f3d_gpu_dispatch(pass, &f->kernels, PREDICT, n); + for (uint32_t it = 0; it < iterations; it++) { + f3d_gpu_dispatch(pass, &f->kernels, DENSITY, n); + f3d_gpu_dispatch(pass, &f->kernels, SPREAD, n); + f3d_gpu_dispatch(pass, &f->kernels, ADOPT_SPREAD, n); + } + f3d_gpu_dispatch(pass, &f->kernels, VELOCITY, n); + f3d_gpu_dispatch(pass, &f->kernels, SMOOTH, n); + f3d_gpu_dispatch(pass, &f->kernels, FINISH, n); + } + wgpuComputePassEncoderEnd(pass); + wgpuComputePassEncoderRelease(pass); + WGPUCommandBuffer commands = wgpuCommandEncoderFinish(encoder, NULL); + wgpuQueueSubmit(f->gpu->queue, 1, &commands); + wgpuCommandBufferRelease(commands); + wgpuCommandEncoderRelease(encoder); + f3d_gpu_readback_request(&f->readback, f->gpu, f->position, f->frame); +} + +static void copy_particles(const void *mapped, void *out, uint32_t n) { + memcpy(out, mapped, (size_t)n * PARTICLE_BYTES); +} + +uint64_t f3d_gpu_fluid_read(F3dGpuFluid *f, float *out, uint32_t capacity, int wait) { + const uint32_t n = capacity < f->count ? capacity : f->count; + return f3d_gpu_readback_take(&f->readback, f->gpu, f->position, f->frame, wait, + copy_particles, out, n); +} diff --git a/packages/flutter3d_physics_native/csrc/gpu/f3d_gpu_internal.h b/packages/flutter3d_physics_native/csrc/gpu/f3d_gpu_internal.h new file mode 100644 index 000000000..602df2464 --- /dev/null +++ b/packages/flutter3d_physics_native/csrc/gpu/f3d_gpu_internal.h @@ -0,0 +1,111 @@ +/* + * What the GPU passes share — P9, phase 10: the device, waiting on it, + * buffers, kernels built from one WGSL module with one explicit layout, and + * reading a buffer back a frame late. + */ +#ifndef F3D_GPU_INTERNAL_H_ +#define F3D_GPU_INTERNAL_H_ + +#include + +#include "f3d_gpu.h" +#include "webgpu/webgpu.h" +#include "webgpu/wgpu.h" + +struct F3dGpu { + WGPUInstance instance; + WGPUAdapter adapter; + WGPUDevice device; + WGPUQueue queue; + char name[256]; +}; + +/* A null-terminated string as wgpu takes one. */ +WGPUStringView f3d_gpu_view(const char *s); + +WGPUBuffer f3d_gpu_buffer(const F3dGpu *gpu, WGPUBufferUsage usage, uint64_t size); + +/* Waits for everything submitted to [gpu]'s queue. */ +void f3d_gpu_finish(const F3dGpu *gpu); + +/* What a kernel binding is. */ +typedef enum F3dGpuBinding { + F3D_GPU_UNIFORM, + /* A uniform of the given size, bound at an offset each dispatch gives: + * at most one in a set of kernels. */ + F3D_GPU_UNIFORM_AT, + F3D_GPU_STORAGE, + F3D_GPU_READ_ONLY, +} F3dGpuBinding; + +#define F3D_GPU_MAX_KERNELS 16 + +/* The entry points of one WGSL module, sharing one bind group whose + * bindings are numbered from nought in the order given. */ +typedef struct F3dGpuKernels { + WGPUShaderModule module; + WGPUBindGroupLayout layout; + WGPUPipelineLayout pipeline_layout; + WGPUComputePipeline pipelines[F3D_GPU_MAX_KERNELS]; + uint32_t count; + WGPUBindGroup bind; + /* Whether a binding is F3D_GPU_UNIFORM_AT: every dispatch then gives an + * offset, nought when it does not care. */ + int bound_at; +} F3dGpuKernels; + +/* Compiles [source] and makes a pipeline for each of [entries]; then binds + * [buffers], with [sizes], to the bindings of [kinds]. 0 on failure, with + * whatever was made released. */ +int f3d_gpu_kernels_create(F3dGpuKernels *k, const F3dGpu *gpu, const char *source, + const char *const *entries, uint32_t entry_count, + const F3dGpuBinding *kinds, const WGPUBuffer *buffers, + const uint64_t *sizes, uint32_t binding_count); +void f3d_gpu_kernels_release(F3dGpuKernels *k); + +/* Dispatches entry [entry] over [threads] threads, sixty-four a group. */ +void f3d_gpu_dispatch(WGPUComputePassEncoder pass, const F3dGpuKernels *k, uint32_t entry, + uint32_t threads); + +/* The same, with the kernels' F3D_GPU_UNIFORM_AT binding at [offset], a + * multiple of 256. */ +void f3d_gpu_dispatch_at(WGPUComputePassEncoder pass, const F3dGpuKernels *k, uint32_t entry, + uint32_t threads, uint32_t offset); + +/* A buffer read back without waiting: each step copies it into one of two + * staging buffers and maps that, and a read takes whichever has come back + * latest. */ +typedef struct F3dGpuReadback F3dGpuReadback; + +typedef struct F3dGpuReadbackSlot { + F3dGpuReadback *owner; + WGPUBuffer staging; + /* 0 free, 1 copying and mapping, 2 mapped. */ + int state; + uint64_t frame; +} F3dGpuReadbackSlot; + +struct F3dGpuReadback { + F3dGpuReadbackSlot slots[2]; + uint64_t bytes; + /* The last frame a read returned: older ones coming back are dropped. */ + uint64_t taken; +}; + +int f3d_gpu_readback_create(F3dGpuReadback *r, const F3dGpu *gpu, uint64_t bytes); +void f3d_gpu_readback_release(F3dGpuReadback *r); + +/* Copies [source] for [frame], unless both staging buffers are still on + * their way back. */ +void f3d_gpu_readback_request(F3dGpuReadback *r, const F3dGpu *gpu, WGPUBuffer source, + uint64_t frame); + +/* The latest frame come back and not yet read, copied by [copy] into + * [out]; 0 when there is none. With [wait], the copy of [frame] — which is + * asked for if it was not — is waited for. */ +uint64_t f3d_gpu_readback_take(F3dGpuReadback *r, const F3dGpu *gpu, WGPUBuffer source, + uint64_t frame, int wait, + void (*copy)(const void *mapped, void *out, uint32_t n), + void *out, uint32_t n); + +#endif /* F3D_GPU_INTERNAL_H_ */ diff --git a/packages/flutter3d_physics_native/csrc/gpu/f3d_gpu_particles.c b/packages/flutter3d_physics_native/csrc/gpu/f3d_gpu_particles.c new file mode 100644 index 000000000..9e7080612 --- /dev/null +++ b/packages/flutter3d_physics_native/csrc/gpu/f3d_gpu_particles.c @@ -0,0 +1,174 @@ +/* + * Particles on the GPU — P9, phase 10: a compute shader that does what + * f3d_particles.c does, line for line. + */ +#include +#include + +#include "f3d_gpu_internal.h" + +struct F3dGpuParticles { + F3dGpu *gpu; + uint32_t capacity; + uint32_t next; + uint64_t frame; + WGPUBuffer slots; + WGPUBuffer forces; + F3dGpuKernels kernels; + F3dGpuReadback readback; +}; + +/* The particles' step: f3d_particles_step, in WGSL. */ +static const char kParticleShader[] = + "struct Particle { pos_life : vec4, vel : vec4 };\n" + "struct Forces {\n" + " gravity : vec4, wind : vec4,\n" + " params : vec4, // drag, floor, restitution, friction\n" + " dt : f32, count : u32, pad0 : u32, pad1 : u32,\n" + "};\n" + "@group(0) @binding(0) var slots : array;\n" + "@group(0) @binding(1) var forces : Forces;\n" + "@compute @workgroup_size(64)\n" + "fn step(@builtin(global_invocation_id) id : vec3) {\n" + " let i = id.x;\n" + " if (i >= forces.count) { return; }\n" + " let p = slots[i];\n" + " if (!(p.pos_life.w > 0.0)) { return; }\n" + " let dt = forces.dt;\n" + " var v = p.vel.xyz + forces.gravity.xyz * dt;\n" + " let keep = 1.0 / (1.0 + forces.params.x * dt);\n" + " v = forces.wind.xyz + (v - forces.wind.xyz) * keep;\n" + " var x = p.pos_life.xyz + v * dt;\n" + " if (x.y < forces.params.y) {\n" + " x.y = forces.params.y;\n" + " if (v.y < 0.0) { v.y = -v.y * forces.params.z; }\n" + " let slide = 1.0 - forces.params.w;\n" + " v.x = v.x * slide;\n" + " v.z = v.z * slide;\n" + " }\n" + " slots[i] = Particle(vec4(x, p.pos_life.w - dt), vec4(v, 0.0));\n" + "}\n"; + +/* Thirty-two bytes a slot: position and life, velocity and a spare. */ +#define SLOT_BYTES 32u +/* Sixty-four bytes of forces, as the shader's struct lays them out. */ +#define FORCES_BYTES 64u + +F3dGpuParticles *f3d_gpu_particles_create(F3dGpu *gpu, uint32_t capacity) { + if (gpu == NULL || capacity == 0 || capacity > (1u << 22)) return NULL; + F3dGpuParticles *p = (F3dGpuParticles *)calloc(1, sizeof(F3dGpuParticles)); + if (p == NULL) return NULL; + p->gpu = gpu; + p->capacity = capacity; + const uint64_t bytes = (uint64_t)capacity * SLOT_BYTES; + p->slots = f3d_gpu_buffer( + gpu, WGPUBufferUsage_Storage | WGPUBufferUsage_CopyDst | WGPUBufferUsage_CopySrc, bytes); + p->forces = f3d_gpu_buffer(gpu, WGPUBufferUsage_Uniform | WGPUBufferUsage_CopyDst, + FORCES_BYTES); + if (p->slots == NULL || p->forces == NULL || + !f3d_gpu_readback_create(&p->readback, gpu, bytes)) { + f3d_gpu_particles_destroy(p); + return NULL; + } + /* All dead: zeroed, so every life is nought. */ + void *zero = calloc(1, (size_t)bytes); + if (zero == NULL) { + f3d_gpu_particles_destroy(p); + return NULL; + } + wgpuQueueWriteBuffer(gpu->queue, p->slots, 0, zero, (size_t)bytes); + free(zero); + static const char *const entries[] = {"step"}; + const F3dGpuBinding kinds[] = {F3D_GPU_STORAGE, F3D_GPU_UNIFORM}; + const WGPUBuffer buffers[] = {p->slots, p->forces}; + const uint64_t sizes[] = {bytes, FORCES_BYTES}; + if (!f3d_gpu_kernels_create(&p->kernels, gpu, kParticleShader, entries, 1, kinds, buffers, + sizes, 2)) { + f3d_gpu_particles_destroy(p); + return NULL; + } + return p; +} + +void f3d_gpu_particles_destroy(F3dGpuParticles *p) { + if (p == NULL) return; + f3d_gpu_kernels_release(&p->kernels); + f3d_gpu_readback_release(&p->readback); + if (p->forces != NULL) wgpuBufferRelease(p->forces); + if (p->slots != NULL) wgpuBufferRelease(p->slots); + free(p); +} + +uint32_t f3d_gpu_particles_emit(F3dGpuParticles *p, const float *data, uint32_t count) { + const uint32_t first = p->next; + float slot[8]; + for (uint32_t i = 0; i < count; i++) { + const float *d = data + (size_t)i * 7u; + slot[0] = d[0]; + slot[1] = d[1]; + slot[2] = d[2]; + slot[3] = d[6]; + slot[4] = d[3]; + slot[5] = d[4]; + slot[6] = d[5]; + slot[7] = 0.0f; + wgpuQueueWriteBuffer(p->gpu->queue, p->slots, (uint64_t)p->next * SLOT_BYTES, slot, + sizeof slot); + p->next = (p->next + 1u) % p->capacity; + } + return first; +} + +void f3d_gpu_particles_step(F3dGpuParticles *p, const F3dGpuParticleForces *f, float dt, + uint32_t steps) { + if (!(dt > 0.0f) || steps == 0) return; + float forces[16]; + memset(forces, 0, sizeof forces); + forces[0] = f->gravity[0]; + forces[1] = f->gravity[1]; + forces[2] = f->gravity[2]; + forces[4] = f->wind[0]; + forces[5] = f->wind[1]; + forces[6] = f->wind[2]; + forces[8] = f->drag; + forces[9] = f->floor_y; + forces[10] = f->restitution; + forces[11] = f->friction; + forces[12] = dt; + memcpy(&forces[13], &p->capacity, sizeof(uint32_t)); + wgpuQueueWriteBuffer(p->gpu->queue, p->forces, 0, forces, sizeof forces); + WGPUCommandEncoder encoder = wgpuDeviceCreateCommandEncoder(p->gpu->device, NULL); + WGPUComputePassEncoder pass = wgpuCommandEncoderBeginComputePass(encoder, NULL); + /* One dispatch a step: each is its own usage scope, so a step sees the + * last one's writes. */ + for (uint32_t s = 0; s < steps; s++) f3d_gpu_dispatch(pass, &p->kernels, 0, p->capacity); + wgpuComputePassEncoderEnd(pass); + wgpuComputePassEncoderRelease(pass); + WGPUCommandBuffer commands = wgpuCommandEncoderFinish(encoder, NULL); + wgpuQueueSubmit(p->gpu->queue, 1, &commands); + wgpuCommandBufferRelease(commands); + wgpuCommandEncoderRelease(encoder); + p->frame++; +} + +static void copy_particles(const void *mapped, void *out, uint32_t n) { + const float *slots = (const float *)mapped; + float *o = (float *)out; + for (uint32_t i = 0; i < n; i++) { + o[i * 4u] = slots[i * 8u]; + o[i * 4u + 1u] = slots[i * 8u + 1u]; + o[i * 4u + 2u] = slots[i * 8u + 2u]; + o[i * 4u + 3u] = slots[i * 8u + 3u]; + } +} + +uint32_t f3d_gpu_particles_read(F3dGpuParticles *p, float *out, uint32_t capacity) { + const uint32_t n = capacity < p->capacity ? capacity : p->capacity; + /* Always the slots as they are now, read again if read before: frames + * are counted from one, the slots as emitted before any step. */ + p->readback.taken = 0; + return f3d_gpu_readback_take(&p->readback, p->gpu, p->slots, p->frame + 1u, 1, + copy_particles, out, n) != 0 + ? n + : 0; +} diff --git a/packages/flutter3d_physics_native/csrc/include/f3d_physics.h b/packages/flutter3d_physics_native/csrc/include/f3d_physics.h new file mode 100644 index 000000000..9851256d3 --- /dev/null +++ b/packages/flutter3d_physics_native/csrc/include/f3d_physics.h @@ -0,0 +1,995 @@ +/* + * The physics core of flutter3d, in C11 — P9. + * + * One world owns everything it steps: bodies, the contacts between their + * shapes and the islands those make, the solver that keeps them apart, + * their heat and fire, the wind they move through, and joints as they + * arrive. Nothing here calls back into the caller during a step; what a step + * produces is read afterwards, in flat buffers, which is what a Dart FFI + * call or a WebAssembly import wants. + * + * Numbers are f3d_real: f32 unless the core is built with F3D_REAL_DOUBLE, + * for a simulation that wants doubles throughout and does not need the + * browser to agree with it bit for bit. The deterministic mode is + * bit-identical across platforms for the same inputs in the same order, + * which is what this build's flags are for: no fused multiply-add + * contraction, no fast math, no flush-to-zero, and no library + * transcendentals: what the core needs of those it computes itself. The + * square root is not one: IEEE 754 rounds it exactly, everywhere. + * + * Positions are relative to the world's origin, which is held in doubles. + * A world kilometres across moves its origin to where the play is + * (f3d_world_shift_origin), and every position near it keeps f32's full + * precision; the renderer draws relative to the same origin. + * + * A body is named by a handle: its slot in the world's arena in the low 32 + * bits and the slot's generation in the high 32. A slot freed and reused + * gets a new generation, so a handle kept past its body's destruction is + * refused rather than answered with somebody else's body. Generations start + * at one, so nought is never a handle. + * + * Units are SI: metres, kilograms, seconds, kelvin, joules, watts. + */ +#ifndef F3D_PHYSICS_H_ +#define F3D_PHYSICS_H_ + +#include + +#if defined(_WIN32) +#define F3D_API __declspec(dllexport) +#else +#define F3D_API __attribute__((visibility("default"))) +#endif + +#ifdef __cplusplus +extern "C" { +#endif + +/* Bumped whenever a function's meaning or signature changes. */ +#define F3D_ABI_VERSION 18u + +#ifdef F3D_REAL_DOUBLE +typedef double f3d_real; +#else +typedef float f3d_real; +#endif + +typedef struct F3dWorld F3dWorld; + +/* A body: (generation << 32) | slot. Nought is never one. */ +typedef uint64_t F3dBody; + +/* A joint, named as a body is. */ +typedef uint64_t F3dJoint; + +typedef enum F3dJointType { + /* Holds B where it was against A, place and turn. */ + F3D_JOINT_FIXED = 0, + /* Holds a point of each together; they turn freely about it. */ + F3D_JOINT_SPHERICAL = 1, + /* A hinge: a point together, turning only about the axis. Limits, + * motor and spring act on the angle. */ + F3D_JOINT_REVOLUTE = 2, + /* A slider: no turning, moving only along the axis. Limits, motor and + * spring act on the travel. */ + F3D_JOINT_PRISMATIC = 3, + /* Two points held a length apart: a rod, or with its spring on, a + * spring, or with a spring of nought hertz and a least of nought, a + * rope. */ + F3D_JOINT_DISTANCE = 4, +} F3dJointType; + +typedef enum F3dBodyType { + /* Moved by gravity, forces, wind and contacts. */ + F3D_BODY_DYNAMIC = 0, + /* Never moves; infinite mass. It still heats, cools and burns. */ + F3D_BODY_FIXED = 1, +} F3dBodyType; + +/* What a body is shaped like: what it collides as, and its inertia, its + * surface and its drag. */ +typedef enum F3dShapeKind { + /* No extent: does not turn, has no surface, feels no wind. */ + F3D_SHAPE_POINT = 0, + /* a = radius. */ + F3D_SHAPE_SPHERE = 1, + /* a, b, c = half extents along the body's x, y and z. */ + F3D_SHAPE_BOX = 2, + /* a = radius, b = half the length of the straight part, along y. */ + F3D_SHAPE_CAPSULE = 3, + /* a = radius, b = half its height, along y. */ + F3D_SHAPE_CYLINDER = 4, + /* a = the base's radius, b = its height, along y, its apex up; its + * origin at its centre of mass, a quarter of the height above the + * base. */ + F3D_SHAPE_CONE = 5, + /* A convex hull the world holds: set with f3d_body_set_hull. */ + F3D_SHAPE_HULL = 6, + /* A triangle mesh the world holds, for fixed bodies: set with + * f3d_body_set_mesh. */ + F3D_SHAPE_MESH = 7, +} F3dShapeKind; + +/* What a body is made of, as heat and fire see it. */ +typedef struct F3dMaterial { + /* J / (kg K). */ + f3d_real specific_heat; + /* Of the surface, nought to one. */ + f3d_real emissivity; + /* K at which it catches fire and below which it goes out; nought for + * a material that never burns. */ + f3d_real ignition_temperature; + /* J released per kilogram burnt. */ + f3d_real heat_of_combustion; + /* kg burnt per second per square metre of surface while alight. */ + f3d_real burn_rate; + /* The share of the mass that can burn, nought to one. */ + f3d_real fuel_fraction; + /* The share of the fire's heat that goes back into the body, nought to + * one; the rest leaves as the hot gas a smoke grid takes. */ + f3d_real flame_feedback; + /* W / (m K): how readily heat crosses into what it touches. */ + f3d_real conductivity; +} F3dMaterial; + +typedef enum F3dMaterialKind { + F3D_MATERIAL_INERT = 0, + F3D_MATERIAL_WOOD = 1, + F3D_MATERIAL_PAPER = 2, + F3D_MATERIAL_RUBBER = 3, + F3D_MATERIAL_STEEL = 4, + F3D_MATERIAL_STONE = 5, +} F3dMaterialKind; + +/* What a step can say happened to a body. */ +typedef enum F3dEventKind { + F3D_EVENT_SLEPT = 0, + F3D_EVENT_WOKE = 1, + F3D_EVENT_IGNITED = 2, + F3D_EVENT_EXTINGUISHED = 3, + F3D_EVENT_BURNT_OUT = 4, + /* Two bodies came to touch, or stopped: the second is the other. + * Touching is within five millimetres, the overlap the solver leaves a + * resting body; a contact further out in the margin is neither. */ + F3D_EVENT_CONTACT_BEGAN = 5, + F3D_EVENT_CONTACT_ENDED = 6, +} F3dEventKind; + +/* Reals one body takes in f3d_world_read_transforms: position xyz, then the + * orientation quaternion xyzw. */ +#define F3D_TRANSFORM_FLOATS 7u + +/* Reals one fire takes in f3d_world_read_fires: position xyz, then the + * watts it gives off as hot gas. */ +#define F3D_FIRE_FLOATS 4u + +/* Reals one contact point takes in f3d_world_read_contacts: the normal + * xyz, out of the second body into the first, the point xyz halfway + * between their surfaces, and the depth, positive inside. */ +#define F3D_CONTACT_FLOATS 7u + +/* Events held unread before the newest are dropped and counted. */ +#define F3D_EVENT_CAPACITY 65536u + +/* F3D_ABI_VERSION, so a binding can refuse a library it was not written + * against. */ +F3D_API uint32_t f3d_abi_version(void); + +/* sizeof(f3d_real): 4, or 8 in a build with F3D_REAL_DOUBLE. */ +F3D_API uint32_t f3d_real_bytes(void); + +/* [bytes] of the core's own memory, 16-byte aligned, or null. For a caller + * with no allocator of its own to hand the core a buffer from — the + * WebAssembly module's JavaScript side, which has no malloc to call. */ +F3D_API void *f3d_buffer_alloc(uint32_t bytes); + +/* Gives back what f3d_buffer_alloc handed out. Null is allowed. */ +F3D_API void f3d_buffer_free(void *buffer); + +/* ------------------------------------------------------------------ world */ + +/* A world with gravity (0, -9.81, 0), still air at 293.15 K and 1.204 kg/m³, + * its origin at nought; or null when out of memory. */ +F3D_API F3dWorld *f3d_world_create(void); + +/* Frees the world and everything in it. Null is allowed. */ +F3D_API void f3d_world_destroy(F3dWorld *world); + +F3D_API void f3d_world_set_gravity(F3dWorld *world, f3d_real x, f3d_real y, + f3d_real z); + +/* Writes the world's gravity into out[0..2]. */ +F3D_API void f3d_world_get_gravity(const F3dWorld *world, f3d_real *out); + +/* The air's temperature, K, and density, kg/m³. 0 and nothing changed for + * a value that is not finite and positive. */ +F3D_API int f3d_world_set_air(F3dWorld *world, f3d_real temperature, + f3d_real density); + +/* Writes the air's temperature and density into out[0..1]. */ +F3D_API void f3d_world_get_air(const F3dWorld *world, f3d_real *out); + +/* The wind everywhere, m/s, added to the grid's where there is one. */ +F3D_API void f3d_world_set_wind(F3dWorld *world, f3d_real x, f3d_real y, + f3d_real z); + +/* A wind field: [nx] × [ny] × [nz] samples of velocity, three reals each, + * x fastest, the first at (ox, oy, oz) relative to the origin and [cell] + * metres apart, read trilinearly and held at its edge beyond it. Copied. + * 0 and the old field kept when out of memory or for a size or spacing + * that is not positive; [velocities] null clears the field. */ +F3D_API int f3d_world_set_wind_grid(F3dWorld *world, f3d_real ox, f3d_real oy, + f3d_real oz, f3d_real cell, uint32_t nx, + uint32_t ny, uint32_t nz, + const f3d_real *velocities); + +/* The wind at (x, y, z) relative to the origin, into out[0..2]. */ +F3D_API void f3d_world_sample_wind(const F3dWorld *world, f3d_real x, + f3d_real y, f3d_real z, f3d_real *out); + +/* How long, s, a body must stay slower than [speed], m/s and rad/s, to + * fall asleep. 0 for a value that is negative or not finite; a [time] of + * nought turns sleep off. Defaults: 0.05 and 0.5. */ +F3D_API int f3d_world_set_sleep(F3dWorld *world, f3d_real speed, + f3d_real time); + +/* Writes the world's origin, in doubles, into out[0..2]. */ +F3D_API void f3d_world_get_origin(const F3dWorld *world, double *out); + +/* Moves the origin by (dx, dy, dz) and every body, and the wind grid, the + * other way, so nothing moves in the world. Positions near the new origin + * get back the precision their distance from the old one cost. */ +F3D_API void f3d_world_shift_origin(F3dWorld *world, double dx, double dy, + double dz); + +/* A convex hull of [count] points, three reals each, for bodies to be + * shaped as: built here, and moved so its centre of mass, taken as solid, + * is at the origin — f3d_world_get_hull_offset says by how much. Many + * bodies can share one; a world keeps its hulls as long as it lives, and + * they are in its snapshots. Returns the hull, numbered from one; nought + * for fewer than four points not all in one plane, a point not finite, + * more than 4096 points, or no memory. */ +F3D_API uint32_t f3d_world_create_hull(F3dWorld *world, const f3d_real *points, + uint32_t count); + +/* A triangle mesh of [vertex_count] vertices, three reals each, and + * [triangle_count] triangles, three indices each, wound counter-clockwise + * seen from the side bodies touch: level geometry, terrain, walls. One + * sided — a body behind a triangle passes through it — and shared vertices + * make it whole: two triangles that share an edge by index slide a body + * across it without a bump, where two that only meet do not. Returns the + * mesh, numbered from one; nought for no triangles, an index past the + * vertices, a vertex not finite, more than a million triangles, or no + * memory. Kept by the world for as long as it lives, in its snapshots. */ +F3D_API uint32_t f3d_world_create_mesh(F3dWorld *world, + const f3d_real *vertices, + uint32_t vertex_count, + const uint32_t *indices, + uint32_t triangle_count); + +/* How many triangles [mesh] has, and how many of their edges it found + * internal: shared with a neighbour across a flat or hollow fold. */ +F3D_API uint32_t f3d_world_mesh_triangle_count(const F3dWorld *world, + uint32_t mesh); +F3D_API uint32_t f3d_world_mesh_internal_edges(const F3dWorld *world, + uint32_t mesh); + +/* What was subtracted from every point of [hull], into out[0..2]. */ +F3D_API int f3d_world_get_hull_offset(const F3dWorld *world, uint32_t hull, + f3d_real *out); + +/* How many of the points [hull] was made from are its corners. */ +F3D_API uint32_t f3d_world_hull_vertex_count(const F3dWorld *world, + uint32_t hull); + +/* How many bodies the world holds. */ +F3D_API uint32_t f3d_world_body_count(const F3dWorld *world); + +/* Advances the world by [dt] seconds. Nothing happens for a dt that is not + * finite and positive. + * + * First the contacts where the bodies stand, what began and ended touching, + * and the islands that sleep and wake. Then the solver, in substeps of + * dt / substeps, each as Box2D v3 takes it: every awake dynamic body's + * velocity integrated, the contacts warm-started from what they pushed + * with last, solved with a soft bias that pushes overlap out, the bodies + * moved, and the contacts solved again with no bias; restitution after the + * last. Then heat. + * + * Within a substep, a body moves as before: + * semi-implicit Euler, as flutter3d_physics steps: the velocity gains gravity, the wind's drag and the forces added + * since the last step, all times dt, and the spin the torques; then the + * position gains the new velocity times dt and the orientation turns by the + * spin, its angular momentum carried through. Every body's heat: what + * crossed each contact, what + * the bus brought, the fire's share, convection to the moving air and + * radiation to it, the water on it boiling off at 373.15 K first. Forces, + * torques and heat added through the bus are held over every substep and + * spent by the step. */ +F3D_API void f3d_world_step(F3dWorld *world, f3d_real dt); + +/* Steps the world on [threads] threads, the caller one of them, from the + * next step on: the tree's queries and the narrow phase split among them. + * The world steps to the same bits on any number. 0 for none, more than + * 64, or threads that would not start, leaving it as it was; in the + * WebAssembly build, 0 for more than one. Not in a snapshot. */ +F3D_API int f3d_world_set_threads(F3dWorld *world, uint32_t threads); +F3D_API uint32_t f3d_world_threads(const F3dWorld *world); + +/* The fast mode, off for a new world: the contacts coloured each step so + * that no two of a colour share a moving body, and solved a colour at a + * time, each colour's contacts at once on every thread, the stages of a + * step met at barriers. Solved in another order than the deterministic + * mode's, so to other bits — but to the same bits on any number of + * threads. In a snapshot. */ +F3D_API int f3d_world_set_fast(F3dWorld *world, int fast); +F3D_API int f3d_world_fast(const F3dWorld *world); + +/* Writes every body's transform, F3D_TRANSFORM_FLOATS reals apiece, into + * [transforms], and its handle into [handles] when that is not null, in the + * world's slot order. At most [capacity] bodies; returns how many were + * written. */ +F3D_API uint32_t f3d_world_read_transforms(const F3dWorld *world, + f3d_real *transforms, + F3dBody *handles, + uint32_t capacity); + +/* Every burning body, F3D_FIRE_FLOATS reals apiece, as + * f3d_world_read_transforms does: what a smoke grid takes its sources + * from. */ +F3D_API uint32_t f3d_world_read_fires(const F3dWorld *world, f3d_real *fires, + F3dBody *handles, uint32_t capacity); + +/* Moves up to [capacity] events, oldest first, into [bodies], [others] + * (the second body of an event between two, nought for the rest; may be + * null) and [kinds], and returns how many. What is not read stays for the + * next call. A contact's handles are as they were when it ended, so one + * ended by a body's removal names a body no longer there. */ +F3D_API uint32_t f3d_world_read_events(F3dWorld *world, F3dBody *bodies, + F3dBody *others, uint32_t *kinds, + uint32_t capacity); + +/* How many substeps a step is solved in, one to sixty-four; default 4. + * More holds tall stacks and fast bodies better and costs that many times + * the solver. 0 for a count out of range. */ +F3D_API int f3d_world_set_substeps(F3dWorld *world, uint32_t substeps); + +/* Soft continuous collision, on by default: a contact reaches as far as + * its two bodies can close in a step, so a body stops at a wall it would + * cross in one. 0 turns it off. */ +F3D_API int f3d_world_set_speculative(F3dWorld *world, int enabled); + +/* How near two shapes must come to make a contact, m; a contact inside it + * but not touching has a negative depth. 0 for a margin that is negative + * or not finite. Default 0.02. */ +F3D_API int f3d_world_set_contact_margin(F3dWorld *world, f3d_real margin); + +/* Every body whose shape's box overlaps the box from (lx, ly, lz) to + * (hx, hy, hz), relative to the origin: up to [capacity] of them, those of + * the lowest slots, written into [out] in slot order. Returns how many + * there are, which may be more than it wrote. Bodies with no shape are + * never found. */ +F3D_API uint32_t f3d_world_query_box(F3dWorld *world, f3d_real lx, f3d_real ly, + f3d_real lz, f3d_real hx, f3d_real hy, + f3d_real hz, F3dBody *out, + uint32_t capacity); + +/* Contact points the last step found, over every pair. */ +F3D_API uint32_t f3d_world_contact_count(const F3dWorld *world); + +/* Writes up to [capacity] contact points, F3D_CONTACT_FLOATS reals apiece, + * into [contacts], and each one's two bodies into [pairs] two at a time + * when that is not null, in order of the pair's slots; returns how many. */ +F3D_API uint32_t f3d_world_read_contacts(const F3dWorld *world, + f3d_real *contacts, F3dBody *pairs, + uint32_t capacity); + +/* Events dropped because F3D_EVENT_CAPACITY were waiting unread. */ +F3D_API uint32_t f3d_world_events_dropped(const F3dWorld *world); + +/* ----------------------------------------------------------------- joints */ + +/* A joint of [type] between [a] and [b], at the anchor (ax, ay, az) and + * along the axis (ux, uy, uz), both relative to the origin and taken where + * the bodies are now: what it holds them to is how they stand. The axis is + * read by the revolute and prismatic joints only. Wakes both. Nought for a + * body not in the world, a body joined to itself, a value not finite, a + * revolute or prismatic joint with no axis, or a distance joint, which is + * f3d_joint_create_distance's. Joined bodies do not collide with each + * other unless f3d_joint_set_collide says so. */ +F3D_API F3dJoint f3d_joint_create(F3dWorld *world, F3dJointType type, + F3dBody a, F3dBody b, f3d_real ax, + f3d_real ay, f3d_real az, f3d_real ux, + f3d_real uy, f3d_real uz); + +/* A distance joint from (ax, ay, az) on [a] to (bx, by, bz) on [b], held at + * the length between them now. */ +F3D_API F3dJoint f3d_joint_create_distance(F3dWorld *world, F3dBody a, + F3dBody b, f3d_real ax, f3d_real ay, + f3d_real az, f3d_real bx, + f3d_real by, f3d_real bz); + +/* Takes the joint out, waking its bodies. A body's joints go with it. */ +F3D_API int f3d_joint_destroy(F3dWorld *world, F3dJoint joint); +F3D_API int f3d_joint_is_valid(const F3dWorld *world, F3dJoint joint); +F3D_API uint32_t f3d_world_joint_count(const F3dWorld *world); + +/* Limits on a revolute joint's angle, radians, a prismatic joint's travel, + * m, or a spherical joint's twist about its axis, radians: lower no more + * than upper; [enabled] nought takes them off. */ +F3D_API int f3d_joint_set_limits(F3dWorld *world, F3dJoint joint, int enabled, + f3d_real lower, f3d_real upper); + +/* A spherical joint's swing held inside a cone: B's axis no more than + * [angle] radians, above nought and at most π, from A's — a shoulder, a + * hip, a neck. With twist limits it is a ragdoll's joint. */ +F3D_API int f3d_joint_set_cone(F3dWorld *world, F3dJoint joint, int enabled, + f3d_real angle); + +/* A spherical joint's friction: B turning against A resisted with at most + * [torque], N m, nought or more — what lets a ragdoll come to rest. A + * revolute or prismatic joint has it as a motor of speed nought. */ +F3D_API int f3d_joint_set_friction(F3dWorld *world, F3dJoint joint, + int enabled, f3d_real torque); + +/* A motor driving a revolute or prismatic joint at [speed], rad/s or m/s, + * with at most [max_force], N m or N. */ +F3D_API int f3d_joint_set_motor(F3dWorld *world, F3dJoint joint, int enabled, + f3d_real speed, f3d_real max_force); + +/* A spring pulling a revolute joint to its starting angle, a prismatic one + * to its starting place, or a distance joint to its length, at [hertz] and + * damping ratio [damping]. On a distance joint the spring is what makes it + * not a rod: at nought hertz it pulls not at all, and only the least and + * most lengths hold. */ +F3D_API int f3d_joint_set_spring(F3dWorld *world, F3dJoint joint, int enabled, + f3d_real hertz, f3d_real damping); + +/* A distance joint's rest [length] and the [least] and [most] its spring + * may stretch it to. */ +F3D_API int f3d_joint_set_length(F3dWorld *world, F3dJoint joint, + f3d_real length, f3d_real least, + f3d_real most); + +/* Whether the joined bodies collide with each other. */ +F3D_API int f3d_joint_set_collide(F3dWorld *world, F3dJoint joint, + int collide); + +/* A revolute joint's angle from where it started, radians in (−π, π]; a + * spherical joint's twist about its axis, the same way; a prismatic + * joint's travel, m; a distance joint's length, m. 0 for the others. */ +F3D_API int f3d_joint_get_value(const F3dWorld *world, F3dJoint joint, + f3d_real *out); + +/* A spherical joint's swing: the angle between B's axis and A's, radians + * from nought to π. 0 for the others. */ +F3D_API int f3d_joint_get_swing(const F3dWorld *world, F3dJoint joint, + f3d_real *out); + +/* The force it held B with over the last substep, N, into out[0..2]. */ +F3D_API int f3d_joint_get_force(const F3dWorld *world, F3dJoint joint, + f3d_real *out); + +/* ---------------------------------------------------------------- queries */ + +/* Reals one hit takes: the point xyz and the normal xyz there, out of what + * was hit, both relative to the origin, then the distance along the ray or + * the fraction of the cast. */ +#define F3D_HIT_FLOATS 7u + +/* What a query sees: bodies whose layer has a bit in [mask], all but + * [ignore] (nought ignores nothing). A body with no shape is never seen. A + * ray or cast that starts inside a shape does not see it; a cast does, at + * nought, when it starts overlapping. */ + +/* The nearest body a ray meets from (ox, oy, oz) along (dx, dy, dz) within + * [max_distance] — the direction need not be a unit — into [body] and + * [hit]; 1 when it meets one. Meshes are seen from their front only. */ +F3D_API int f3d_world_ray_cast(F3dWorld *world, f3d_real ox, f3d_real oy, + f3d_real oz, f3d_real dx, f3d_real dy, + f3d_real dz, f3d_real max_distance, + uint32_t mask, F3dBody ignore, F3dBody *body, + f3d_real *hit); + +/* Every body the ray meets, nearest first, up to [capacity] of them into + * [bodies] and [hits]; returns how many it meets. */ +F3D_API uint32_t f3d_world_ray_cast_all(F3dWorld *world, f3d_real ox, + f3d_real oy, f3d_real oz, f3d_real dx, + f3d_real dy, f3d_real dz, + f3d_real max_distance, uint32_t mask, + F3dBody ignore, F3dBody *bodies, + f3d_real *hits, uint32_t capacity); + +/* Every body overlapping a shape of [kind] — a sphere, box, capsule, + * cylinder or cone, a, b, c as for a body — rounded by [rounding], at + * (px, py, pz) turned by the quaternion (qx, qy, qz, qw): up to [capacity] + * of them, in slot order; returns how many. */ +F3D_API uint32_t f3d_world_overlap_shape( + F3dWorld *world, F3dShapeKind kind, f3d_real a, f3d_real b, f3d_real c, + f3d_real rounding, f3d_real px, f3d_real py, f3d_real pz, f3d_real qx, + f3d_real qy, f3d_real qz, f3d_real qw, uint32_t mask, F3dBody ignore, + F3dBody *out, uint32_t capacity); + +/* The first body that shape meets moved by (tx, ty, tz) without turning, + * into [body] and [hit] with the fraction of the move it got; 1 when it + * meets one. */ +F3D_API int f3d_world_cast_shape(F3dWorld *world, F3dShapeKind kind, + f3d_real a, f3d_real b, f3d_real c, + f3d_real rounding, f3d_real px, f3d_real py, + f3d_real pz, f3d_real qx, f3d_real qy, + f3d_real qz, f3d_real qw, f3d_real tx, + f3d_real ty, f3d_real tz, uint32_t mask, + F3dBody ignore, F3dBody *body, f3d_real *hit); + +/* ------------------------------------------------------------- characters */ + +/* Bits of what f3d_world_move_character returns. */ +#define F3D_CHARACTER_GROUNDED 1u +#define F3D_CHARACTER_WALL 2u +#define F3D_CHARACTER_CEILING 4u +#define F3D_CHARACTER_STEPPED 8u + +/* Moves an upright capsule of [radius] and straight [half_height] — a + * character — from position[0..2] by (dx, dy, dz), sliding along what it + * meets, and writes where it ends into position. Ground is what it stands + * on whose normal's height is at least [max_slope_cos]; steeper is wall, + * which it slides along and does not climb. A step up to [step_height] it + * climbs, and it keeps to ground it is walking down within that height. + * The ground's normal goes into ground[0..2] and its body into + * [ground_body] (nought when in the air). Returns F3D_CHARACTER_ bits. + * Kinematic: nothing pushes it, and it moves nothing. */ +F3D_API uint32_t f3d_world_move_character( + F3dWorld *world, f3d_real radius, f3d_real half_height, f3d_real *position, + f3d_real dx, f3d_real dy, f3d_real dz, f3d_real max_slope_cos, + f3d_real step_height, uint32_t mask, F3dBody ignore, F3dBody *ground_body, + f3d_real *ground); + +/* -------------------------------------------------------------- particles */ + +/* Particles: sparks, spray, dust, smoke — many points that fall, drift in + * the wind, bounce off a floor and die. Visual, not part of the game's + * state: the GPU steps the same system in f3d_gpu.h, and this one is its + * reference and the fallback where there is none. A fixed number of slots, + * filled round and round: a new particle takes the next slot, the oldest + * first; a slot whose life has run out is still and waits. */ +typedef struct F3dParticles F3dParticles; + +/* Reals one particle takes in f3d_particles_read: position xyz, then the + * life it has left, s, nought or less when dead. */ +#define F3D_PARTICLE_FLOATS 4u + +/* How particles move: gravity, the wind they drift towards at [drag] per + * second, and a floor at height [floor_y] they bounce off with + * [restitution] and lose [friction] of their sliding speed on. */ +typedef struct F3dParticleForces { + f3d_real gravity[3]; + f3d_real wind[3]; + f3d_real drag; + f3d_real floor_y; + f3d_real restitution; + f3d_real friction; +} F3dParticleForces; + +/* [capacity] slots, all dead; null for none or no memory. */ +F3D_API F3dParticles *f3d_particles_create(uint32_t capacity); +F3D_API void f3d_particles_destroy(F3dParticles *particles); +F3D_API uint32_t f3d_particles_capacity(const F3dParticles *particles); + +/* [count] particles into the next slots: each its position xyz, velocity + * xyz, and life, seven reals. Returns the slot the first went into. */ +F3D_API uint32_t f3d_particles_emit(F3dParticles *particles, + const f3d_real *particles_data, + uint32_t count); + +/* Steps every living particle by [dt]: its velocity gains gravity, drifts + * towards the wind as v' = w + (v + g dt − w) / (1 + drag dt), its + * position gains the velocity, and below the floor it is put back on it, + * its fall turned back by the restitution and its slide slowed by the + * friction; its life loses dt. */ +F3D_API void f3d_particles_step(F3dParticles *particles, + const F3dParticleForces *forces, f3d_real dt); + +/* Every slot, F3D_PARTICLE_FLOATS reals apiece, into [out]: up to + * [capacity]; returns how many. */ +F3D_API uint32_t f3d_particles_read(const F3dParticles *particles, + f3d_real *out, uint32_t capacity); + +/* ----------------------------------------------------------------- debris */ + +/* Debris: the visual bodies — rubble, shards, crates' splinters, the + * thousands of things a blast throws that nobody steers and nothing in the + * game reads. Balls, each with its own size and mass, that fall, knock into + * each other, roll and come to rest on still planes and boxes. The GPU + * steps the same system in f3d_gpu.h and is read a frame late; this one is + * its reference and the fallback where there is none. + * + * Each substep finds contacts through a hashed grid of cells as wide as + * three of the largest radii, and solves them all at once from the same + * velocities — Jacobi, every body splitting its mass among its contacts so + * a heap does not blow up — then moves and turns the bodies. A fixed + * number of slots, filled round and round; a slot of radius nought is + * empty. */ +typedef struct F3dDebris F3dDebris; + +/* Reals one body takes in f3d_debris_add: position xyz, velocity xyz, + * radius, mass. */ +#define F3D_DEBRIS_INPUT_FLOATS 8u +/* Reals one body takes in f3d_debris_read: position xyz, orientation + * xyzw, radius. */ +#define F3D_DEBRIS_FLOATS 8u +/* Reals one still shape takes in f3d_debris_set_statics: a plane as its + * normal xyz and its offset along it, then 0, 0, 0, 0; a box, unturned, as + * its centre xyz and 0, then its half extents xyz and 1. */ +#define F3D_DEBRIS_STATIC_FLOATS 8u +#define F3D_DEBRIS_MAX_STATICS 64u + +typedef struct F3dDebrisSettings { + f3d_real gravity[3]; + f3d_real friction; + /* Applied when two close faster than 1 m/s. */ + f3d_real restitution; + /* Per second, as v / (1 + c dt). */ + f3d_real linear_damping; + f3d_real angular_damping; + /* No body is let faster than this. */ + f3d_real max_speed; + /* At least one each: substeps a step, and impulse passes a substep. */ + uint32_t substeps; + uint32_t iterations; +} F3dDebrisSettings; + +/* [capacity] empty slots; null for none or no memory. */ +F3D_API F3dDebris *f3d_debris_create(uint32_t capacity); +F3D_API void f3d_debris_destroy(F3dDebris *debris); +F3D_API uint32_t f3d_debris_capacity(const F3dDebris *debris); + +/* [count] bodies into the next slots, F3D_DEBRIS_INPUT_FLOATS reals apiece, + * unturned; a radius or mass of nought or less empties the slot. Returns + * the slot the first went into. */ +F3D_API uint32_t f3d_debris_add(F3dDebris *debris, const f3d_real *data, + uint32_t count); + +/* The still shapes, replacing the last; 0 when there are more than + * F3D_DEBRIS_MAX_STATICS. */ +F3D_API int f3d_debris_set_statics(F3dDebris *debris, const f3d_real *data, + uint32_t count); + +F3D_API void f3d_debris_step(F3dDebris *debris, + const F3dDebrisSettings *settings, f3d_real dt); + +/* Every slot, F3D_DEBRIS_FLOATS reals apiece, into [out]: up to + * [capacity]; returns how many. */ +F3D_API uint32_t f3d_debris_read(const F3dDebris *debris, f3d_real *out, + uint32_t capacity); + +/* ------------------------------------------------------------------ cloth */ + +/* Cloth: points held at their distances by constraints — XPBD in small + * steps, one pass over the constraints a substep. The constraints are + * coloured when the cloth is made, so that no two of a colour share a + * point, and solved colour by colour: the GPU steps the same cloth in + * f3d_gpu.h a colour a dispatch, with nothing summed in any order, and + * this one is its reference and the fallback where there is none. Points + * collide with balls, a floor, and nothing else — not each other. */ +typedef struct F3dCloth F3dCloth; + +/* Reals one point takes, in f3d_cloth_create and f3d_cloth_read: position + * xyz and inverse mass, nought for a point pinned where it is. */ +#define F3D_CLOTH_FLOATS 4u +#define F3D_CLOTH_MAX_BALLS 16u + +typedef struct F3dClothSettings { + f3d_real gravity[3]; + /* The air's velocity, and how fast a point drifts to it, per second. */ + f3d_real wind[3]; + f3d_real drag; + /* Per second, as v / (1 + c dt). */ + f3d_real damping; + f3d_real floor_y; + /* How far from a ball or the floor a point is held. */ + f3d_real thickness; + /* The part of a touching point's slide taken away each substep. */ + f3d_real friction; + /* At least one. */ + uint32_t substeps; +} F3dClothSettings; + +/* A cloth of [point_count] points, F3D_CLOTH_FLOATS reals apiece, held by + * [edge_count] constraints: the pairs of points in [edges], each held at + * the distance it starts at with its own compliance, m/N (nought: rigid). + * Null for no points, a point out of range, an edge from a point to + * itself, or no memory. */ +F3D_API F3dCloth *f3d_cloth_create(const f3d_real *points, uint32_t point_count, + const uint32_t *edges, const f3d_real *compliance, + uint32_t edge_count); +F3D_API void f3d_cloth_destroy(F3dCloth *cloth); +F3D_API uint32_t f3d_cloth_point_count(const F3dCloth *cloth); + +/* How many colours the constraints took. */ +F3D_API uint32_t f3d_cloth_colour_count(const F3dCloth *cloth); +F3D_API uint32_t f3d_cloth_edge_count(const F3dCloth *cloth); + +/* The constraints as they are solved, colour by colour: each one's pair of + * points into [pairs], its length into [rest] and its compliance into + * [compliance], edge-count apiece; where each colour starts into + * [colour_start], colour-count and one. What the GPU's cloth is made + * from, so the colouring is done in one place. */ +F3D_API void f3d_cloth_edges(const F3dCloth *cloth, uint32_t *pairs, f3d_real *rest, + f3d_real *compliance, uint32_t *colour_start); + +/* The balls, x y z radius apiece, replacing the last; 0 when more than + * F3D_CLOTH_MAX_BALLS. */ +F3D_API int f3d_cloth_set_balls(F3dCloth *cloth, const f3d_real *balls, uint32_t count); + +/* Puts point [index] at [x y z], still: how a pinned point is carried. */ +F3D_API void f3d_cloth_move_point(F3dCloth *cloth, uint32_t index, f3d_real x, + f3d_real y, f3d_real z); + +/* Each substep: gravity, damping and the wind into the velocity, the + * points moved by it, every constraint colour by colour, the balls and the + * floor, and the velocity from where the points went. */ +F3D_API void f3d_cloth_step(F3dCloth *cloth, const F3dClothSettings *settings, + f3d_real dt); + +/* Every point, F3D_CLOTH_FLOATS reals apiece, into [out]: up to + * [capacity]; returns how many. */ +F3D_API uint32_t f3d_cloth_read(const F3dCloth *cloth, f3d_real *out, uint32_t capacity); + +/* ------------------------------------------------------------------ fluid */ + +/* Fluid: water as particles, position-based (PBF) — each held near the + * density at rest of a cubic lattice of [spacing], with a kernel twice the + * spacing wide, inside a tank. The GPU steps the same fluid in f3d_gpu.h; + * this one is its reference and the fallback where there is none. Only + * ever pushed apart, never pulled together: no clumping, and no surface + * tension either. */ +typedef struct F3dFluid F3dFluid; + +/* Reals one particle takes in f3d_fluid_add: position xyz, velocity xyz. */ +#define F3D_FLUID_INPUT_FLOATS 6u +/* Reals one particle takes in f3d_fluid_read: position xyz, and its + * density over the density at rest. */ +#define F3D_FLUID_FLOATS 4u + +typedef struct F3dFluidSettings { + f3d_real gravity[3]; + /* The tank's corners: no particle centre comes nearer its walls than + * half the spacing. */ + f3d_real tank_min[3]; + f3d_real tank_max[3]; + /* XSPH: how far a particle's velocity goes to its neighbours' a substep. */ + f3d_real viscosity; + /* Softens the density constraint; larger is softer and steadier. */ + f3d_real relaxation; + /* At least one each: substeps a step, density passes a substep. */ + uint32_t substeps; + uint32_t iterations; +} F3dFluidSettings; + +/* Room for [capacity] particles [spacing] apart at rest; null for none, a + * spacing not above nought, or no memory. */ +F3D_API F3dFluid *f3d_fluid_create(uint32_t capacity, f3d_real spacing); +F3D_API void f3d_fluid_destroy(F3dFluid *fluid); +F3D_API uint32_t f3d_fluid_capacity(const F3dFluid *fluid); + +/* The density at rest, in particles of mass one per cubic metre. */ +F3D_API f3d_real f3d_fluid_rest_density(const F3dFluid *fluid); + +/* [count] particles, F3D_FLUID_INPUT_FLOATS reals apiece, into the next + * slots, round and round once full. Returns the slot the first went in. */ +F3D_API uint32_t f3d_fluid_add(F3dFluid *fluid, const f3d_real *data, uint32_t count); + +F3D_API void f3d_fluid_step(F3dFluid *fluid, const F3dFluidSettings *settings, f3d_real dt); + +/* Every particle added, F3D_FLUID_FLOATS reals apiece, into [out]: up to + * [capacity]; returns how many. */ +F3D_API uint32_t f3d_fluid_read(const F3dFluid *fluid, f3d_real *out, uint32_t capacity); + +/* -------------------------------------------------------------- snapshots */ + +/* Bytes f3d_world_snapshot_write needs for the world as it is. */ +F3D_API uint32_t f3d_world_snapshot_size(const F3dWorld *world); + +/* Writes the world's whole state into [buffer] and returns the bytes + * written, or nought when [size] is too small. A world restored from it + * steps to the same bits the original does. */ +F3D_API uint32_t f3d_world_snapshot_write(const F3dWorld *world, + uint8_t *buffer, uint32_t size); + +/* Puts the world back as [buffer] says. 1, or 0 and the world unchanged for + * a buffer that is not a snapshot from this build of the core. Handles kept + * from before name what they named when the snapshot was taken. */ +F3D_API int f3d_world_restore(F3dWorld *world, const uint8_t *buffer, + uint32_t size); + +/* ----------------------------------------------------------------- bodies */ + +/* A body of [type] at (px, py, pz) with [mass] kilograms, at rest, + * unrotated, a point of inert material at the air's temperature. Nought + * when the world cannot grow, or when a dynamic body is given a mass that + * is not finite and positive. A fixed body's mass is its thermal mass + * only, and may be nought. */ +F3D_API F3dBody f3d_body_create(F3dWorld *world, F3dBodyType type, f3d_real px, + f3d_real py, f3d_real pz, f3d_real mass); + +/* Takes [body] out of the world. 1 when it was there, 0 for a stale or + * foreign handle. */ +F3D_API int f3d_body_destroy(F3dWorld *world, F3dBody body); + +/* 1 while [body] names a body in [world]. */ +F3D_API int f3d_body_is_valid(const F3dWorld *world, F3dBody body); + +/* Every accessor below: 1 and the value written, or 0 for a handle that is + * not valid or a value that is not finite. Setting a body's motion wakes + * it. */ +F3D_API int f3d_body_set_velocity(F3dWorld *world, F3dBody body, f3d_real x, + f3d_real y, f3d_real z); +F3D_API int f3d_body_get_velocity(const F3dWorld *world, F3dBody body, + f3d_real *out); +F3D_API int f3d_body_set_position(F3dWorld *world, F3dBody body, f3d_real x, + f3d_real y, f3d_real z); +F3D_API int f3d_body_get_position(const F3dWorld *world, F3dBody body, + f3d_real *out); + +/* The position in the world's own coordinates, origin added, in doubles. */ +F3D_API int f3d_body_get_world_position(const F3dWorld *world, F3dBody body, + double *out); + +/* Radians per second about the world's axes. Nothing turns a body that + * cannot: a point, a fixed body, or one whose rotation is locked. */ +F3D_API int f3d_body_set_angular_velocity(F3dWorld *world, F3dBody body, + f3d_real x, f3d_real y, f3d_real z); +F3D_API int f3d_body_get_angular_velocity(const F3dWorld *world, F3dBody body, + f3d_real *out); + +/* A quaternion xyzw, normalised on the way in; a zero one is the + * identity. */ +F3D_API int f3d_body_set_orientation(F3dWorld *world, F3dBody body, f3d_real x, + f3d_real y, f3d_real z, f3d_real w); +F3D_API int f3d_body_get_orientation(const F3dWorld *world, F3dBody body, + f3d_real *out); + +/* The body's shape, as F3dShapeKind says what a, b and c are. Its inertia + * follows from its mass, its surface and drag from its size. 0 for a size + * that is not finite and positive where the kind reads it. */ +F3D_API int f3d_body_set_shape(F3dWorld *world, F3dBody body, + F3dShapeKind kind, f3d_real a, f3d_real b, + f3d_real c); + +/* The moments of inertia about the body's own axes, kg m², into + * out[0..2]: the whole tensor for every shape but a hull, which also has + * products of inertia. */ +F3D_API int f3d_body_get_inertia(const F3dWorld *world, F3dBody body, + f3d_real *out); + +/* The whole inertia tensor in the body's axes: xx, yy, zz, xy, xz, yz. */ +F3D_API int f3d_body_get_inertia_tensor(const F3dWorld *world, F3dBody body, + f3d_real *out); + +/* Rounds the body's shape out by [radius]: the shape grown by a ball, so a + * box gets rounded edges and corners and rolls off them as a real crate + * does. Its inertia, surface and drag are the shape it rounds out to. 0 + * for a radius negative or not finite, or for a point. */ +F3D_API int f3d_body_set_rounding(F3dWorld *world, F3dBody body, + f3d_real radius); + +/* Shapes the body as [hull], one f3d_world_create_hull returned. 0 for a + * hull the world does not hold. */ +F3D_API int f3d_body_set_hull(F3dWorld *world, F3dBody body, uint32_t hull); + +/* Shapes a fixed body as [mesh]. 0 for a mesh the world does not hold, or + * a body that is not fixed: an open mesh has no inside to weigh. */ +F3D_API int f3d_body_set_mesh(F3dWorld *world, F3dBody body, uint32_t mesh); + +/* Kilograms: less, once it has burnt. */ +F3D_API int f3d_body_get_mass(const F3dWorld *world, F3dBody body, + f3d_real *out); + +/* 1 keeps the body from turning, whatever its shape. */ +F3D_API int f3d_body_lock_rotation(F3dWorld *world, F3dBody body, int locked); + +/* Per second, the share of velocity and of spin taken away, each as + * 1 / (1 + dt · damping). Nought, the default, keeps them. */ +F3D_API int f3d_body_set_damping(F3dWorld *world, F3dBody body, + f3d_real linear, f3d_real angular); + +/* The drag coefficient against the wind; nought, the default, takes the + * shape's own: 0.47 a sphere, 1.05 a box, 0.6 a capsule. */ +F3D_API int f3d_body_set_drag(F3dWorld *world, F3dBody body, + f3d_real coefficient); + +/* An impulse, N s, through the centre, or at (px, py, pz) relative to the + * origin, which spins the body as well. */ +F3D_API int f3d_body_apply_impulse(F3dWorld *world, F3dBody body, f3d_real x, + f3d_real y, f3d_real z); +F3D_API int f3d_body_apply_impulse_at(F3dWorld *world, F3dBody body, + f3d_real x, f3d_real y, f3d_real z, + f3d_real px, f3d_real py, f3d_real pz); + +/* The bus: a force, N, or a torque, N m, about the world's axes, held over + * the next step and spent by it; added up when added twice. */ +F3D_API int f3d_body_add_force(F3dWorld *world, F3dBody body, f3d_real x, + f3d_real y, f3d_real z); +F3D_API int f3d_body_add_torque(F3dWorld *world, F3dBody body, f3d_real x, + f3d_real y, f3d_real z); + +/* Hard continuous collision for [body]: after the solve it is swept from + * where the step began to where it ended against every body but another + * bullet, and put back at its first time of impact, its velocity kept. For + * what is small and fast — a bullet, a puck — and what soft collision might + * still let through, and what spins fast enough to turn through a wall in + * one step. Its turn is swept with it. */ +F3D_API int f3d_body_set_bullet(F3dWorld *world, F3dBody body, int bullet); + +/* Coulomb's coefficient, nought up; default 0.6. A pair slides on the + * geometric mean of its two, so either can make it slippery. */ +F3D_API int f3d_body_set_friction(F3dWorld *world, F3dBody body, + f3d_real friction); + +/* The share of the approach speed that comes back, nought to one; default + * nought. A pair bounces with the larger of its two, and nothing bounces + * that met slower than a metre a second. */ +F3D_API int f3d_body_set_restitution(F3dWorld *world, F3dBody body, + f3d_real restitution); + +/* What the body is, [layer], and what it meets, [mask]: two bodies collide + * when each one's layer has a bit in the other's mask. Defaults 1 and every + * bit. */ +F3D_API int f3d_body_set_collision_filter(F3dWorld *world, F3dBody body, + uint32_t layer, uint32_t mask); + +/* 1 while the body sleeps. Bodies sleep and wake by islands: those joined + * by their contacts sleep when all of them have been still for the sleep + * time, and wake together when any of them moves. */ +F3D_API int f3d_body_is_asleep(const F3dWorld *world, F3dBody body); + +/* Wakes the body, and starts its sleep clock again. */ +F3D_API int f3d_body_wake(F3dWorld *world, F3dBody body); + +/* ------------------------------------------------------- heat and fire */ + +/* A material's typical values into [out]. 0 for a kind there is none of. */ +F3D_API int f3d_material_preset(F3dMaterialKind kind, F3dMaterial *out); + +/* What the body is made of. Its fuel is its mass times the material's + * fuel fraction, counted from now. 0 for a value out of its range. A fixed + * body of no thermal mass — no mass and no water — is a reservoir: it gives + * and takes heat through its contacts and keeps its temperature. */ +F3D_API int f3d_body_set_material(F3dWorld *world, F3dBody body, + const F3dMaterial *material); + +/* Kelvin. Set, it does not light a fire or put one out until the step + * says so. */ +F3D_API int f3d_body_set_temperature(F3dWorld *world, F3dBody body, + f3d_real kelvin); +F3D_API int f3d_body_get_temperature(const F3dWorld *world, F3dBody body, + f3d_real *out); + +/* The bus: joules into the body over the next step, or out of it for a + * negative amount. */ +F3D_API int f3d_body_add_heat(F3dWorld *world, F3dBody body, f3d_real joules); + +/* The bus: kilograms of water onto the body, at the air's temperature and + * mixed with the body's heat at once, or off it for a negative amount. + * Water holds the body at its boiling point until it has boiled away, + * which is how it puts a fire out. Its heat is counted, not its weight. */ +F3D_API int f3d_body_add_water(F3dWorld *world, F3dBody body, f3d_real kg); +F3D_API int f3d_body_get_water(const F3dWorld *world, F3dBody body, + f3d_real *out); + +/* Kilograms that can still burn. */ +F3D_API int f3d_body_get_fuel(const F3dWorld *world, F3dBody body, + f3d_real *out); + +/* 1 while the body burns, into *out. */ +F3D_API int f3d_body_is_burning(const F3dWorld *world, F3dBody body, int *out); + +/* Watts the fire gave off as hot gas over the last step. */ +F3D_API int f3d_body_get_heat_release(const F3dWorld *world, F3dBody body, + f3d_real *out); + +#ifdef __cplusplus +} +#endif + +#endif /* F3D_PHYSICS_H_ */ diff --git a/packages/flutter3d_physics_native/csrc/src/f3d_ccd.c b/packages/flutter3d_physics_native/csrc/src/f3d_ccd.c new file mode 100644 index 000000000..b881a82cf --- /dev/null +++ b/packages/flutter3d_physics_native/csrc/src/f3d_ccd.c @@ -0,0 +1,200 @@ +/* + * Hard continuous collision — P9, phase 8: bullets swept for their time of + * impact after the solve. + * + * Soft collision — contacts that reach as far as a body moves in a step — + * stops most fast bodies at what they would cross. A bullet is swept too: + * from where the step began to where it ended, along a line and turning, + * against every body near that line but another bullet, by conservative + * advancement — the gap between them, over the most the bullet can close + * it by, is a time it cannot hit before; step that far and ask again. The + * first hit puts the bullet back there, place and turn, the solver's + * velocity kept, and the next step's contact takes it from there. Its turn + * is swept with it, substep by substep along the path the solver took, so + * a blade spun through a wall in one step — five radians of it, which its + * first and last turns would read the short way round, backwards — stops at + * the wall as surely as a bullet shot at it. A turn of more than half a + * revolution in one substep is past what this follows. + */ +#include "f3d_internal.h" + +typedef struct Candidates { + const F3dWorld *world; + uint32_t self; + uint32_t slots[256]; + uint32_t count; +} Candidates; + +static int candidate(void *context, int32_t leaf) { + Candidates *c = (Candidates *)context; + const uint32_t other = c->world->tree.nodes[leaf].slot; + if (other != c->self && c->count < 256u) c->slots[c->count++] = other; + return c->count < 256u; +} + +/* A bullet's path through the step: from where it began to where it + * ended, its place along a line and its turn the shorter way round. */ +typedef struct Sweep { + F3dVec3 start, move; + F3dQuat from, to; + /* The most any point of it moves for its turn over the whole step: the + * turn's fastest rate times how far its shape reaches from its centre; + * nought for a ball, which no turn changes. */ + f3d_real spin_reach; +} Sweep; + +/* The turn a fraction [t] of the way: the normalised blend of the two + * quaternions. */ +static F3dQuat turn_at(const Sweep *w, f3d_real t) { + const f3d_real u = F3D_R(1.0) - t; + F3dQuat q; + q.x = w->from.x * u + w->to.x * t; + q.y = w->from.y * u + w->to.y * t; + q.z = w->from.z * u + w->to.z * t; + q.w = w->from.w * u + w->to.w * t; + const f3d_real len = f3d_sqrt(q.x * q.x + q.y * q.y + q.z * q.z + q.w * q.w); + q.x /= len; + q.y /= len; + q.z /= len; + q.w /= len; + return q; +} + +/* The first time, nought to one along the sweep, that the bullet [p] comes + * within the slop of [other]; one when it does not. The gap over the most + * it can close per step — along the normal for its move, anywhere for its + * turn — is a time it cannot hit before. */ +static f3d_real impact(const F3dPlaced *p, const Sweep *w, + const F3dPlaced *other) { + const f3d_real length = f3d_sqrt(f3d_dot(w->move, w->move)); + const f3d_real target = F3D_R(0.5) * F3D_LINEAR_SLOP; + F3dPlaced at = *p; + f3d_real t = F3D_R(0.0), gap = F3D_R(0.0); + for (int iteration = 0; iteration < 64; iteration++) { + at.at = f3d_madd(w->start, w->move, t); + at.axes = f3d_mat_of(turn_at(w, t)); + F3dManifold m; + f3d_zero(&m, sizeof m); + /* Within what is left of the sweep, or it cannot reach. */ + const f3d_real reach = + (F3D_R(1.0) - t) * (length + w->spin_reach) + F3D_LINEAR_SLOP; + if (f3d_collide(&at, other, reach, &m) == 0) return F3D_R(1.0); + f3d_real deepest = m.points[0].depth; + for (uint32_t k = 1; k < m.count; k++) { + deepest = f3d_max(deepest, m.points[k].depth); + } + gap = -deepest; + /* The normal points out of the other body into the bullet. Its move + * closes the gap only against the normal; its turn can close it from + * anywhere. Neither closing it, it cannot hit — a bullet lying on a + * floor and sliding over it is not held where it began. */ + const f3d_real closing = + f3d_max(-f3d_dot(w->move, m.normal), F3D_R(0.0)) + w->spin_reach; + if (!(closing > F3D_R(1e-12) * (length + w->spin_reach))) return F3D_R(1.0); + /* Touching where the substep began is the contact solver's, not the + * sweep's: a bullet resting on a floor would be held where it began by + * the faintest downward drift. An impact that ends one substep is + * found in it, short of its end, before the next begins touching. */ + if (gap <= F3D_LINEAR_SLOP) return iteration == 0 ? F3D_R(1.0) : t; + t += (gap - target) / closing; + if (t >= F3D_R(1.0)) return F3D_R(1.0); + } + /* Still apart after every advance: a body grazing past, turning, closes + * on nothing; one still closing is near enough to stop at. */ + return gap <= F3D_R(4.0) * F3D_LINEAR_SLOP ? t : F3D_R(1.0); +} + +/* The most any point of a shape reaching [reach] from its centre moves for + * the blended turn from [from] to [to], the shorter way round — which it + * makes [to] — over the blend. With β the half angle between them, the + * normalised blend turns at most at 4 tan(β/2) = 4 sin β / (1 + cos β): the + * turn's own angle, 2β, when it is small, a quarter more at a half turn. */ +static f3d_real spin_reach_of(F3dQuat from, F3dQuat *to, f3d_real reach) { + f3d_real cosine = + from.x * to->x + from.y * to->y + from.z * to->z + from.w * to->w; + if (cosine < F3D_R(0.0)) { + to->x = -to->x; + to->y = -to->y; + to->z = -to->z; + to->w = -to->w; + cosine = -cosine; + } + cosine = f3d_min(cosine, F3D_R(1.0)); + const f3d_real sine = f3d_sqrt(F3D_R(1.0) - cosine * cosine); + return F3D_R(4.0) * sine / (F3D_R(1.0) + cosine) * reach; +} + +void f3d_step_continuous(F3dWorld *world, const F3dSolverBody *bodies) { + const uint32_t row = world->s.substeps + 1u; + for (uint32_t i = 0; i < world->s.used; i++) { + F3dSlot *s = &world->slots[i]; + if (!s->live || bodies[i].bullet < 0 || s->shape == F3D_SHAPE_POINT || + world->proxies == NULL || i >= world->proxy_capacity) { + continue; + } + const F3dVec3 *at = world->bullet_at + (size_t)bodies[i].bullet * row; + const F3dQuat *turn = world->bullet_turn + (size_t)bodies[i].bullet * row; + /* How far its shape reaches from its centre: its box's half diagonal + * and how far that box's middle sits off the centre. */ + const F3dBox own = f3d_box_of(world, s, F3D_R(0.0)); + const F3dVec3 mid = f3d_scale(f3d_add(own.lo, own.hi), F3D_R(0.5)); + const F3dVec3 half = f3d_scale(f3d_sub(own.hi, own.lo), F3D_R(0.5)); + const F3dVec3 off = f3d_sub(mid, s->position); + const f3d_real reach = + f3d_sqrt(f3d_dot(half, half)) + f3d_sqrt(f3d_dot(off, off)); + const int round = s->shape == F3D_SHAPE_SPHERE; + /* What it can reach: within its reach of every place on its path. */ + F3dBox box; + box.lo = box.hi = at[0]; + f3d_real travelled = F3D_R(0.0); + for (uint32_t k = 0; k < row; k++) { + box.lo = f3d_v3(f3d_min(box.lo.x, at[k].x), f3d_min(box.lo.y, at[k].y), + f3d_min(box.lo.z, at[k].z)); + box.hi = f3d_v3(f3d_max(box.hi.x, at[k].x), f3d_max(box.hi.y, at[k].y), + f3d_max(box.hi.z, at[k].z)); + if (k > 0) { + const F3dVec3 d = f3d_sub(at[k], at[k - 1]); + F3dQuat to = turn[k]; + travelled += f3d_sqrt(f3d_dot(d, d)) + + (round ? F3D_R(0.0) : spin_reach_of(turn[k - 1], &to, reach)); + } + } + if (travelled <= F3D_LINEAR_SLOP) continue; + const f3d_real r = reach + F3D_LINEAR_SLOP; + box.lo = f3d_sub(box.lo, f3d_v3(r, r, r)); + box.hi = f3d_add(box.hi, f3d_v3(r, r, r)); + Candidates c; + c.world = world; + c.self = i; + c.count = 0; + f3d_tree_query(&world->tree, box, candidate, &c); + const F3dPlaced p = f3d_placed_of(world, s); + /* Substep by substep along the path: the first substep with a hit, at + * its first hit, is where it stops. */ + for (uint32_t k = 0; k + 1u < row; k++) { + Sweep w; + w.start = at[k]; + w.move = f3d_sub(at[k + 1u], at[k]); + w.from = turn[k]; + w.to = turn[k + 1u]; + const f3d_real spin = spin_reach_of(w.from, &w.to, reach); + w.spin_reach = round ? F3D_R(0.0) : spin; + f3d_real first = F3D_R(1.0); + for (uint32_t q = 0; q < c.count; q++) { + const F3dSlot *o = &world->slots[c.slots[q]]; + if (!o->live || o->shape == F3D_SHAPE_POINT) continue; + if (o->flags & F3D_FLAG_BULLET) continue; + if (!(s->layer & o->mask) || !(o->layer & s->mask)) continue; + if (f3d_joined(world, i, c.slots[q])) continue; + const F3dPlaced other = f3d_placed_of(world, o); + const f3d_real t = impact(&p, &w, &other); + if (t < first) first = t; + } + if (first < F3D_R(1.0)) { + s->position = f3d_madd(w.start, w.move, first); + s->orientation = turn_at(&w, first); + break; + } + } + } +} diff --git a/packages/flutter3d_physics_native/csrc/src/f3d_cloth.c b/packages/flutter3d_physics_native/csrc/src/f3d_cloth.c new file mode 100644 index 000000000..fed81958e --- /dev/null +++ b/packages/flutter3d_physics_native/csrc/src/f3d_cloth.c @@ -0,0 +1,243 @@ +/* + * Cloth on the CPU — P9, phase 10: the reference for the GPU's, and the + * fallback where there is none. See f3d_physics.h for what it does; the + * WGSL in f3d_gpu_cloth.c does the same, pass for pass. + * + * XPBD in small steps (Macklin et al., 2019): every substep predicts the + * points, solves each constraint once with its multiplier starting from + * nought — so a compliance α holds a constraint as a spring of stiffness + * 1/α — and takes the velocity from where the points went. The + * constraints are coloured greedily when the cloth is made and stored + * colour by colour; inside a colour no two share a point, so the order + * they are solved in changes nothing, not even the rounding. + */ +#include "f3d_internal.h" + +typedef struct Edge { + uint32_t a, b; + f3d_real rest; + f3d_real compliance; +} Edge; + +/* Colours a point's constraints may take: a bit each. */ +#define MAX_COLOURS 256u + +struct F3dCloth { + F3dVec3 *position; + F3dVec3 *previous; + F3dVec3 *velocity; + f3d_real *inv_mass; + uint32_t point_count; + Edge *edges; + uint32_t edge_count; + /* Colour c's constraints are edges[colour_start[c]] up to + * edges[colour_start[c + 1]]. */ + uint32_t *colour_start; + uint32_t colour_count; + f3d_real balls[F3D_CLOTH_MAX_BALLS * 4u]; + uint32_t ball_count; +}; + +F3dCloth *f3d_cloth_create(const f3d_real *points, uint32_t point_count, const uint32_t *edges, + const f3d_real *compliance, uint32_t edge_count) { + if (point_count == 0 || point_count > (1u << 24) || edge_count > (1u << 26)) return NULL; + for (uint32_t e = 0; e < edge_count; e++) { + const uint32_t a = edges[e * 2u], b = edges[e * 2u + 1u]; + if (a >= point_count || b >= point_count || a == b) return NULL; + } + F3dCloth *c = (F3dCloth *)f3d_alloc(sizeof(F3dCloth)); + if (c == NULL) return NULL; + f3d_zero(c, sizeof(F3dCloth)); + const size_t vec_bytes = (size_t)point_count * sizeof(F3dVec3); + c->position = (F3dVec3 *)f3d_alloc(vec_bytes); + c->previous = (F3dVec3 *)f3d_alloc(vec_bytes); + c->velocity = (F3dVec3 *)f3d_alloc(vec_bytes); + c->inv_mass = (f3d_real *)f3d_alloc((size_t)point_count * sizeof(f3d_real)); + c->edges = (Edge *)f3d_alloc((size_t)(edge_count > 0 ? edge_count : 1u) * sizeof(Edge)); + /* Each point's colours taken, and each edge's colour, for the sort. */ + uint64_t *taken = (uint64_t *)f3d_alloc((size_t)point_count * (MAX_COLOURS / 64u) * + sizeof(uint64_t)); + uint32_t *colour = (uint32_t *)f3d_alloc((size_t)(edge_count > 0 ? edge_count : 1u) * + sizeof(uint32_t)); + c->colour_start = (uint32_t *)f3d_alloc((MAX_COLOURS + 1u) * sizeof(uint32_t)); + if (c->position == NULL || c->previous == NULL || c->velocity == NULL || c->inv_mass == NULL || + c->edges == NULL || taken == NULL || colour == NULL || c->colour_start == NULL) { + f3d_free(taken); + f3d_free(colour); + f3d_cloth_destroy(c); + return NULL; + } + c->point_count = point_count; + c->edge_count = edge_count; + for (uint32_t i = 0; i < point_count; i++) { + const f3d_real *p = points + (size_t)i * F3D_CLOTH_FLOATS; + c->position[i] = f3d_v3(p[0], p[1], p[2]); + c->previous[i] = c->position[i]; + c->velocity[i] = f3d_v3(0, 0, 0); + c->inv_mass[i] = p[3] > F3D_R(0.0) ? p[3] : F3D_R(0.0); + } + /* Greedy colouring: each edge the first colour neither of its points has + * yet. */ + f3d_zero(taken, (size_t)point_count * (MAX_COLOURS / 64u) * sizeof(uint64_t)); + f3d_zero(c->colour_start, (MAX_COLOURS + 1u) * sizeof(uint32_t)); + for (uint32_t e = 0; e < edge_count; e++) { + const uint32_t a = edges[e * 2u], b = edges[e * 2u + 1u]; + uint32_t k = 0; + while (k < MAX_COLOURS && + (((taken[a * 4u + k / 64u] | taken[b * 4u + k / 64u]) >> (k % 64u)) & 1u)) { + k++; + } + if (k == MAX_COLOURS) { + f3d_free(taken); + f3d_free(colour); + f3d_cloth_destroy(c); + return NULL; + } + taken[a * 4u + k / 64u] |= (uint64_t)1 << (k % 64u); + taken[b * 4u + k / 64u] |= (uint64_t)1 << (k % 64u); + colour[e] = k; + c->colour_start[k + 1u]++; + if (k + 1u > c->colour_count) c->colour_count = k + 1u; + } + for (uint32_t k = 0; k < MAX_COLOURS; k++) c->colour_start[k + 1u] += c->colour_start[k]; + /* Sorted by colour, in the order given inside each. */ + uint32_t fill[MAX_COLOURS]; + for (uint32_t k = 0; k < MAX_COLOURS; k++) fill[k] = c->colour_start[k]; + for (uint32_t e = 0; e < edge_count; e++) { + const uint32_t a = edges[e * 2u], b = edges[e * 2u + 1u]; + Edge *to = &c->edges[fill[colour[e]]++]; + to->a = a; + to->b = b; + const F3dVec3 d = f3d_sub(c->position[a], c->position[b]); + to->rest = f3d_sqrt(f3d_dot(d, d)); + to->compliance = compliance[e] > F3D_R(0.0) ? compliance[e] : F3D_R(0.0); + } + f3d_free(taken); + f3d_free(colour); + return c; +} + +void f3d_cloth_destroy(F3dCloth *c) { + if (c == NULL) return; + f3d_free(c->position); + f3d_free(c->previous); + f3d_free(c->velocity); + f3d_free(c->inv_mass); + f3d_free(c->edges); + f3d_free(c->colour_start); + f3d_free(c); +} + +uint32_t f3d_cloth_point_count(const F3dCloth *c) { return c->point_count; } + +uint32_t f3d_cloth_colour_count(const F3dCloth *c) { return c->colour_count; } + +uint32_t f3d_cloth_edge_count(const F3dCloth *c) { return c->edge_count; } + +void f3d_cloth_edges(const F3dCloth *c, uint32_t *pairs, f3d_real *rest, f3d_real *compliance, + uint32_t *colour_start) { + for (uint32_t e = 0; e < c->edge_count; e++) { + pairs[e * 2u] = c->edges[e].a; + pairs[e * 2u + 1u] = c->edges[e].b; + rest[e] = c->edges[e].rest; + compliance[e] = c->edges[e].compliance; + } + for (uint32_t k = 0; k <= c->colour_count; k++) colour_start[k] = c->colour_start[k]; +} + +int f3d_cloth_set_balls(F3dCloth *c, const f3d_real *balls, uint32_t count) { + if (count > F3D_CLOTH_MAX_BALLS) return 0; + for (uint32_t i = 0; i < count * 4u; i++) c->balls[i] = balls[i]; + c->ball_count = count; + return 1; +} + +void f3d_cloth_move_point(F3dCloth *c, uint32_t index, f3d_real x, f3d_real y, f3d_real z) { + if (index >= c->point_count) return; + c->position[index] = f3d_v3(x, y, z); + c->velocity[index] = f3d_v3(0, 0, 0); +} + +/* Takes [friction] of a point's slide along [n] since the substep began. */ +static F3dVec3 rub(F3dVec3 x, F3dVec3 previous, F3dVec3 n, f3d_real friction) { + const F3dVec3 moved = f3d_sub(x, previous); + const F3dVec3 slide = f3d_madd(moved, n, -f3d_dot(moved, n)); + return f3d_madd(x, slide, -friction); +} + +void f3d_cloth_step(F3dCloth *c, const F3dClothSettings *s, f3d_real dt) { + if (!(f3d_finite(dt) && dt > F3D_R(0.0))) return; + const uint32_t substeps = s->substeps > 0 ? s->substeps : 1u; + const f3d_real h = dt / (f3d_real)substeps; + const F3dVec3 g = f3d_v3(s->gravity[0], s->gravity[1], s->gravity[2]); + const F3dVec3 wind = f3d_v3(s->wind[0], s->wind[1], s->wind[2]); + const f3d_real drift = F3D_R(1.0) / (F3D_R(1.0) + s->drag * h); + const f3d_real keep = F3D_R(1.0) / (F3D_R(1.0) + s->damping * h); + const f3d_real inv_h2 = F3D_R(1.0) / (h * h); + const f3d_real floor_at = s->floor_y + s->thickness; + for (uint32_t sub = 0; sub < substeps; sub++) { + /* Predict. */ + for (uint32_t i = 0; i < c->point_count; i++) { + c->previous[i] = c->position[i]; + if (c->inv_mass[i] == F3D_R(0.0)) continue; + F3dVec3 v = f3d_madd(c->velocity[i], g, h); + v = f3d_add(wind, f3d_scale(f3d_sub(v, wind), drift)); + v = f3d_scale(v, keep); + c->velocity[i] = v; + c->position[i] = f3d_madd(c->position[i], v, h); + } + /* The constraints, colour by colour. */ + for (uint32_t k = 0; k < c->colour_count; k++) { + for (uint32_t e = c->colour_start[k]; e < c->colour_start[k + 1u]; e++) { + const Edge *edge = &c->edges[e]; + const f3d_real wa = c->inv_mass[edge->a], wb = c->inv_mass[edge->b]; + const f3d_real alpha = edge->compliance * inv_h2; + const f3d_real w = wa + wb + alpha; + if (!(w > F3D_R(0.0))) continue; + const F3dVec3 d = f3d_sub(c->position[edge->a], c->position[edge->b]); + const f3d_real len = f3d_sqrt(f3d_dot(d, d)); + if (!(len > F3D_R(0.0))) continue; + const f3d_real lambda = (edge->rest - len) / w; + const F3dVec3 n = f3d_scale(d, F3D_R(1.0) / len); + c->position[edge->a] = f3d_madd(c->position[edge->a], n, wa * lambda); + c->position[edge->b] = f3d_madd(c->position[edge->b], n, -wb * lambda); + } + } + /* The balls and the floor, and the velocity from where it went. */ + for (uint32_t i = 0; i < c->point_count; i++) { + if (c->inv_mass[i] == F3D_R(0.0)) { + c->velocity[i] = f3d_v3(0, 0, 0); + continue; + } + F3dVec3 x = c->position[i]; + const F3dVec3 was = c->previous[i]; + for (uint32_t b = 0; b < c->ball_count; b++) { + const f3d_real *ball = &c->balls[b * 4u]; + const F3dVec3 d = f3d_sub(x, f3d_v3(ball[0], ball[1], ball[2])); + const f3d_real len = f3d_sqrt(f3d_dot(d, d)); + const f3d_real reach = ball[3] + s->thickness; + if (!(len < reach)) continue; + const F3dVec3 n = len > F3D_R(0.0) ? f3d_scale(d, F3D_R(1.0) / len) : f3d_v3(0, 1, 0); + x = f3d_madd(f3d_v3(ball[0], ball[1], ball[2]), n, reach); + x = rub(x, was, n, s->friction); + } + if (x.y < floor_at) { + x.y = floor_at; + x = rub(x, was, f3d_v3(0, 1, 0), s->friction); + } + c->position[i] = x; + c->velocity[i] = f3d_scale(f3d_sub(x, was), F3D_R(1.0) / h); + } + } +} + +uint32_t f3d_cloth_read(const F3dCloth *c, f3d_real *out, uint32_t capacity) { + const uint32_t n = capacity < c->point_count ? capacity : c->point_count; + for (uint32_t i = 0; i < n; i++) { + out[i * 4u] = c->position[i].x; + out[i * 4u + 1u] = c->position[i].y; + out[i * 4u + 2u] = c->position[i].z; + out[i * 4u + 3u] = c->inv_mass[i]; + } + return n; +} diff --git a/packages/flutter3d_physics_native/csrc/src/f3d_collide.c b/packages/flutter3d_physics_native/csrc/src/f3d_collide.c new file mode 100644 index 000000000..c06e71af3 --- /dev/null +++ b/packages/flutter3d_physics_native/csrc/src/f3d_collide.c @@ -0,0 +1,700 @@ +/* + * The collision stage — P9, phase 2: which pairs are near (a sort and + * sweep along x, until phase 4's tree), where they touch (f3d_narrow.c), + * what began and ended touching, and the islands that sleep and wake as + * one. + * + * Every order here is fixed by slot numbers, never by where something + * happens to sit in memory, so the same world steps to the same contacts, + * the same events and the same sleep on every platform. + */ +#include "f3d_internal.h" + +F3dPlaced f3d_placed_of(const F3dWorld *world, const F3dSlot *s) { + F3dPlaced p; + p.kind = s->shape; + p.size = s->size; + p.at = s->position; + p.axes = f3d_mat_of(s->orientation); + p.rounding = s->rounding; + p.hull = NULL; + p.vertices = NULL; + p.triangles = NULL; + p.mesh = NULL; + p.mesh_tree = NULL; + p.edge_flags = NULL; + if (s->shape == F3D_SHAPE_MESH && s->hull != 0 && + s->hull <= world->s.mesh_count) { + const F3dMesh *m = &world->meshes[s->hull - 1u]; + p.mesh = m; + p.vertices = world->mesh_vertices + (size_t)m->first_vertex * 3u; + p.triangles = world->mesh_triangles + (size_t)m->first_triangle * 3u; + p.edge_flags = world->mesh_edges + m->first_triangle; + p.mesh_tree = s->hull <= world->mesh_tree_count + ? &world->mesh_trees[s->hull - 1u] + : NULL; + } + if (s->shape == F3D_SHAPE_HULL && s->hull != 0 && + s->hull <= world->s.hull_count) { + p.hull = &world->hulls[s->hull - 1u]; + p.vertices = world->hull_vertices + (size_t)p.hull->first_vertex * 3u; + p.triangles = world->hull_triangles + (size_t)p.hull->first_triangle * 3u; + } + return p; +} + +int f3d_world_set_contact_margin(F3dWorld *world, f3d_real margin) { + if (!(f3d_finite(margin) && margin >= F3D_R(0.0))) return 0; + world->s.contact_margin = margin; + return 1; +} + +int f3d_body_set_collision_filter(F3dWorld *world, F3dBody body, + uint32_t layer, uint32_t mask) { + F3dSlot *s = f3d_slot_of(world, body); + if (s == NULL) return 0; + s->layer = layer; + s->mask = mask; + f3d_wake(world, s); + return 1; +} + +uint32_t f3d_world_contact_count(const F3dWorld *world) { + uint32_t points = 0; + for (uint32_t i = 0; i < world->s.manifold_count; i++) { + points += world->manifolds[i].count; + } + return points; +} + +uint32_t f3d_world_read_contacts(const F3dWorld *world, f3d_real *contacts, + F3dBody *pairs, uint32_t capacity) { + uint32_t written = 0; + for (uint32_t i = 0; i < world->s.manifold_count; i++) { + const F3dManifold *m = &world->manifolds[i]; + for (uint32_t k = 0; k < m->count && written < capacity; k++) { + f3d_real *c = contacts + (size_t)written * F3D_CONTACT_FLOATS; + c[0] = m->normal.x; + c[1] = m->normal.y; + c[2] = m->normal.z; + c[3] = m->points[k].point.x; + c[4] = m->points[k].point.y; + c[5] = m->points[k].point.z; + c[6] = m->points[k].depth; + if (pairs != NULL) { + pairs[written * 2u] = m->a; + pairs[written * 2u + 1u] = m->b; + } + written++; + } + } + return written; +} + +/* ------------------------------------------------------------- scratch */ + +/* [bytes] of the world's scratch, grown when it is not enough; null when + * it cannot grow. */ +void *f3d_scratch(F3dWorld *world, size_t bytes) { + if (bytes <= world->scratch_bytes) return world->scratch; + size_t grown = world->scratch_bytes == 0 ? 4096u : world->scratch_bytes; + while (grown < bytes) grown *= 2u; + void *block = f3d_realloc(world->scratch, grown); + if (block == NULL) return NULL; + world->scratch = block; + world->scratch_bytes = grown; + return block; +} + +/* Room for [count] manifolds in the next step's array. */ +static int reserve_next(F3dWorld *world, uint32_t count) { + if (count <= world->next_capacity) return 1; + uint32_t grown = world->next_capacity == 0 ? 64u : world->next_capacity; + while (grown < count) { + if (grown > UINT32_MAX / 2u) return 0; + grown *= 2u; + } + F3dManifold *block = (F3dManifold *)f3d_realloc( + world->next_manifolds, (size_t)grown * sizeof(F3dManifold)); + if (block == NULL) return 0; + world->next_manifolds = block; + world->next_capacity = grown; + return 1; +} + +/* ------------------------------------------------------------- sorting */ + +/* A stable merge sort of 64-bit keys carrying a 32-bit value: written out + * here, because the WebAssembly build has no qsort, and because qsort + * promises no order for equal keys and this needs one. */ +typedef struct Keyed { + uint64_t key; + uint32_t value; + uint32_t reserved; +} Keyed; + +static void sort_keyed(Keyed *items, Keyed *spare, uint32_t count) { + for (uint32_t width = 1; width < count; width *= 2u) { + for (uint32_t lo = 0; lo < count; lo += 2u * width) { + const uint32_t mid = lo + width < count ? lo + width : count; + const uint32_t hi = lo + 2u * width < count ? lo + 2u * width : count; + uint32_t i = lo, j = mid, k = lo; + while (i < mid && j < hi) { + spare[k++] = items[j].key < items[i].key ? items[j++] : items[i++]; + } + while (i < mid) spare[k++] = items[i++]; + while (j < hi) spare[k++] = items[j++]; + } + for (uint32_t k = 0; k < count; k++) items[k] = spare[k]; + if (width > UINT32_MAX / 2u) break; + } +} + +/* --------------------------------------------------------------- stage */ + +static int active(const F3dSlot *s) { + return s->type == F3D_BODY_DYNAMIC && !(s->flags & F3D_FLAG_ASLEEP); +} + +static uint64_t pair_key(uint32_t a, uint32_t b) { + return ((uint64_t)a << 32) | b; +} + +static uint64_t manifold_key(const F3dManifold *m) { + return pair_key((uint32_t)(m->a & 0xffffffffu), + (uint32_t)(m->b & 0xffffffffu)); +} + +/* The last step's manifold for this exact pair of bodies, by a binary + * search of the sorted array; null when there was none. */ +static const F3dManifold *previous(const F3dWorld *world, F3dBody a, + F3dBody b) { + const uint64_t key = pair_key((uint32_t)(a & 0xffffffffu), + (uint32_t)(b & 0xffffffffu)); + uint32_t lo = 0, hi = world->s.manifold_count; + while (lo < hi) { + const uint32_t mid = lo + (hi - lo) / 2u; + const uint64_t k = manifold_key(&world->manifolds[mid]); + if (k < key) { + lo = mid + 1u; + } else { + hi = mid; + } + } + if (lo == world->s.manifold_count) return NULL; + const F3dManifold *m = &world->manifolds[lo]; + return m->a == a && m->b == b ? m : NULL; +} + +static uint32_t find(uint32_t *parent, uint32_t i) { + while (parent[i] != i) { + parent[i] = parent[parent[i]]; + i = parent[i]; + } + return i; +} + +/* Joins two islands under the lower root, so the roots do not depend on + * the order the joins came in. */ +static void join(uint32_t *parent, uint32_t a, uint32_t b) { + const uint32_t ra = find(parent, a), rb = find(parent, b); + if (ra < rb) { + parent[rb] = ra; + } else if (rb < ra) { + parent[ra] = rb; + } +} + +static void sleep_body(F3dWorld *world, F3dSlot *s) { + s->flags |= F3D_FLAG_ASLEEP; + s->still = F3D_R(0.0); + s->velocity = f3d_v3(F3D_R(0.0), F3D_R(0.0), F3D_R(0.0)); + s->spin = f3d_v3(F3D_R(0.0), F3D_R(0.0), F3D_R(0.0)); + f3d_push_event(world, f3d_handle_of(world, s), F3D_EVENT_SLEPT); +} + +/* Islands: dynamic bodies joined by their contacts. One sleeps when every + * body in it has been still for the sleep time, and wakes when any body in + * it moves: a stack goes to sleep as a stack, and a crate knocked off the + * top wakes the crate it was resting on. Fixed bodies join no island, or + * the floor would make one island of the level. */ +static void islands(F3dWorld *world, uint32_t *parent) { + const uint32_t used = world->s.used; + for (uint32_t i = 0; i < used; i++) parent[i] = i; + for (uint32_t i = 0; i < world->s.manifold_count; i++) { + const F3dManifold *m = &world->manifolds[i]; + const uint32_t a = (uint32_t)(m->a & 0xffffffffu); + const uint32_t b = (uint32_t)(m->b & 0xffffffffu); + if (world->slots[a].type == F3D_BODY_DYNAMIC && + world->slots[b].type == F3D_BODY_DYNAMIC) { + join(parent, a, b); + } + } + /* A joint joins its bodies' islands as a contact does: a chain sleeps as + * a chain, and a pull on one end wakes the other. */ + for (uint32_t i = 0; i < world->s.joint_used; i++) { + const F3dJointSlot *j = &world->joints[i]; + if (!j->live) continue; + const F3dSlot *sa = f3d_slot_of(world, j->a); + const F3dSlot *sb = f3d_slot_of(world, j->b); + if (sa == NULL || sb == NULL) continue; + if (sa->type == F3D_BODY_DYNAMIC && sb->type == F3D_BODY_DYNAMIC) { + join(parent, (uint32_t)(sa - world->slots), (uint32_t)(sb - world->slots)); + } + } + if (!(world->s.sleep_time > F3D_R(0.0))) return; + /* Per root: bit 0, a body awake and moving; bit 1, a body asleep; bit 2, + * a body awake. Kept in the slots' spare high bits of a second pass's + * array: parent[] itself, once each body knows its root. */ + uint32_t *state = parent + used; + for (uint32_t i = 0; i < used; i++) state[i] = 0; + for (uint32_t i = 0; i < used; i++) { + const F3dSlot *s = &world->slots[i]; + if (!s->live || s->type != F3D_BODY_DYNAMIC) continue; + const uint32_t root = find(parent, i); + if (s->flags & F3D_FLAG_ASLEEP) { + state[root] |= 2u; + } else { + state[root] |= 4u; + if (s->still < world->s.sleep_time) state[root] |= 1u; + } + } + for (uint32_t i = 0; i < used; i++) { + F3dSlot *s = &world->slots[i]; + if (!s->live || s->type != F3D_BODY_DYNAMIC) continue; + const uint32_t st = state[find(parent, i)]; + if ((st & 1u) && (s->flags & F3D_FLAG_ASLEEP)) { + f3d_wake(world, s); + } else if (!(st & 1u) && (st & 4u) && !(s->flags & F3D_FLAG_ASLEEP)) { + sleep_body(world, s); + } + } +} + +/* What one worker's queries of the tree gather: the pairs, in its own lane, + * grown as they come. The lanes are put together and sorted after, so how + * the bodies were shared out changes nothing. */ +typedef struct Gather { + F3dWorld *world; + F3dLane *lane; + uint32_t self; + int later_only; +} Gather; + +static int add_pair(Gather *g, uint32_t a, uint32_t b) { + F3dLane *lane = g->lane; + if ((size_t)(lane->count + 1u) * sizeof(Keyed) > lane->capacity) { + const size_t grown = lane->capacity == 0 ? 64u * sizeof(Keyed) : lane->capacity * 2u; + void *items = f3d_realloc(lane->items, grown); + if (items == NULL) { + lane->failed = 1; + return 0; + } + lane->items = items; + lane->capacity = grown; + } + Keyed *k = &((Keyed *)lane->items)[lane->count++]; + k->key = a < b ? pair_key(a, b) : pair_key(b, a); + k->value = 0; + k->reserved = 0; + return 1; +} + +/* What a leaf's query of the tree finds: every other leaf it overlaps, as + * a pair — or, rebuilding every pair, only the leaves of later slots, so + * each pair is found once. */ +static int leaf_pair(void *context, int32_t leaf) { + Gather *g = (Gather *)context; + const uint32_t other = g->world->tree.nodes[leaf].slot; + if (other == g->self || (g->later_only && other < g->self)) return 1; + return add_pair(g, g->self, other); +} + +/* Soft CCD: how far past the margin a pair's contact reaches this step — + * as far as the two can close in it, by their speeds and spins. The + * contact's solver lets them close a gap and no more, so a wall a body + * would cross in one step is a wall it stops at. Held to ten metres, past + * which a contact would stop bodies that were only going to pass close. */ +static f3d_real speculative(const F3dWorld *world, const F3dSlot *a, + const F3dSlot *b, f3d_real dt) { + if (!world->s.speculative) return F3D_R(0.0); + f3d_real reach = F3D_R(0.0); + const F3dSlot *both[2] = {a, b}; + for (int k = 0; k < 2; k++) { + const F3dSlot *s = both[k]; + if (s->type != F3D_BODY_DYNAMIC || (s->flags & F3D_FLAG_ASLEEP)) continue; + reach += f3d_sqrt(f3d_dot(s->velocity, s->velocity)) * dt + + f3d_sqrt(f3d_dot(s->spin, s->spin)) * dt * f3d_reach_of(world, s); + } + return f3d_min(reach, F3D_R(10.0)); +} + +/* A worker's share of the leaves whose pairs are to be found: every slot + * [0, count) when [slots] is null, or the slots it lists. */ +typedef struct FindPass { + F3dWorld *world; + const uint32_t *slots; + int later_only; +} FindPass; + +static void find_share(void *context, uint32_t worker, uint32_t begin, uint32_t end) { + const FindPass *f = (const FindPass *)context; + Gather g; + g.world = f->world; + g.lane = &f->world->lanes[worker]; + g.later_only = f->later_only; + for (uint32_t k = begin; k < end && !g.lane->failed; k++) { + const uint32_t i = f->slots != NULL ? f->slots[k] : k; + const int32_t leaf = f->world->proxies[i]; + if (leaf == -1) continue; + g.self = i; + f3d_tree_query(&f->world->tree, f->world->tree.nodes[leaf].box, leaf_pair, &g); + } +} + +static void clear_lanes(F3dWorld *world) { + for (uint32_t w = 0; w < f3d_pool_size(world->pool); w++) { + world->lanes[w].count = 0; + world->lanes[w].failed = 0; + } +} + +/* The lanes' pairs put together in [out], sorted, each once; their count, + * or UINT32_MAX when a lane could not grow. [out] has room for all of them + * twice over, the second half a spare for the sort. */ +static uint32_t merge_lanes(F3dWorld *world, Keyed *out) { + uint32_t total = 0; + for (uint32_t w = 0; w < f3d_pool_size(world->pool); w++) { + if (world->lanes[w].failed) return UINT32_MAX; + f3d_copy(out + total, world->lanes[w].items, (size_t)world->lanes[w].count * sizeof(Keyed)); + total += world->lanes[w].count; + } + sort_keyed(out, out + total, total); + uint32_t unique = 0; + for (uint32_t i = 0; i < total; i++) { + if (unique == 0 || out[unique - 1u].key != out[i].key) out[unique++] = out[i]; + } + return unique; +} + +static uint32_t lane_total(const F3dWorld *world) { + uint32_t total = 0; + for (uint32_t w = 0; w < f3d_pool_size(world->pool); w++) total += world->lanes[w].count; + return total; +} + +static int reserve_pairs(F3dWorld *world, uint32_t count) { + if (count <= world->pair_capacity) return 1; + uint32_t grown = world->pair_capacity == 0 ? 256u : world->pair_capacity; + while (grown < count) grown *= 2u; + uint64_t *pairs = (uint64_t *)f3d_realloc(world->pairs, (size_t)grown * sizeof(uint64_t)); + if (pairs == NULL) return 0; + world->pairs = pairs; + world->pair_capacity = grown; + return 1; +} + +/* Brings the pairs of overlapping leaves up to date: every pair found + * again when they cannot be trusted, else those whose leaves parted + * dropped and those of the leaves that moved found and merged in. 0 when + * memory ran out, the pairs then to be found again next step. */ +static int update_pairs(F3dWorld *world) { + const int rebuild = !world->pairs_ready; + clear_lanes(world); + FindPass find; + find.world = world; + find.slots = rebuild ? NULL : world->moved; + find.later_only = rebuild; + f3d_pool_run(world->pool, rebuild ? world->s.used : world->moved_count, find_share, &find); + world->moved_count = 0; + world->pairs_ready = 0; + const uint32_t found = lane_total(world); + Keyed *fresh = (Keyed *)f3d_scratch(world, (size_t)found * 2u * sizeof(Keyed) + 16u); + if (fresh == NULL) return 0; + const uint32_t unique = merge_lanes(world, fresh); + if (unique == UINT32_MAX) return 0; + uint32_t kept = 0; + if (!rebuild) { + for (uint32_t k = 0; k < world->pair_count; k++) { + const uint32_t a = (uint32_t)(world->pairs[k] >> 32); + const uint32_t b = (uint32_t)(world->pairs[k] & 0xffffffffu); + const int32_t la = a < world->proxy_capacity ? world->proxies[a] : -1; + const int32_t lb = b < world->proxy_capacity ? world->proxies[b] : -1; + if (la == -1 || lb == -1) continue; + if (!f3d_box_overlap(world->tree.nodes[la].box, world->tree.nodes[lb].box)) continue; + world->pairs[kept++] = world->pairs[k]; + } + } + /* The kept and the fresh, both in order, merged from the back. */ + if (!reserve_pairs(world, kept + unique)) return 0; + uint32_t i = kept, j = unique, out = kept + unique; + while (j > 0) { + if (i > 0 && world->pairs[i - 1u] > fresh[j - 1u].key) { + world->pairs[--out] = world->pairs[--i]; + } else { + world->pairs[--out] = fresh[--j].key; + } + } + /* A fresh pair already kept appears twice, side by side. */ + uint32_t count = 0; + for (uint32_t k = 0; k < kept + unique; k++) { + if (count == 0 || world->pairs[count - 1u] != world->pairs[k]) { + world->pairs[count++] = world->pairs[k]; + } + } + world->pair_count = count; + world->pairs_ready = 1; + return 1; +} + +/* A worker's share of the slots, each one's box swept through the step. */ +typedef struct SweepPass { + F3dWorld *world; + f3d_real margin, dt; +} SweepPass; + +static void sweep_share(void *context, uint32_t worker, uint32_t begin, uint32_t end) { + (void)worker; + const SweepPass *p = (const SweepPass *)context; + for (uint32_t i = begin; i < end; i++) { + if (p->world->proxies[i] == -1) continue; + p->world->swept[i] = f3d_swept_box(p->world, &p->world->slots[i], p->margin, p->dt); + } +} + +/* A worker's share of the pairs of overlapping leaves, each kept as a pair + * for the narrow phase when one of the two is awake and dynamic, each + * collides with the other, no joint keeps them apart, and their boxes + * swept through the step overlap — what a query by the awake body's swept + * box would find. */ +static void candidate_share(void *context, uint32_t worker, uint32_t begin, uint32_t end) { + F3dWorld *world = (F3dWorld *)context; + Gather g; + g.world = world; + g.lane = &world->lanes[worker]; + for (uint32_t k = begin; k < end && !g.lane->failed; k++) { + const uint32_t a = (uint32_t)(world->pairs[k] >> 32); + const uint32_t b = (uint32_t)(world->pairs[k] & 0xffffffffu); + const F3dSlot *sa = &world->slots[a], *sb = &world->slots[b]; + if (!active(sa) && !active(sb)) continue; + if (!(sa->layer & sb->mask) || !(sb->layer & sa->mask)) continue; + if (f3d_joined(world, a, b)) continue; + if (!f3d_box_overlap(world->swept[a], world->swept[b])) continue; + add_pair(&g, a, b); + } +} + +/* A worker's share of the narrow phase: pair i's manifold made in the next + * array's slot i, and whether there is one in made[i]. */ +typedef struct NarrowPass { + F3dWorld *world; + const Keyed *pairs; + uint8_t *made; + f3d_real margin, dt; +} NarrowPass; + +static void narrow_share(void *context, uint32_t worker, uint32_t begin, uint32_t end); + +int f3d_world_set_speculative(F3dWorld *world, int enabled) { + world->s.speculative = enabled ? 1u : 0u; + return 1; +} + +int f3d_body_set_bullet(F3dWorld *world, F3dBody body, int bullet) { + F3dSlot *s = f3d_slot_of(world, body); + if (s == NULL) return 0; + if (bullet) { + s->flags |= F3D_FLAG_BULLET; + } else { + s->flags &= (uint8_t)~F3D_FLAG_BULLET; + } + return 1; +} + +static int moved(const F3dSlot *s) { + return s != NULL && (s->flags & F3D_FLAG_MOVED) != 0; +} + +void f3d_step_collide(F3dWorld *world, f3d_real dt) { + const uint32_t used = world->s.used; + const f3d_real margin = world->s.contact_margin; + /* A body placed, turned or reshaped by hand wakes what slept against it: + * the contact they kept is no longer where they are. */ + for (uint32_t i = 0; i < world->s.manifold_count; i++) { + const F3dManifold *m = &world->manifolds[i]; + F3dSlot *sa = f3d_slot_of(world, m->a), *sb = f3d_slot_of(world, m->b); + if (!moved(sa) && !moved(sb)) continue; + if (sa != NULL) f3d_wake(world, sa); + if (sb != NULL) f3d_wake(world, sb); + } + f3d_update_proxies(world, dt); + f3d_build_mesh_trees(world); + /* Each awake body asks the tree what is near it, the bodies shared out + * among the workers. Two bodies neither of which can move are not asked + * about: they keep last step's contact. */ + f3d_joined_ready(world); + const uint32_t workers = f3d_pool_size(world->pool); + if (!update_pairs(world)) return; + if (world->swept_capacity < used) { + F3dBox *swept = (F3dBox *)f3d_realloc(world->swept, (size_t)used * sizeof(F3dBox)); + if (swept == NULL) return; + world->swept = swept; + world->swept_capacity = used; + } + SweepPass sweep; + sweep.world = world; + sweep.margin = F3D_R(0.5) * margin; + sweep.dt = dt; + f3d_pool_run(world->pool, used, sweep_share, &sweep); + clear_lanes(world); + f3d_pool_run(world->pool, world->pair_count, candidate_share, world); + /* And the pairs that keep their contact, on the caller's lane. */ + Gather kept; + kept.world = world; + kept.lane = &world->lanes[0]; + for (uint32_t i = 0; i < world->s.manifold_count && !kept.lane->failed; i++) { + const F3dManifold *m = &world->manifolds[i]; + const F3dSlot *sa = f3d_slot_of(world, m->a); + const F3dSlot *sb = f3d_slot_of(world, m->b); + if (sa == NULL || sb == NULL || active(sa) || active(sb)) continue; + if (sa->shape == F3D_SHAPE_POINT || sb->shape == F3D_SHAPE_POINT) continue; + add_pair(&kept, (uint32_t)(m->a & 0xffffffffu), (uint32_t)(m->b & 0xffffffffu)); + } + for (uint32_t i = 0; i < used; i++) { + world->slots[i].flags &= (uint8_t)~F3D_FLAG_MOVED; + } + /* The scratch: the islands' two arrays, then the pairs and their spare, + * then a byte a pair for the narrow phase. */ + uint32_t total = 0; + for (uint32_t w = 0; w < workers; w++) { + if (world->lanes[w].failed) return; + total += world->lanes[w].count; + } + const size_t fixed = + ((size_t)used * sizeof(uint32_t) * 2u + 15u) & ~(size_t)15u; + if (f3d_scratch(world, fixed + (size_t)total * (sizeof(Keyed) * 2u + 1u) + 16u) == NULL) { + return; + } + Keyed *pairs = (Keyed *)((uint8_t *)world->scratch + fixed); + uint32_t filled = 0; + for (uint32_t w = 0; w < workers; w++) { + f3d_copy(pairs + filled, world->lanes[w].items, (size_t)world->lanes[w].count * sizeof(Keyed)); + filled += world->lanes[w].count; + } + sort_keyed(pairs, pairs + total, total); + uint32_t pair_count = 0; + for (uint32_t i = 0; i < total; i++) { + if (pair_count == 0 || pairs[pair_count - 1].key != pairs[i].key) { + pairs[pair_count++] = pairs[i]; + } + } + if (!reserve_next(world, pair_count)) return; + /* The narrow phase, pair by pair, shared out among the workers, each + * pair's manifold made in its own slot; then the slots with one are + * closed up in key order. */ + uint8_t *made = (uint8_t *)(pairs + 2u * (size_t)total); + NarrowPass narrow; + narrow.world = world; + narrow.pairs = pairs; + narrow.made = made; + narrow.margin = margin; + narrow.dt = dt; + f3d_pool_run(world->pool, pair_count, narrow_share, &narrow); + uint32_t found = 0; + for (uint32_t i = 0; i < pair_count; i++) { + if (!made[i]) continue; + if (found != i) { + f3d_copy(&world->next_manifolds[found], &world->next_manifolds[i], sizeof(F3dManifold)); + } + found++; + } + /* What began and ended touching: the two sorted arrays side by side. */ + uint32_t i = 0, j = 0; + const uint32_t before = world->s.manifold_count; + while (i < before || j < found) { + const F3dManifold *old = i < before ? &world->manifolds[i] : NULL; + const F3dManifold *now = j < found ? &world->next_manifolds[j] : NULL; + const uint64_t ko = old ? manifold_key(old) : UINT64_MAX; + const uint64_t kn = now ? manifold_key(now) : UINT64_MAX; + if (old && now && ko == kn && old->a == now->a && old->b == now->b) { + if (old->touching && !now->touching) { + f3d_push_pair_event(world, now->a, now->b, F3D_EVENT_CONTACT_ENDED); + } else if (!old->touching && now->touching) { + f3d_push_pair_event(world, now->a, now->b, F3D_EVENT_CONTACT_BEGAN); + } + i++; + j++; + } else if (now == NULL || (old && ko <= kn)) { + if (old->touching) { + f3d_push_pair_event(world, old->a, old->b, F3D_EVENT_CONTACT_ENDED); + } + i++; + } else { + if (now->touching) { + f3d_push_pair_event(world, now->a, now->b, F3D_EVENT_CONTACT_BEGAN); + } + j++; + } + } + /* The new manifolds become the world's, and the old array is the next + * step's room. */ + F3dManifold *swap = world->manifolds; + const uint32_t swap_capacity = world->manifold_capacity; + world->manifolds = world->next_manifolds; + world->manifold_capacity = world->next_capacity; + world->next_manifolds = swap; + world->next_capacity = swap_capacity; + world->s.manifold_count = found; + /* A body woken by a contact is woken here, after the contacts are set: + * an awake, moving body touching a sleeping one wakes its whole island. */ + islands(world, (uint32_t *)world->scratch); +} + +/* Two bodies that are both asleep or fixed keep last step's manifold: + * nothing between them has moved, and dropping it would end a contact + * nobody broke. */ +static void narrow_share(void *context, uint32_t worker, uint32_t begin, uint32_t end) { + (void)worker; + const NarrowPass *n = (const NarrowPass *)context; + F3dWorld *world = n->world; + for (uint32_t i = begin; i < end; i++) { + n->made[i] = 0; + const uint32_t a = (uint32_t)(n->pairs[i].key >> 32); + const uint32_t b = (uint32_t)(n->pairs[i].key & 0xffffffffu); + const F3dSlot *sa = &world->slots[a]; + const F3dSlot *sb = &world->slots[b]; + const F3dBody ha = f3d_handle_of(world, sa), hb = f3d_handle_of(world, sb); + F3dManifold *m = &world->next_manifolds[i]; + if (!active(sa) && !active(sb)) { + const F3dManifold *kept = previous(world, ha, hb); + if (kept != NULL) { + /* By bytes: the padding is part of what a snapshot compares. */ + f3d_copy(m, kept, sizeof *m); + n->made[i] = 1; + } + continue; + } + f3d_zero(m, sizeof *m); + const F3dPlaced pa = f3d_placed_of(world, sa), pb = f3d_placed_of(world, sb); + if (f3d_collide(&pa, &pb, n->margin + speculative(world, sa, sb, n->dt), m) == 0) { + continue; + } + m->a = ha; + m->b = hb; + /* Warm start: a point made by the same features as one last step + * starts from what that one pushed with. */ + const F3dManifold *old = previous(world, ha, hb); + if (old != NULL) { + for (uint32_t k = 0; k < m->count; k++) { + for (uint32_t o = 0; o < old->count; o++) { + if (old->points[o].id != m->points[k].id) continue; + m->points[k].normal_impulse = old->points[o].normal_impulse; + m->points[k].tangent_impulse[0] = old->points[o].tangent_impulse[0]; + m->points[k].tangent_impulse[1] = old->points[o].tangent_impulse[1]; + break; + } + } + } + n->made[i] = 1; + } +} diff --git a/packages/flutter3d_physics_native/csrc/src/f3d_convex.c b/packages/flutter3d_physics_native/csrc/src/f3d_convex.c new file mode 100644 index 000000000..61f1fd963 --- /dev/null +++ b/packages/flutter3d_physics_native/csrc/src/f3d_convex.c @@ -0,0 +1,1248 @@ +/* + * The general narrow phase — P9, phase 5: any two convex shapes, by their + * support functions. + * + * Every shape is a core and a rounding: a ball is a point rounded by its + * radius, a capsule a segment, and a box, cylinder, cone or hull is itself + * rounded by whatever rounding the body has. GJK (Gilbert, Johnson and + * Keerthi; the closest points of a simplex after Ericson's Real-Time + * Collision Detection, §5.1) finds how far apart the cores are, and the + * contact is the roundings less that. Where the cores themselves overlap, + * EPA (van den Bergen) expands GJK's last simplex to the face of the + * Minkowski difference nearest the origin, which is the way out and how + * deep. + * + * One point is a pin to balance on, so the manifold comes from the two + * shapes' features facing each other along the normal — a face, an edge or + * a corner each: face against face clips one polygon to the other, an edge + * against a face clips the edge, two parallel edges keep their overlap. + * Every loop has a fixed bound and a fixed order, so the same pair answers + * with the same bits everywhere. + */ +#include "f3d_internal.h" + +#ifdef F3D_REAL_DOUBLE +#define F3D_GJK_RELATIVE F3D_R(1e-12) +#define F3D_GJK_TOUCH F3D_R(1e-20) +#define F3D_EPA_TOLERANCE F3D_R(1e-9) +#else +#define F3D_GJK_RELATIVE F3D_R(1e-6) +/* Closer than this share of the difference's size, squared, is touching: + * a float holds the cores' distance no finer than a part in a hundred + * thousand of how big they are, and below it GJK's simplex wobbles round + * the origin rather than closing on it. */ +#define F3D_GJK_TOUCH F3D_R(1e-10) +#define F3D_EPA_TOLERANCE F3D_R(1e-5) +#endif + +/* Within five degrees of square to a face is resting on it. */ +#define F3D_FACE_COS F3D_R(0.9961947) +#define F3D_FACE_SIN F3D_R(0.0871557) + +static F3dVec3 local_of(const F3dPlaced *p, F3dVec3 v) { + return f3d_v3(f3d_dot(p->axes.c[0], v), f3d_dot(p->axes.c[1], v), + f3d_dot(p->axes.c[2], v)); +} + +static F3dVec3 world_of(const F3dPlaced *p, F3dVec3 l) { + return f3d_add(p->at, f3d_add(f3d_add(f3d_scale(p->axes.c[0], l.x), + f3d_scale(p->axes.c[1], l.y)), + f3d_scale(p->axes.c[2], l.z))); +} + +static F3dVec3 hull_vertex(const F3dPlaced *p, uint32_t i) { + return f3d_v3(p->vertices[i * 3u], p->vertices[i * 3u + 1u], + p->vertices[i * 3u + 2u]); +} + +/* How far a shape is rounded beyond its core. */ +static f3d_real rounding_of(const F3dPlaced *p) { + if (p->kind == F3D_SHAPE_SPHERE || p->kind == F3D_SHAPE_CAPSULE) { + return p->size.x + p->rounding; + } + return p->rounding; +} + +/* The core's furthest point along [d], both in the shape's own frame. */ +static F3dVec3 core_support(const F3dPlaced *p, F3dVec3 d) { + const F3dVec3 s = p->size; + switch (p->kind) { + case F3D_SHAPE_CAPSULE: + return f3d_v3(F3D_R(0.0), d.y >= F3D_R(0.0) ? s.y : -s.y, F3D_R(0.0)); + case F3D_SHAPE_BOX: + return f3d_v3(d.x >= F3D_R(0.0) ? s.x : -s.x, + d.y >= F3D_R(0.0) ? s.y : -s.y, + d.z >= F3D_R(0.0) ? s.z : -s.z); + case F3D_SHAPE_CYLINDER: { + const f3d_real flat = f3d_sqrt(d.x * d.x + d.z * d.z); + const f3d_real k = flat > F3D_R(1e-12) ? s.x / flat : F3D_R(0.0); + return f3d_v3(d.x * k, d.y >= F3D_R(0.0) ? s.y : -s.y, d.z * k); + } + case F3D_SHAPE_CONE: { + const F3dVec3 apex = f3d_v3(F3D_R(0.0), F3D_R(0.75) * s.y, F3D_R(0.0)); + const f3d_real flat = f3d_sqrt(d.x * d.x + d.z * d.z); + const f3d_real k = flat > F3D_R(1e-12) ? s.x / flat : F3D_R(0.0); + const F3dVec3 rim = f3d_v3(d.x * k, F3D_R(-0.25) * s.y, d.z * k); + return f3d_dot(apex, d) >= f3d_dot(rim, d) ? apex : rim; + } + case F3D_SHAPE_TRIANGLE: { + uint32_t best = 0; + f3d_real most = f3d_dot(hull_vertex(p, 0), d); + for (uint32_t i = 1; i < 3u; i++) { + const f3d_real v = f3d_dot(hull_vertex(p, i), d); + if (v > most) { + most = v; + best = i; + } + } + return hull_vertex(p, best); + } + case F3D_SHAPE_HULL: { + if (p->hull == NULL) break; + uint32_t best = 0; + f3d_real most = f3d_dot(hull_vertex(p, 0), d); + for (uint32_t i = 1; i < p->hull->vertex_count; i++) { + const f3d_real v = f3d_dot(hull_vertex(p, i), d); + if (v > most) { + most = v; + best = i; + } + } + return hull_vertex(p, best); + } + default: + break; + } + return f3d_v3(F3D_R(0.0), F3D_R(0.0), F3D_R(0.0)); +} + +typedef struct Vertex { + /* a − b, and the two points it came from. */ + F3dVec3 w, a, b; +} Vertex; + +static Vertex support(const F3dPlaced *a, const F3dPlaced *b, F3dVec3 d) { + Vertex v; + v.a = world_of(a, core_support(a, local_of(a, d))); + v.b = world_of(b, core_support(b, local_of(b, f3d_scale(d, F3D_R(-1.0))))); + v.w = f3d_sub(v.a, v.b); + return v; +} + +/* ---------------------------------------------------------------- GJK */ + +typedef struct Simplex { + Vertex v[4]; + f3d_real l[4]; + uint32_t n; +} Simplex; + +static void keep(Simplex *s, const uint32_t *which, const f3d_real *weights, + uint32_t count) { + Vertex v[4]; + for (uint32_t i = 0; i < count; i++) v[i] = s->v[which[i]]; + for (uint32_t i = 0; i < count; i++) { + s->v[i] = v[i]; + s->l[i] = weights[i]; + } + s->n = count; +} + +/* The closest point of the triangle i, j, k to the origin, Ericson's + * ClosestPtPointTriangle, the simplex reduced to the feature it lies on; + * returns the point. */ +static F3dVec3 closest_triangle(Simplex *s, uint32_t i, uint32_t j, + uint32_t k) { + const F3dVec3 a = s->v[i].w, b = s->v[j].w, c = s->v[k].w; + const F3dVec3 ab = f3d_sub(b, a), ac = f3d_sub(c, a); + const F3dVec3 ap = f3d_scale(a, F3D_R(-1.0)); + const f3d_real d1 = f3d_dot(ab, ap), d2 = f3d_dot(ac, ap); + if (d1 <= F3D_R(0.0) && d2 <= F3D_R(0.0)) { + const uint32_t w[1] = {i}; + const f3d_real l[1] = {F3D_R(1.0)}; + keep(s, w, l, 1); + return a; + } + const F3dVec3 bp = f3d_scale(b, F3D_R(-1.0)); + const f3d_real d3 = f3d_dot(ab, bp), d4 = f3d_dot(ac, bp); + if (d3 >= F3D_R(0.0) && d4 <= d3) { + const uint32_t w[1] = {j}; + const f3d_real l[1] = {F3D_R(1.0)}; + keep(s, w, l, 1); + return b; + } + const f3d_real vc = d1 * d4 - d3 * d2; + if (vc <= F3D_R(0.0) && d1 >= F3D_R(0.0) && d3 <= F3D_R(0.0)) { + const f3d_real t = d1 / (d1 - d3); + const uint32_t w[2] = {i, j}; + const f3d_real l[2] = {F3D_R(1.0) - t, t}; + keep(s, w, l, 2); + return f3d_madd(a, ab, t); + } + const F3dVec3 cp = f3d_scale(c, F3D_R(-1.0)); + const f3d_real d5 = f3d_dot(ab, cp), d6 = f3d_dot(ac, cp); + if (d6 >= F3D_R(0.0) && d5 <= d6) { + const uint32_t w[1] = {k}; + const f3d_real l[1] = {F3D_R(1.0)}; + keep(s, w, l, 1); + return c; + } + const f3d_real vb = d5 * d2 - d1 * d6; + if (vb <= F3D_R(0.0) && d2 >= F3D_R(0.0) && d6 <= F3D_R(0.0)) { + const f3d_real t = d2 / (d2 - d6); + const uint32_t w[2] = {i, k}; + const f3d_real l[2] = {F3D_R(1.0) - t, t}; + keep(s, w, l, 2); + return f3d_madd(a, ac, t); + } + const f3d_real va = d3 * d6 - d5 * d4; + if (va <= F3D_R(0.0) && d4 - d3 >= F3D_R(0.0) && d5 - d6 >= F3D_R(0.0)) { + const f3d_real t = (d4 - d3) / ((d4 - d3) + (d5 - d6)); + const uint32_t w[2] = {j, k}; + const f3d_real l[2] = {F3D_R(1.0) - t, t}; + keep(s, w, l, 2); + return f3d_madd(b, f3d_sub(c, b), t); + } + const f3d_real denom = F3D_R(1.0) / (va + vb + vc); + const f3d_real v = vb * denom, w2 = vc * denom; + const uint32_t w[3] = {i, j, k}; + const f3d_real l[3] = {F3D_R(1.0) - v - w2, v, w2}; + keep(s, w, l, 3); + return f3d_add(a, f3d_add(f3d_scale(ab, v), f3d_scale(ac, w2))); +} + +/* Whether the origin lies on the far side of face a, b, c from d. */ +static int outside(F3dVec3 a, F3dVec3 b, F3dVec3 c, F3dVec3 d) { + const F3dVec3 n = f3d_cross(f3d_sub(b, a), f3d_sub(c, a)); + const f3d_real o = -f3d_dot(n, a); + const f3d_real other = f3d_dot(n, f3d_sub(d, a)); + /* A flat tetrahedron has no inside; every face is looked at. */ + if (f3d_abs(other) <= F3D_R(1e-12) * (F3D_R(1.0) + f3d_dot(n, n))) return 1; + return o * other < F3D_R(0.0); +} + +/* The simplex's closest point to the origin, the simplex reduced to the + * feature it lies on; [inside] is set when a tetrahedron holds the origin. */ +static F3dVec3 closest(Simplex *s, int *inside) { + *inside = 0; + switch (s->n) { + case 1: + s->l[0] = F3D_R(1.0); + return s->v[0].w; + case 2: { + const F3dVec3 a = s->v[0].w, ab = f3d_sub(s->v[1].w, a); + const f3d_real dd = f3d_dot(ab, ab); + f3d_real t = dd > F3D_R(0.0) ? -f3d_dot(a, ab) / dd : F3D_R(0.0); + if (t <= F3D_R(0.0)) { + const uint32_t w[1] = {0}; + const f3d_real l[1] = {F3D_R(1.0)}; + keep(s, w, l, 1); + return a; + } + if (t >= F3D_R(1.0)) { + const uint32_t w[1] = {1}; + const f3d_real l[1] = {F3D_R(1.0)}; + keep(s, w, l, 1); + return s->v[0].w; + } + s->l[0] = F3D_R(1.0) - t; + s->l[1] = t; + return f3d_madd(a, ab, t); + } + case 3: + return closest_triangle(s, 0, 1, 2); + default: { + const F3dVec3 a = s->v[0].w, b = s->v[1].w, c = s->v[2].w, + d = s->v[3].w; + const uint32_t faces[4][4] = { + {0, 1, 2, 3}, {0, 2, 3, 1}, {0, 3, 1, 2}, {1, 3, 2, 0}}; + const F3dVec3 pts[4] = {a, b, c, d}; + int any = 0; + Simplex best = *s; + f3d_real nearest = F3D_R(1e30); + F3dVec3 point = a; + for (int f = 0; f < 4; f++) { + if (!outside(pts[faces[f][0]], pts[faces[f][1]], pts[faces[f][2]], + pts[faces[f][3]])) { + continue; + } + any = 1; + Simplex trial = *s; + const F3dVec3 q = + closest_triangle(&trial, faces[f][0], faces[f][1], faces[f][2]); + const f3d_real d2 = f3d_dot(q, q); + if (d2 < nearest) { + nearest = d2; + best = trial; + point = q; + } + } + if (!any) { + *inside = 1; + return f3d_v3(F3D_R(0.0), F3D_R(0.0), F3D_R(0.0)); + } + *s = best; + return point; + } + } +} + +/* ---------------------------------------------------------------- EPA */ + +#define F3D_EPA_VERTICES 64 +#define F3D_EPA_FACES 128 + +typedef struct Face { + uint8_t i, j, k, live; + F3dVec3 n; + f3d_real d; +} Face; + +typedef struct Polytope { + Vertex v[F3D_EPA_VERTICES]; + uint32_t nv; + Face f[F3D_EPA_FACES]; + uint32_t nf; +} Polytope; + +static int add_face(Polytope *p, uint32_t i, uint32_t j, uint32_t k) { + if (p->nf >= F3D_EPA_FACES) return 0; + const F3dVec3 a = p->v[i].w; + F3dVec3 n = f3d_cross(f3d_sub(p->v[j].w, a), f3d_sub(p->v[k].w, a)); + const f3d_real len = f3d_sqrt(f3d_dot(n, n)); + Face *f = &p->f[p->nf++]; + f->i = (uint8_t)i; + f->j = (uint8_t)j; + f->k = (uint8_t)k; + f->live = len > F3D_R(1e-18); + n = f->live ? f3d_scale(n, F3D_R(1.0) / len) : n; + f->n = n; + f->d = f3d_dot(n, a); + return 1; +} + +/* Grows GJK's simplex to a tetrahedron round the origin. */ +static int blow_up(const F3dPlaced *a, const F3dPlaced *b, Simplex *s) { + const F3dVec3 axes[6] = { + {F3D_R(1.0), F3D_R(0.0), F3D_R(0.0)}, {F3D_R(-1.0), F3D_R(0.0), F3D_R(0.0)}, + {F3D_R(0.0), F3D_R(1.0), F3D_R(0.0)}, {F3D_R(0.0), F3D_R(-1.0), F3D_R(0.0)}, + {F3D_R(0.0), F3D_R(0.0), F3D_R(1.0)}, {F3D_R(0.0), F3D_R(0.0), F3D_R(-1.0)}}; + const f3d_real tiny = F3D_R(1e-12); + if (s->n == 1) { + for (int k = 0; k < 6 && s->n == 1; k++) { + const Vertex v = support(a, b, axes[k]); + const F3dVec3 d = f3d_sub(v.w, s->v[0].w); + if (f3d_dot(d, d) > tiny) s->v[s->n++] = v; + } + } + if (s->n == 2) { + const F3dVec3 line = f3d_sub(s->v[1].w, s->v[0].w); + for (int k = 0; k < 6 && s->n == 2; k += 2) { + const F3dVec3 d = f3d_cross(line, axes[k]); + if (f3d_dot(d, d) <= tiny) continue; + for (int sign = 0; sign < 2 && s->n == 2; sign++) { + const Vertex v = + support(a, b, sign ? f3d_scale(d, F3D_R(-1.0)) : d); + const F3dVec3 off = f3d_cross(line, f3d_sub(v.w, s->v[0].w)); + if (f3d_dot(off, off) > tiny) s->v[s->n++] = v; + } + } + } + if (s->n == 3) { + const F3dVec3 n = f3d_cross(f3d_sub(s->v[1].w, s->v[0].w), + f3d_sub(s->v[2].w, s->v[0].w)); + for (int sign = 0; sign < 2 && s->n == 3; sign++) { + const Vertex v = support(a, b, sign ? f3d_scale(n, F3D_R(-1.0)) : n); + if (f3d_abs(f3d_dot(n, f3d_sub(v.w, s->v[0].w))) > tiny) { + s->v[s->n++] = v; + } + } + } + return s->n == 4; +} + +/* EPA from a tetrahedron round the origin: the face of the Minkowski + * difference nearest the origin, its normal and distance, and the points + * on the two cores it came from. 0 when the polytope will not grow. */ +static int expand(const F3dPlaced *a, const F3dPlaced *b, const Simplex *s, + F3dVec3 *normal, f3d_real *depth, F3dVec3 *pa, + F3dVec3 *pb) { + static const int tetra[4][4] = { + {0, 1, 2, 3}, {0, 3, 1, 2}, {0, 2, 3, 1}, {1, 3, 2, 0}}; + Polytope p; + p.nv = 4; + p.nf = 0; + for (int i = 0; i < 4; i++) p.v[i] = s->v[i]; + for (int f = 0; f < 4; f++) { + uint32_t i = (uint32_t)tetra[f][0], j = (uint32_t)tetra[f][1]; + const uint32_t k = (uint32_t)tetra[f][2], o = (uint32_t)tetra[f][3]; + const F3dVec3 n = f3d_cross(f3d_sub(p.v[j].w, p.v[i].w), + f3d_sub(p.v[k].w, p.v[i].w)); + if (f3d_dot(n, f3d_sub(p.v[o].w, p.v[i].w)) > F3D_R(0.0)) { + const uint32_t t = i; + i = j; + j = t; + } + add_face(&p, i, j, k); + } + int best = -1; + for (int iteration = 0; iteration < 64; iteration++) { + best = -1; + for (uint32_t f = 0; f < p.nf; f++) { + if (!p.f[f].live) continue; + if (best < 0 || p.f[f].d < p.f[best].d) best = (int)f; + } + if (best < 0) return 0; + const Face near = p.f[best]; + const Vertex w = support(a, b, near.n); + if (f3d_dot(w.w, near.n) - near.d <= F3D_EPA_TOLERANCE || + p.nv >= F3D_EPA_VERTICES) { + break; + } + /* What w sees goes; the edges round what it sees, the horizon, are + * joined to it. An edge two seen faces share appears twice, in + * opposite directions, and is not on the horizon. */ + uint8_t edges[F3D_EPA_FACES * 3][2]; + uint32_t ne = 0; + for (uint32_t f = 0; f < p.nf; f++) { + Face *face = &p.f[f]; + if (!face->live) continue; + if (f3d_dot(face->n, f3d_sub(w.w, p.v[face->i].w)) <= F3D_R(0.0)) { + continue; + } + face->live = 0; + const uint8_t e[3][2] = { + {face->i, face->j}, {face->j, face->k}, {face->k, face->i}}; + for (int k = 0; k < 3; k++) { + int shared = -1; + for (uint32_t q = 0; q < ne; q++) { + if (edges[q][0] == e[k][1] && edges[q][1] == e[k][0]) shared = (int)q; + } + if (shared >= 0) { + edges[shared][0] = edges[ne - 1][0]; + edges[shared][1] = edges[ne - 1][1]; + ne--; + } else if (ne < F3D_EPA_FACES * 3) { + edges[ne][0] = e[k][0]; + edges[ne][1] = e[k][1]; + ne++; + } + } + } + const uint32_t nw = p.nv++; + p.v[nw] = w; + /* Dead faces are compacted away before the new ones go in, so the + * table does not fill with what the polytope no longer has. */ + uint32_t live = 0; + for (uint32_t f = 0; f < p.nf; f++) { + if (p.f[f].live) p.f[live++] = p.f[f]; + } + p.nf = live; + for (uint32_t q = 0; q < ne; q++) { + if (!add_face(&p, edges[q][0], edges[q][1], nw)) break; + } + } + if (best < 0) return 0; + const Face face = p.f[best]; + /* The origin's projection on the face, in the face's barycentrics. */ + const F3dVec3 x = f3d_scale(face.n, face.d); + const F3dVec3 v0 = f3d_sub(p.v[face.j].w, p.v[face.i].w); + const F3dVec3 v1 = f3d_sub(p.v[face.k].w, p.v[face.i].w); + const F3dVec3 v2 = f3d_sub(x, p.v[face.i].w); + const f3d_real d00 = f3d_dot(v0, v0), d01 = f3d_dot(v0, v1); + const f3d_real d11 = f3d_dot(v1, v1), d20 = f3d_dot(v2, v0); + const f3d_real d21 = f3d_dot(v2, v1); + const f3d_real den = d00 * d11 - d01 * d01; + f3d_real u = F3D_R(0.0), v = F3D_R(0.0); + if (den > F3D_R(0.0)) { + u = (d11 * d20 - d01 * d21) / den; + v = (d00 * d21 - d01 * d20) / den; + } + const f3d_real l0 = F3D_R(1.0) - u - v; + *pa = f3d_add(f3d_add(f3d_scale(p.v[face.i].a, l0), + f3d_scale(p.v[face.j].a, u)), + f3d_scale(p.v[face.k].a, v)); + *pb = f3d_add(f3d_add(f3d_scale(p.v[face.i].b, l0), + f3d_scale(p.v[face.j].b, u)), + f3d_scale(p.v[face.k].b, v)); + *normal = face.n; + *depth = face.d; + return 1; +} + +/* ----------------------------------------------------------- features */ + +#define F3D_FEATURE_POINTS 16 + +typedef struct Feature { + F3dVec3 p[F3D_FEATURE_POINTS]; + uint32_t n; +} Feature; + +/* Puts a polygon's points in order round their middle, seen along [d]: by + * a pseudo-angle that rises with the angle and needs no arctangent. */ +static void order(Feature *f, F3dVec3 d) { + if (f->n < 3) return; + F3dVec3 mid = f3d_v3(F3D_R(0.0), F3D_R(0.0), F3D_R(0.0)); + for (uint32_t i = 0; i < f->n; i++) mid = f3d_add(mid, f->p[i]); + mid = f3d_scale(mid, F3D_R(1.0) / (f3d_real)f->n); + const F3dVec3 u0 = f3d_sub(f->p[0], mid); + const F3dVec3 u = f3d_scale(u0, F3D_R(1.0) / f3d_max(f3d_sqrt(f3d_dot(u0, u0)), F3D_R(1e-12))); + const F3dVec3 v = f3d_cross(d, u); + f3d_real key[F3D_FEATURE_POINTS]; + for (uint32_t i = 0; i < f->n; i++) { + const F3dVec3 r = f3d_sub(f->p[i], mid); + const f3d_real x = f3d_dot(r, u), y = f3d_dot(r, v); + const f3d_real s = f3d_abs(x) + f3d_abs(y); + const f3d_real t = s > F3D_R(0.0) ? y / s : F3D_R(0.0); + key[i] = x >= F3D_R(0.0) ? t : F3D_R(2.0) - t; + } + for (uint32_t i = 1; i < f->n; i++) { + const F3dVec3 pi = f->p[i]; + const f3d_real ki = key[i]; + uint32_t j = i; + while (j > 0 && key[j - 1] > ki) { + f->p[j] = f->p[j - 1]; + key[j] = key[j - 1]; + j--; + } + f->p[j] = pi; + key[j] = ki; + } +} + +static void push_point(Feature *f, F3dVec3 p) { + if (f->n < F3D_FEATURE_POINTS) f->p[f->n++] = p; +} + +/* A disc of eight points round the shape's axis at height y. */ +static void disc(Feature *f, f3d_real r, f3d_real y) { + const f3d_real c = F3D_R(0.70710678118654752); + const f3d_real xs[8] = {F3D_R(1.0), c, F3D_R(0.0), -c, F3D_R(-1.0), -c, F3D_R(0.0), c}; + const f3d_real zs[8] = {F3D_R(0.0), c, F3D_R(1.0), c, F3D_R(0.0), -c, F3D_R(-1.0), -c}; + for (int k = 0; k < 8; k++) push_point(f, f3d_v3(r * xs[k], y, r * zs[k])); +} + +/* The shape's feature facing along [d] (world, not unit): its face if d is + * within five degrees of square to one, its edge if within five degrees of + * along one, else its furthest point; on the rounded surface, in the + * world. */ +static Feature feature(const F3dPlaced *p, F3dVec3 d) { + Feature f; + f.n = 0; + const f3d_real dl = f3d_sqrt(f3d_dot(d, d)); + const F3dVec3 dn = f3d_scale(d, F3D_R(1.0) / f3d_max(dl, F3D_R(1e-18))); + const F3dVec3 l = local_of(p, dn); + const F3dVec3 s = p->size; + switch (p->kind) { + case F3D_SHAPE_CAPSULE: + if (f3d_abs(l.y) < F3D_FACE_SIN) { + push_point(&f, f3d_v3(F3D_R(0.0), -s.y, F3D_R(0.0))); + push_point(&f, f3d_v3(F3D_R(0.0), s.y, F3D_R(0.0))); + } + break; + case F3D_SHAPE_BOX: { + const f3d_real c[3] = {l.x, l.y, l.z}; + const f3d_real h[3] = {s.x, s.y, s.z}; + int big = 0, small = 0; + for (int k = 1; k < 3; k++) { + if (f3d_abs(c[k]) > f3d_abs(c[big])) big = k; + if (f3d_abs(c[k]) < f3d_abs(c[small])) small = k; + } + if (f3d_abs(c[big]) > F3D_FACE_COS) { + const int u = (big + 1) % 3, v = (big + 2) % 3; + const f3d_real su[4] = {F3D_R(1.0), F3D_R(-1.0), F3D_R(-1.0), F3D_R(1.0)}; + const f3d_real sv[4] = {F3D_R(1.0), F3D_R(1.0), F3D_R(-1.0), F3D_R(-1.0)}; + for (int k = 0; k < 4; k++) { + f3d_real q[3]; + q[big] = c[big] >= F3D_R(0.0) ? h[big] : -h[big]; + q[u] = su[k] * h[u]; + q[v] = sv[k] * h[v]; + push_point(&f, f3d_v3(q[0], q[1], q[2])); + } + } else if (f3d_abs(c[small]) < F3D_FACE_SIN) { + for (int k = 0; k < 2; k++) { + f3d_real q[3]; + for (int a = 0; a < 3; a++) { + q[a] = c[a] >= F3D_R(0.0) ? h[a] : -h[a]; + } + q[small] = k ? h[small] : -h[small]; + push_point(&f, f3d_v3(q[0], q[1], q[2])); + } + } + break; + } + case F3D_SHAPE_CYLINDER: { + if (f3d_abs(l.y) > F3D_FACE_COS) { + disc(&f, s.x, l.y >= F3D_R(0.0) ? s.y : -s.y); + } else if (f3d_abs(l.y) < F3D_FACE_SIN) { + const f3d_real flat = f3d_sqrt(l.x * l.x + l.z * l.z); + const f3d_real k = s.x / flat; + push_point(&f, f3d_v3(l.x * k, -s.y, l.z * k)); + push_point(&f, f3d_v3(l.x * k, s.y, l.z * k)); + } + break; + } + case F3D_SHAPE_CONE: { + const f3d_real big = s.y, r = s.x; + if (l.y < -F3D_FACE_COS) { + disc(&f, r, F3D_R(-0.25) * big); + break; + } + const f3d_real flat = f3d_sqrt(l.x * l.x + l.z * l.z); + if (flat <= F3D_R(1e-12)) break; + const f3d_real ux = l.x / flat, uz = l.z / flat; + const f3d_real slant = f3d_sqrt(big * big + r * r); + const f3d_real along = (big * (l.x * ux + l.z * uz) + r * l.y) / slant; + if (along > F3D_FACE_COS) { + push_point(&f, f3d_v3(F3D_R(0.0), F3D_R(0.75) * big, F3D_R(0.0))); + push_point(&f, f3d_v3(r * ux, F3D_R(-0.25) * big, r * uz)); + } + break; + } + case F3D_SHAPE_TRIANGLE: { + /* Its face within five degrees of square to d; else the edge whose + * two ends reach as far along d, within five degrees; else a + * corner. */ + const F3dVec3 a = hull_vertex(p, 0), b = hull_vertex(p, 1); + const F3dVec3 c = hull_vertex(p, 2); + F3dVec3 n = f3d_cross(f3d_sub(b, a), f3d_sub(c, a)); + const f3d_real len = f3d_sqrt(f3d_dot(n, n)); + if (!(len > F3D_R(0.0))) break; + n = f3d_scale(n, F3D_R(1.0) / len); + if (f3d_abs(f3d_dot(n, l)) > F3D_FACE_COS) { + push_point(&f, a); + push_point(&f, b); + push_point(&f, c); + break; + } + const F3dVec3 corner[3] = {a, b, c}; + for (int k = 0; k < 3; k++) { + const F3dVec3 e = f3d_sub(corner[(k + 1) % 3], corner[k]); + const f3d_real el = f3d_sqrt(f3d_dot(e, e)); + if (!(el > F3D_R(0.0))) continue; + const f3d_real across = f3d_abs(f3d_dot(e, l)) / el; + const f3d_real reach = f3d_dot(corner[k], l); + const f3d_real other = f3d_dot(corner[(k + 2) % 3], l); + if (across < F3D_FACE_SIN && reach >= other) { + push_point(&f, corner[k]); + push_point(&f, corner[(k + 1) % 3]); + break; + } + } + break; + } + case F3D_SHAPE_HULL: { + if (p->hull == NULL) break; + /* The triangle facing most along d; if square enough, every + * triangle in its plane, which is the hull's face. */ + int best = -1; + f3d_real most = F3D_R(-2.0), best_off = F3D_R(0.0); + F3dVec3 best_n = f3d_v3(F3D_R(0.0), F3D_R(0.0), F3D_R(0.0)); + const uint32_t nt = p->hull->triangle_count; + for (uint32_t t = 0; t < nt; t++) { + const F3dVec3 a = hull_vertex(p, p->triangles[t * 3u]); + const F3dVec3 b = hull_vertex(p, p->triangles[t * 3u + 1u]); + const F3dVec3 c = hull_vertex(p, p->triangles[t * 3u + 2u]); + F3dVec3 n = f3d_cross(f3d_sub(b, a), f3d_sub(c, a)); + const f3d_real len = f3d_sqrt(f3d_dot(n, n)); + if (!(len > F3D_R(0.0))) continue; + n = f3d_scale(n, F3D_R(1.0) / len); + const f3d_real facing = f3d_dot(n, l); + if (facing > most) { + most = facing; + best = (int)t; + best_n = n; + best_off = f3d_dot(n, a); + } + } + if (best < 0 || most <= F3D_FACE_COS) break; + const F3dVec3 extent = f3d_sub(p->hull->hi, p->hull->lo); + const f3d_real tol = + F3D_R(1e-4) * (F3D_R(1.0) + f3d_sqrt(f3d_dot(extent, extent))); + uint32_t used[F3D_FEATURE_POINTS]; + uint32_t nused = 0; + for (uint32_t t = 0; t < nt; t++) { + const uint32_t *tri = &p->triangles[t * 3u]; + const F3dVec3 a = hull_vertex(p, tri[0]); + const F3dVec3 b = hull_vertex(p, tri[1]); + const F3dVec3 c = hull_vertex(p, tri[2]); + F3dVec3 n = f3d_cross(f3d_sub(b, a), f3d_sub(c, a)); + const f3d_real len = f3d_sqrt(f3d_dot(n, n)); + if (!(len > F3D_R(0.0))) continue; + n = f3d_scale(n, F3D_R(1.0) / len); + if (f3d_dot(n, best_n) < F3D_R(0.9999) || + f3d_abs(f3d_dot(n, a) - best_off) > tol) { + continue; + } + for (int k = 0; k < 3; k++) { + int seen = 0; + for (uint32_t q = 0; q < nused; q++) seen |= used[q] == tri[k]; + if (!seen && nused < F3D_FEATURE_POINTS) used[nused++] = tri[k]; + } + } + for (uint32_t q = 0; q < nused; q++) { + push_point(&f, hull_vertex(p, used[q])); + } + break; + } + default: + break; + } + if (f.n == 0) push_point(&f, core_support(p, l)); + const f3d_real r = rounding_of(p); + for (uint32_t i = 0; i < f.n; i++) { + f.p[i] = f3d_madd(world_of(p, f.p[i]), dn, r); + } + order(&f, dn); + return f; +} + +/* Keeps the part of [in] inside the convex polygon [ref], seen along n: + * Sutherland–Hodgman against each of its edges, or for a segment, each end + * cut back to the edge it crosses. */ +static uint32_t clip_to(const F3dVec3 *in, uint32_t count, const Feature *ref, + F3dVec3 n, F3dVec3 *out) { + enum { ROOM = 2 * F3D_FEATURE_POINTS + 8 }; + F3dVec3 a[ROOM], b[ROOM]; + uint32_t m = count < ROOM ? count : ROOM; + for (uint32_t i = 0; i < m; i++) a[i] = in[i]; + F3dVec3 mid = f3d_v3(F3D_R(0.0), F3D_R(0.0), F3D_R(0.0)); + for (uint32_t i = 0; i < ref->n; i++) mid = f3d_add(mid, ref->p[i]); + mid = f3d_scale(mid, F3D_R(1.0) / (f3d_real)ref->n); + for (uint32_t e = 0; e < ref->n && m > 0; e++) { + const F3dVec3 r0 = ref->p[e], r1 = ref->p[(e + 1) % ref->n]; + F3dVec3 inward = f3d_cross(n, f3d_sub(r1, r0)); + if (f3d_dot(inward, f3d_sub(mid, r0)) < F3D_R(0.0)) { + inward = f3d_scale(inward, F3D_R(-1.0)); + } + if (m == 2) { + const f3d_real dp = f3d_dot(inward, f3d_sub(a[0], r0)); + const f3d_real dq = f3d_dot(inward, f3d_sub(a[1], r0)); + if (dp < F3D_R(0.0) && dq < F3D_R(0.0)) { + m = 0; + break; + } + const F3dVec3 p = a[0], q = a[1]; + if (dp < F3D_R(0.0)) a[0] = f3d_madd(p, f3d_sub(q, p), dp / (dp - dq)); + if (dq < F3D_R(0.0)) a[1] = f3d_madd(p, f3d_sub(q, p), dp / (dp - dq)); + continue; + } + uint32_t w = 0; + for (uint32_t i = 0; i < m; i++) { + const F3dVec3 p = a[i], q = a[(i + 1) % m]; + const f3d_real dp = f3d_dot(inward, f3d_sub(p, r0)); + const f3d_real dq = f3d_dot(inward, f3d_sub(q, r0)); + if (dp >= F3D_R(0.0) && w < ROOM) b[w++] = p; + if (((dp > F3D_R(0.0) && dq < F3D_R(0.0)) || + (dp < F3D_R(0.0) && dq > F3D_R(0.0))) && + w < ROOM) { + b[w++] = f3d_madd(p, f3d_sub(q, p), dp / (dp - dq)); + } + } + m = w; + for (uint32_t i = 0; i < m; i++) a[i] = b[i]; + } + for (uint32_t i = 0; i < m; i++) out[i] = a[i]; + return m; +} + +/* Of many points, the four that hold the face: the deepest, the furthest + * from it, and the widest either side of the line between them. */ +static void four(F3dVec3 *p, f3d_real *depth, uint32_t *ids, uint32_t *count, + F3dVec3 n) { + if (*count <= F3D_MANIFOLD_POINTS) return; + uint32_t pick[4] = {0, 0, 0, 0}; + for (uint32_t i = 1; i < *count; i++) { + if (depth[i] > depth[pick[0]]) pick[0] = i; + } + f3d_real far = F3D_R(-1.0); + for (uint32_t i = 0; i < *count; i++) { + const F3dVec3 d = f3d_sub(p[i], p[pick[0]]); + if (f3d_dot(d, d) > far) { + far = f3d_dot(d, d); + pick[1] = i; + } + } + const F3dVec3 line = f3d_sub(p[pick[1]], p[pick[0]]); + f3d_real most = F3D_R(0.0), least = F3D_R(0.0); + pick[2] = pick[0]; + pick[3] = pick[1]; + for (uint32_t i = 0; i < *count; i++) { + const f3d_real area = + f3d_dot(f3d_cross(line, f3d_sub(p[i], p[pick[0]])), n); + if (area > most) { + most = area; + pick[2] = i; + } + if (area < least) { + least = area; + pick[3] = i; + } + } + F3dVec3 kp[4]; + f3d_real kd[4]; + uint32_t ki[4]; + uint32_t nk = 0; + for (uint32_t k = 0; k < 4; k++) { + int seen = 0; + for (uint32_t j = 0; j < k; j++) seen |= pick[j] == pick[k]; + if (seen) continue; + kp[nk] = p[pick[k]]; + kd[nk] = depth[pick[k]]; + ki[nk] = ids[pick[k]]; + nk++; + } + for (uint32_t k = 0; k < nk; k++) { + p[k] = kp[k]; + depth[k] = kd[k]; + ids[k] = ki[k]; + } + *count = nk; +} + +static void emit(F3dManifold *out, F3dVec3 point, f3d_real depth, uint32_t id) { + if (out->count >= F3D_MANIFOLD_POINTS) return; + F3dContactPoint *c = &out->points[out->count++]; + c->point = point; + c->depth = depth; + c->id = id; +} + +/* A face's own plane: its normal by Newell's method, turned to face along + * [toward], and its offset. Depth is measured from this, not from the + * face's lowest corner along the contact normal: a floor twenty metres + * across under a normal a tenth of a degree off would put its far corner + * five centimetres low. */ +static F3dVec3 face_normal(const Feature *f, F3dVec3 toward, f3d_real *offset) { + F3dVec3 n = f3d_v3(F3D_R(0.0), F3D_R(0.0), F3D_R(0.0)); + F3dVec3 mid = n; + for (uint32_t i = 0; i < f->n; i++) { + const F3dVec3 p = f->p[i], q = f->p[(i + 1) % f->n]; + n.x += (p.y - q.y) * (p.z + q.z); + n.y += (p.z - q.z) * (p.x + q.x); + n.z += (p.x - q.x) * (p.y + q.y); + mid = f3d_add(mid, p); + } + const f3d_real len = f3d_sqrt(f3d_dot(n, n)); + n = len > F3D_R(0.0) ? f3d_scale(n, F3D_R(1.0) / len) : toward; + if (f3d_dot(n, toward) < F3D_R(0.0)) n = f3d_scale(n, F3D_R(-1.0)); + mid = f3d_scale(mid, F3D_R(1.0) / (f3d_real)f->n); + *offset = f3d_dot(n, mid); + return n; +} + +/* The manifold from the two features facing along n (out of b into a). + * Returns how many points it found; nought leaves the single point to the + * caller. */ +static uint32_t from_features(const F3dPlaced *a, const F3dPlaced *b, + F3dVec3 n, f3d_real margin, F3dManifold *out) { + const Feature fa = feature(a, f3d_scale(n, F3D_R(-1.0))); + const Feature fb = feature(b, n); + F3dVec3 pts[2 * F3D_FEATURE_POINTS + 8]; + f3d_real depth[2 * F3D_FEATURE_POINTS + 8]; + uint32_t count = 0; + if (fa.n >= 3 && fb.n >= 2) { + /* b's face or edge clipped to a's face; deep as far as b reaches past + * a's plane. a's face looks towards b, against n. */ + f3d_real plane; + const F3dVec3 nf = face_normal(&fa, f3d_scale(n, F3D_R(-1.0)), &plane); + count = clip_to(fb.p, fb.n, &fa, n, pts); + uint32_t kept = 0; + for (uint32_t i = 0; i < count; i++) { + const f3d_real d = plane - f3d_dot(nf, pts[i]); + if (d <= -margin) continue; + pts[kept] = f3d_madd(pts[i], n, F3D_R(-0.5) * d); + depth[kept++] = d; + } + count = kept; + } else if (fb.n >= 3 && fa.n == 2) { + f3d_real plane; + const F3dVec3 nf = face_normal(&fb, n, &plane); + count = clip_to(fa.p, fa.n, &fb, n, pts); + uint32_t kept = 0; + for (uint32_t i = 0; i < count; i++) { + const f3d_real d = plane - f3d_dot(nf, pts[i]); + if (d <= -margin) continue; + pts[kept] = f3d_madd(pts[i], n, F3D_R(0.5) * d); + depth[kept++] = d; + } + count = kept; + } else if (fa.n == 2 && fb.n == 2) { + /* Two edges lying along each other keep their overlap. */ + const F3dVec3 da = f3d_sub(fa.p[1], fa.p[0]), db = f3d_sub(fb.p[1], fb.p[0]); + const f3d_real la = f3d_dot(da, da), lb = f3d_dot(db, db); + const F3dVec3 x = f3d_cross(da, db); + if (la > F3D_R(1e-12) && lb > F3D_R(1e-12) && + f3d_dot(x, x) <= F3D_R(1e-4) * la * lb) { + f3d_real lo = f3d_dot(f3d_sub(fb.p[0], fa.p[0]), da) / la; + f3d_real hi = f3d_dot(f3d_sub(fb.p[1], fa.p[0]), da) / la; + if (lo > hi) { + const f3d_real t = lo; + lo = hi; + hi = t; + } + lo = f3d_max(lo, F3D_R(0.0)); + hi = f3d_min(hi, F3D_R(1.0)); + if (hi - lo > F3D_R(1e-3)) { + const f3d_real ends[2] = {lo, hi}; + for (int k = 0; k < 2; k++) { + const F3dVec3 pa = f3d_madd(fa.p[0], da, ends[k]); + const f3d_real t = f3d_clamp( + f3d_dot(f3d_sub(pa, fb.p[0]), db) / lb, F3D_R(0.0), F3D_R(1.0)); + const F3dVec3 pb = f3d_madd(fb.p[0], db, t); + const f3d_real d = f3d_dot(n, f3d_sub(pb, pa)); + if (d <= -margin) continue; + pts[count] = f3d_scale(f3d_add(pa, pb), F3D_R(0.5)); + depth[count++] = d; + } + } + } + } + uint32_t ids[2 * F3D_FEATURE_POINTS + 8]; + for (uint32_t i = 0; i < count; i++) ids[i] = 0x200u + i; + four(pts, depth, ids, &count, n); + for (uint32_t i = 0; i < count; i++) emit(out, pts[i], depth[i], ids[i]); + return count; +} + +uint32_t f3d_collide_convex(const F3dPlaced *a, const F3dPlaced *b, + f3d_real margin, F3dManifold *out) { + out->count = 0; + const f3d_real ra = rounding_of(a), rb = rounding_of(b); + /* GJK on the cores. */ + Simplex s; + F3dVec3 start = f3d_sub(a->at, b->at); + if (f3d_dot(start, start) <= F3D_R(1e-18)) { + start = f3d_v3(F3D_R(1.0), F3D_R(0.0), F3D_R(0.0)); + } + s.v[0] = support(a, b, start); + s.l[0] = F3D_R(1.0); + s.n = 1; + F3dVec3 v = s.v[0].w; + f3d_real size = f3d_dot(v, v); + int overlap = 0; + for (int iteration = 0; iteration < 64; iteration++) { + const f3d_real vv = f3d_dot(v, v); + /* Touching, by the difference's own scale: van den Bergen's test. */ + if (vv <= F3D_GJK_TOUCH * size) { + overlap = 1; + break; + } + const Vertex w = support(a, b, f3d_scale(v, F3D_R(-1.0))); + size = f3d_max(size, f3d_dot(w.w, w.w)); + if (vv - f3d_dot(v, w.w) <= F3D_GJK_RELATIVE * vv) break; + int repeated = 0; + for (uint32_t i = 0; i < s.n; i++) { + const F3dVec3 d = f3d_sub(w.w, s.v[i].w); + repeated |= f3d_dot(d, d) <= F3D_R(1e-18); + } + if (repeated) break; + Simplex next = s; + next.v[next.n++] = w; + int inside = 0; + const F3dVec3 nv = closest(&next, &inside); + if (inside) { + s = next; + overlap = 1; + break; + } + /* No nearer: what there is, is the answer. */ + if (f3d_dot(nv, nv) >= vv) break; + s = next; + v = nv; + } + F3dVec3 n, pa, pb; + f3d_real depth; + if (!overlap) { + pa = f3d_v3(F3D_R(0.0), F3D_R(0.0), F3D_R(0.0)); + pb = pa; + for (uint32_t i = 0; i < s.n; i++) { + pa = f3d_madd(pa, s.v[i].a, s.l[i]); + pb = f3d_madd(pb, s.v[i].b, s.l[i]); + } + const F3dVec3 d = f3d_sub(pa, pb); + const f3d_real distance = f3d_sqrt(f3d_dot(d, d)); + depth = ra + rb - distance; + if (depth <= -margin) return 0; + if (!(distance > F3D_R(0.0))) return 0; + n = f3d_scale(d, F3D_R(1.0) / distance); + } else { + F3dVec3 face; + f3d_real into; + if (!blow_up(a, b, &s) || !expand(a, b, &s, &face, &into, &pa, &pb)) { + return 0; + } + /* Moving a by −face·into takes the origin out of the difference: a + * goes out along −face. */ + n = f3d_scale(face, F3D_R(-1.0)); + depth = into + ra + rb; + } + out->normal = n; + if (from_features(a, b, n, margin, out) > 0) return out->count; + const F3dVec3 on_a = f3d_madd(pa, n, -ra); + const F3dVec3 on_b = f3d_madd(pb, n, rb); + emit(out, f3d_scale(f3d_add(on_a, on_b), F3D_R(0.5)), depth, 0x100u); + return out->count; +} + +/* -------------------------------------------------------------- meshes */ + +/* The point of triangle a, b, c nearest [p]: Ericson's + * ClosestPtPointTriangle. */ +static F3dVec3 nearest_on_triangle(F3dVec3 p, F3dVec3 a, F3dVec3 b, F3dVec3 c) { + const F3dVec3 ab = f3d_sub(b, a), ac = f3d_sub(c, a), ap = f3d_sub(p, a); + const f3d_real d1 = f3d_dot(ab, ap), d2 = f3d_dot(ac, ap); + if (d1 <= F3D_R(0.0) && d2 <= F3D_R(0.0)) return a; + const F3dVec3 bp = f3d_sub(p, b); + const f3d_real d3 = f3d_dot(ab, bp), d4 = f3d_dot(ac, bp); + if (d3 >= F3D_R(0.0) && d4 <= d3) return b; + const f3d_real vc = d1 * d4 - d3 * d2; + if (vc <= F3D_R(0.0) && d1 >= F3D_R(0.0) && d3 <= F3D_R(0.0)) { + return f3d_madd(a, ab, d1 / (d1 - d3)); + } + const F3dVec3 cp = f3d_sub(p, c); + const f3d_real d5 = f3d_dot(ab, cp), d6 = f3d_dot(ac, cp); + if (d6 >= F3D_R(0.0) && d5 <= d6) return c; + const f3d_real vb = d5 * d2 - d1 * d6; + if (vb <= F3D_R(0.0) && d2 >= F3D_R(0.0) && d6 <= F3D_R(0.0)) { + return f3d_madd(a, ac, d2 / (d2 - d6)); + } + const f3d_real va = d3 * d6 - d5 * d4; + if (va <= F3D_R(0.0) && d4 - d3 >= F3D_R(0.0) && d5 - d6 >= F3D_R(0.0)) { + return f3d_madd(b, f3d_sub(c, b), (d4 - d3) / ((d4 - d3) + (d5 - d6))); + } + const f3d_real denom = F3D_R(1.0) / (va + vb + vc); + return f3d_add(a, f3d_add(f3d_scale(ab, vb * denom), f3d_scale(ac, vc * denom))); +} + +#define F3D_ROUND_CANDIDATES 8 + +/* A ball or a capsule against one triangle: its axis's ends, and the + * points of its axis nearest each edge, each against the triangle as a + * ball — so a capsule lying on a triangle rests on both ends. The deepest + * gives the normal; the rest that agree with it are kept. */ +static uint32_t round_triangle(const F3dPlaced *body, F3dVec3 a, F3dVec3 b, + F3dVec3 c, F3dVec3 face, f3d_real margin, + F3dManifold *out) { + F3dVec3 p0 = body->at, p1 = body->at; + if (body->kind == F3D_SHAPE_CAPSULE) { + const F3dVec3 half = f3d_scale(body->axes.c[1], body->size.y); + p0 = f3d_sub(body->at, half); + p1 = f3d_add(body->at, half); + } + const f3d_real r = rounding_of(body); + F3dVec3 on[F3D_ROUND_CANDIDATES]; + uint32_t n_on = 0; + on[n_on++] = p0; + if (body->kind == F3D_SHAPE_CAPSULE) { + on[n_on++] = p1; + const F3dVec3 corner[3] = {a, b, c}; + for (int k = 0; k < 3; k++) { + f3d_real s, t; + f3d_nearest_of_segments(p0, p1, corner[k], corner[(k + 1) % 3], &s, &t); + on[n_on++] = f3d_madd(p0, f3d_sub(p1, p0), s); + } + /* Through the triangle's plane, the crossing too. */ + const f3d_real d0 = f3d_dot(face, f3d_sub(p0, a)); + const f3d_real d1 = f3d_dot(face, f3d_sub(p1, a)); + if ((d0 < F3D_R(0.0)) != (d1 < F3D_R(0.0))) { + on[n_on++] = f3d_madd(p0, f3d_sub(p1, p0), d0 / (d0 - d1)); + } + } + F3dVec3 pts[F3D_ROUND_CANDIDATES], normals[F3D_ROUND_CANDIDATES]; + f3d_real depth[F3D_ROUND_CANDIDATES]; + uint32_t found = 0; + for (uint32_t i = 0; i < n_on; i++) { + const F3dVec3 q = on[i]; + const F3dVec3 near = nearest_on_triangle(q, a, b, c); + const F3dVec3 d = f3d_sub(q, near); + const f3d_real dist = f3d_sqrt(f3d_dot(d, d)); + /* On or behind the plane: out along the face, as deep as it is in. */ + const f3d_real above = f3d_dot(face, f3d_sub(q, a)); + F3dVec3 nn; + f3d_real dd; + if (dist <= F3D_R(1e-9) || above < F3D_R(0.0)) { + nn = face; + dd = r - above; + } else { + nn = f3d_scale(d, F3D_R(1.0) / dist); + dd = r - dist; + } + if (dd <= -margin) continue; + int same = 0; + for (uint32_t k = 0; k < found; k++) { + const F3dVec3 e = f3d_sub(pts[k], q); + same |= f3d_dot(e, e) <= F3D_R(1e-12); + } + if (same) continue; + pts[found] = q; + normals[found] = nn; + depth[found] = dd; + found++; + } + if (found == 0) return 0; + uint32_t best = 0; + for (uint32_t i = 1; i < found; i++) { + if (depth[i] > depth[best]) best = i; + } + out->normal = normals[best]; + for (uint32_t i = 0; i < found; i++) { + if (f3d_dot(normals[i], normals[best]) < F3D_R(0.95)) continue; + const F3dVec3 surface = f3d_madd(pts[i], normals[i], -r); + emit(out, f3d_madd(surface, normals[i], F3D_R(0.5) * depth[i]), depth[i], i); + } + return out->count; +} + +/* Gathers the triangles a query finds. */ +#define F3D_MESH_NEAR 512 + +typedef struct NearTriangles { + uint32_t t[F3D_MESH_NEAR]; + uint32_t n; + const F3dTree *tree; +} NearTriangles; + +static int near_triangle(void *context, int32_t leaf) { + NearTriangles *g = (NearTriangles *)context; + if (g->n < F3D_MESH_NEAR) g->t[g->n++] = g->tree->nodes[leaf].slot; + return g->n < F3D_MESH_NEAR; +} + +uint32_t f3d_collide_mesh(const F3dPlaced *mesh, const F3dPlaced *body, + f3d_real margin, F3dManifold *out) { + out->count = 0; + if (mesh->mesh == NULL || mesh->mesh_tree == NULL) return 0; + const f3d_real r = rounding_of(body); + /* The body's box in the mesh's frame, by its support along each of the + * mesh's axes. */ + F3dBox box; + f3d_real lo[3], hi[3]; + for (int k = 0; k < 3; k++) { + const F3dVec3 axis = mesh->axes.c[k]; + const F3dVec3 up = world_of(body, core_support(body, local_of(body, axis))); + const F3dVec3 down = world_of( + body, core_support(body, local_of(body, f3d_scale(axis, F3D_R(-1.0))))); + hi[k] = f3d_dot(axis, f3d_sub(up, mesh->at)) + r + margin; + lo[k] = f3d_dot(axis, f3d_sub(down, mesh->at)) - r - margin; + } + box.lo = f3d_v3(lo[0], lo[1], lo[2]); + box.hi = f3d_v3(hi[0], hi[1], hi[2]); + NearTriangles near; + near.n = 0; + near.tree = mesh->mesh_tree; + f3d_tree_query(mesh->mesh_tree, box, near_triangle, &near); + /* In index order, whatever order the tree gave them: the answer must not + * depend on the tree's shape. */ + for (uint32_t i = 1; i < near.n; i++) { + const uint32_t v = near.t[i]; + uint32_t j = i; + while (j > 0 && near.t[j - 1] > v) { + near.t[j] = near.t[j - 1]; + j--; + } + near.t[j] = v; + } + enum { ROOM = 64 }; + F3dVec3 pts[ROOM], normals[ROOM]; + f3d_real depth[ROOM]; + uint32_t ids[ROOM]; + uint32_t found = 0; + for (uint32_t q = 0; q < near.n; q++) { + const uint32_t t = near.t[q]; + const uint32_t *tri = &mesh->triangles[t * 3u]; + f3d_real corners[9]; + F3dVec3 v[3]; + for (int k = 0; k < 3; k++) { + v[k] = world_of(mesh, hull_vertex(mesh, tri[k])); + corners[k * 3] = v[k].x; + corners[k * 3 + 1] = v[k].y; + corners[k * 3 + 2] = v[k].z; + } + F3dVec3 face = f3d_cross(f3d_sub(v[1], v[0]), f3d_sub(v[2], v[0])); + const f3d_real len = f3d_sqrt(f3d_dot(face, face)); + if (!(len > F3D_R(0.0))) continue; + face = f3d_scale(face, F3D_R(1.0) / len); + /* One sided: a body whose centre is behind the triangle does not see + * it. */ + if (f3d_dot(face, f3d_sub(body->at, v[0])) < F3D_R(0.0)) continue; + F3dManifold tm; + f3d_zero(&tm, sizeof tm); + uint32_t n; + if (body->kind == F3D_SHAPE_SPHERE || body->kind == F3D_SHAPE_CAPSULE) { + n = round_triangle(body, v[0], v[1], v[2], face, margin, &tm); + } else { + F3dPlaced p; + f3d_zero(&p, sizeof p); + p.kind = F3D_SHAPE_TRIANGLE; + p.axes.c[0] = f3d_v3(F3D_R(1.0), F3D_R(0.0), F3D_R(0.0)); + p.axes.c[1] = f3d_v3(F3D_R(0.0), F3D_R(1.0), F3D_R(0.0)); + p.axes.c[2] = f3d_v3(F3D_R(0.0), F3D_R(0.0), F3D_R(1.0)); + p.vertices = corners; + n = f3d_collide_convex(body, &p, margin, &tm); + } + if (n == 0) continue; + /* An edge or corner contact on an internal edge is the face's: the + * body is on the same surface across the seam, and the edge's normal + * would trip it. A contact whose normal is not the face's is taken back + * to the face only when every edge it lies on is internal. */ + if (f3d_dot(tm.normal, face) < F3D_R(0.9999) && tm.count == 1) { + const F3dVec3 x = tm.points[0].point; + const F3dVec3 e0 = f3d_sub(v[1], v[0]), e1 = f3d_sub(v[2], v[0]); + const F3dVec3 e2 = f3d_sub(x, v[0]); + const f3d_real d00 = f3d_dot(e0, e0), d01 = f3d_dot(e0, e1); + const f3d_real d11 = f3d_dot(e1, e1), d20 = f3d_dot(e2, e0); + const f3d_real d21 = f3d_dot(e2, e1); + const f3d_real den = d00 * d11 - d01 * d01; + const f3d_real wb = den > F3D_R(0.0) ? (d11 * d20 - d01 * d21) / den : F3D_R(0.0); + const f3d_real wc = den > F3D_R(0.0) ? (d00 * d21 - d01 * d20) / den : F3D_R(0.0); + const f3d_real wa = F3D_R(1.0) - wb - wc; + const f3d_real tol = F3D_R(1e-3); + const uint8_t flags = mesh->edge_flags[t]; + /* Edge k runs from corner k to the next; it is the one opposite the + * corner whose weight is nought. */ + int internal = 1; + if (wc <= tol && !(flags & 1u)) internal = 0; + if (wa <= tol && !(flags & 2u)) internal = 0; + if (wb <= tol && !(flags & 4u)) internal = 0; + if (internal) { + const F3dVec3 s = world_of( + body, core_support(body, local_of(body, f3d_scale(face, F3D_R(-1.0))))); + const f3d_real d = f3d_dot(face, v[0]) - f3d_dot(face, s) + r; + if (d <= -margin) continue; + tm.normal = face; + const F3dVec3 deepest = f3d_madd(s, face, -r); + tm.points[0].point = f3d_madd(deepest, face, F3D_R(0.5) * d); + tm.points[0].depth = d; + } + } + for (uint32_t k = 0; k < tm.count && found < ROOM; k++) { + pts[found] = tm.points[k].point; + normals[found] = tm.normal; + depth[found] = tm.points[k].depth; + ids[found] = (t << 4) | (tm.points[k].id & 15u); + found++; + } + } + if (found == 0) return 0; + /* One manifold: the deepest point's normal, and every point whose + * triangle agrees with it to eighteen degrees. */ + uint32_t best = 0; + for (uint32_t i = 1; i < found; i++) { + if (depth[i] > depth[best]) best = i; + } + const F3dVec3 n = normals[best]; + uint32_t kept = 0; + for (uint32_t i = 0; i < found; i++) { + if (f3d_dot(normals[i], n) < F3D_R(0.95)) continue; + pts[kept] = pts[i]; + depth[kept] = depth[i]; + ids[kept] = ids[i]; + kept++; + } + four(pts, depth, ids, &kept, n); + out->normal = n; + for (uint32_t i = 0; i < kept; i++) emit(out, pts[i], depth[i], ids[i]); + return out->count; +} diff --git a/packages/flutter3d_physics_native/csrc/src/f3d_debris.c b/packages/flutter3d_physics_native/csrc/src/f3d_debris.c new file mode 100644 index 000000000..e8ffce8ad --- /dev/null +++ b/packages/flutter3d_physics_native/csrc/src/f3d_debris.c @@ -0,0 +1,404 @@ +/* + * Debris on the CPU — P9, phase 10: the reference for the GPU's visual + * bodies, and the fallback where there is none. See f3d_physics.h for what + * they do; the WGSL in f3d_gpu.c does the same, pass for pass. + * + * A substep is four passes, each reading what the last one wrote and + * nothing it writes itself, so the GPU can run every body of a pass at + * once: + * + * 1. gravity, the speed limit, and each body into its cell of the grid; + * 2. each body counts its contacts — pairs and still shapes that touch + * or nearly do; + * 3. each body sums the impulses of its contacts into a new velocity, + * every contact computed from the old ones, and a pair always from + * its lower slot's side, so both bodies get the same impulse to the + * bit — as many times over as the settings' iterations; + * 4. damping, and each body moved and turned. + * + * The impulse of a contact: it is let close by no more than its gap, and + * pushed apart at four fifths of its depth past the slop a substep, up to + * 3 m/s; + * faster than 1 m/s it bounces at the restitution; friction takes the + * slide up to μ times the push. Every body counts as its mass divided + * among its contacts — mass splitting, which keeps a Jacobi heap from + * blowing up. + * + * Pushing the overlap out apart from the velocity (a split impulse) was + * tried and is worse here: Jacobi leaves a deep heap a little velocity + * downwards each substep, and only a push in the velocity takes it back. + */ +#include "f3d_internal.h" + +typedef struct Body { + F3dVec3 position; + f3d_real radius; + F3dVec3 velocity; + f3d_real inv_mass; + F3dVec3 spin; + f3d_real inv_inertia; + F3dQuat turn; +} Body; + +typedef struct Still { + /* A plane: normal and offset. A box: centre and nought. */ + f3d_real a[4]; + /* A plane: noughts. A box: half extents and one. */ + f3d_real b[4]; +} Still; + +struct F3dDebris { + Body *bodies; + /* Pass 3's velocities and spins, applied in pass 4. */ + F3dVec3 *new_velocity; + F3dVec3 *new_spin; + uint32_t *contacts; + /* The grid: each cell's first body, and each body's next in its cell; + * NONE ends a list. */ + uint32_t *cell_head; + uint32_t *next_in_cell; + uint32_t cells; + uint32_t capacity; + uint32_t next; + f3d_real max_radius; + Still statics[F3D_DEBRIS_MAX_STATICS]; + uint32_t static_count; +}; + +#define NONE 0xFFFFFFFFu + +#define SLOP F3D_R(0.0005) +#define PUSH F3D_R(0.8) +#define MAX_PUSH F3D_R(3.0) +#define BOUNCE_SPEED F3D_R(1.0) + +F3dDebris *f3d_debris_create(uint32_t capacity) { + if (capacity == 0 || capacity > (1u << 22)) return NULL; + F3dDebris *d = (F3dDebris *)f3d_alloc(sizeof(F3dDebris)); + if (d == NULL) return NULL; + f3d_zero(d, sizeof(F3dDebris)); + uint32_t cells = 1; + while (cells < capacity * 2u) cells <<= 1; + d->cells = cells; + d->capacity = capacity; + d->bodies = (Body *)f3d_alloc((size_t)capacity * sizeof(Body)); + d->new_velocity = (F3dVec3 *)f3d_alloc((size_t)capacity * sizeof(F3dVec3)); + d->new_spin = (F3dVec3 *)f3d_alloc((size_t)capacity * sizeof(F3dVec3)); + d->contacts = (uint32_t *)f3d_alloc((size_t)capacity * sizeof(uint32_t)); + d->cell_head = (uint32_t *)f3d_alloc((size_t)cells * sizeof(uint32_t)); + d->next_in_cell = (uint32_t *)f3d_alloc((size_t)capacity * sizeof(uint32_t)); + if (d->bodies == NULL || d->new_velocity == NULL || d->new_spin == NULL || + d->contacts == NULL || d->cell_head == NULL || d->next_in_cell == NULL) { + f3d_debris_destroy(d); + return NULL; + } + f3d_zero(d->bodies, (size_t)capacity * sizeof(Body)); + for (uint32_t i = 0; i < capacity; i++) d->bodies[i].turn.w = F3D_R(1.0); + return d; +} + +void f3d_debris_destroy(F3dDebris *d) { + if (d == NULL) return; + f3d_free(d->bodies); + f3d_free(d->new_velocity); + f3d_free(d->new_spin); + f3d_free(d->contacts); + f3d_free(d->cell_head); + f3d_free(d->next_in_cell); + f3d_free(d); +} + +uint32_t f3d_debris_capacity(const F3dDebris *d) { return d->capacity; } + +uint32_t f3d_debris_add(F3dDebris *d, const f3d_real *data, uint32_t count) { + const uint32_t first = d->next; + for (uint32_t i = 0; i < count; i++) { + const f3d_real *in = data + (size_t)i * F3D_DEBRIS_INPUT_FLOATS; + Body *b = &d->bodies[d->next]; + f3d_zero(b, sizeof(Body)); + b->turn.w = F3D_R(1.0); + const f3d_real r = in[6], m = in[7]; + if (f3d_finite(r) && f3d_finite(m) && r > F3D_R(0.0) && m > F3D_R(0.0)) { + b->position = f3d_v3(in[0], in[1], in[2]); + b->velocity = f3d_v3(in[3], in[4], in[5]); + b->radius = r; + b->inv_mass = F3D_R(1.0) / m; + /* A solid ball: I = 2/5 m r². */ + b->inv_inertia = F3D_R(2.5) / (m * r * r); + if (r > d->max_radius) d->max_radius = r; + } + d->next = (d->next + 1u) % d->capacity; + } + return first; +} + +int f3d_debris_set_statics(F3dDebris *d, const f3d_real *data, uint32_t count) { + if (count > F3D_DEBRIS_MAX_STATICS) return 0; + for (uint32_t i = 0; i < count; i++) { + for (int k = 0; k < 4; k++) { + d->statics[i].a[k] = data[i * 8u + (uint32_t)k]; + d->statics[i].b[k] = data[i * 8u + 4u + (uint32_t)k]; + } + } + d->static_count = count; + return 1; +} + +/* The cell a coordinate falls in: floor(x / cell), held to ±10⁹. */ +static int32_t cell_coord(f3d_real x, f3d_real inv_cell) { + const f3d_real v = f3d_clamp(x * inv_cell, F3D_R(-1e9), F3D_R(1e9)); + int32_t t = (int32_t)v; + if ((f3d_real)t > v) t--; + return t; +} + +static uint32_t cell_hash(int32_t x, int32_t y, int32_t z, uint32_t cells) { + return (((uint32_t)x * 73856093u) ^ ((uint32_t)y * 19349663u) ^ + ((uint32_t)z * 83492791u)) & + (cells - 1u); +} + +/* What a contact needs: the normal from the other towards this body, the + * gap along it (less than nought when overlapping), and where it touches, + * from each centre. */ +typedef struct Touch { + F3dVec3 normal; + f3d_real gap; +} Touch; + +static Touch touch_pair(const Body *lo, const Body *hi) { + const F3dVec3 d = f3d_sub(lo->position, hi->position); + const f3d_real len = f3d_sqrt(f3d_dot(d, d)); + Touch t; + t.normal = len > F3D_R(0.0) ? f3d_scale(d, F3D_R(1.0) / len) : f3d_v3(0, 1, 0); + t.gap = len - lo->radius - hi->radius; + return t; +} + +static Touch touch_still(const Body *b, const Still *s) { + Touch t; + if (s->b[3] == F3D_R(0.0)) { + t.normal = f3d_v3(s->a[0], s->a[1], s->a[2]); + t.gap = f3d_dot(b->position, t.normal) - s->a[3] - b->radius; + return t; + } + const f3d_real c[3] = {s->a[0], s->a[1], s->a[2]}; + const f3d_real p[3] = {b->position.x, b->position.y, b->position.z}; + f3d_real q[3]; + for (int k = 0; k < 3; k++) q[k] = f3d_clamp(p[k], c[k] - s->b[k], c[k] + s->b[k]); + const F3dVec3 d = f3d_v3(p[0] - q[0], p[1] - q[1], p[2] - q[2]); + const f3d_real len = f3d_sqrt(f3d_dot(d, d)); + if (len > F3D_R(0.0)) { + t.normal = f3d_scale(d, F3D_R(1.0) / len); + t.gap = len - b->radius; + return t; + } + /* The centre inside: out through the nearest face. */ + int axis = 0; + f3d_real best = s->b[0] - f3d_abs(p[0] - c[0]); + for (int k = 1; k < 3; k++) { + const f3d_real depth = s->b[k] - f3d_abs(p[k] - c[k]); + if (depth < best) { + best = depth; + axis = k; + } + } + f3d_real n[3] = {0, 0, 0}; + n[axis] = p[axis] < c[axis] ? F3D_R(-1.0) : F3D_R(1.0); + t.normal = f3d_v3(n[0], n[1], n[2]); + t.gap = -best - b->radius; + return t; +} + +/* The velocity of a body's surface where it touches along [n], -n r from + * its centre. */ +static F3dVec3 surface_velocity(const Body *b, F3dVec3 n, f3d_real r) { + return f3d_add(b->velocity, f3d_cross(b->spin, f3d_scale(n, -r))); +} + +/* Whether a contact counts: its gap within [reach]. By where the bodies + * are, not how fast they go, so a substep's contacts stay the same through + * all its impulse passes, as their counts do. */ +static int active(Touch t, f3d_real reach) { return t.gap <= reach; } + +/* How far a pair's contact reaches: a quarter of their radii together; + * a still shape's, the body's radius. Closer than three of the largest + * radii from centre to centre, so the grid's neighbours hold every pair. */ +static f3d_real pair_reach(const Body *lo, const Body *hi) { + return F3D_R(0.25) * (lo->radius + hi->radius); +} + +/* The impulse on [lo] of its contact with [hi] (null for a still shape, + * whose surface does not move), each counting [n_lo] and [n_hi] contacts: + * nought when the contact is not active or holds without one. */ +static F3dVec3 impulse(const Body *lo, uint32_t n_lo, const Body *hi, + uint32_t n_hi, Touch t, const F3dDebrisSettings *s, + f3d_real h) { + if (!active(t, hi != NULL ? pair_reach(lo, hi) : lo->radius)) return f3d_v3(0, 0, 0); + F3dVec3 rel = surface_velocity(lo, t.normal, lo->radius); + if (hi != NULL) { + rel = f3d_sub(rel, surface_velocity(hi, f3d_scale(t.normal, F3D_R(-1.0)), hi->radius)); + } + const f3d_real vn = f3d_dot(rel, t.normal); + f3d_real target = t.gap > F3D_R(0.0) + ? -t.gap / h + : f3d_min(f3d_max(-t.gap - SLOP, F3D_R(0.0)) * PUSH / h, MAX_PUSH); + if (vn < -BOUNCE_SPEED && t.gap <= SLOP) target = f3d_max(target, -s->restitution * vn); + const f3d_real dvn = target - vn; + if (!(dvn > F3D_R(0.0))) return f3d_v3(0, 0, 0); + const f3d_real w_lo = lo->inv_mass * (f3d_real)n_lo; + const f3d_real w_hi = hi != NULL ? hi->inv_mass * (f3d_real)n_hi : F3D_R(0.0); + const f3d_real pn = dvn / (w_lo + w_hi); + F3dVec3 p = f3d_scale(t.normal, pn); + const F3dVec3 vt = f3d_madd(rel, t.normal, -vn); + const f3d_real slide = f3d_sqrt(f3d_dot(vt, vt)); + if (slide > F3D_R(0.0)) { + f3d_real k = (lo->inv_mass + lo->radius * lo->radius * lo->inv_inertia) * (f3d_real)n_lo; + if (hi != NULL) { + k += (hi->inv_mass + hi->radius * hi->radius * hi->inv_inertia) * (f3d_real)n_hi; + } + const f3d_real pt = f3d_min(slide / k, s->friction * pn); + p = f3d_madd(p, vt, -pt / slide); + } + return p; +} + +void f3d_debris_step(F3dDebris *d, const F3dDebrisSettings *s, f3d_real dt) { + if (!(f3d_finite(dt) && dt > F3D_R(0.0))) return; + const uint32_t substeps = s->substeps > 0 ? s->substeps : 1u; + const uint32_t iterations = s->iterations > 0 ? s->iterations : 1u; + const f3d_real h = dt / (f3d_real)substeps; + const F3dVec3 g = f3d_v3(s->gravity[0], s->gravity[1], s->gravity[2]); + const f3d_real cell = d->max_radius * F3D_R(3.0); + if (!(cell > F3D_R(0.0))) return; + const f3d_real inv_cell = F3D_R(1.0) / cell; + const f3d_real keep_v = F3D_R(1.0) / (F3D_R(1.0) + s->linear_damping * h); + const f3d_real keep_w = F3D_R(1.0) / (F3D_R(1.0) + s->angular_damping * h); + for (uint32_t sub = 0; sub < substeps; sub++) { + /* 1. Gravity, the speed limit, the grid. */ + for (uint32_t c = 0; c < d->cells; c++) d->cell_head[c] = NONE; + for (uint32_t i = 0; i < d->capacity; i++) { + Body *b = &d->bodies[i]; + if (!(b->radius > F3D_R(0.0))) continue; + b->velocity = f3d_madd(b->velocity, g, h); + const f3d_real speed2 = f3d_dot(b->velocity, b->velocity); + if (speed2 > s->max_speed * s->max_speed) { + b->velocity = f3d_scale(b->velocity, s->max_speed / f3d_sqrt(speed2)); + } + const uint32_t c = cell_hash(cell_coord(b->position.x, inv_cell), + cell_coord(b->position.y, inv_cell), + cell_coord(b->position.z, inv_cell), d->cells); + d->next_in_cell[i] = d->cell_head[c]; + d->cell_head[c] = i; + } + /* 2 and 3: each body's contacts, counted, then their impulses, as many + * times as the settings say. */ + for (uint32_t pass = 0; pass <= iterations; pass++) { + const int counting = pass == 0; + /* An impulse pass after the first starts from the last one's. */ + if (pass > 1) { + for (uint32_t i = 0; i < d->capacity; i++) { + d->bodies[i].velocity = d->new_velocity[i]; + d->bodies[i].spin = d->new_spin[i]; + } + } + for (uint32_t i = 0; i < d->capacity; i++) { + const Body *b = &d->bodies[i]; + if (!(b->radius > F3D_R(0.0))) continue; + uint32_t count = 0; + F3dVec3 dv = b->velocity, dw = b->spin; + const int32_t cx = cell_coord(b->position.x, inv_cell); + const int32_t cy = cell_coord(b->position.y, inv_cell); + const int32_t cz = cell_coord(b->position.z, inv_cell); + for (int32_t x = cx - 1; x <= cx + 1; x++) { + for (int32_t y = cy - 1; y <= cy + 1; y++) { + for (int32_t z = cz - 1; z <= cz + 1; z++) { + for (uint32_t j = d->cell_head[cell_hash(x, y, z, d->cells)]; j != NONE; + j = d->next_in_cell[j]) { + if (j == i) continue; + const Body *o = &d->bodies[j]; + /* Another cell sharing this one's hash: met there. */ + if (cell_coord(o->position.x, inv_cell) != x || + cell_coord(o->position.y, inv_cell) != y || + cell_coord(o->position.z, inv_cell) != z) { + continue; + } + const Body *lo = i < j ? b : o; + const Body *hi = i < j ? o : b; + const Touch t = touch_pair(lo, hi); + if (counting) { + if (active(t, pair_reach(lo, hi))) count++; + continue; + } + const uint32_t n_lo = d->contacts[i < j ? i : j]; + const uint32_t n_hi = d->contacts[i < j ? j : i]; + const F3dVec3 p = impulse(lo, n_lo, hi, n_hi, t, s, h); + /* On this body, from where it touches. */ + const f3d_real sign = i < j ? F3D_R(1.0) : F3D_R(-1.0); + const F3dVec3 n_out = f3d_scale(t.normal, sign); + const F3dVec3 mine = f3d_scale(p, sign); + dv = f3d_madd(dv, mine, b->inv_mass); + dw = f3d_madd(dw, f3d_cross(f3d_scale(n_out, -b->radius), mine), + b->inv_inertia); + } + } + } + } + for (uint32_t k = 0; k < d->static_count; k++) { + const Touch t = touch_still(b, &d->statics[k]); + if (counting) { + if (active(t, b->radius)) count++; + continue; + } + const F3dVec3 p = impulse(b, d->contacts[i], NULL, 0, t, s, h); + dv = f3d_madd(dv, p, b->inv_mass); + dw = f3d_madd(dw, f3d_cross(f3d_scale(t.normal, -b->radius), p), b->inv_inertia); + } + if (counting) { + d->contacts[i] = count; + } else { + d->new_velocity[i] = dv; + d->new_spin[i] = dw; + } + } + } + /* 4. Damping, and every body moved and turned. */ + for (uint32_t i = 0; i < d->capacity; i++) { + Body *b = &d->bodies[i]; + if (!(b->radius > F3D_R(0.0))) continue; + b->velocity = f3d_scale(d->new_velocity[i], keep_v); + b->spin = f3d_scale(d->new_spin[i], keep_w); + b->position = f3d_madd(b->position, b->velocity, h); + const F3dVec3 w = b->spin; + const F3dQuat q = b->turn; + const f3d_real k = F3D_R(0.5) * h; + F3dQuat r; + r.x = q.x + k * (w.x * q.w + w.y * q.z - w.z * q.y); + r.y = q.y + k * (w.y * q.w + w.z * q.x - w.x * q.z); + r.z = q.z + k * (w.z * q.w + w.x * q.y - w.y * q.x); + r.w = q.w - k * (w.x * q.x + w.y * q.y + w.z * q.z); + const f3d_real len = f3d_sqrt(r.x * r.x + r.y * r.y + r.z * r.z + r.w * r.w); + b->turn.x = r.x / len; + b->turn.y = r.y / len; + b->turn.z = r.z / len; + b->turn.w = r.w / len; + } + } +} + +uint32_t f3d_debris_read(const F3dDebris *d, f3d_real *out, uint32_t capacity) { + const uint32_t n = capacity < d->capacity ? capacity : d->capacity; + for (uint32_t i = 0; i < n; i++) { + const Body *b = &d->bodies[i]; + f3d_real *o = out + (size_t)i * F3D_DEBRIS_FLOATS; + o[0] = b->position.x; + o[1] = b->position.y; + o[2] = b->position.z; + o[3] = b->turn.x; + o[4] = b->turn.y; + o[5] = b->turn.z; + o[6] = b->turn.w; + o[7] = b->radius; + } + return n; +} diff --git a/packages/flutter3d_physics_native/csrc/src/f3d_fluid.c b/packages/flutter3d_physics_native/csrc/src/f3d_fluid.c new file mode 100644 index 000000000..66678995f --- /dev/null +++ b/packages/flutter3d_physics_native/csrc/src/f3d_fluid.c @@ -0,0 +1,346 @@ +/* + * Fluid on the CPU — P9, phase 10: the reference for the GPU's, and the + * fallback where there is none. See f3d_physics.h for what it does; the + * WGSL in f3d_gpu_fluid.c does the same, pass for pass. + * + * Position-based fluids (Macklin and Müller, 2013), each substep: + * + * 1. gravity into the velocity, a predicted position from it, and each + * particle into its cell of a hashed grid as wide as the kernel, with + * the cell it went in kept beside it; + * 2. as many times as the settings say: each particle's density from its + * neighbours and the tank's walls, and the multiplier that would bring + * it to rest density — only ever pushing apart, never pulling, so + * particles do not clump; then each particle moved by its own and its + * neighbours' multipliers, pushed off the walls, and held inside; + * 3. the velocity from where it went, smoothed towards its neighbours' + * (XSPH viscosity), and the position taken. + * + * A wall counts as the fluid going on past it: layers of still particles + * the spacing apart, the first half a spacing beyond the wall, each right + * under the particle — so they add to its density, and push it straight + * off the wall as a neighbour of the same multiplier would. Without them + * a particle by the floor has no neighbours below, is short of density, + * and the water above presses the bottom layer flat against the floor: + * 325 particles where a layer holds 200, and the water too shallow. + * + * Each pass reads what the last wrote and writes only its own particle's, + * so the GPU runs every particle of a pass at once. Mass is one per + * particle, and rest density is what the kernel sums to over a cubic + * lattice of the spacing. + */ +#include "f3d_internal.h" + +struct F3dFluid { + F3dVec3 *position; + F3dVec3 *predicted; + F3dVec3 *velocity; + F3dVec3 *next; + f3d_real *lambda; + f3d_real *density; + uint32_t *cell_head; + uint32_t *next_in_cell; + int32_t *cell; + uint32_t cells; + uint32_t capacity; + uint32_t count; + uint32_t next_slot; + f3d_real spacing; + f3d_real h; + f3d_real rest_density; + f3d_real poly6; + f3d_real spiky; + /* The tank's walls, for the step under way. */ + F3dVec3 tank_min; + F3dVec3 tank_max; +}; + +#define NONE 0xFFFFFFFFu +/* π, for the kernels' constants: a number, not a call. */ +#define PI F3D_R(3.14159265358979323846) + +static f3d_real poly6(const F3dFluid *f, f3d_real r2) { + const f3d_real d = f->h * f->h - r2; + return d > F3D_R(0.0) ? f->poly6 * d * d * d : F3D_R(0.0); +} + +/* The spiky kernel's gradient at [d], |d| = [r]: towards d, falling to + * nought at h. */ +static F3dVec3 spiky(const F3dFluid *f, F3dVec3 d, f3d_real r) { + if (!(r > F3D_R(0.0)) || !(r < f->h)) return f3d_v3(0, 0, 0); + const f3d_real k = f->h - r; + return f3d_scale(d, f->spiky * k * k / r); +} + +F3dFluid *f3d_fluid_create(uint32_t capacity, f3d_real spacing) { + if (capacity == 0 || capacity > (1u << 22) || !(f3d_finite(spacing) && spacing > F3D_R(0.0))) { + return NULL; + } + F3dFluid *f = (F3dFluid *)f3d_alloc(sizeof(F3dFluid)); + if (f == NULL) return NULL; + f3d_zero(f, sizeof(F3dFluid)); + uint32_t cells = 1; + while (cells < capacity * 2u) cells <<= 1; + f->cells = cells; + f->capacity = capacity; + const size_t vec = (size_t)capacity * sizeof(F3dVec3); + const size_t real = (size_t)capacity * sizeof(f3d_real); + f->position = (F3dVec3 *)f3d_alloc(vec); + f->predicted = (F3dVec3 *)f3d_alloc(vec); + f->velocity = (F3dVec3 *)f3d_alloc(vec); + f->next = (F3dVec3 *)f3d_alloc(vec); + f->lambda = (f3d_real *)f3d_alloc(real); + f->density = (f3d_real *)f3d_alloc(real); + f->cell_head = (uint32_t *)f3d_alloc((size_t)cells * sizeof(uint32_t)); + f->next_in_cell = (uint32_t *)f3d_alloc((size_t)capacity * sizeof(uint32_t)); + f->cell = (int32_t *)f3d_alloc((size_t)capacity * 3u * sizeof(int32_t)); + if (f->position == NULL || f->predicted == NULL || f->velocity == NULL || f->next == NULL || + f->lambda == NULL || f->density == NULL || f->cell_head == NULL || + f->next_in_cell == NULL || f->cell == NULL) { + f3d_fluid_destroy(f); + return NULL; + } + f3d_zero(f->density, real); + f->spacing = spacing; + f->h = spacing * F3D_R(2.0); + const f3d_real h2 = f->h * f->h; + const f3d_real h3 = h2 * f->h; + f->poly6 = F3D_R(315.0) / (F3D_R(64.0) * PI * h3 * h3 * h3); + f->spiky = F3D_R(-45.0) / (PI * h3 * h3); + /* Rest density: the kernel summed over a cubic lattice of the spacing. */ + f3d_real rest = 0; + for (int x = -2; x <= 2; x++) { + for (int y = -2; y <= 2; y++) { + for (int z = -2; z <= 2; z++) { + const f3d_real r2 = (f3d_real)(x * x + y * y + z * z) * spacing * spacing; + rest += poly6(f, r2); + } + } + } + f->rest_density = rest; + return f; +} + +void f3d_fluid_destroy(F3dFluid *f) { + if (f == NULL) return; + f3d_free(f->position); + f3d_free(f->predicted); + f3d_free(f->velocity); + f3d_free(f->next); + f3d_free(f->lambda); + f3d_free(f->density); + f3d_free(f->cell_head); + f3d_free(f->next_in_cell); + f3d_free(f->cell); + f3d_free(f); +} + +uint32_t f3d_fluid_capacity(const F3dFluid *f) { return f->capacity; } + +f3d_real f3d_fluid_rest_density(const F3dFluid *f) { return f->rest_density; } + +uint32_t f3d_fluid_add(F3dFluid *f, const f3d_real *data, uint32_t count) { + const uint32_t first = f->next_slot; + for (uint32_t i = 0; i < count; i++) { + const f3d_real *in = data + (size_t)i * 6u; + const uint32_t at = f->next_slot; + f->position[at] = f3d_v3(in[0], in[1], in[2]); + f->velocity[at] = f3d_v3(in[3], in[4], in[5]); + f->density[at] = 0; + f->next_slot = (f->next_slot + 1u) % f->capacity; + if (f->count < f->capacity) f->count++; + } + return first; +} + +static int32_t cell_coord(f3d_real x, f3d_real inv_cell) { + const f3d_real v = f3d_clamp(x * inv_cell, F3D_R(-1e9), F3D_R(1e9)); + int32_t t = (int32_t)v; + if ((f3d_real)t > v) t--; + return t; +} + +static uint32_t cell_hash(int32_t x, int32_t y, int32_t z, uint32_t cells) { + return (((uint32_t)x * 73856093u) ^ ((uint32_t)y * 19349663u) ^ + ((uint32_t)z * 83492791u)) & + (cells - 1u); +} + +/* The walls' share of a particle at [p]: the density of the still layers + * past each wall within reach, and the push of their spiky gradients, + * which in-plane cancel and leave the normal. */ +typedef struct Walls { + f3d_real density; + F3dVec3 push; +} Walls; + +static Walls walls(const F3dFluid *f, F3dVec3 p) { + Walls w; + w.density = 0; + w.push = f3d_v3(0, 0, 0); + const f3d_real at[3] = {p.x, p.y, p.z}; + const f3d_real lo[3] = {f->tank_min.x, f->tank_min.y, f->tank_min.z}; + const f3d_real hi[3] = {f->tank_max.x, f->tank_max.y, f->tank_max.z}; + const f3d_real s = f->spacing; + for (int axis = 0; axis < 3; axis++) { + for (int side = 0; side < 2; side++) { + const f3d_real off = side == 0 ? at[axis] - lo[axis] : hi[axis] - at[axis]; + f3d_real normal = 0; + for (f3d_real z = off + s * F3D_R(0.5); z < f->h; z += s) { + for (int i = -2; i <= 2; i++) { + for (int j = -2; j <= 2; j++) { + const f3d_real r2 = (f3d_real)(i * i + j * j) * s * s + z * z; + if (!(r2 < f->h * f->h)) continue; + w.density += poly6(f, r2); + const f3d_real r = f3d_sqrt(r2); + const f3d_real k = f->h - r; + normal += f->spiky * k * k / r * z; + } + } + } + F3dVec3 dir = f3d_v3(0, 0, 0); + const f3d_real sign = side == 0 ? F3D_R(1.0) : F3D_R(-1.0); + if (axis == 0) dir.x = sign; + if (axis == 1) dir.y = sign; + if (axis == 2) dir.z = sign; + w.push = f3d_madd(w.push, dir, normal); + } + } + return w; +} + +/* What a neighbour pass does with one neighbour. */ +typedef enum Visit { DENSITY, MOVE, SMOOTH } Visit; + +typedef struct Sum { + f3d_real density; + F3dVec3 gradient; + f3d_real gradient2; + F3dVec3 move; + F3dVec3 smooth; +} Sum; + +/* Sums [visit] over particle [i]'s neighbours: every particle in the 27 + * cells round the one it went in, met in the cell it went in. */ +static Sum neighbours(const F3dFluid *f, uint32_t i, Visit visit) { + Sum sum; + f3d_zero(&sum, sizeof sum); + const F3dVec3 p = f->predicted[i]; + const int32_t *c = &f->cell[i * 3u]; + for (int32_t x = c[0] - 1; x <= c[0] + 1; x++) { + for (int32_t y = c[1] - 1; y <= c[1] + 1; y++) { + for (int32_t z = c[2] - 1; z <= c[2] + 1; z++) { + for (uint32_t j = f->cell_head[cell_hash(x, y, z, f->cells)]; j != NONE; + j = f->next_in_cell[j]) { + const int32_t *cj = &f->cell[j * 3u]; + if (cj[0] != x || cj[1] != y || cj[2] != z) continue; + const F3dVec3 d = f3d_sub(p, f->predicted[j]); + const f3d_real r2 = f3d_dot(d, d); + if (!(r2 < f->h * f->h)) continue; + const f3d_real r = f3d_sqrt(r2); + switch (visit) { + case DENSITY: { + sum.density += poly6(f, r2); + if (j == i) break; + const F3dVec3 g = f3d_scale(spiky(f, d, r), F3D_R(1.0) / f->rest_density); + sum.gradient = f3d_add(sum.gradient, g); + sum.gradient2 += f3d_dot(g, g); + break; + } + case MOVE: + if (j == i) break; + sum.move = f3d_madd(sum.move, spiky(f, d, r), f->lambda[i] + f->lambda[j]); + break; + case SMOOTH: + if (j == i) break; + sum.smooth = f3d_madd(sum.smooth, f3d_sub(f->velocity[j], f->velocity[i]), + poly6(f, r2) / f->rest_density); + break; + } + } + } + } + } + if (visit == DENSITY || visit == MOVE) { + const Walls w = walls(f, p); + if (visit == DENSITY) { + sum.density += w.density; + sum.gradient = f3d_madd(sum.gradient, w.push, F3D_R(1.0) / f->rest_density); + } else { + /* As a neighbour with the particle's own multiplier. */ + sum.move = f3d_madd(sum.move, w.push, f->lambda[i] + f->lambda[i]); + } + } + return sum; +} + +void f3d_fluid_step(F3dFluid *f, const F3dFluidSettings *s, f3d_real dt) { + if (!(f3d_finite(dt) && dt > F3D_R(0.0)) || f->count == 0) return; + const uint32_t substeps = s->substeps > 0 ? s->substeps : 1u; + const uint32_t iterations = s->iterations > 0 ? s->iterations : 1u; + const f3d_real h = dt / (f3d_real)substeps; + const F3dVec3 g = f3d_v3(s->gravity[0], s->gravity[1], s->gravity[2]); + const f3d_real inv_cell = F3D_R(1.0) / f->h; + const f3d_real margin = f->spacing * F3D_R(0.5); + const F3dVec3 lo = f3d_v3(s->tank_min[0] + margin, s->tank_min[1] + margin, s->tank_min[2] + margin); + const F3dVec3 hi = f3d_v3(s->tank_max[0] - margin, s->tank_max[1] - margin, s->tank_max[2] - margin); + const uint32_t n = f->count; + f->tank_min = f3d_v3(s->tank_min[0], s->tank_min[1], s->tank_min[2]); + f->tank_max = f3d_v3(s->tank_max[0], s->tank_max[1], s->tank_max[2]); + for (uint32_t sub = 0; sub < substeps; sub++) { + /* 1. Predict, and the grid. */ + for (uint32_t c = 0; c < f->cells; c++) f->cell_head[c] = NONE; + for (uint32_t i = 0; i < n; i++) { + f->velocity[i] = f3d_madd(f->velocity[i], g, h); + f->predicted[i] = f3d_madd(f->position[i], f->velocity[i], h); + int32_t *c = &f->cell[i * 3u]; + c[0] = cell_coord(f->predicted[i].x, inv_cell); + c[1] = cell_coord(f->predicted[i].y, inv_cell); + c[2] = cell_coord(f->predicted[i].z, inv_cell); + const uint32_t at = cell_hash(c[0], c[1], c[2], f->cells); + f->next_in_cell[i] = f->cell_head[at]; + f->cell_head[at] = i; + } + /* 2. Density, as many times as asked. */ + for (uint32_t it = 0; it < iterations; it++) { + for (uint32_t i = 0; i < n; i++) { + const Sum sum = neighbours(f, i, DENSITY); + f->density[i] = sum.density; + const f3d_real crowd = f3d_max(sum.density / f->rest_density - F3D_R(1.0), F3D_R(0.0)); + f->lambda[i] = -crowd / (sum.gradient2 + f3d_dot(sum.gradient, sum.gradient) + s->relaxation); + } + for (uint32_t i = 0; i < n; i++) { + const Sum sum = neighbours(f, i, MOVE); + F3dVec3 p = f3d_madd(f->predicted[i], sum.move, F3D_R(1.0) / f->rest_density); + p.x = f3d_clamp(p.x, lo.x, hi.x); + p.y = f3d_clamp(p.y, lo.y, hi.y); + p.z = f3d_clamp(p.z, lo.z, hi.z); + f->next[i] = p; + } + for (uint32_t i = 0; i < n; i++) f->predicted[i] = f->next[i]; + } + /* 3. The velocity from where it went, smoothed; the position taken. */ + for (uint32_t i = 0; i < n; i++) { + f->velocity[i] = f3d_scale(f3d_sub(f->predicted[i], f->position[i]), F3D_R(1.0) / h); + } + for (uint32_t i = 0; i < n; i++) { + const Sum sum = neighbours(f, i, SMOOTH); + f->next[i] = f3d_madd(f->velocity[i], sum.smooth, s->viscosity); + } + for (uint32_t i = 0; i < n; i++) { + f->velocity[i] = f->next[i]; + f->position[i] = f->predicted[i]; + } + } +} + +uint32_t f3d_fluid_read(const F3dFluid *f, f3d_real *out, uint32_t capacity) { + const uint32_t n = capacity < f->count ? capacity : f->count; + for (uint32_t i = 0; i < n; i++) { + out[i * 4u] = f->position[i].x; + out[i * 4u + 1u] = f->position[i].y; + out[i * 4u + 2u] = f->position[i].z; + out[i * 4u + 3u] = f->density[i] / f->rest_density; + } + return n; +} diff --git a/packages/flutter3d_physics_native/csrc/src/f3d_heat.c b/packages/flutter3d_physics_native/csrc/src/f3d_heat.c new file mode 100644 index 000000000..37e01a64b --- /dev/null +++ b/packages/flutter3d_physics_native/csrc/src/f3d_heat.c @@ -0,0 +1,363 @@ +/* + * Heat and fire on bodies — P9: what a body is made of, its temperature, + * the heat and water the bus brings it, and the fire it can carry. + * + * A body is one temperature throughout — a lumped mass, which is right + * while heat crosses it faster than it leaves its surface (a Biot number + * under a tenth) and is what a game's crate, log or tyre needs. Bodies that + * touch pass heat across the contact. + */ +#include "f3d_internal.h" + +int f3d_material_preset(F3dMaterialKind kind, F3dMaterial *out) { + F3dMaterial m; + f3d_zero(&m, sizeof m); + m.emissivity = F3D_R(0.9); + m.conductivity = F3D_R(1.0); + switch (kind) { + case F3D_MATERIAL_INERT: + m.specific_heat = F3D_R(1000.0); + break; + case F3D_MATERIAL_WOOD: + /* Pine: piloted ignition near 300 °C, about fifteen megajoules a + * kilogram, and a burning surface losing about eleven grams a second + * per square metre. */ + m.specific_heat = F3D_R(1700.0); + m.conductivity = F3D_R(0.12); + m.ignition_temperature = F3D_R(573.15); + m.heat_of_combustion = F3D_R(1.5e7); + m.burn_rate = F3D_R(0.011); + m.fuel_fraction = F3D_R(0.8); + m.flame_feedback = F3D_R(0.3); + break; + case F3D_MATERIAL_PAPER: + /* It catches at 451 °F. */ + m.specific_heat = F3D_R(1340.0); + m.conductivity = F3D_R(0.05); + m.ignition_temperature = F3D_R(506.15); + m.heat_of_combustion = F3D_R(1.6e7); + m.burn_rate = F3D_R(0.02); + m.fuel_fraction = F3D_R(0.9); + m.flame_feedback = F3D_R(0.3); + break; + case F3D_MATERIAL_RUBBER: + m.specific_heat = F3D_R(2010.0); + m.conductivity = F3D_R(0.16); + m.emissivity = F3D_R(0.92); + m.ignition_temperature = F3D_R(623.15); + m.heat_of_combustion = F3D_R(3.2e7); + m.burn_rate = F3D_R(0.03); + m.fuel_fraction = F3D_R(0.9); + m.flame_feedback = F3D_R(0.3); + break; + case F3D_MATERIAL_STEEL: + /* Weathered, not polished: a polished surface is a tenth of this. */ + m.specific_heat = F3D_R(490.0); + m.conductivity = F3D_R(50.0); + m.emissivity = F3D_R(0.6); + break; + case F3D_MATERIAL_STONE: + m.specific_heat = F3D_R(840.0); + m.conductivity = F3D_R(2.5); + m.emissivity = F3D_R(0.93); + break; + default: + return 0; + } + *out = m; + return 1; +} + +static int in_range(f3d_real x, f3d_real low, f3d_real high) { + return f3d_finite(x) && x >= low && x <= high; +} + +int f3d_body_set_material(F3dWorld *world, F3dBody body, + const F3dMaterial *material) { + F3dSlot *s = f3d_slot_of(world, body); + if (s == NULL || material == NULL) return 0; + const F3dMaterial m = *material; + const f3d_real big = F3D_R(1e30); + if (!(f3d_finite(m.specific_heat) && m.specific_heat > F3D_R(0.0))) return 0; + if (!in_range(m.emissivity, F3D_R(0.0), F3D_R(1.0))) return 0; + if (!in_range(m.ignition_temperature, F3D_R(0.0), big)) return 0; + if (!in_range(m.heat_of_combustion, F3D_R(0.0), big)) return 0; + if (!in_range(m.burn_rate, F3D_R(0.0), big)) return 0; + /* Below one: a body that burnt all of itself would have no mass left to + * move. */ + if (!(in_range(m.fuel_fraction, F3D_R(0.0), F3D_R(1.0)) && + m.fuel_fraction < F3D_R(1.0))) { + return 0; + } + if (!in_range(m.flame_feedback, F3D_R(0.0), F3D_R(1.0))) return 0; + if (!(f3d_finite(m.conductivity) && m.conductivity > F3D_R(0.0))) return 0; + s->material = m; + s->fuel = s->mass * m.fuel_fraction; + if (s->fuel <= F3D_R(0.0) || m.ignition_temperature <= F3D_R(0.0)) { + s->flags &= (uint8_t)~F3D_FLAG_BURNING; + s->heat_release = F3D_R(0.0); + } + return 1; +} + +int f3d_body_set_temperature(F3dWorld *world, F3dBody body, f3d_real kelvin) { + F3dSlot *s = f3d_slot_of(world, body); + if (s == NULL || !(f3d_finite(kelvin) && kelvin > F3D_R(0.0))) return 0; + s->temperature = kelvin; + return 1; +} + +int f3d_body_get_temperature(const F3dWorld *world, F3dBody body, + f3d_real *out) { + const F3dSlot *s = f3d_slot_of(world, body); + if (s == NULL) return 0; + *out = s->temperature; + return 1; +} + +int f3d_body_add_heat(F3dWorld *world, F3dBody body, f3d_real joules) { + F3dSlot *s = f3d_slot_of(world, body); + if (s == NULL || !f3d_finite(joules)) return 0; + s->heat += joules; + return 1; +} + +int f3d_body_add_water(F3dWorld *world, F3dBody body, f3d_real kg) { + F3dSlot *s = f3d_slot_of(world, body); + if (s == NULL || !f3d_finite(kg)) return 0; + if (kg > F3D_R(0.0)) { + /* It lands at the air's temperature and mixes with what is there: + * counted at the body's own, it would bring heat from nowhere. Taken + * off, it leaves at the body's, and the body's temperature stays. */ + const f3d_real body_heat = + s->mass * s->material.specific_heat + s->water * F3D_WATER_HEAT; + const f3d_real added = kg * F3D_WATER_HEAT; + s->temperature = (body_heat * s->temperature + + added * world->s.air_temperature) / + (body_heat + added); + } + s->water = f3d_max(s->water + kg, F3D_R(0.0)); + return 1; +} + +int f3d_body_get_water(const F3dWorld *world, F3dBody body, f3d_real *out) { + const F3dSlot *s = f3d_slot_of(world, body); + if (s == NULL) return 0; + *out = s->water; + return 1; +} + +int f3d_body_get_fuel(const F3dWorld *world, F3dBody body, f3d_real *out) { + const F3dSlot *s = f3d_slot_of(world, body); + if (s == NULL) return 0; + *out = s->fuel; + return 1; +} + +int f3d_body_is_burning(const F3dWorld *world, F3dBody body, int *out) { + const F3dSlot *s = f3d_slot_of(world, body); + if (s == NULL) return 0; + *out = (s->flags & F3D_FLAG_BURNING) != 0; + return 1; +} + +int f3d_body_get_heat_release(const F3dWorld *world, F3dBody body, + f3d_real *out) { + const F3dSlot *s = f3d_slot_of(world, body); + if (s == NULL) return 0; + *out = s->heat_release; + return 1; +} + +/* W / (m² K) from a surface to air moving past it at [u] m/s: Watson's + * correlation, 10.45 − u + 10 √u, the one wind chill was built on. It is + * measured from still air to twenty metres a second, so it is held there: + * past that it would start to fall. */ +static f3d_real convection(f3d_real u) { + const f3d_real v = f3d_min(f3d_max(u, F3D_R(0.0)), F3D_R(20.0)); + return F3D_R(10.45) - v + F3D_R(10.0) * f3d_sqrt(v); +} + +/* J/K the body holds: its own and its water's. */ +static f3d_real capacity_of(const F3dSlot *s) { + return s->mass * s->material.specific_heat + s->water * F3D_WATER_HEAT; +} + +/* How big a body is where it touches: a ball's radius, a capsule's, a + * box's least half extent. */ +static f3d_real radius_of(const F3dSlot *s) { + switch (s->shape) { + case F3D_SHAPE_SPHERE: + case F3D_SHAPE_CAPSULE: + return s->size.x; + case F3D_SHAPE_BOX: + return f3d_min(s->size.x, f3d_min(s->size.y, s->size.z)); + default: + return F3D_R(0.0); + } +} + +/* Twice the area of the polygon a, b, c, d in that order, seen along n. */ +static f3d_real quad2(F3dVec3 a, F3dVec3 b, F3dVec3 c, F3dVec3 d, F3dVec3 n) { + return f3d_abs(f3d_dot(f3d_cross(f3d_sub(c, a), f3d_sub(d, b)), n)); +} + +/* The area two bodies touch over, m². A face resting on a face touches over + * the polygon its points span — of four points in whatever order, the + * largest of the three ways round is the convex one. A curved body pressed + * in by δ touches over Hertz's circle of radius √(Rδ), and two points of a + * line touch over its length by that circle's width. */ +static f3d_real contact_area(const F3dManifold *m, f3d_real r) { + f3d_real depth = F3D_R(0.0); + for (uint32_t i = 0; i < m->count; i++) { + depth = f3d_max(depth, m->points[i].depth); + } + const f3d_real pressed = r * depth; + const F3dVec3 n = m->normal; + const F3dContactPoint *p = m->points; + f3d_real area = F3D_PI * pressed; + if (m->count == 2) { + const F3dVec3 d = f3d_sub(p[1].point, p[0].point); + area = f3d_max(area, f3d_sqrt(f3d_dot(d, d)) * F3D_R(2.0) * + f3d_sqrt(pressed)); + } else if (m->count == 3) { + area = f3d_max(area, F3D_R(0.5) * f3d_abs(f3d_dot( + f3d_cross(f3d_sub(p[1].point, p[0].point), + f3d_sub(p[2].point, p[0].point)), + n))); + } else if (m->count == 4) { + const f3d_real a = quad2(p[0].point, p[1].point, p[2].point, p[3].point, n); + const f3d_real b = quad2(p[0].point, p[1].point, p[3].point, p[2].point, n); + const f3d_real c = quad2(p[0].point, p[2].point, p[1].point, p[3].point, n); + area = f3d_max(area, F3D_R(0.5) * f3d_max(a, f3d_max(b, c))); + } + return area; +} + +/* Heat across every contact that touches. The conductance is Holm's + * constriction of two bodies meeting over a spot of radius a, + * G = 4a / (1/k₁ + 1/k₂), with a the radius of a circle of the contact's + * area; and the exchange is taken implicitly for the pair, so it carries + * them towards one temperature and never past it. In key order, so the + * same world passes the same heat. */ +static void conduct(F3dWorld *world, f3d_real dt) { + for (uint32_t i = 0; i < world->s.manifold_count; i++) { + const F3dManifold *m = &world->manifolds[i]; + if (!m->touching) continue; + F3dSlot *a = f3d_slot_of(world, m->a); + F3dSlot *b = f3d_slot_of(world, m->b); + if (a == NULL || b == NULL) continue; + const f3d_real ca = capacity_of(a), cb = capacity_of(b); + /* No thermal mass: a fixed one is a reservoir, and nothing else can + * have none. */ + const f3d_real ia = ca > F3D_R(0.0) ? F3D_R(1.0) / ca : F3D_R(0.0); + const f3d_real ib = cb > F3D_R(0.0) ? F3D_R(1.0) / cb : F3D_R(0.0); + if (ia == F3D_R(0.0) && ib == F3D_R(0.0)) continue; + const f3d_real ra = radius_of(a), rb = radius_of(b); + const f3d_real r = ra > F3D_R(0.0) && rb > F3D_R(0.0) + ? ra * rb / (ra + rb) + : f3d_max(ra, rb); + const f3d_real area = contact_area(m, r); + if (!(area > F3D_R(0.0))) continue; + const f3d_real spot = f3d_sqrt(area / F3D_PI); + const f3d_real g = + F3D_R(4.0) * spot / + (F3D_R(1.0) / a->material.conductivity + + F3D_R(1.0) / b->material.conductivity); + const f3d_real gdt = g * dt; + const f3d_real q = gdt * (b->temperature - a->temperature) / + (F3D_R(1.0) + gdt * (ia + ib)); + a->temperature += q * ia; + b->temperature -= q * ib; + } +} + +void f3d_step_heat(F3dWorld *world, f3d_real dt) { + const f3d_real ta = world->s.air_temperature; + conduct(world, dt); + for (uint32_t i = 0; i < world->s.used; i++) { + F3dSlot *s = &world->slots[i]; + if (!s->live) continue; + const F3dMaterial *m = &s->material; + f3d_real power = s->heat / dt; + s->heat = F3D_R(0.0); + s->heat_release = F3D_R(0.0); + /* The fire: a burning surface loses mass at the burn rate, the mass + * releases its heat of combustion, and the flame's share of that goes + * back into the body; the rest leaves as hot gas. The mass leaves at + * the body's velocity, so the velocity does not change. */ + if (s->flags & F3D_FLAG_BURNING) { + const f3d_real burnt = + f3d_min(m->burn_rate * s->surface * dt, s->fuel); + const f3d_real released = burnt * m->heat_of_combustion / dt; + power += m->flame_feedback * released; + s->heat_release = (F3D_R(1.0) - m->flame_feedback) * released; + s->fuel -= burnt; + s->mass -= burnt; + f3d_refresh_mass(world, s); + if (s->fuel <= F3D_R(0.0)) { + s->fuel = F3D_R(0.0); + s->flags &= (uint8_t)~F3D_FLAG_BURNING; + f3d_push_event(world, f3d_handle_of(world, s), F3D_EVENT_BURNT_OUT); + } + } + const f3d_real dry = s->mass * m->specific_heat; + const f3d_real capacity = dry + s->water * F3D_WATER_HEAT; + if (!(capacity > F3D_R(0.0))) continue; + /* What the surface gives the air: convection to the air moving past + * it, and radiation εσ(T⁴ − Tₐ⁴), written exactly as + * εσ(T² + Tₐ²)(T + Tₐ)·(T − Tₐ) so both are a conductance times the + * difference. Taken implicitly with the conductance as it is, the + * step cannot overshoot the air's temperature, however small the body + * or long the step. */ + f3d_real conductance = F3D_R(0.0); + if (s->surface > F3D_R(0.0)) { + f3d_real wind[3]; + f3d_world_sample_wind(world, s->position.x, s->position.y, + s->position.z, wind); + const f3d_real ux = s->velocity.x - wind[0]; + const f3d_real uy = s->velocity.y - wind[1]; + const f3d_real uz = s->velocity.z - wind[2]; + const f3d_real u = f3d_sqrt(ux * ux + uy * uy + uz * uz); + const f3d_real t = s->temperature; + conductance = s->surface * + (convection(u) + m->emissivity * F3D_STEFAN_BOLTZMANN * + (t * t + ta * ta) * (t + ta)); + } + f3d_real next = (capacity * s->temperature + dt * (power + conductance * ta)) / + (capacity + dt * conductance); + /* Water on the body holds it at its boiling point: what would heat + * the body past it boils water off instead, and only once the water + * has gone does the body heat on. */ + if (s->water > F3D_R(0.0) && next > F3D_WATER_BOILS) { + const f3d_real excess = (next - F3D_WATER_BOILS) * capacity; + const f3d_real boils = excess / F3D_WATER_LATENT; + if (boils < s->water) { + s->water -= boils; + next = F3D_WATER_BOILS; + } else { + const f3d_real left = excess - s->water * F3D_WATER_LATENT; + s->water = F3D_R(0.0); + next = dry > F3D_R(0.0) ? F3D_WATER_BOILS + left / dry + : F3D_WATER_BOILS; + } + } + s->temperature = f3d_max(next, F3D_R(1e-3)); + /* Alight at the ignition temperature, out below it. A wet body is held + * at boiling, below any ignition temperature here, so water puts a fire + * out and keeps a wet body from catching. */ + const f3d_real ignition = m->ignition_temperature; + if (ignition <= F3D_R(0.0)) continue; + if (s->flags & F3D_FLAG_BURNING) { + if (s->temperature < ignition) { + s->flags &= (uint8_t)~F3D_FLAG_BURNING; + f3d_push_event(world, f3d_handle_of(world, s), + F3D_EVENT_EXTINGUISHED); + } + } else if (s->fuel > F3D_R(0.0) && s->water <= F3D_R(0.0) && + s->temperature >= ignition) { + s->flags |= F3D_FLAG_BURNING; + f3d_push_event(world, f3d_handle_of(world, s), F3D_EVENT_IGNITED); + } + } +} diff --git a/packages/flutter3d_physics_native/csrc/src/f3d_hull.c b/packages/flutter3d_physics_native/csrc/src/f3d_hull.c new file mode 100644 index 000000000..03666fc0c --- /dev/null +++ b/packages/flutter3d_physics_native/csrc/src/f3d_hull.c @@ -0,0 +1,350 @@ +/* + * Convex hulls — P9, phase 5: built from points, weighed, and held by the + * world for bodies to be shaped as. + * + * The hull is built incrementally: a tetrahedron of four far-apart points, + * then each further point in the order given, replacing the faces it sees + * with a fan from it to the horizon round them. In doubles, whatever the + * core's reals, since a hull is built once and a sliver face from rounding + * would be there for good. The order is the caller's, so the same points + * make the same hull everywhere. + * + * Its mass is the solid's, from the tetrahedra its faces make with a point + * inside: volume, centre and the covariance of each, summed, gives the + * inertia tensor about the centre of mass (Blow and Binstock, "How to find + * the inertia tensor (or other mass properties) of a 3D solid body + * represented by a triangle mesh", 2004). The hull is moved so that centre + * is the body's origin: a body turns about its centre of mass, and every + * other shape's is at its origin already. + */ +#include "f3d_internal.h" + +#define F3D_HULL_POINTS 4096u + +typedef struct HullFace { + uint32_t a, b, c; + double n[3]; + double d; +} HullFace; + +static void sub3(const double *a, const double *b, double *o) { + o[0] = a[0] - b[0]; + o[1] = a[1] - b[1]; + o[2] = a[2] - b[2]; +} + +static void cross3(const double *a, const double *b, double *o) { + o[0] = a[1] * b[2] - a[2] * b[1]; + o[1] = a[2] * b[0] - a[0] * b[2]; + o[2] = a[0] * b[1] - a[1] * b[0]; +} + +static double dot3(const double *a, const double *b) { + return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; +} + +static double sqrt_d(double x) { +#if defined(_MSC_VER) + return sqrt(x); +#else + return __builtin_sqrt(x); +#endif +} + +/* The face a, b, c with its outward normal; 0 when it has no area. */ +static int make_face(const double *p, uint32_t a, uint32_t b, uint32_t c, + HullFace *f) { + double e1[3], e2[3], n[3]; + sub3(&p[b * 3u], &p[a * 3u], e1); + sub3(&p[c * 3u], &p[a * 3u], e2); + cross3(e1, e2, n); + const double len = sqrt_d(dot3(n, n)); + f->a = a; + f->b = b; + f->c = c; + if (!(len > 0.0)) { + f->n[0] = f->n[1] = f->n[2] = 0.0; + f->d = 0.0; + return 0; + } + f->n[0] = n[0] / len; + f->n[1] = n[1] / len; + f->n[2] = n[2] / len; + f->d = dot3(f->n, &p[a * 3u]); + return 1; +} + +uint32_t f3d_world_create_hull(F3dWorld *world, const f3d_real *points, + uint32_t count) { + if (points == NULL || count < 4u || count > F3D_HULL_POINTS) return 0; + for (uint32_t i = 0; i < count * 3u; i++) { + if (!f3d_finite(points[i])) return 0; + } + double *p = (double *)f3d_alloc((size_t)count * 3u * sizeof(double)); + /* Room for every face a hull of [count] points can have, twice over for + * the ones a step replaces before they are compacted. */ + const uint32_t room = 4u * count + 8u; + HullFace *faces = (HullFace *)f3d_alloc((size_t)room * sizeof(HullFace)); + HullFace *spare = (HullFace *)f3d_alloc((size_t)room * sizeof(HullFace)); + uint32_t *edges = (uint32_t *)f3d_alloc((size_t)room * 6u * sizeof(uint32_t)); + uint32_t *remap = (uint32_t *)f3d_alloc((size_t)count * sizeof(uint32_t)); + uint32_t result = 0; + if (p == NULL || faces == NULL || spare == NULL || edges == NULL || + remap == NULL) { + goto done; + } + for (uint32_t i = 0; i < count * 3u; i++) p[i] = (double)points[i]; + double lo[3] = {p[0], p[1], p[2]}, hi[3] = {p[0], p[1], p[2]}; + for (uint32_t i = 1; i < count; i++) { + for (int k = 0; k < 3; k++) { + if (p[i * 3u + k] < lo[k]) lo[k] = p[i * 3u + k]; + if (p[i * 3u + k] > hi[k]) hi[k] = p[i * 3u + k]; + } + } + double span[3]; + sub3(hi, lo, span); + const double scale = sqrt_d(dot3(span, span)); + if (!(scale > 0.0)) goto done; + const double eps = 1e-9 * scale; + /* Four points far apart: the lowest in x, the furthest from it, the + * furthest from their line, the furthest from their plane. */ + uint32_t i0 = 0; + for (uint32_t i = 1; i < count; i++) { + if (p[i * 3u] < p[i0 * 3u]) i0 = i; + } + uint32_t i1 = i0; + double best = 0.0; + for (uint32_t i = 0; i < count; i++) { + double d[3]; + sub3(&p[i * 3u], &p[i0 * 3u], d); + if (dot3(d, d) > best) { + best = dot3(d, d); + i1 = i; + } + } + if (!(best > eps * eps)) goto done; + uint32_t i2 = i0; + best = 0.0; + double line[3]; + sub3(&p[i1 * 3u], &p[i0 * 3u], line); + for (uint32_t i = 0; i < count; i++) { + double d[3], x[3]; + sub3(&p[i * 3u], &p[i0 * 3u], d); + cross3(line, d, x); + if (dot3(x, x) > best) { + best = dot3(x, x); + i2 = i; + } + } + if (!(best > eps * eps * dot3(line, line))) goto done; + HullFace base; + make_face(p, i0, i1, i2, &base); + uint32_t i3 = i0; + best = 0.0; + for (uint32_t i = 0; i < count; i++) { + const double d = dot3(base.n, &p[i * 3u]) - base.d; + if ((d < 0.0 ? -d : d) > best) { + best = d < 0.0 ? -d : d; + i3 = i; + } + } + if (!(best > eps)) goto done; + /* The tetrahedron, its faces turned outward: away from the fourth + * point. */ + uint32_t nf = 0; + { + const uint32_t t[4][4] = { + {i0, i1, i2, i3}, {i0, i3, i1, i2}, {i0, i2, i3, i1}, {i1, i3, i2, i0}}; + for (int f = 0; f < 4; f++) { + HullFace face; + make_face(p, t[f][0], t[f][1], t[f][2], &face); + if (dot3(face.n, &p[t[f][3] * 3u]) - face.d > 0.0) { + make_face(p, t[f][1], t[f][0], t[f][2], &face); + } + faces[nf++] = face; + } + } + for (uint32_t k = 0; k < count; k++) { + if (k == i0 || k == i1 || k == i2 || k == i3) continue; + const double *q = &p[k * 3u]; + /* The faces it sees, and the horizon round them: each of their edges + * whose reverse is not also an edge of one it sees. */ + uint32_t ne = 0, kept = 0; + for (uint32_t f = 0; f < nf; f++) { + if (dot3(faces[f].n, q) - faces[f].d <= eps) { + spare[kept++] = faces[f]; + continue; + } + const uint32_t e[3][2] = {{faces[f].a, faces[f].b}, + {faces[f].b, faces[f].c}, + {faces[f].c, faces[f].a}}; + for (int j = 0; j < 3; j++) { + int shared = -1; + for (uint32_t m = 0; m < ne; m++) { + if (edges[m * 2u] == e[j][1] && edges[m * 2u + 1u] == e[j][0]) { + shared = (int)m; + } + } + if (shared >= 0) { + edges[(uint32_t)shared * 2u] = edges[(ne - 1u) * 2u]; + edges[(uint32_t)shared * 2u + 1u] = edges[(ne - 1u) * 2u + 1u]; + ne--; + } else { + edges[ne * 2u] = e[j][0]; + edges[ne * 2u + 1u] = e[j][1]; + ne++; + } + } + } + if (kept == nf) continue; /* Inside: it sees nothing. */ + for (uint32_t m = 0; m < ne && kept < room; m++) { + HullFace face; + if (make_face(p, edges[m * 2u], edges[m * 2u + 1u], k, &face)) { + spare[kept++] = face; + } + } + HullFace *swap = faces; + faces = spare; + spare = swap; + nf = kept; + } + /* The corners, numbered in the order the points came. */ + for (uint32_t i = 0; i < count; i++) remap[i] = UINT32_MAX; + for (uint32_t f = 0; f < nf; f++) { + remap[faces[f].a] = remap[faces[f].b] = remap[faces[f].c] = 0; + } + uint32_t nv = 0; + for (uint32_t i = 0; i < count; i++) { + if (remap[i] != UINT32_MAX) remap[i] = nv++; + } + /* The solid's mass, from the tetrahedra its faces make with the mean of + * its corners. */ + double o[3] = {0.0, 0.0, 0.0}; + for (uint32_t i = 0; i < count; i++) { + if (remap[i] == UINT32_MAX) continue; + for (int k = 0; k < 3; k++) o[k] += p[i * 3u + k]; + } + for (int k = 0; k < 3; k++) o[k] /= (double)nv; + double volume = 0.0, surface = 0.0, centre[3] = {0.0, 0.0, 0.0}; + double cov[3][3] = {{0.0, 0.0, 0.0}, {0.0, 0.0, 0.0}, {0.0, 0.0, 0.0}}; + for (uint32_t f = 0; f < nf; f++) { + double a[3], b[3], c[3], x[3], e1[3], e2[3]; + sub3(&p[faces[f].a * 3u], o, a); + sub3(&p[faces[f].b * 3u], o, b); + sub3(&p[faces[f].c * 3u], o, c); + cross3(b, c, x); + const double v = dot3(a, x) / 6.0; + sub3(b, a, e1); + sub3(c, a, e2); + cross3(e1, e2, x); + surface += 0.5 * sqrt_d(dot3(x, x)); + volume += v; + const double s[3] = {a[0] + b[0] + c[0], a[1] + b[1] + c[1], + a[2] + b[2] + c[2]}; + for (int i = 0; i < 3; i++) { + centre[i] += v * s[i] / 4.0; + for (int j = 0; j < 3; j++) { + cov[i][j] += v / 20.0 * + (a[i] * a[j] + b[i] * b[j] + c[i] * c[j] + s[i] * s[j]); + } + } + } + if (!(volume > 0.0)) goto done; + for (int i = 0; i < 3; i++) centre[i] /= volume; + /* The covariance about the centre of mass, then the inertia tensor of a + * solid of unit density, then of a kilogram. */ + for (int i = 0; i < 3; i++) { + for (int j = 0; j < 3; j++) cov[i][j] -= volume * centre[i] * centre[j]; + } + const double trace = cov[0][0] + cov[1][1] + cov[2][2]; + F3dSym3 unit; + unit.xx = (f3d_real)((trace - cov[0][0]) / volume); + unit.yy = (f3d_real)((trace - cov[1][1]) / volume); + unit.zz = (f3d_real)((trace - cov[2][2]) / volume); + unit.xy = (f3d_real)(-cov[0][1] / volume); + unit.xz = (f3d_real)(-cov[0][2] / volume); + unit.yz = (f3d_real)(-cov[1][2] / volume); + const double offset[3] = {o[0] + centre[0], o[1] + centre[1], + o[2] + centre[2]}; + /* Into the world's arrays, every allocation made before any is kept. */ + const uint32_t first_vertex = world->s.hull_vertex_count; + const uint32_t first_triangle = world->s.hull_triangle_count; + f3d_real *vertices = (f3d_real *)f3d_realloc( + world->hull_vertices, + ((size_t)first_vertex + nv) * 3u * sizeof(f3d_real)); + if (vertices == NULL) goto done; + world->hull_vertices = vertices; + uint32_t *triangles = (uint32_t *)f3d_realloc( + world->hull_triangles, + ((size_t)first_triangle + nf) * 3u * sizeof(uint32_t)); + if (triangles == NULL) goto done; + world->hull_triangles = triangles; + F3dHull *hulls = (F3dHull *)f3d_realloc( + world->hulls, ((size_t)world->s.hull_count + 1u) * sizeof(F3dHull)); + if (hulls == NULL) goto done; + world->hulls = hulls; + F3dHull h; + f3d_zero(&h, sizeof h); + h.first_vertex = first_vertex; + h.vertex_count = nv; + h.first_triangle = first_triangle; + h.triangle_count = nf; + h.offset = f3d_v3((f3d_real)offset[0], (f3d_real)offset[1], + (f3d_real)offset[2]); + h.volume = (f3d_real)volume; + h.surface = (f3d_real)surface; + h.unit_inertia = unit; + int first = 1; + for (uint32_t i = 0; i < count; i++) { + if (remap[i] == UINT32_MAX) continue; + const F3dVec3 v = f3d_v3((f3d_real)(p[i * 3u] - offset[0]), + (f3d_real)(p[i * 3u + 1u] - offset[1]), + (f3d_real)(p[i * 3u + 2u] - offset[2])); + f3d_real *out = &vertices[((size_t)first_vertex + remap[i]) * 3u]; + out[0] = v.x; + out[1] = v.y; + out[2] = v.z; + if (first) { + h.lo = h.hi = v; + first = 0; + } else { + h.lo = f3d_v3(f3d_min(h.lo.x, v.x), f3d_min(h.lo.y, v.y), + f3d_min(h.lo.z, v.z)); + h.hi = f3d_v3(f3d_max(h.hi.x, v.x), f3d_max(h.hi.y, v.y), + f3d_max(h.hi.z, v.z)); + } + } + for (uint32_t f = 0; f < nf; f++) { + uint32_t *t = &triangles[((size_t)first_triangle + f) * 3u]; + t[0] = remap[faces[f].a]; + t[1] = remap[faces[f].b]; + t[2] = remap[faces[f].c]; + } + f3d_copy(&hulls[world->s.hull_count], &h, sizeof h); + world->s.hull_count++; + world->s.hull_vertex_count += nv; + world->s.hull_triangle_count += nf; + result = world->s.hull_count; +done: + f3d_free(p); + f3d_free(faces); + f3d_free(spare); + f3d_free(edges); + f3d_free(remap); + return result; +} + +int f3d_world_get_hull_offset(const F3dWorld *world, uint32_t hull, + f3d_real *out) { + if (hull == 0 || hull > world->s.hull_count) return 0; + const F3dVec3 o = world->hulls[hull - 1u].offset; + out[0] = o.x; + out[1] = o.y; + out[2] = o.z; + return 1; +} + +uint32_t f3d_world_hull_vertex_count(const F3dWorld *world, uint32_t hull) { + if (hull == 0 || hull > world->s.hull_count) return 0; + return world->hulls[hull - 1u].vertex_count; +} diff --git a/packages/flutter3d_physics_native/csrc/src/f3d_internal.h b/packages/flutter3d_physics_native/csrc/src/f3d_internal.h new file mode 100644 index 000000000..386c23a32 --- /dev/null +++ b/packages/flutter3d_physics_native/csrc/src/f3d_internal.h @@ -0,0 +1,722 @@ +/* + * What the core's files share and the API does not show. + */ +#ifndef F3D_INTERNAL_H_ +#define F3D_INTERNAL_H_ + +#include +#include + +#include "f3d_physics.h" + +/* --------------------------------------------------------------- memory + * + * Every allocation goes through these, so the WebAssembly build — which has + * no C library — supplies them once, in f3d_memory_wasm.c, and nothing else + * in the core knows which build it is in. + */ +void *f3d_alloc(size_t bytes); +void *f3d_realloc(void *block, size_t bytes); +void f3d_free(void *block); +void f3d_zero(void *block, size_t bytes); +void f3d_copy(void *to, const void *from, size_t bytes); + +/* ----------------------------------------------------------------- math */ + +/* A literal as an f3d_real, so one expression reads the same in either + * build. */ +#define F3D_R(x) ((f3d_real)(x)) + +#define F3D_PI F3D_R(3.14159265358979323846) + +/* Overlap the solver leaves alone, m: pushing a resting box out to the last + * micrometre would have it lose contact, fall a substep and land again. A + * contact this close or closer counts as touching, since this is as close + * as the solver brings a body it holds. */ +#define F3D_LINEAR_SLOP F3D_R(0.005) + +/* Stefan–Boltzmann, W / (m² K⁴). */ +#define F3D_STEFAN_BOLTZMANN F3D_R(5.670374419e-8) + +/* Water: J / (kg K), J / kg to boil it, and K at which it boils. */ +#define F3D_WATER_HEAT F3D_R(4186.0) +#define F3D_WATER_LATENT F3D_R(2.257e6) +#define F3D_WATER_BOILS F3D_R(373.15) + +/* 32-bit x86 does its arithmetic on the x87 by default, in registers + * wider than an f32 or an f64 and rounded when they are stored, so the + * same step lands on other bits than everywhere else (csrc/tests/ + * test_digest.c caught all seven of its scenes). SSE2 rounds every + * operation as IEEE 754 says; without it this build refuses. */ +#if (defined(__i386__) && !defined(__SSE2_MATH__)) || \ + (defined(_M_IX86) && (!defined(_M_IX86_FP) || _M_IX86_FP < 2)) +#error "32-bit x86 must do its arithmetic in SSE2: build with -msse2 -mfpmath=sse (MSVC: /arch:SSE2)" +#endif + +/* Whether [x] is neither infinite nor NaN, without : the + * WebAssembly build has none, and isfinite may be a library call. */ +static inline int f3d_finite(f3d_real x) { return x - x == F3D_R(0.0); } + +/* The square root, which IEEE 754 rounds exactly: an instruction on every + * target, never a library call — f32.sqrt in WebAssembly, sqrtss on x86, + * fsqrt on ARM. */ +#if defined(_MSC_VER) +#include +#ifdef F3D_REAL_DOUBLE +static inline f3d_real f3d_sqrt(f3d_real x) { return sqrt(x); } +#else +static inline f3d_real f3d_sqrt(f3d_real x) { return sqrtf(x); } +#endif +#else +#ifdef F3D_REAL_DOUBLE +static inline f3d_real f3d_sqrt(f3d_real x) { return __builtin_sqrt(x); } +#else +static inline f3d_real f3d_sqrt(f3d_real x) { return __builtin_sqrtf(x); } +#endif +#endif + +static inline f3d_real f3d_min(f3d_real a, f3d_real b) { return a < b ? a : b; } +static inline f3d_real f3d_max(f3d_real a, f3d_real b) { return a > b ? a : b; } + +typedef struct F3dVec3 { + f3d_real x, y, z; +} F3dVec3; + +typedef struct F3dQuat { + f3d_real x, y, z, w; +} F3dQuat; + +/* A symmetric 3×3 matrix: the six numbers that are not mirrors. */ +typedef struct F3dSym3 { + f3d_real xx, yy, zz, xy, xz, yz; +} F3dSym3; + +/* R · M · Rᵀ, with R the rotation of the unit quaternion [q]: a tensor in + * a body's own axes, in the world's. */ +F3dSym3 f3d_sym_turned(F3dQuat q, F3dSym3 m); + +static inline F3dSym3 f3d_sym_diag(f3d_real x, f3d_real y, f3d_real z) { + F3dSym3 m; + m.xx = x; + m.yy = y; + m.zz = z; + m.xy = m.xz = m.yz = 0; + return m; +} + +/* The inverse of a symmetric matrix by its cofactors, or nought for one + * that has none. */ +F3dSym3 f3d_sym_inverse(F3dSym3 m); + +static inline F3dVec3 f3d_sym_times(F3dSym3 m, F3dVec3 v) { + F3dVec3 out; + out.x = m.xx * v.x + m.xy * v.y + m.xz * v.z; + out.y = m.xy * v.x + m.yy * v.y + m.yz * v.z; + out.z = m.xz * v.x + m.yz * v.y + m.zz * v.z; + return out; +} + +static inline F3dVec3 f3d_v3(f3d_real x, f3d_real y, f3d_real z) { + F3dVec3 v; + v.x = x; + v.y = y; + v.z = z; + return v; +} +static inline F3dVec3 f3d_add(F3dVec3 a, F3dVec3 b) { + return f3d_v3(a.x + b.x, a.y + b.y, a.z + b.z); +} +static inline F3dVec3 f3d_sub(F3dVec3 a, F3dVec3 b) { + return f3d_v3(a.x - b.x, a.y - b.y, a.z - b.z); +} +static inline F3dVec3 f3d_scale(F3dVec3 a, f3d_real k) { + return f3d_v3(a.x * k, a.y * k, a.z * k); +} +/* a + b·k. */ +static inline F3dVec3 f3d_madd(F3dVec3 a, F3dVec3 b, f3d_real k) { + return f3d_v3(a.x + b.x * k, a.y + b.y * k, a.z + b.z * k); +} +static inline f3d_real f3d_dot(F3dVec3 a, F3dVec3 b) { + return a.x * b.x + a.y * b.y + a.z * b.z; +} +static inline F3dVec3 f3d_cross(F3dVec3 a, F3dVec3 b) { + return f3d_v3(a.y * b.z - a.z * b.y, a.z * b.x - a.x * b.z, + a.x * b.y - a.y * b.x); +} +static inline f3d_real f3d_abs(f3d_real x) { return x < F3D_R(0.0) ? -x : x; } +static inline f3d_real f3d_clamp(f3d_real x, f3d_real lo, f3d_real hi) { + return x < lo ? lo : (x > hi ? hi : x); +} + +/* A rotation as its three columns: the body's own axes in the world's. */ +typedef struct F3dMat3 { + F3dVec3 c[3]; +} F3dMat3; + +F3dMat3 f3d_mat_of(F3dQuat q); + +/* ------------------------------------------------------------- contacts */ + +/* Points a manifold holds at most: four keep a box resting on a face from + * rocking, and more add nothing a solver uses. */ +#define F3D_MANIFOLD_POINTS 4u + +typedef struct F3dContactPoint { + /* Halfway between the two surfaces, relative to the origin. */ + F3dVec3 point; + /* Positive inside, negative a gap within the margin. */ + f3d_real depth; + /* Which features made it, so a solver can carry its impulse to the next + * step's point from the same features. */ + uint32_t id; + /* What the solver pushed with, along the normal and the two tangents, per + * substep: where the next step starts from. */ + f3d_real normal_impulse; + f3d_real tangent_impulse[2]; +} F3dContactPoint; + +/* Everything two bodies' shapes touch at. Plain data: a snapshot copies it + * whole. */ +typedef struct F3dManifold { + /* The lower slot is a, the higher b. */ + F3dBody a; + F3dBody b; + /* Out of b, into a: the way a moves to come apart. */ + F3dVec3 normal; + uint32_t count; + /* 1 while some point is within the solver's slop of touching, or past + * it: not only within the margin. */ + uint32_t touching; + F3dContactPoint points[F3D_MANIFOLD_POINTS]; +} F3dManifold; + +/* A shape where it stands, as the narrow phase reads it. */ +/* A shape no body has: one triangle of a mesh, as the narrow phase reads + * it, its three corners in the world in [vertices]. */ +#define F3D_SHAPE_TRIANGLE 15u + +typedef struct F3dPlaced { + uint32_t kind; + F3dVec3 size; + F3dVec3 at; + F3dMat3 axes; + /* How far the shape is rounded out. */ + f3d_real rounding; + /* A hull's vertices and triangles, and their counts; null for the rest. + * A triangle's three corners. */ + const struct F3dHull *hull; + const f3d_real *vertices; + const uint32_t *triangles; + /* A mesh, its tree, and its edges' flags. */ + const struct F3dMesh *mesh; + const struct F3dTree *mesh_tree; + const uint8_t *edge_flags; +} F3dPlaced; + +/* Fills [out]'s normal and points for [a] against [b], within [margin], + * and returns how many points; nought when they are further apart. The + * normal points out of b into a. */ +uint32_t f3d_collide(const F3dPlaced *a, const F3dPlaced *b, f3d_real margin, + F3dManifold *out); + +/* ---------------------------------------------------------------- world */ + +/* Bits of F3dSlot.flags. */ +enum { + F3D_FLAG_ASLEEP = 1u << 0, + F3D_FLAG_LOCKED = 1u << 1, + F3D_FLAG_BURNING = 1u << 2, + /* Placed, turned or reshaped by the caller since the last step: whatever + * it slept against has to look again. */ + F3D_FLAG_MOVED = 1u << 3, + /* Swept for its time of impact after the solve: a bullet does not pass + * through a wall however thin, at any speed. */ + F3D_FLAG_BULLET = 1u << 4, +}; + +/* ----------------------------------------------------------------- tree */ + +typedef struct F3dBox { + F3dVec3 lo, hi; +} F3dBox; + +/* A node of the broadphase tree: a leaf holds one body's fat box, an + * inner node the box round its two children. */ +typedef struct F3dTreeNode { + F3dBox box; + /* The parent, or the next free node while free; -1 for none. */ + int32_t parent; + /* Children; child1 is -1 for a leaf. */ + int32_t child1, child2; + /* A leaf's is nought; -1 while free. */ + int32_t height; + /* A leaf's body slot. */ + uint32_t slot; +} F3dTreeNode; + +/* A dynamic tree of boxes, balanced by rotations, as Box2D's + * b2DynamicTree: what finds the pairs near each other, and what queries + * search. Derived state: not in a snapshot, built again after a restore. */ +typedef struct F3dTree { + F3dTreeNode *nodes; + uint32_t capacity; + int32_t root; + int32_t free_list; + uint32_t leaves; +} F3dTree; + +/* How far past a body's box its leaf reaches, m: it moves inside that + * before the tree has to move it. */ +#define F3D_FAT_MARGIN F3D_R(0.1) + +int32_t f3d_tree_insert(F3dTree *tree, F3dBox box, uint32_t slot); +void f3d_tree_remove(F3dTree *tree, int32_t leaf); +void f3d_tree_clear(F3dTree *tree); + +/* Calls [visit] for every leaf whose box the segment from [origin] along + * [dir] for up to *[limit] crosses, nearest box first where it can; [visit] + * may shorten *[limit] to cut what is left. */ +void f3d_tree_ray(const F3dTree *tree, F3dVec3 origin, F3dVec3 dir, + f3d_real *limit, + int (*visit)(void *context, int32_t leaf), void *context); + +/* Calls [visit] for every leaf whose box overlaps [box]; stops early when + * it returns nought. */ +void f3d_tree_query(const F3dTree *tree, F3dBox box, + int (*visit)(void *context, int32_t leaf), void *context); + + +static inline int f3d_box_overlap(F3dBox a, F3dBox b) { + return a.lo.x <= b.hi.x && b.lo.x <= a.hi.x && a.lo.y <= b.hi.y && + b.lo.y <= a.hi.y && a.lo.z <= b.hi.z && b.lo.z <= a.hi.z; +} + + + +/* One arena slot. A free slot keeps its generation, so the next body it + * holds gets a new one, and links to the next free slot. + * + * Plain data, zeroed when taken, so a snapshot copies it whole and its + * padding is the same bytes every time. */ +typedef struct F3dSlot { + uint32_t generation; + /* Next free slot plus one, or nought; meaningful while free. */ + uint32_t next_free; + uint8_t live; + uint8_t type; + uint8_t shape; + uint8_t flags; + F3dVec3 position; + F3dQuat orientation; + F3dVec3 velocity; + F3dVec3 spin; + F3dVec3 force; + F3dVec3 torque; + /* The shape's size, as F3dShapeKind reads it. */ + F3dVec3 size; + f3d_real mass; + f3d_real inverse_mass; + /* The inertia tensor in the body's own axes, and its inverse — nought + * where the body does not turn. Diagonal for every shape but a hull. */ + F3dSym3 inertia; + F3dSym3 inverse_inertia; + /* The hull or mesh it is shaped as, one past its index in the world's + * table of them; nought for none. */ + uint32_t hull; + /* How far its shape is rounded out, m: the shape grown by a ball of this + * radius. */ + f3d_real rounding; + f3d_real linear_damping; + f3d_real angular_damping; + /* As set; nought takes the shape's. */ + f3d_real drag; + /* m², and the coefficient the step uses. */ + f3d_real surface; + f3d_real shape_drag; + /* Seconds spent below the sleep speed. */ + f3d_real still; + F3dMaterial material; + f3d_real temperature; + f3d_real heat; + f3d_real water; + f3d_real fuel; + /* W given off as gas over the last step. */ + f3d_real heat_release; + /* Coulomb's coefficient, and the share of the approach speed that comes + * back. */ + f3d_real friction; + f3d_real restitution; + /* What it is, and what it meets: a pair collides when each one's layer is + * in the other's mask. */ + uint32_t layer; + uint32_t mask; +} F3dSlot; + +typedef struct F3dEventRecord { + F3dBody body; + /* The other body, for an event between two; nought otherwise. */ + F3dBody other; + uint32_t kind; + uint32_t reserved; +} F3dEventRecord; + +/* A convex hull: its vertices and triangles in the world's two arrays, + * what one kilogram of it weighs into its inertia, and how it was moved to + * put its centre of mass at the body's origin. Plain data. */ +typedef struct F3dHull { + uint32_t first_vertex, vertex_count; + uint32_t first_triangle, triangle_count; + /* Subtracted from every point it was made from. */ + F3dVec3 offset; + /* Its box in its own frame. */ + F3dVec3 lo, hi; + f3d_real volume; + f3d_real surface; + /* The inertia tensor of a kilogram of it, about its centre. */ + F3dSym3 unit_inertia; +} F3dHull; + +/* A triangle mesh: its vertices and triangles in the world's arrays, a + * byte of flags a triangle — bit k set when its edge from corner k to the + * next is internal, shared with a neighbour across a flat or hollow fold — + * and its box. Plain data. */ +typedef struct F3dMesh { + uint32_t first_vertex, vertex_count; + uint32_t first_triangle, triangle_count; + F3dVec3 lo, hi; + f3d_real surface; +} F3dMesh; + +/* Bits of F3dJointSlot.flags. */ +enum { + F3D_JOINT_LIMIT = 1u << 0, + F3D_JOINT_MOTOR = 1u << 1, + F3D_JOINT_SPRING = 1u << 2, + F3D_JOINT_COLLIDE = 1u << 3, + /* A spherical joint's swing held inside a cone. */ + F3D_JOINT_CONE = 1u << 4, + F3D_JOINT_FRICTION = 1u << 5, +}; + +/* One joint's arena slot. Plain data, zeroed when taken: a snapshot copies + * it whole, warm-start impulses and all. */ +typedef struct F3dJointSlot { + uint32_t generation; + uint32_t next_free; + uint8_t live; + uint8_t type; + uint8_t flags; + uint8_t reserved; + F3dBody a, b; + /* Where it holds each body, in that body's frame. */ + F3dVec3 local_a, local_b; + /* Its axis in A's frame, and the same axis in B's. */ + F3dVec3 axis_a, axis_b; + /* B's turn relative to A's when it was made: what "no angle" is. */ + F3dQuat reference; + f3d_real lower, upper; + f3d_real motor_speed, motor_force; + f3d_real spring_hertz, spring_damping; + /* A distance joint's rest, least and most length. */ + f3d_real length, least, most; + /* What it pushed with, per substep: its locked directions, solved as + * one block — up to three of the point and three of the turn — then the + * limits, the motor, the spring. */ + f3d_real impulse[6]; + f3d_real lower_impulse, upper_impulse, motor_impulse, spring_impulse; + /* The linear impulse it put on B over the last substep, in the world. */ + F3dVec3 pushed; + /* A spherical joint's cone: the most B's axis may swing from A's, and + * what holding it pushed with. */ + f3d_real cone; + f3d_real cone_impulse; + /* A spherical joint's friction: the most torque it resists turning + * with, N m, and what it pushed with about the world's axes. */ + f3d_real friction; + F3dVec3 friction_impulse; +} F3dJointSlot; + +/* Everything in a world that is not behind a pointer: what a snapshot + * copies in one piece. */ +typedef struct F3dWorldState { + double origin[3]; + F3dVec3 gravity; + F3dVec3 wind; + f3d_real air_temperature; + f3d_real air_density; + f3d_real sleep_speed; + f3d_real sleep_time; + /* Slots ever used: the arena's high-water mark. */ + uint32_t used; + uint32_t live; + /* First free slot plus one, or nought. */ + uint32_t free_head; + /* The wind grid, or none while its counts are nought. */ + F3dVec3 grid_origin; + f3d_real grid_cell; + uint32_t grid_n[3]; + /* Events waiting, from the oldest at events_head. */ + uint32_t events_head; + uint32_t events_count; + uint32_t events_dropped; + /* How near counts as a contact. */ + f3d_real contact_margin; + /* 1 while contacts reach as far as a body moves in a step. */ + uint32_t speculative; + /* Manifolds the last step found, in order of their pair. */ + uint32_t manifold_count; + /* How many substeps a step is solved in. */ + uint32_t substeps; + /* The hulls, and the vertices and triangles they hold. */ + uint32_t hull_count; + uint32_t hull_vertex_count; + uint32_t hull_triangle_count; + /* The meshes, and the vertices and triangles they hold. */ + uint32_t mesh_count; + uint32_t mesh_vertex_count; + uint32_t mesh_triangle_count; + /* The joints' arena, as the bodies'. */ + uint32_t joint_used; + uint32_t joint_live; + uint32_t joint_free_head; + /* The last substep's length, s: what a joint's impulse is divided by to + * say its force. */ + f3d_real last_substep; + /* 1 in the fast mode: contacts solved a colour at a time, in parallel. */ + uint32_t fast; +} F3dWorldState; + +/* ------------------------------------------------------------------- pool */ + +/* The most threads a world steps on. */ +#define F3D_MAX_THREADS 64u + +typedef struct F3dPool F3dPool; + +/* A pass's share of [begin, end), run by worker [worker]. */ +typedef void (*F3dTask)(void *context, uint32_t worker, uint32_t begin, uint32_t end); + +/* [threads] workers, the caller one of them: null for fewer than two, more + * than F3D_MAX_THREADS, no memory, threads that would not start, or the + * WebAssembly build. */ +F3dPool *f3d_pool_create(uint32_t threads); +void f3d_pool_destroy(F3dPool *pool); + +/* How many workers [pool] has; one for none. */ +uint32_t f3d_pool_size(const F3dPool *pool); + +/* Runs [task] over [count] items, split evenly among the workers, and + * returns once every share is done; on the caller alone when [pool] is + * null. */ +void f3d_pool_run(F3dPool *pool, uint32_t count, F3dTask task, void *context); + +/* A barrier the workers of one pass meet at, spinning: the stages of a + * step that must each finish before the next begins, inside one pass. */ +typedef struct F3dBarrier { + volatile uint32_t arrived; + volatile uint32_t sense; + uint32_t workers; +} F3dBarrier; + +void f3d_barrier_init(F3dBarrier *barrier, uint32_t workers); + +/* Waits until all the barrier's workers have come; [sense] is the caller's + * own, nought to begin with, and flips each time. */ +void f3d_barrier_wait(F3dBarrier *barrier, uint32_t *sense); + +/* What one worker gathers in a pass, kept between steps so it need not + * grow again. */ +typedef struct F3dLane { + void *items; + size_t capacity; + uint32_t count; + int failed; +} F3dLane; + +struct F3dWorld { + F3dWorldState s; + F3dSlot *slots; + uint32_t capacity; + /* Three reals a sample. */ + f3d_real *grid; + /* A ring of F3D_EVENT_CAPACITY, allocated with the first event. */ + F3dEventRecord *events; + /* The last step's manifolds, manifold_count of them, and room for the + * next step's beside them. */ + F3dManifold *manifolds; + F3dManifold *next_manifolds; + uint32_t manifold_capacity; + uint32_t next_capacity; + /* The hulls: headers, vertices three reals each, triangles three indices + * into their hull's vertices each. In a snapshot. */ + F3dHull *hulls; + f3d_real *hull_vertices; + uint32_t *hull_triangles; + /* The meshes likewise, with a byte of edge flags a triangle; and a tree + * of each mesh's triangles, built from them, not in a snapshot. */ + F3dMesh *meshes; + f3d_real *mesh_vertices; + uint32_t *mesh_triangles; + uint8_t *mesh_edges; + F3dTree *mesh_trees; + uint32_t mesh_tree_count; + /* The joints, and the pairs of slots joined by a joint that keeps them + * from colliding, sorted: built from the joints when they change, not in + * a snapshot. */ + F3dJointSlot *joints; + uint32_t joint_capacity; + uint64_t *joined; + uint32_t joined_count; + int joined_stale; + /* The threads the world steps on, null for the caller alone, and what + * each gathers. Not in a snapshot: a restored world steps on one. */ + F3dPool *pool; + F3dLane lanes[F3D_MAX_THREADS]; + /* The broadphase, and each slot's leaf in it (-1 for none), as long as + * the arena. Neither is in a snapshot. */ + F3dTree tree; + int32_t *proxies; + uint32_t proxy_capacity; + /* Every two slots whose leaves overlap, as pair keys in order: what the + * pairs are found among, kept from step to step and changed only where + * a leaf moved. The slots whose leaves moved since, and whether the + * pairs are to be trusted at all — not after a restore, nor when the + * moved list could not grow. Neither is in a snapshot. */ + uint64_t *pairs; + uint32_t pair_count, pair_capacity; + uint32_t *moved; + uint32_t moved_count, moved_capacity; + int pairs_ready; + /* Each slot's box swept through the step, worked out once a step. */ + F3dBox *swept; + uint32_t swept_capacity; + /* Where each bullet stood after each substep of the last step: its + * path, for the sweep. Rows of substeps + 1 poses. Not in a snapshot. */ + F3dVec3 *bullet_at; + F3dQuat *bullet_turn; + uint32_t bullet_capacity; + /* Scratch the collision stage grows and keeps, so a step allocates only + * when the world grows. None of it outlives a step. */ + void *scratch; + size_t scratch_bytes; +}; + +/* The slot [body] names in [world], or null for a stale or foreign one. */ +F3dSlot *f3d_slot_of(const F3dWorld *world, F3dBody body); + +F3dBody f3d_handle_of(const F3dWorld *world, const F3dSlot *slot); + +/* Brings a body's inertia, surface and drag up to date with its shape and + * mass. */ +void f3d_refresh_mass(const F3dWorld *world, F3dSlot *slot); + +void f3d_push_event(F3dWorld *world, F3dBody body, uint32_t kind); +void f3d_push_pair_event(F3dWorld *world, F3dBody body, F3dBody other, + uint32_t kind); + +/* Wakes a body, raising its event when it slept. */ +void f3d_wake(F3dWorld *world, F3dSlot *slot); + +/* A body's box, as tight as its shape, grown by [margin] on every side. */ +F3dBox f3d_box_of(const F3dWorld *world, const F3dSlot *slot, f3d_real margin); + +/* Brings every body's leaf up to date: made for a body that has a shape, + * moved for one that left its fat box, taken out for one that is gone or + * has none. */ +void f3d_update_proxies(F3dWorld *world, f3d_real dt); + +/* A body's box grown by [margin], and for an awake body with speculative + * contacts on, joined to where it will be after [dt] at its velocity: what + * it can touch this step. */ +F3dBox f3d_swept_box(const F3dWorld *world, const F3dSlot *slot, + f3d_real margin, f3d_real dt); + +/* How far a body reaches from its centre: the half diagonal of its box. */ +f3d_real f3d_reach_of(const F3dWorld *world, const F3dSlot *slot); + +/* A shape's placement, from its slot. */ +F3dPlaced f3d_placed_of(const F3dWorld *world, const F3dSlot *slot); + +/* The general narrow phase, for any two shapes: GJK between their cores, + * EPA where the cores overlap, and the manifold from their faces. */ +/* A convex shape against a mesh: the triangles near it from the mesh's + * tree, each against it, merged into one manifold whose normal points out + * of the mesh into the shape. */ +uint32_t f3d_collide_mesh(const F3dPlaced *mesh, const F3dPlaced *body, + f3d_real margin, F3dManifold *out); + +/* Builds each mesh's tree that is missing: after a restore, or for a mesh + * just made. */ +void f3d_build_mesh_trees(F3dWorld *world); +void f3d_clear_mesh_trees(F3dWorld *world); + +/* The nearest pair of points on p0→p1 and q0→q1, as fractions along each: + * Ericson's closest points of two segments. */ +void f3d_nearest_of_segments(F3dVec3 p0, F3dVec3 p1, F3dVec3 q0, F3dVec3 q1, + f3d_real *s, f3d_real *t); + +uint32_t f3d_collide_convex(const F3dPlaced *a, const F3dPlaced *b, + f3d_real margin, F3dManifold *out); + +/* Whether anything can turn the body: a shape with inertia, dynamic, not + * locked. */ +int f3d_turns(const F3dSlot *slot); + +/* One substep of [h] seconds of a dynamic body's velocity — gravity, the + * bus's forces and torques, the wind's drag, damping — and of its position + * and orientation; and the end of a step for any body — the bus spent and + * the sleep clock run. */ +void f3d_integrate_velocity(const F3dWorld *world, F3dSlot *slot, f3d_real h); +void f3d_integrate_position(F3dSlot *slot, f3d_real h); +void f3d_finish_motion(const F3dWorld *world, F3dSlot *slot, f3d_real dt); + +/* [bytes] of the world's scratch, kept between steps and grown when it is + * not enough; null when it cannot grow. One stage's at a time. */ +void *f3d_scratch(F3dWorld *world, size_t bytes); + +/* The angle of (x, y), in (−π, π], from a series: the core's own, the same + * bits everywhere. */ +f3d_real f3d_atan2(f3d_real y, f3d_real x); + +/* Whether a joint joins the slots [a] and [b] and keeps them from + * colliding. */ +int f3d_joined(F3dWorld *world, uint32_t a, uint32_t b); + +/* Builds the joined pairs' table if it is out of date, so that f3d_joined + * only reads after this: what threads asking at once need. */ +void f3d_joined_ready(F3dWorld *world); + +/* Takes out every joint on the body in [slot]: called as it goes. */ +void f3d_unjoin(F3dWorld *world, uint32_t slot); + +/* What the solver holds of each body for a step: where it started, and how + * hard it is to move — nothing, for a body the solver may not move. */ +typedef struct F3dSolverBody { + F3dVec3 start; + F3dQuat turn; + /* A bullet's row in the world's record of where it stood at each + * substep; -1 for any other body. */ + int32_t bullet; + f3d_real inverse_mass; + F3dSym3 inverse_inertia; +} F3dSolverBody; + +/* The joints' part of the solver, in each substep: warm-started, then + * solved with the soft bias or without it. Their anchors and masses are + * worked out from where the bodies are each time. */ +void f3d_warm_joints(F3dWorld *world, const F3dSolverBody *bodies); +void f3d_solve_joints(F3dWorld *world, const F3dSolverBody *bodies, + f3d_real h, int use_bias); + + +/* The step's stages: contacts where the bodies stand, the solver's + * substeps, heat. */ +void f3d_step_collide(F3dWorld *world, f3d_real dt); + +/* Hard CCD, after the solve: each bullet swept from where the step began to + * where it ended, and put back at its first time of impact. */ +void f3d_step_continuous(F3dWorld *world, const F3dSolverBody *bodies); +void f3d_step_solve(F3dWorld *world, f3d_real dt); +void f3d_step_heat(F3dWorld *world, f3d_real dt); + +#endif /* F3D_INTERNAL_H_ */ diff --git a/packages/flutter3d_physics_native/csrc/src/f3d_joint.c b/packages/flutter3d_physics_native/csrc/src/f3d_joint.c new file mode 100644 index 000000000..1d88f54a4 --- /dev/null +++ b/packages/flutter3d_physics_native/csrc/src/f3d_joint.c @@ -0,0 +1,1030 @@ +/* + * Joints — P9, phase 7: fixed, spherical, revolute, prismatic and distance + * joints, with limits, motors and springs, solved in the same soft substeps + * as the contacts, as Box2D v3 solves them. + * + * Each joint is a set of constraints on the relative motion of its two + * bodies at their anchors: a point held together, turning locked about two + * axes or three, travel locked across an axis, a length. In each substep + * the anchors and axes are taken from where the bodies stand, the masses + * worked out from them, and every constraint solved with the joint's soft + * spring — at most sixty hertz, a quarter of the substep rate, damping ratio + * two — and then again with none, as the contacts are. Springs, motors and + * limits come first, the locks last, so a lock has the last word. + * + * A joint's angle needs an arctangent, which the core may not take from a + * library; it has its own, from a series, the same bits everywhere. + */ +#include "f3d_internal.h" + +#define F3D_JOINT_DAMPING F3D_R(2.0) + +/* ------------------------------------------------------------- arithmetic */ + +/* atan(t) for |t| ≤ tan(π/8): the series t − t³/3 + t⁵/5 − …, to well past + * the real's precision — under 2·10⁻⁸ after eight terms in f32, under + * 10⁻¹⁵ after sixteen in f64. */ +static f3d_real atan_small(f3d_real t) { +#ifdef F3D_REAL_DOUBLE + const int terms = 18; +#else + const int terms = 9; +#endif + const f3d_real t2 = t * t; + f3d_real sum = F3D_R(0.0), power = t; + for (int k = 0; k < terms; k++) { + const f3d_real term = power / (f3d_real)(2 * k + 1); + sum = (k & 1) ? sum - term : sum + term; + power *= t2; + } + return sum; +} + +/* atan(t) for t in [0, 1]: brought under tan(π/8) by + * atan t = π/4 + atan((t − 1)/(t + 1)). */ +static f3d_real atan_unit(f3d_real t) { + if (t <= F3D_R(0.41421356237309505)) return atan_small(t); + return F3D_PI / F3D_R(4.0) + atan_small((t - F3D_R(1.0)) / (t + F3D_R(1.0))); +} + +/* The angle of (x, y), in (−π, π]. */ +f3d_real f3d_atan2(f3d_real y, f3d_real x) { + const f3d_real ax = f3d_abs(x), ay = f3d_abs(y); + if (ax == F3D_R(0.0) && ay == F3D_R(0.0)) return F3D_R(0.0); + f3d_real a = ay <= ax ? atan_unit(ay / ax) + : F3D_PI / F3D_R(2.0) - atan_unit(ax / ay); + if (x < F3D_R(0.0)) a = F3D_PI - a; + return y < F3D_R(0.0) ? -a : a; +} + +static F3dQuat qmul(F3dQuat a, F3dQuat b) { + F3dQuat q; + q.x = a.w * b.x + a.x * b.w + a.y * b.z - a.z * b.y; + q.y = a.w * b.y - a.x * b.z + a.y * b.w + a.z * b.x; + q.z = a.w * b.z + a.x * b.y - a.y * b.x + a.z * b.w; + q.w = a.w * b.w - a.x * b.x - a.y * b.y - a.z * b.z; + return q; +} + +static F3dQuat qconj(F3dQuat q) { + q.x = -q.x; + q.y = -q.y; + q.z = -q.z; + return q; +} + +static F3dVec3 turn(const F3dMat3 *m, F3dVec3 v) { + return f3d_add(f3d_add(f3d_scale(m->c[0], v.x), f3d_scale(m->c[1], v.y)), + f3d_scale(m->c[2], v.z)); +} + +static F3dVec3 unturn(const F3dMat3 *m, F3dVec3 v) { + return f3d_v3(f3d_dot(m->c[0], v), f3d_dot(m->c[1], v), f3d_dot(m->c[2], v)); +} + +/* A unit vector square to [n]. */ +static F3dVec3 square_to(F3dVec3 n) { + const F3dVec3 axis = f3d_abs(n.x) < F3D_R(0.57735) + ? f3d_v3(F3D_R(1.0), F3D_R(0.0), F3D_R(0.0)) + : f3d_v3(F3D_R(0.0), F3D_R(1.0), F3D_R(0.0)); + const F3dVec3 t = f3d_cross(n, axis); + return f3d_scale(t, F3D_R(1.0) / f3d_sqrt(f3d_dot(t, t))); +} + +/* ------------------------------------------------------------------ arena */ + +static F3dJoint handle_of(uint32_t slot, uint32_t generation) { + return ((uint64_t)generation << 32) | (uint64_t)slot; +} + +static F3dJointSlot *joint_of(const F3dWorld *world, F3dJoint joint) { + const uint32_t slot = (uint32_t)(joint & 0xffffffffu); + const uint32_t generation = (uint32_t)(joint >> 32); + if (generation == 0 || slot >= world->s.joint_used) return NULL; + F3dJointSlot *j = &world->joints[slot]; + return j->live && j->generation == generation ? j : NULL; +} + +static uint32_t take_joint(F3dWorld *world) { + if (world->s.joint_free_head != 0) { + const uint32_t slot = world->s.joint_free_head - 1u; + world->s.joint_free_head = world->joints[slot].next_free; + return slot; + } + if (world->s.joint_used == world->joint_capacity) { + const uint32_t grown = + world->joint_capacity == 0 ? 16u : world->joint_capacity * 2u; + if (grown <= world->joint_capacity) return UINT32_MAX; + F3dJointSlot *joints = (F3dJointSlot *)f3d_realloc( + world->joints, (size_t)grown * sizeof(F3dJointSlot)); + if (joints == NULL) return UINT32_MAX; + f3d_zero(joints + world->joint_capacity, + (size_t)(grown - world->joint_capacity) * sizeof(F3dJointSlot)); + world->joints = joints; + world->joint_capacity = grown; + } + return world->s.joint_used++; +} + +static F3dJoint make(F3dWorld *world, F3dJointType type, F3dBody a, F3dBody b, + F3dVec3 pa, F3dVec3 pb, F3dVec3 axis) { + F3dSlot *sa = f3d_slot_of(world, a), *sb = f3d_slot_of(world, b); + if (sa == NULL || sb == NULL || sa == sb) return 0; + const uint32_t slot = take_joint(world); + if (slot == UINT32_MAX) return 0; + F3dJointSlot *j = &world->joints[slot]; + uint32_t generation = j->generation + 1u; + if (generation == 0) generation = 1; + f3d_zero(j, sizeof *j); + j->generation = generation; + j->live = 1; + j->type = (uint8_t)type; + j->a = a; + j->b = b; + const F3dMat3 ra = f3d_mat_of(sa->orientation); + const F3dMat3 rb = f3d_mat_of(sb->orientation); + j->local_a = unturn(&ra, f3d_sub(pa, sa->position)); + j->local_b = unturn(&rb, f3d_sub(pb, sb->position)); + j->axis_a = unturn(&ra, axis); + j->axis_b = unturn(&rb, axis); + j->reference = qmul(qconj(sa->orientation), sb->orientation); + const F3dVec3 d = f3d_sub(pb, pa); + j->length = j->least = j->most = f3d_sqrt(f3d_dot(d, d)); + world->s.joint_live++; + world->joined_stale = 1; + f3d_wake(world, sa); + f3d_wake(world, sb); + return handle_of(slot, generation); +} + +static int finite3(f3d_real x, f3d_real y, f3d_real z) { + return f3d_finite(x) && f3d_finite(y) && f3d_finite(z); +} + +F3dJoint f3d_joint_create(F3dWorld *world, F3dJointType type, F3dBody a, + F3dBody b, f3d_real ax, f3d_real ay, f3d_real az, + f3d_real ux, f3d_real uy, f3d_real uz) { + if (type != F3D_JOINT_FIXED && type != F3D_JOINT_SPHERICAL && + type != F3D_JOINT_REVOLUTE && type != F3D_JOINT_PRISMATIC) { + return 0; + } + if (!finite3(ax, ay, az) || !finite3(ux, uy, uz)) return 0; + F3dVec3 axis = f3d_v3(ux, uy, uz); + const f3d_real len = f3d_sqrt(f3d_dot(axis, axis)); + if (type == F3D_JOINT_REVOLUTE || type == F3D_JOINT_PRISMATIC) { + if (!(len > F3D_R(1e-12))) return 0; + } + axis = len > F3D_R(1e-12) ? f3d_scale(axis, F3D_R(1.0) / len) + : f3d_v3(F3D_R(0.0), F3D_R(1.0), F3D_R(0.0)); + const F3dVec3 p = f3d_v3(ax, ay, az); + return make(world, type, a, b, p, p, axis); +} + +F3dJoint f3d_joint_create_distance(F3dWorld *world, F3dBody a, F3dBody b, + f3d_real ax, f3d_real ay, f3d_real az, + f3d_real bx, f3d_real by, f3d_real bz) { + if (!finite3(ax, ay, az) || !finite3(bx, by, bz)) return 0; + return make(world, F3D_JOINT_DISTANCE, a, b, f3d_v3(ax, ay, az), + f3d_v3(bx, by, bz), f3d_v3(F3D_R(0.0), F3D_R(1.0), F3D_R(0.0))); +} + +static void wake_both(F3dWorld *world, const F3dJointSlot *j) { + F3dSlot *sa = f3d_slot_of(world, j->a), *sb = f3d_slot_of(world, j->b); + if (sa != NULL) f3d_wake(world, sa); + if (sb != NULL) f3d_wake(world, sb); +} + +static void free_joint(F3dWorld *world, F3dJointSlot *j) { + wake_both(world, j); + j->live = 0; + j->next_free = world->s.joint_free_head; + world->s.joint_free_head = (uint32_t)(j - world->joints) + 1u; + world->s.joint_live--; + world->joined_stale = 1; +} + +int f3d_joint_destroy(F3dWorld *world, F3dJoint joint) { + F3dJointSlot *j = joint_of(world, joint); + if (j == NULL) return 0; + free_joint(world, j); + return 1; +} + +void f3d_unjoin(F3dWorld *world, uint32_t slot) { + for (uint32_t i = 0; i < world->s.joint_used; i++) { + F3dJointSlot *j = &world->joints[i]; + if (!j->live) continue; + if ((uint32_t)(j->a & 0xffffffffu) == slot || + (uint32_t)(j->b & 0xffffffffu) == slot) { + free_joint(world, j); + } + } +} + +int f3d_joint_is_valid(const F3dWorld *world, F3dJoint joint) { + return joint_of(world, joint) != NULL; +} + +uint32_t f3d_world_joint_count(const F3dWorld *world) { + return world->s.joint_live; +} + +int f3d_joint_set_limits(F3dWorld *world, F3dJoint joint, int enabled, + f3d_real lower, f3d_real upper) { + F3dJointSlot *j = joint_of(world, joint); + if (j == NULL) return 0; + if (j->type != F3D_JOINT_REVOLUTE && j->type != F3D_JOINT_PRISMATIC && + j->type != F3D_JOINT_SPHERICAL) { + return 0; + } + if (enabled && !(f3d_finite(lower) && f3d_finite(upper) && lower <= upper)) { + return 0; + } + if (enabled) { + j->flags |= F3D_JOINT_LIMIT; + j->lower = lower; + j->upper = upper; + } else { + j->flags &= (uint8_t)~F3D_JOINT_LIMIT; + } + j->lower_impulse = j->upper_impulse = F3D_R(0.0); + wake_both(world, j); + return 1; +} + +int f3d_joint_set_cone(F3dWorld *world, F3dJoint joint, int enabled, f3d_real angle) { + F3dJointSlot *j = joint_of(world, joint); + if (j == NULL || j->type != F3D_JOINT_SPHERICAL) return 0; + if (enabled && !(f3d_finite(angle) && angle > F3D_R(0.0) && angle <= F3D_PI)) return 0; + if (enabled) { + j->flags |= F3D_JOINT_CONE; + j->cone = angle; + } else { + j->flags &= (uint8_t)~F3D_JOINT_CONE; + } + j->cone_impulse = F3D_R(0.0); + wake_both(world, j); + return 1; +} + +int f3d_joint_set_friction(F3dWorld *world, F3dJoint joint, int enabled, f3d_real torque) { + F3dJointSlot *j = joint_of(world, joint); + if (j == NULL || j->type != F3D_JOINT_SPHERICAL) return 0; + if (enabled && !(f3d_finite(torque) && torque >= F3D_R(0.0))) return 0; + if (enabled) { + j->flags |= F3D_JOINT_FRICTION; + j->friction = torque; + } else { + j->flags &= (uint8_t)~F3D_JOINT_FRICTION; + } + j->friction_impulse = f3d_v3(F3D_R(0.0), F3D_R(0.0), F3D_R(0.0)); + wake_both(world, j); + return 1; +} + +int f3d_joint_set_motor(F3dWorld *world, F3dJoint joint, int enabled, + f3d_real speed, f3d_real max_force) { + F3dJointSlot *j = joint_of(world, joint); + if (j == NULL) return 0; + if (j->type != F3D_JOINT_REVOLUTE && j->type != F3D_JOINT_PRISMATIC) return 0; + if (enabled && !(f3d_finite(speed) && f3d_finite(max_force) && + max_force >= F3D_R(0.0))) { + return 0; + } + if (enabled) { + j->flags |= F3D_JOINT_MOTOR; + j->motor_speed = speed; + j->motor_force = max_force; + } else { + j->flags &= (uint8_t)~F3D_JOINT_MOTOR; + j->motor_impulse = F3D_R(0.0); + } + wake_both(world, j); + return 1; +} + +int f3d_joint_set_spring(F3dWorld *world, F3dJoint joint, int enabled, + f3d_real hertz, f3d_real damping) { + F3dJointSlot *j = joint_of(world, joint); + if (j == NULL) return 0; + if (j->type == F3D_JOINT_FIXED || j->type == F3D_JOINT_SPHERICAL) return 0; + if (enabled && !(f3d_finite(hertz) && hertz >= F3D_R(0.0) && + f3d_finite(damping) && damping >= F3D_R(0.0))) { + return 0; + } + if (enabled) { + j->flags |= F3D_JOINT_SPRING; + j->spring_hertz = hertz; + j->spring_damping = damping; + } else { + j->flags &= (uint8_t)~F3D_JOINT_SPRING; + j->spring_impulse = F3D_R(0.0); + } + /* A distance joint changing between rod and spring starts its impulses + * afresh: each holds what the other would not. */ + if (j->type == F3D_JOINT_DISTANCE) { + j->impulse[0] = F3D_R(0.0); + j->lower_impulse = j->upper_impulse = F3D_R(0.0); + } + wake_both(world, j); + return 1; +} + +int f3d_joint_set_length(F3dWorld *world, F3dJoint joint, f3d_real length, + f3d_real least, f3d_real most) { + F3dJointSlot *j = joint_of(world, joint); + if (j == NULL || j->type != F3D_JOINT_DISTANCE) return 0; + if (!(f3d_finite(length) && f3d_finite(least) && f3d_finite(most))) return 0; + if (!(least >= F3D_R(0.0) && least <= length && length <= most)) return 0; + j->length = length; + j->least = least; + j->most = most; + wake_both(world, j); + return 1; +} + +int f3d_joint_set_collide(F3dWorld *world, F3dJoint joint, int collide) { + F3dJointSlot *j = joint_of(world, joint); + if (j == NULL) return 0; + if (collide) { + j->flags |= F3D_JOINT_COLLIDE; + } else { + j->flags &= (uint8_t)~F3D_JOINT_COLLIDE; + } + world->joined_stale = 1; + wake_both(world, j); + return 1; +} + +int f3d_joint_get_force(const F3dWorld *world, F3dJoint joint, f3d_real *out) { + const F3dJointSlot *j = joint_of(world, joint); + if (j == NULL) return 0; + const f3d_real h = world->s.last_substep; + const f3d_real k = h > F3D_R(0.0) ? F3D_R(1.0) / h : F3D_R(0.0); + out[0] = j->pushed.x * k; + out[1] = j->pushed.y * k; + out[2] = j->pushed.z * k; + return 1; +} + +/* ------------------------------------------------------------ the filter */ + +static void sort_keys(uint64_t *a, uint64_t *spare, uint32_t n) { + for (uint32_t width = 1; width < n; width *= 2u) { + for (uint32_t lo = 0; lo < n; lo += 2u * width) { + const uint32_t mid = lo + width < n ? lo + width : n; + const uint32_t hi = lo + 2u * width < n ? lo + 2u * width : n; + uint32_t i = lo, k = mid, o = lo; + while (i < mid && k < hi) spare[o++] = a[k] < a[i] ? a[k++] : a[i++]; + while (i < mid) spare[o++] = a[i++]; + while (k < hi) spare[o++] = a[k++]; + } + for (uint32_t o = 0; o < n; o++) a[o] = spare[o]; + if (width > UINT32_MAX / 2u) break; + } +} + +void f3d_joined_ready(F3dWorld *world) { + if (world->s.joint_live == 0 || !world->joined_stale) return; + f3d_free(world->joined); + world->joined = NULL; + world->joined_count = 0; + const uint32_t n = world->s.joint_used; + uint64_t *keys = (uint64_t *)f3d_alloc((size_t)n * 2u * sizeof(uint64_t) + 8u); + if (keys == NULL) return; + uint32_t count = 0; + for (uint32_t i = 0; i < n; i++) { + const F3dJointSlot *j = &world->joints[i]; + if (!j->live || (j->flags & F3D_JOINT_COLLIDE)) continue; + const uint32_t sa = (uint32_t)(j->a & 0xffffffffu); + const uint32_t sb = (uint32_t)(j->b & 0xffffffffu); + keys[count++] = sa < sb ? ((uint64_t)sa << 32) | sb + : ((uint64_t)sb << 32) | sa; + } + sort_keys(keys, keys + n, count); + world->joined = keys; + world->joined_count = count; + world->joined_stale = 0; +} + +int f3d_joined(F3dWorld *world, uint32_t a, uint32_t b) { + if (world->s.joint_live == 0) return 0; + f3d_joined_ready(world); + if (world->joined_stale) return 0; + const uint64_t key = + a < b ? ((uint64_t)a << 32) | b : ((uint64_t)b << 32) | a; + uint32_t lo = 0, hi = world->joined_count; + while (lo < hi) { + const uint32_t mid = lo + (hi - lo) / 2u; + if (world->joined[mid] < key) { + lo = mid + 1u; + } else { + hi = mid; + } + } + return lo < world->joined_count && world->joined[lo] == key; +} + +/* ----------------------------------------------------------------- solver */ + +typedef struct Soft { + f3d_real bias_rate, mass_scale, impulse_scale; +} Soft; + +static Soft soft(f3d_real hertz, f3d_real zeta, f3d_real h) { + Soft s; + if (!(hertz > F3D_R(0.0))) { + s.bias_rate = F3D_R(0.0); + s.mass_scale = F3D_R(0.0); + s.impulse_scale = F3D_R(1.0); + return s; + } + const f3d_real omega = F3D_R(2.0) * F3D_PI * hertz; + const f3d_real a1 = F3D_R(2.0) * zeta + h * omega; + const f3d_real a2 = h * omega * a1; + const f3d_real a3 = F3D_R(1.0) / (F3D_R(1.0) + a2); + s.bias_rate = omega / a1; + s.mass_scale = a2 * a3; + s.impulse_scale = a3; + return s; +} + +/* Where a joint's two bodies stand this substep, as its constraints read + * them. */ +typedef struct Frame { + F3dSlot *a, *b; + const F3dSolverBody *ba, *bb; + F3dMat3 ra, rb; + F3dVec3 la, lb; /* Anchors from each centre, in the world. */ + F3dVec3 d; /* B's anchor less A's. */ + F3dVec3 axis; /* The joint's axis, from A. */ +} Frame; + +static int frame_of(F3dWorld *world, const F3dJointSlot *j, + const F3dSolverBody *bodies, Frame *f) { + f->a = f3d_slot_of(world, j->a); + f->b = f3d_slot_of(world, j->b); + if (f->a == NULL || f->b == NULL) return 0; + f->ba = &bodies[f->a - world->slots]; + f->bb = &bodies[f->b - world->slots]; + if (f->ba->inverse_mass == F3D_R(0.0) && f->bb->inverse_mass == F3D_R(0.0) && + !f3d_turns(f->a) && !f3d_turns(f->b)) { + return 0; + } + f->ra = f3d_mat_of(f->a->orientation); + f->rb = f3d_mat_of(f->b->orientation); + f->la = turn(&f->ra, j->local_a); + f->lb = turn(&f->rb, j->local_b); + f->d = f3d_sub(f3d_add(f->b->position, f->lb), f3d_add(f->a->position, f->la)); + f->axis = turn(&f->ra, j->axis_a); + return 1; +} + +static F3dVec3 velocity_at(const F3dSlot *s, F3dVec3 r) { + return f3d_add(s->velocity, f3d_cross(s->spin, r)); +} + +/* A linear impulse [p] on B at rb, and its opposite on A at ra. */ +static void push(Frame *f, F3dVec3 ra, F3dVec3 rb, F3dVec3 p) { + f->a->velocity = f3d_madd(f->a->velocity, p, -f->ba->inverse_mass); + f->a->spin = f3d_sub(f->a->spin, + f3d_sym_times(f->ba->inverse_inertia, f3d_cross(ra, p))); + f->b->velocity = f3d_madd(f->b->velocity, p, f->bb->inverse_mass); + f->b->spin = f3d_add(f->b->spin, + f3d_sym_times(f->bb->inverse_inertia, f3d_cross(rb, p))); +} + +/* An angular impulse [l] on B, and its opposite on A. */ +static void twist(Frame *f, F3dVec3 l) { + f->a->spin = f3d_sub(f->a->spin, f3d_sym_times(f->ba->inverse_inertia, l)); + f->b->spin = f3d_add(f->b->spin, f3d_sym_times(f->bb->inverse_inertia, l)); +} + +/* 1 / (J M⁻¹ Jᵀ) of a linear direction n at levers ra, rb. */ +static f3d_real linear_mass(const Frame *f, F3dVec3 ra, F3dVec3 rb, F3dVec3 n) { + const F3dVec3 ca = f3d_cross(ra, n), cb = f3d_cross(rb, n); + const f3d_real k = f->ba->inverse_mass + f->bb->inverse_mass + + f3d_dot(ca, f3d_sym_times(f->ba->inverse_inertia, ca)) + + f3d_dot(cb, f3d_sym_times(f->bb->inverse_inertia, cb)); + return k > F3D_R(0.0) ? F3D_R(1.0) / k : F3D_R(0.0); +} + +static f3d_real angular_mass(const Frame *f, F3dVec3 n) { + const f3d_real k = f3d_dot(n, f3d_sym_times(f->ba->inverse_inertia, n)) + + f3d_dot(n, f3d_sym_times(f->bb->inverse_inertia, n)); + return k > F3D_R(0.0) ? F3D_R(1.0) / k : F3D_R(0.0); +} + +/* B's turn from where it started against A, in A's frame, as a small + * rotation vector: twice the error quaternion's vector part. */ +static F3dVec3 turn_error(const F3dJointSlot *j, const Frame *f) { + F3dQuat e = qmul(qmul(qconj(f->a->orientation), f->b->orientation), + qconj(j->reference)); + if (e.w < F3D_R(0.0)) { + e.x = -e.x; + e.y = -e.y; + e.z = -e.z; + e.w = -e.w; + } + return f3d_v3(F3D_R(2.0) * e.x, F3D_R(2.0) * e.y, F3D_R(2.0) * e.z); +} + +static f3d_real hinge_angle(const F3dJointSlot *j, const Frame *f) { + F3dQuat e = qmul(qmul(qconj(f->a->orientation), f->b->orientation), + qconj(j->reference)); + if (e.w < F3D_R(0.0)) { + e.x = -e.x; + e.y = -e.y; + e.z = -e.z; + e.w = -e.w; + } + const f3d_real along = e.x * j->axis_a.x + e.y * j->axis_a.y + e.z * j->axis_a.z; + return F3D_R(2.0) * f3d_atan2(along, e.w); +} + +/* One row of a joint's locked block: what it asks of each body's velocity + * and spin, and how far it is from being met. */ +typedef struct Row { + F3dVec3 lin_a, ang_a, lin_b, ang_b; + f3d_real c; +} Row; + +static Row point_row(const Frame *f, F3dVec3 n, f3d_real c, F3dVec3 ra) { + Row r; + r.lin_a = f3d_scale(n, F3D_R(-1.0)); + r.ang_a = f3d_scale(f3d_cross(ra, n), F3D_R(-1.0)); + r.lin_b = n; + r.ang_b = f3d_cross(f->lb, n); + r.c = c; + return r; +} + +static Row turn_row(F3dVec3 n, f3d_real c) { + Row r; + r.lin_a = r.lin_b = f3d_v3(F3D_R(0.0), F3D_R(0.0), F3D_R(0.0)); + r.ang_a = f3d_scale(n, F3D_R(-1.0)); + r.ang_b = n; + r.c = c; + return r; +} + +/* The rows a joint locks: its point, its turns, its slide across the + * axis. Returns how many. */ +static int rows_of(const F3dJointSlot *j, const Frame *f, Row *rows) { + const F3dVec3 e[3] = {f3d_v3(F3D_R(1.0), F3D_R(0.0), F3D_R(0.0)), + f3d_v3(F3D_R(0.0), F3D_R(1.0), F3D_R(0.0)), + f3d_v3(F3D_R(0.0), F3D_R(0.0), F3D_R(1.0))}; + const f3d_real dd[3] = {f->d.x, f->d.y, f->d.z}; + int n = 0; + switch (j->type) { + case F3D_JOINT_FIXED: { + const F3dVec3 err = turn(&f->ra, turn_error(j, f)); + const f3d_real ee[3] = {err.x, err.y, err.z}; + for (int k = 0; k < 3; k++) rows[n++] = point_row(f, e[k], dd[k], f->la); + for (int k = 0; k < 3; k++) rows[n++] = turn_row(e[k], ee[k]); + break; + } + case F3D_JOINT_SPHERICAL: + for (int k = 0; k < 3; k++) rows[n++] = point_row(f, e[k], dd[k], f->la); + break; + case F3D_JOINT_REVOLUTE: { + /* B's axis kept on A's: its cross with A's, a small turn about the + * two directions square to the axis, is the error. */ + const F3dVec3 a1 = f->axis, a2 = turn(&f->rb, j->axis_b); + const F3dVec3 t0 = square_to(a1), t1 = f3d_cross(a1, t0); + const F3dVec3 err = f3d_cross(a1, a2); + for (int k = 0; k < 3; k++) rows[n++] = point_row(f, e[k], dd[k], f->la); + rows[n++] = turn_row(t0, f3d_dot(err, t0)); + rows[n++] = turn_row(t1, f3d_dot(err, t1)); + break; + } + case F3D_JOINT_PRISMATIC: { + /* No turning, and B's anchor kept on A's axis: A's lever reaches to + * where B's anchor is along it. */ + const F3dVec3 a = f->axis, t0 = square_to(a), t1 = f3d_cross(a, t0); + const F3dVec3 err = turn(&f->ra, turn_error(j, f)); + const f3d_real ee[3] = {err.x, err.y, err.z}; + const F3dVec3 ra = f3d_add(f->la, f->d); + rows[n++] = point_row(f, t0, f3d_dot(f->d, t0), ra); + rows[n++] = point_row(f, t1, f3d_dot(f->d, t1), ra); + for (int k = 0; k < 3; k++) rows[n++] = turn_row(e[k], ee[k]); + break; + } + default: + break; + } + return n; +} + +static f3d_real row_rate(const Frame *f, const Row *r) { + return f3d_dot(r->lin_a, f->a->velocity) + f3d_dot(r->ang_a, f->a->spin) + + f3d_dot(r->lin_b, f->b->velocity) + f3d_dot(r->ang_b, f->b->spin); +} + +/* Jᵀλ: the rows' impulses on both bodies. */ +static void apply_rows(Frame *f, const Row *rows, int n, const f3d_real *l) { + F3dVec3 la = f3d_v3(F3D_R(0.0), F3D_R(0.0), F3D_R(0.0)), aa = la, lb = la, ab = la; + for (int k = 0; k < n; k++) { + la = f3d_madd(la, rows[k].lin_a, l[k]); + aa = f3d_madd(aa, rows[k].ang_a, l[k]); + lb = f3d_madd(lb, rows[k].lin_b, l[k]); + ab = f3d_madd(ab, rows[k].ang_b, l[k]); + } + f->a->velocity = f3d_madd(f->a->velocity, la, f->ba->inverse_mass); + f->a->spin = f3d_add(f->a->spin, f3d_sym_times(f->ba->inverse_inertia, aa)); + f->b->velocity = f3d_madd(f->b->velocity, lb, f->bb->inverse_mass); + f->b->spin = f3d_add(f->b->spin, f3d_sym_times(f->bb->inverse_inertia, ab)); +} + +/* Solves the symmetric positive system k x = r of n ≤ 6 by Cholesky's + * factoring, in place; 0 when k is not positive — two bodies neither of + * which can move along some row. */ +static int cholesky(f3d_real k[6][6], int n, const f3d_real *r, f3d_real *x) { + for (int i = 0; i < n; i++) { + for (int c = 0; c <= i; c++) { + f3d_real sum = k[i][c]; + for (int m = 0; m < c; m++) sum -= k[i][m] * k[c][m]; + if (i == c) { + if (!(sum > F3D_R(1e-20))) return 0; + k[i][i] = f3d_sqrt(sum); + } else { + k[i][c] = sum / k[c][c]; + } + } + } + f3d_real y[6]; + for (int i = 0; i < n; i++) { + f3d_real sum = r[i]; + for (int m = 0; m < i; m++) sum -= k[i][m] * y[m]; + y[i] = sum / k[i][i]; + } + for (int i = n - 1; i >= 0; i--) { + f3d_real sum = y[i]; + for (int m = i + 1; m < n; m++) sum -= k[m][i] * x[m]; + x[i] = sum / k[i][i]; + } + return 1; +} + +/* Every locked direction of the joint at once: the coupled block, so a + * heavy lever on a light ball is held as firmly as a light one — solved + * one direction after another, the point would spin the ball to stop it + * and the lock would take the spin back, a substep at a time. */ +static void solve_block(F3dJointSlot *j, Frame *f, Soft s, int use_bias) { + Row rows[6]; + const int n = rows_of(j, f, rows); + if (n == 0) return; + f3d_real k[6][6], rhs[6], x[6]; + for (int r = 0; r < n; r++) { + for (int c = 0; c <= r; c++) { + const Row *p = &rows[r], *q = &rows[c]; + const f3d_real v = + f->ba->inverse_mass * f3d_dot(p->lin_a, q->lin_a) + + f3d_dot(p->ang_a, f3d_sym_times(f->ba->inverse_inertia, q->ang_a)) + + f->bb->inverse_mass * f3d_dot(p->lin_b, q->lin_b) + + f3d_dot(p->ang_b, f3d_sym_times(f->bb->inverse_inertia, q->ang_b)); + k[r][c] = k[c][r] = v; + } + } + f3d_real ms = F3D_R(1.0), is = F3D_R(0.0), rate = F3D_R(0.0); + if (use_bias) { + ms = s.mass_scale; + is = s.impulse_scale; + rate = s.bias_rate; + } + for (int r = 0; r < n; r++) rhs[r] = row_rate(f, &rows[r]) + rate * rows[r].c; + if (!cholesky(k, n, rhs, x)) return; + f3d_real l[6]; + for (int r = 0; r < n; r++) { + l[r] = -ms * x[r] - is * j->impulse[r]; + j->impulse[r] += l[r]; + } + apply_rows(f, rows, n, l); +} + +/* One free direction of a joint — a hinge's angle, a slider's travel, a + * rod's length — as a value, its rate, its mass, and how to push along + * it. */ +typedef struct Axis { + f3d_real value, rate, mass; + int angular; + F3dVec3 n, ra, rb; +} Axis; + +static void push_axis(Frame *f, const Axis *x, f3d_real impulse) { + if (x->angular) { + twist(f, f3d_scale(x->n, impulse)); + } else { + push(f, x->ra, x->rb, f3d_scale(x->n, impulse)); + } +} + +static f3d_real rate_of(const Frame *f, const Axis *x) { + if (x->angular) return f3d_dot(f3d_sub(f->b->spin, f->a->spin), x->n); + return f3d_dot(f3d_sub(velocity_at(f->b, x->rb), velocity_at(f->a, x->ra)), + x->n); +} + +/* The spring, the motor and the limits along a free direction. */ +static void solve_axis(F3dJointSlot *j, Frame *f, Axis *x, f3d_real target, + f3d_real h, Soft s, int use_bias) { + if (j->flags & F3D_JOINT_SPRING) { + const Soft sp = soft(j->spring_hertz, j->spring_damping, h); + const f3d_real c = x->value - target; + const f3d_real lambda = + -x->mass * sp.mass_scale * (rate_of(f, x) + sp.bias_rate * c) - + sp.impulse_scale * j->spring_impulse; + j->spring_impulse += lambda; + push_axis(f, x, lambda); + } + if (j->flags & F3D_JOINT_MOTOR) { + const f3d_real most = j->motor_force * h; + const f3d_real lambda = -x->mass * (rate_of(f, x) - j->motor_speed); + const f3d_real total = + f3d_clamp(j->motor_impulse + lambda, -most, most); + const f3d_real delta = total - j->motor_impulse; + j->motor_impulse = total; + push_axis(f, x, delta); + } + if (j->flags & F3D_JOINT_LIMIT) { + const f3d_real inv_h = F3D_R(1.0) / h; + /* Lower: value − lower stays above nought; upper: upper − value. Apart, + * they may close this substep and no more; past, the soft spring takes + * them back. */ + for (int side = 0; side < 2; side++) { + const f3d_real sign = side ? F3D_R(-1.0) : F3D_R(1.0); + const f3d_real c = side ? j->upper - x->value : x->value - j->lower; + f3d_real *acc = side ? &j->upper_impulse : &j->lower_impulse; + f3d_real bias = F3D_R(0.0), ms = F3D_R(1.0), is = F3D_R(0.0); + if (c > F3D_R(0.0)) { + bias = c * inv_h; + } else if (use_bias) { + bias = s.bias_rate * c; + ms = s.mass_scale; + is = s.impulse_scale; + } + const f3d_real cdot = sign * rate_of(f, x); + const f3d_real lambda = -x->mass * ms * (cdot + bias) - is * *acc; + const f3d_real total = f3d_max(*acc + lambda, F3D_R(0.0)); + const f3d_real delta = total - *acc; + *acc = total; + push_axis(f, x, sign * delta); + } + } +} + +/* A spherical joint's swing: the angle from A's axis to B's, and the + * direction to turn B about to open it — nought when the two are as good + * as one, and no direction is the way out. */ +static f3d_real swing_of(const F3dJointSlot *j, const Frame *f, F3dVec3 *n) { + const F3dVec3 a1 = f->axis, a2 = turn(&f->rb, j->axis_b); + const F3dVec3 c = f3d_cross(a1, a2); + const f3d_real s = f3d_sqrt(f3d_dot(c, c)); + *n = s > F3D_R(1e-9) ? f3d_scale(c, F3D_R(1.0) / s) : f3d_v3(F3D_R(0.0), F3D_R(0.0), F3D_R(0.0)); + return f3d_atan2(s, f3d_dot(a1, a2)); +} + +/* How a spherical joint's twist answers B's turn against A: about the + * axes' sum over one and their cosine. Its swing does not change it, but + * swung far the twist turns faster than the turn about A's axis — on a + * shoulder swung a radian and a half, by nearly half again — and a limit + * pushing about A's axis alone would push on the swing instead. */ +static F3dVec3 twist_axis(const F3dJointSlot *j, const Frame *f) { + const F3dVec3 a1 = f->axis, a2 = turn(&f->rb, j->axis_b); + const f3d_real c = f3d_max(F3D_R(1.0) + f3d_dot(a1, a2), F3D_R(1e-3)); + return f3d_scale(f3d_add(a1, a2), F3D_R(1.0) / c); +} + +/* The cone: the swing held at most j->cone, as a limit is — apart, it may + * close this substep and no more; past, the soft spring takes it back. */ +static void solve_cone(F3dJointSlot *j, Frame *f, f3d_real h, Soft s, int use_bias) { + if (!(j->flags & F3D_JOINT_CONE)) return; + F3dVec3 n; + const f3d_real swing = swing_of(j, f, &n); + if (n.x == F3D_R(0.0) && n.y == F3D_R(0.0) && n.z == F3D_R(0.0)) return; + const f3d_real c = j->cone - swing; + f3d_real bias = F3D_R(0.0), ms = F3D_R(1.0), is = F3D_R(0.0); + if (c > F3D_R(0.0)) { + bias = c / h; + } else if (use_bias) { + bias = s.bias_rate * c; + ms = s.mass_scale; + is = s.impulse_scale; + } + const f3d_real cdot = -f3d_dot(f3d_sub(f->b->spin, f->a->spin), n); + const f3d_real lambda = -angular_mass(f, n) * ms * (cdot + bias) - is * j->cone_impulse; + const f3d_real total = f3d_max(j->cone_impulse + lambda, F3D_R(0.0)); + const f3d_real delta = total - j->cone_impulse; + j->cone_impulse = total; + twist(f, f3d_scale(n, -delta)); +} + +/* Friction: B's turn against A's resisted about each of the world's axes + * with at most the joint's torque over the substep. */ +static void solve_friction(F3dJointSlot *j, Frame *f, f3d_real h) { + if (!(j->flags & F3D_JOINT_FRICTION)) return; + const f3d_real most = j->friction * h; + f3d_real *held[3] = {&j->friction_impulse.x, &j->friction_impulse.y, &j->friction_impulse.z}; + for (int k = 0; k < 3; k++) { + const F3dVec3 n = f3d_v3(k == 0 ? F3D_R(1.0) : F3D_R(0.0), k == 1 ? F3D_R(1.0) : F3D_R(0.0), + k == 2 ? F3D_R(1.0) : F3D_R(0.0)); + const f3d_real turning = f3d_dot(f3d_sub(f->b->spin, f->a->spin), n); + const f3d_real lambda = -angular_mass(f, n) * turning; + const f3d_real total = f3d_clamp(*held[k] + lambda, -most, most); + const f3d_real delta = total - *held[k]; + *held[k] = total; + twist(f, f3d_scale(n, delta)); + } +} + +static Soft joint_softness(f3d_real h) { + return soft(f3d_min(F3D_R(60.0), F3D_R(0.25) / h), F3D_JOINT_DAMPING, h); +} + +void f3d_solve_joints(F3dWorld *world, const F3dSolverBody *bodies, f3d_real h, + int use_bias) { + if (world->s.joint_live == 0) return; + world->s.last_substep = h; + const Soft s = joint_softness(h); + for (uint32_t i = 0; i < world->s.joint_used; i++) { + F3dJointSlot *j = &world->joints[i]; + if (!j->live) continue; + Frame f; + if (!frame_of(world, j, bodies, &f)) continue; + Axis x; + switch (j->type) { + case F3D_JOINT_FIXED: + solve_block(j, &f, s, use_bias); + break; + case F3D_JOINT_SPHERICAL: + /* Its friction, its twist about the axis, limited as a hinge's + * angle is, then its swing inside the cone, then its point. */ + solve_friction(j, &f, h); + if (j->flags & F3D_JOINT_LIMIT) { + x.angular = 1; + x.n = twist_axis(j, &f); + x.value = hinge_angle(j, &f); + x.mass = angular_mass(&f, x.n); + solve_axis(j, &f, &x, F3D_R(0.0), h, s, use_bias); + } + solve_cone(j, &f, h, s, use_bias); + solve_block(j, &f, s, use_bias); + break; + case F3D_JOINT_REVOLUTE: + x.angular = 1; + x.n = f.axis; + x.value = hinge_angle(j, &f); + x.mass = angular_mass(&f, f.axis); + solve_axis(j, &f, &x, F3D_R(0.0), h, s, use_bias); + solve_block(j, &f, s, use_bias); + break; + case F3D_JOINT_PRISMATIC: + x.angular = 0; + x.n = f.axis; + x.ra = f3d_add(f.la, f.d); + x.rb = f.lb; + x.value = f3d_dot(f.d, f.axis); + x.mass = linear_mass(&f, x.ra, x.rb, f.axis); + solve_axis(j, &f, &x, F3D_R(0.0), h, s, use_bias); + solve_block(j, &f, s, use_bias); + break; + case F3D_JOINT_DISTANCE: { + const f3d_real len = f3d_sqrt(f3d_dot(f.d, f.d)); + x.angular = 0; + x.n = len > F3D_R(1e-9) ? f3d_scale(f.d, F3D_R(1.0) / len) + : f3d_v3(F3D_R(0.0), F3D_R(1.0), F3D_R(0.0)); + x.ra = f.la; + x.rb = f.lb; + x.value = len; + x.mass = linear_mass(&f, x.ra, x.rb, x.n); + if (j->flags & F3D_JOINT_SPRING) { + /* A spring between the least and most lengths, which hold hard: + * the limits, read as lengths. */ + const uint8_t keep = j->flags; + const f3d_real lower = j->lower, upper = j->upper; + j->lower = j->least; + j->upper = j->most; + j->flags |= F3D_JOINT_LIMIT; + if (!(j->spring_hertz > F3D_R(0.0))) j->flags &= (uint8_t)~F3D_JOINT_SPRING; + solve_axis(j, &f, &x, j->length, h, s, use_bias); + j->flags = keep; + j->lower = lower; + j->upper = upper; + } else { + /* A rod. */ + f3d_real bias = F3D_R(0.0), ms = F3D_R(1.0), is = F3D_R(0.0); + if (use_bias) { + bias = s.bias_rate * (len - j->length); + ms = s.mass_scale; + is = s.impulse_scale; + } + const f3d_real lambda = + -x.mass * ms * (rate_of(&f, &x) + bias) - is * j->impulse[0]; + j->impulse[0] += lambda; + push_axis(&f, &x, lambda); + } + break; + } + default: + break; + } + } +} + +void f3d_warm_joints(F3dWorld *world, const F3dSolverBody *bodies) { + for (uint32_t i = 0; i < world->s.joint_used; i++) { + F3dJointSlot *j = &world->joints[i]; + if (!j->live) continue; + Frame f; + if (!frame_of(world, j, bodies, &f)) continue; + const f3d_real axial = + j->spring_impulse + j->motor_impulse + j->lower_impulse - j->upper_impulse; + /* The block's impulses along its rows as they stand now, then the free + * direction's. What B was pushed with, linearly, is kept to report. */ + Row rows[6]; + const int n = rows_of(j, &f, rows); + apply_rows(&f, rows, n, j->impulse); + F3dVec3 pushed = f3d_v3(F3D_R(0.0), F3D_R(0.0), F3D_R(0.0)); + for (int k = 0; k < n; k++) pushed = f3d_madd(pushed, rows[k].lin_b, j->impulse[k]); + switch (j->type) { + case F3D_JOINT_REVOLUTE: + twist(&f, f3d_scale(f.axis, axial)); + break; + case F3D_JOINT_SPHERICAL: { + twist(&f, f3d_scale(twist_axis(j, &f), axial)); + F3dVec3 n; + swing_of(j, &f, &n); + twist(&f, f3d_scale(n, -j->cone_impulse)); + twist(&f, j->friction_impulse); + break; + } + case F3D_JOINT_PRISMATIC: { + const F3dVec3 p = f3d_scale(f.axis, axial); + push(&f, f3d_add(f.la, f.d), f.lb, p); + pushed = f3d_add(pushed, p); + break; + } + case F3D_JOINT_DISTANCE: { + const f3d_real len = f3d_sqrt(f3d_dot(f.d, f.d)); + const F3dVec3 nn = len > F3D_R(1e-9) ? f3d_scale(f.d, F3D_R(1.0) / len) + : f3d_v3(F3D_R(0.0), F3D_R(1.0), F3D_R(0.0)); + /* The rod's own impulse only while it is a rod: made a spring or a + * rope, what it held as a rod would hold on for ever. */ + const f3d_real rod = (j->flags & F3D_JOINT_SPRING) ? F3D_R(0.0) : j->impulse[0]; + pushed = f3d_scale(nn, rod + axial); + push(&f, f.la, f.lb, pushed); + break; + } + default: + break; + } + j->pushed = pushed; + } +} + +int f3d_joint_get_value(const F3dWorld *world, F3dJoint joint, f3d_real *out) { + const F3dJointSlot *j = joint_of(world, joint); + if (j == NULL) return 0; + Frame f; + f.a = f3d_slot_of(world, j->a); + f.b = f3d_slot_of(world, j->b); + *out = F3D_R(0.0); + if (f.a == NULL || f.b == NULL) return 1; + f.ra = f3d_mat_of(f.a->orientation); + f.rb = f3d_mat_of(f.b->orientation); + f.la = turn(&f.ra, j->local_a); + f.lb = turn(&f.rb, j->local_b); + f.d = f3d_sub(f3d_add(f.b->position, f.lb), f3d_add(f.a->position, f.la)); + f.axis = turn(&f.ra, j->axis_a); + switch (j->type) { + case F3D_JOINT_REVOLUTE: + case F3D_JOINT_SPHERICAL: + *out = hinge_angle(j, &f); + break; + case F3D_JOINT_PRISMATIC: + *out = f3d_dot(f.d, f.axis); + break; + case F3D_JOINT_DISTANCE: + *out = f3d_sqrt(f3d_dot(f.d, f.d)); + break; + default: + break; + } + return 1; +} + +int f3d_joint_get_swing(const F3dWorld *world, F3dJoint joint, f3d_real *out) { + const F3dJointSlot *j = joint_of(world, joint); + if (j == NULL) return 0; + *out = F3D_R(0.0); + Frame f; + f.a = f3d_slot_of(world, j->a); + f.b = f3d_slot_of(world, j->b); + if (f.a == NULL || f.b == NULL || j->type != F3D_JOINT_SPHERICAL) return 1; + f.ra = f3d_mat_of(f.a->orientation); + f.rb = f3d_mat_of(f.b->orientation); + f.axis = turn(&f.ra, j->axis_a); + F3dVec3 n; + *out = swing_of(j, &f, &n); + return 1; +} diff --git a/packages/flutter3d_physics_native/csrc/src/f3d_memory_libc.c b/packages/flutter3d_physics_native/csrc/src/f3d_memory_libc.c new file mode 100644 index 000000000..2853bf20a --- /dev/null +++ b/packages/flutter3d_physics_native/csrc/src/f3d_memory_libc.c @@ -0,0 +1,27 @@ +/* + * Memory through the C library, for every build but WebAssembly's — + * f3d_memory_wasm.c is that one's. + */ +#ifndef __wasm__ + +#include +#include + +#include "f3d_internal.h" + +void *f3d_alloc(size_t bytes) { return malloc(bytes); } + +void *f3d_realloc(void *block, size_t bytes) { return realloc(block, bytes); } + +void f3d_free(void *block) { free(block); } + +void f3d_zero(void *block, size_t bytes) { memset(block, 0, bytes); } + +void f3d_copy(void *to, const void *from, size_t bytes) { + memcpy(to, from, bytes); +} + +#else +/* Not this build; ISO C wants the file to declare something. */ +typedef int f3d_memory_libc_not_this_build; +#endif diff --git a/packages/flutter3d_physics_native/csrc/src/f3d_memory_wasm.c b/packages/flutter3d_physics_native/csrc/src/f3d_memory_wasm.c new file mode 100644 index 000000000..8b962f2cb --- /dev/null +++ b/packages/flutter3d_physics_native/csrc/src/f3d_memory_wasm.c @@ -0,0 +1,149 @@ +/* + * Memory for the WebAssembly build, which has no C library — P9. + * + * A first-fit free list over linear memory grown a page at a time. Every + * block has a header holding its size; a freed block goes back on the list + * and is split when a smaller request fits it, merged with nothing. That is + * enough for a physics world, which allocates a few growing arrays and + * frees them when the world goes, and it keeps the module free of every + * import: the page count and the bytes are all it touches. + * + * memset and memcpy are here too, because clang emits calls to them for + * struct copies and zeroing whatever the source says. + * + * In the threads build (F3D_WASM_THREADS) the workers allocate too — a + * worker's lane of pairs grows in the middle of a step — so the list is + * held by a spin lock. + */ +#ifdef __wasm__ + +#include "f3d_internal.h" + +#define F3D_PAGE 65536u +#define F3D_ALIGN 16u + +typedef struct Block { + size_t size; /* Bytes after the header. */ + struct Block *next_free; +} Block; + +#define HEADER ((sizeof(Block) + F3D_ALIGN - 1) & ~(size_t)(F3D_ALIGN - 1)) + +extern unsigned char __heap_base; + +static unsigned char *g_top; +static unsigned char *g_end; +static Block *g_free; + +void *memset(void *dst, int value, size_t n) { + unsigned char *d = (unsigned char *)dst; + for (size_t i = 0; i < n; i++) d[i] = (unsigned char)value; + return dst; +} + +void *memcpy(void *dst, const void *src, size_t n) { + unsigned char *d = (unsigned char *)dst; + const unsigned char *s = (const unsigned char *)src; + for (size_t i = 0; i < n; i++) d[i] = s[i]; + return dst; +} + +static size_t round_up(size_t bytes) { + return (bytes + F3D_ALIGN - 1) & ~(size_t)(F3D_ALIGN - 1); +} + +/* Grows linear memory until [bytes] more fit above the top. */ +static int reserve(size_t bytes) { + if (g_top == NULL) { + g_top = (unsigned char *)round_up((size_t)&__heap_base); + g_end = (unsigned char *)(__builtin_wasm_memory_size(0) * F3D_PAGE); + } + while ((size_t)(g_end - g_top) < bytes) { + if (__builtin_wasm_memory_grow(0, 1) == (size_t)-1) return 0; + g_end += F3D_PAGE; + } + return 1; +} + +#ifdef F3D_WASM_THREADS +static volatile int g_lock; + +static void lock(void) { + while (__atomic_exchange_n(&g_lock, 1, __ATOMIC_ACQUIRE) != 0) { + } +} + +static void unlock(void) { __atomic_store_n(&g_lock, 0, __ATOMIC_RELEASE); } +#else +static void lock(void) {} +static void unlock(void) {} +#endif + +static void *alloc_held(size_t bytes) { + const size_t size = round_up(bytes == 0 ? 1 : bytes); + Block **link = &g_free; + for (Block *b = g_free; b != NULL; link = &b->next_free, b = b->next_free) { + if (b->size < size) continue; + *link = b->next_free; + if (b->size >= size + HEADER + F3D_ALIGN) { + /* Split: the rest goes back on the list as a block of its own. */ + Block *rest = (Block *)((unsigned char *)b + HEADER + size); + rest->size = b->size - size - HEADER; + rest->next_free = g_free; + g_free = rest; + b->size = size; + } + return (unsigned char *)b + HEADER; + } + if (!reserve(HEADER + size)) return NULL; + Block *b = (Block *)g_top; + b->size = size; + b->next_free = NULL; + g_top += HEADER + size; + return (unsigned char *)b + HEADER; +} + +static void free_held(void *block) { + if (block == NULL) return; + Block *b = (Block *)((unsigned char *)block - HEADER); + b->next_free = g_free; + g_free = b; +} + +void *f3d_alloc(size_t bytes) { + lock(); + void *block = alloc_held(bytes); + unlock(); + return block; +} + +void f3d_free(void *block) { + lock(); + free_held(block); + unlock(); +} + +void *f3d_realloc(void *block, size_t bytes) { + if (block == NULL) return f3d_alloc(bytes); + Block *b = (Block *)((unsigned char *)block - HEADER); + if (b->size >= bytes) return block; + lock(); + void *grown = alloc_held(bytes); + if (grown != NULL) { + memcpy(grown, block, b->size); + free_held(block); + } + unlock(); + return grown; +} + +void f3d_zero(void *block, size_t bytes) { memset(block, 0, bytes); } + +void f3d_copy(void *to, const void *from, size_t bytes) { + memcpy(to, from, bytes); +} + +#else +/* Not this build; ISO C wants the file to declare something. */ +typedef int f3d_memory_wasm_not_this_build; +#endif diff --git a/packages/flutter3d_physics_native/csrc/src/f3d_mesh.c b/packages/flutter3d_physics_native/csrc/src/f3d_mesh.c new file mode 100644 index 000000000..a0aa6fb80 --- /dev/null +++ b/packages/flutter3d_physics_native/csrc/src/f3d_mesh.c @@ -0,0 +1,237 @@ +/* + * Triangle meshes — P9, phase 6: level geometry, terrain and walls, held by + * the world for fixed bodies to be shaped as. + * + * A mesh is its vertices and triangles as given, a byte of flags a triangle + * saying which of its edges are internal, and a tree of its triangles. An + * edge is internal when another triangle shares it by index and the fold + * between them is flat or hollow: a body sliding across it is still on the + * same surface, and a contact there takes the face's normal rather than + * the edge's, so a crate sliding over a floor of a thousand triangles does + * not trip on their seams. A ridge — a fold that sticks out — is a real + * edge and keeps its own normal. + * + * The trees are built from the triangles in their order and are not in a + * snapshot; the contacts do not depend on their shape, since the triangles + * a query finds are taken in index order. + */ +#include "f3d_internal.h" + +#define F3D_MESH_TRIANGLES (1u << 20) + +/* One edge of one triangle, by its two vertex indices, lower first. */ +typedef struct EdgeKey { + uint64_t key; + uint32_t triangle; + uint32_t edge; +} EdgeKey; + +static void sort_edges(EdgeKey *items, EdgeKey *spare, uint32_t count) { + for (uint32_t width = 1; width < count; width *= 2u) { + for (uint32_t lo = 0; lo < count; lo += 2u * width) { + const uint32_t mid = lo + width < count ? lo + width : count; + const uint32_t hi = lo + 2u * width < count ? lo + 2u * width : count; + uint32_t i = lo, j = mid, k = lo; + while (i < mid && j < hi) { + spare[k++] = items[j].key < items[i].key ? items[j++] : items[i++]; + } + while (i < mid) spare[k++] = items[i++]; + while (j < hi) spare[k++] = items[j++]; + } + for (uint32_t k = 0; k < count; k++) items[k] = spare[k]; + if (width > UINT32_MAX / 2u) break; + } +} + +static F3dVec3 vertex(const f3d_real *v, uint32_t i) { + return f3d_v3(v[i * 3u], v[i * 3u + 1u], v[i * 3u + 2u]); +} + +uint32_t f3d_world_create_mesh(F3dWorld *world, const f3d_real *vertices, + uint32_t vertex_count, const uint32_t *indices, + uint32_t triangle_count) { + if (vertices == NULL || indices == NULL || triangle_count == 0 || + triangle_count > F3D_MESH_TRIANGLES || vertex_count < 3u || + vertex_count > 3u * F3D_MESH_TRIANGLES) { + return 0; + } + for (uint32_t i = 0; i < vertex_count * 3u; i++) { + if (!f3d_finite(vertices[i])) return 0; + } + for (uint32_t i = 0; i < triangle_count * 3u; i++) { + if (indices[i] >= vertex_count) return 0; + } + const uint32_t edge_count = triangle_count * 3u; + EdgeKey *edges = (EdgeKey *)f3d_alloc((size_t)edge_count * sizeof(EdgeKey)); + EdgeKey *spare = (EdgeKey *)f3d_alloc((size_t)edge_count * sizeof(EdgeKey)); + uint8_t *flags = (uint8_t *)f3d_alloc(triangle_count); + uint32_t result = 0; + if (edges == NULL || spare == NULL || flags == NULL) goto done; + f3d_zero(flags, triangle_count); + for (uint32_t t = 0; t < triangle_count; t++) { + for (uint32_t k = 0; k < 3u; k++) { + const uint32_t a = indices[t * 3u + k]; + const uint32_t b = indices[t * 3u + (k + 1u) % 3u]; + EdgeKey *e = &edges[t * 3u + k]; + e->key = a < b ? ((uint64_t)a << 32) | b : ((uint64_t)b << 32) | a; + e->triangle = t; + e->edge = k; + } + } + sort_edges(edges, spare, edge_count); + /* The span, for what counts as flat. */ + F3dVec3 lo = vertex(vertices, indices[0]), hi = lo; + for (uint32_t i = 0; i < triangle_count * 3u; i++) { + const F3dVec3 v = vertex(vertices, indices[i]); + lo = f3d_v3(f3d_min(lo.x, v.x), f3d_min(lo.y, v.y), f3d_min(lo.z, v.z)); + hi = f3d_v3(f3d_max(hi.x, v.x), f3d_max(hi.y, v.y), f3d_max(hi.z, v.z)); + } + const F3dVec3 span = f3d_sub(hi, lo); + const f3d_real flat = F3D_R(1e-5) * f3d_sqrt(f3d_dot(span, span)); + /* Each pair of triangles sharing an edge: internal unless the fold is a + * ridge — the other triangle's far corner below this one's plane. Both + * sides of an edge see the same fold, so both get the same flag. An edge + * three triangles share is no surface's edge, and stays a real one. */ + for (uint32_t i = 0; i + 1u < edge_count;) { + uint32_t j = i + 1u; + while (j < edge_count && edges[j].key == edges[i].key) j++; + if (j - i == 2u) { + const EdgeKey *e0 = &edges[i], *e1 = &edges[i + 1u]; + const uint32_t *t0 = &indices[e0->triangle * 3u]; + const uint32_t *t1 = &indices[e1->triangle * 3u]; + const F3dVec3 a = vertex(vertices, t0[0]); + const F3dVec3 n0 = f3d_cross(f3d_sub(vertex(vertices, t0[1]), a), + f3d_sub(vertex(vertices, t0[2]), a)); + const f3d_real len = f3d_sqrt(f3d_dot(n0, n0)); + const F3dVec3 far = vertex(vertices, t1[(e1->edge + 2u) % 3u]); + const f3d_real below = + len > F3D_R(0.0) ? f3d_dot(n0, f3d_sub(far, a)) / len : F3D_R(0.0); + if (below >= -flat) { + flags[e0->triangle] |= (uint8_t)(1u << e0->edge); + flags[e1->triangle] |= (uint8_t)(1u << e1->edge); + } + } + i = j; + } + /* Into the world's arrays: every allocation before anything is kept. */ + const uint32_t first_vertex = world->s.mesh_vertex_count; + const uint32_t first_triangle = world->s.mesh_triangle_count; + f3d_real *wv = (f3d_real *)f3d_realloc( + world->mesh_vertices, + ((size_t)first_vertex + vertex_count) * 3u * sizeof(f3d_real)); + if (wv == NULL) goto done; + world->mesh_vertices = wv; + uint32_t *wt = (uint32_t *)f3d_realloc( + world->mesh_triangles, + ((size_t)first_triangle + triangle_count) * 3u * sizeof(uint32_t)); + if (wt == NULL) goto done; + world->mesh_triangles = wt; + uint8_t *we = (uint8_t *)f3d_realloc( + world->mesh_edges, (size_t)first_triangle + triangle_count); + if (we == NULL) goto done; + world->mesh_edges = we; + F3dMesh *meshes = (F3dMesh *)f3d_realloc( + world->meshes, ((size_t)world->s.mesh_count + 1u) * sizeof(F3dMesh)); + if (meshes == NULL) goto done; + world->meshes = meshes; + f3d_copy(wv + (size_t)first_vertex * 3u, vertices, + (size_t)vertex_count * 3u * sizeof(f3d_real)); + f3d_copy(wt + (size_t)first_triangle * 3u, indices, + (size_t)triangle_count * 3u * sizeof(uint32_t)); + f3d_copy(we + first_triangle, flags, triangle_count); + F3dMesh m; + f3d_zero(&m, sizeof m); + m.first_vertex = first_vertex; + m.vertex_count = vertex_count; + m.first_triangle = first_triangle; + m.triangle_count = triangle_count; + m.lo = lo; + m.hi = hi; + for (uint32_t t = 0; t < triangle_count; t++) { + const F3dVec3 a = vertex(vertices, indices[t * 3u]); + const F3dVec3 n = f3d_cross(f3d_sub(vertex(vertices, indices[t * 3u + 1u]), a), + f3d_sub(vertex(vertices, indices[t * 3u + 2u]), a)); + m.surface += F3D_R(0.5) * f3d_sqrt(f3d_dot(n, n)); + } + f3d_copy(&meshes[world->s.mesh_count], &m, sizeof m); + world->s.mesh_count++; + world->s.mesh_vertex_count += vertex_count; + world->s.mesh_triangle_count += triangle_count; + result = world->s.mesh_count; + f3d_build_mesh_trees(world); +done: + f3d_free(edges); + f3d_free(spare); + f3d_free(flags); + return result; +} + +void f3d_build_mesh_trees(F3dWorld *world) { + if (world->mesh_tree_count >= world->s.mesh_count) return; + F3dTree *trees = (F3dTree *)f3d_realloc( + world->mesh_trees, (size_t)world->s.mesh_count * sizeof(F3dTree)); + if (trees == NULL) return; + world->mesh_trees = trees; + for (uint32_t i = world->mesh_tree_count; i < world->s.mesh_count; i++) { + F3dTree *tree = &trees[i]; + f3d_zero(tree, sizeof *tree); + tree->root = -1; + tree->free_list = -1; + const F3dMesh *m = &world->meshes[i]; + const f3d_real *v = world->mesh_vertices + (size_t)m->first_vertex * 3u; + const uint32_t *t = world->mesh_triangles + (size_t)m->first_triangle * 3u; + for (uint32_t k = 0; k < m->triangle_count; k++) { + const F3dVec3 a = vertex(v, t[k * 3u]), b = vertex(v, t[k * 3u + 1u]); + const F3dVec3 c = vertex(v, t[k * 3u + 2u]); + F3dBox box; + box.lo = f3d_v3(f3d_min(a.x, f3d_min(b.x, c.x)), + f3d_min(a.y, f3d_min(b.y, c.y)), + f3d_min(a.z, f3d_min(b.z, c.z))); + box.hi = f3d_v3(f3d_max(a.x, f3d_max(b.x, c.x)), + f3d_max(a.y, f3d_max(b.y, c.y)), + f3d_max(a.z, f3d_max(b.z, c.z))); + f3d_tree_insert(tree, box, k); + } + } + world->mesh_tree_count = world->s.mesh_count; +} + +void f3d_clear_mesh_trees(F3dWorld *world) { + for (uint32_t i = 0; i < world->mesh_tree_count; i++) { + f3d_tree_clear(&world->mesh_trees[i]); + } + f3d_free(world->mesh_trees); + world->mesh_trees = NULL; + world->mesh_tree_count = 0; +} + +uint32_t f3d_world_mesh_triangle_count(const F3dWorld *world, uint32_t mesh) { + if (mesh == 0 || mesh > world->s.mesh_count) return 0; + return world->meshes[mesh - 1u].triangle_count; +} + +uint32_t f3d_world_mesh_internal_edges(const F3dWorld *world, uint32_t mesh) { + if (mesh == 0 || mesh > world->s.mesh_count) return 0; + const F3dMesh *m = &world->meshes[mesh - 1u]; + uint32_t count = 0; + for (uint32_t t = 0; t < m->triangle_count; t++) { + const uint8_t f = world->mesh_edges[m->first_triangle + t]; + count += (f & 1u) + ((f >> 1) & 1u) + ((f >> 2) & 1u); + } + /* Each internal edge is counted from both its triangles. */ + return count / 2u; +} + +int f3d_body_set_mesh(F3dWorld *world, F3dBody body, uint32_t mesh) { + F3dSlot *s = f3d_slot_of(world, body); + if (s == NULL || mesh == 0 || mesh > world->s.mesh_count) return 0; + if (s->type != F3D_BODY_FIXED) return 0; + s->shape = F3D_SHAPE_MESH; + s->hull = mesh; + s->rounding = F3D_R(0.0); + s->size = f3d_v3(F3D_R(0.0), F3D_R(0.0), F3D_R(0.0)); + f3d_refresh_mass(world, s); + s->flags |= F3D_FLAG_MOVED; + f3d_wake(world, s); + return 1; +} diff --git a/packages/flutter3d_physics_native/csrc/src/f3d_motion.c b/packages/flutter3d_physics_native/csrc/src/f3d_motion.c new file mode 100644 index 000000000..50296dac2 --- /dev/null +++ b/packages/flutter3d_physics_native/csrc/src/f3d_motion.c @@ -0,0 +1,347 @@ +/* + * How bodies move — P9: shapes as inertia, surface and drag; the wind's + * drag; semi-implicit Euler for the centre and a momentum-keeping turn for + * the orientation; and sleep. + */ +#include "f3d_internal.h" + +F3dSym3 f3d_sym_turned(F3dQuat q, F3dSym3 t) { + const f3d_real x = q.x, y = q.y, z = q.z, w = q.w; + const f3d_real r00 = F3D_R(1.0) - F3D_R(2.0) * (y * y + z * z); + const f3d_real r01 = F3D_R(2.0) * (x * y - z * w); + const f3d_real r02 = F3D_R(2.0) * (x * z + y * w); + const f3d_real r10 = F3D_R(2.0) * (x * y + z * w); + const f3d_real r11 = F3D_R(1.0) - F3D_R(2.0) * (x * x + z * z); + const f3d_real r12 = F3D_R(2.0) * (y * z - x * w); + const f3d_real r20 = F3D_R(2.0) * (x * z - y * w); + const f3d_real r21 = F3D_R(2.0) * (y * z + x * w); + const f3d_real r22 = F3D_R(1.0) - F3D_R(2.0) * (x * x + y * y); + F3dSym3 m; + if (t.xy == F3D_R(0.0) && t.xz == F3D_R(0.0) && t.yz == F3D_R(0.0)) { + /* Diagonal, as every shape but a hull is: R · diag(d) · Rᵀ written out, + * the same arithmetic as before hulls came. */ + const f3d_real dx = t.xx, dy = t.yy, dz = t.zz; + m.xx = r00 * r00 * dx + r01 * r01 * dy + r02 * r02 * dz; + m.yy = r10 * r10 * dx + r11 * r11 * dy + r12 * r12 * dz; + m.zz = r20 * r20 * dx + r21 * r21 * dy + r22 * r22 * dz; + m.xy = r00 * r10 * dx + r01 * r11 * dy + r02 * r12 * dz; + m.xz = r00 * r20 * dx + r01 * r21 * dy + r02 * r22 * dz; + m.yz = r10 * r20 * dx + r11 * r21 * dy + r12 * r22 * dz; + return m; + } + /* R · T, then that times Rᵀ. */ + const f3d_real r[3][3] = {{r00, r01, r02}, {r10, r11, r12}, {r20, r21, r22}}; + const f3d_real tt[3][3] = { + {t.xx, t.xy, t.xz}, {t.xy, t.yy, t.yz}, {t.xz, t.yz, t.zz}}; + f3d_real rt[3][3]; + for (int i = 0; i < 3; i++) { + for (int j = 0; j < 3; j++) { + rt[i][j] = r[i][0] * tt[0][j] + r[i][1] * tt[1][j] + r[i][2] * tt[2][j]; + } + } + f3d_real o[3][3]; + for (int i = 0; i < 3; i++) { + for (int j = 0; j < 3; j++) { + o[i][j] = rt[i][0] * r[j][0] + rt[i][1] * r[j][1] + rt[i][2] * r[j][2]; + } + } + m.xx = o[0][0]; + m.yy = o[1][1]; + m.zz = o[2][2]; + m.xy = o[0][1]; + m.xz = o[0][2]; + m.yz = o[1][2]; + return m; +} + +F3dSym3 f3d_sym_inverse(F3dSym3 m) { + const f3d_real c00 = m.yy * m.zz - m.yz * m.yz; + const f3d_real c01 = m.xz * m.yz - m.xy * m.zz; + const f3d_real c02 = m.xy * m.yz - m.xz * m.yy; + const f3d_real det = m.xx * c00 + m.xy * c01 + m.xz * c02; + if (!(det > F3D_R(0.0)) || !f3d_finite(det)) { + return f3d_sym_diag(F3D_R(0.0), F3D_R(0.0), F3D_R(0.0)); + } + if (m.xy == F3D_R(0.0) && m.xz == F3D_R(0.0) && m.yz == F3D_R(0.0)) { + return f3d_sym_diag(F3D_R(1.0) / m.xx, F3D_R(1.0) / m.yy, + F3D_R(1.0) / m.zz); + } + const f3d_real inv = F3D_R(1.0) / det; + F3dSym3 o; + o.xx = c00 * inv; + o.xy = c01 * inv; + o.xz = c02 * inv; + o.yy = (m.xx * m.zz - m.xz * m.xz) * inv; + o.yz = (m.xy * m.xz - m.xx * m.yz) * inv; + o.zz = (m.xx * m.yy - m.xy * m.xy) * inv; + return o; +} + +/* A shape's size with its rounding: a rounded box's inertia, surface and + * drag are taken as the box it rounds out to — the corners it fills are a + * few per cent of either at the radii a game rounds by. */ +static F3dVec3 grown(const F3dSlot *s) { + const f3d_real r = s->rounding; + switch (s->shape) { + case F3D_SHAPE_SPHERE: + return f3d_v3(s->size.x + r, F3D_R(0.0), F3D_R(0.0)); + case F3D_SHAPE_CAPSULE: + return f3d_v3(s->size.x + r, s->size.y, F3D_R(0.0)); + case F3D_SHAPE_CYLINDER: + return f3d_v3(s->size.x + r, s->size.y + r, F3D_R(0.0)); + case F3D_SHAPE_CONE: + return f3d_v3(s->size.x + r, s->size.y + r, F3D_R(0.0)); + default: + return f3d_add(s->size, f3d_v3(r, r, r)); + } +} + +/* The inertia tensor of [mass] spread evenly through the shape, about its + * centre of mass: as flutter3d_physics' inertia.dart has a box's + * m/3 · (b² + c²) with half extents, a sphere's 2/5 m r², a capsule as a + * cylinder and two hemispherical caps sharing the mass by volume; a + * cylinder's ½ m r² about its axis and m (3r² + 4h²)/12 across; a cone's, + * about its centroid a quarter of its height above the base, + * 3/10 m r² about its axis and 3/20 m r² + 3/80 m H² across; and a hull's + * from its tetrahedra, kilogram for kilogram. */ +static F3dSym3 inertia_of(const F3dWorld *world, const F3dSlot *s) { + const f3d_real m = s->mass; + if (!(m > F3D_R(0.0))) return f3d_sym_diag(F3D_R(0.0), F3D_R(0.0), F3D_R(0.0)); + const F3dVec3 d = grown(s); + switch (s->shape) { + case F3D_SHAPE_SPHERE: { + const f3d_real i = F3D_R(0.4) * m * d.x * d.x; + return f3d_sym_diag(i, i, i); + } + case F3D_SHAPE_BOX: { + const f3d_real third = m / F3D_R(3.0); + return f3d_sym_diag(third * (d.y * d.y + d.z * d.z), + third * (d.x * d.x + d.z * d.z), + third * (d.x * d.x + d.y * d.y)); + } + case F3D_SHAPE_CAPSULE: { + const f3d_real r = d.x, h = d.y, r2 = r * r; + const f3d_real cylinder_volume = F3D_R(2.0) * h * r2; + const f3d_real caps_volume = F3D_R(4.0) / F3D_R(3.0) * r2 * r; + const f3d_real cylinder = + m * cylinder_volume / (cylinder_volume + caps_volume); + const f3d_real caps = m - cylinder; + const f3d_real axial = + cylinder * F3D_R(0.5) * r2 + caps * F3D_R(0.4) * r2; + const f3d_real across = + cylinder * (F3D_R(0.25) * r2 + h * h / F3D_R(3.0)) + + caps * (F3D_R(0.4) * r2 + h * h + F3D_R(0.75) * h * r); + return f3d_sym_diag(across, axial, across); + } + case F3D_SHAPE_CYLINDER: { + const f3d_real r2 = d.x * d.x, h2 = d.y * d.y; + const f3d_real across = + m * (F3D_R(3.0) * r2 + F3D_R(4.0) * h2) / F3D_R(12.0); + return f3d_sym_diag(across, F3D_R(0.5) * m * r2, across); + } + case F3D_SHAPE_CONE: { + const f3d_real r2 = d.x * d.x, big = d.y * d.y; + const f3d_real across = m * (F3D_R(3.0) / F3D_R(20.0) * r2 + + F3D_R(3.0) / F3D_R(80.0) * big); + return f3d_sym_diag(across, F3D_R(0.3) * m * r2, across); + } + case F3D_SHAPE_HULL: { + if (s->hull == 0 || s->hull > world->s.hull_count) break; + const F3dSym3 u = world->hulls[s->hull - 1u].unit_inertia; + F3dSym3 o; + o.xx = u.xx * m; + o.yy = u.yy * m; + o.zz = u.zz * m; + o.xy = u.xy * m; + o.xz = u.xz * m; + o.yz = u.yz * m; + return o; + } + default: + break; + } + return f3d_sym_diag(F3D_R(0.0), F3D_R(0.0), F3D_R(0.0)); +} + +static f3d_real surface_of(const F3dWorld *world, const F3dSlot *s) { + const F3dVec3 d = grown(s); + switch (s->shape) { + case F3D_SHAPE_SPHERE: + return F3D_R(4.0) * F3D_PI * d.x * d.x; + case F3D_SHAPE_BOX: + return F3D_R(8.0) * (d.x * d.y + d.y * d.z + d.z * d.x); + case F3D_SHAPE_CAPSULE: + return F3D_R(4.0) * F3D_PI * d.x * d.x + + F3D_R(4.0) * F3D_PI * d.x * d.y; + case F3D_SHAPE_CYLINDER: + return F3D_R(2.0) * F3D_PI * d.x * d.x + + F3D_R(4.0) * F3D_PI * d.x * d.y; + case F3D_SHAPE_CONE: + /* The base and the slant: π r² + π r √(r² + H²). */ + return F3D_PI * d.x * d.x + + F3D_PI * d.x * f3d_sqrt(d.x * d.x + d.y * d.y); + case F3D_SHAPE_HULL: + if (s->hull == 0 || s->hull > world->s.hull_count) return F3D_R(0.0); + return world->hulls[s->hull - 1u].surface; + case F3D_SHAPE_MESH: + if (s->hull == 0 || s->hull > world->s.mesh_count) return F3D_R(0.0); + return world->meshes[s->hull - 1u].surface; + default: + return F3D_R(0.0); + } +} + +static f3d_real shape_drag_of(const F3dSlot *s) { + switch (s->shape) { + case F3D_SHAPE_SPHERE: + return F3D_R(0.47); + case F3D_SHAPE_BOX: + return F3D_R(1.05); + case F3D_SHAPE_CAPSULE: + return F3D_R(0.6); + case F3D_SHAPE_CYLINDER: + return F3D_R(0.82); + case F3D_SHAPE_CONE: + return F3D_R(0.5); + case F3D_SHAPE_HULL: + return F3D_R(1.0); + default: + return F3D_R(0.0); + } +} + +void f3d_refresh_mass(const F3dWorld *world, F3dSlot *s) { + const int dynamic = s->type == F3D_BODY_DYNAMIC && s->mass > F3D_R(0.0); + s->inverse_mass = dynamic ? F3D_R(1.0) / s->mass : F3D_R(0.0); + s->inertia = inertia_of(world, s); + s->inverse_inertia = dynamic && !(s->flags & F3D_FLAG_LOCKED) + ? f3d_sym_inverse(s->inertia) + : f3d_sym_diag(F3D_R(0.0), F3D_R(0.0), F3D_R(0.0)); + s->surface = surface_of(world, s); + s->shape_drag = s->drag > F3D_R(0.0) ? s->drag : shape_drag_of(s); +} + +/* Turns the body through one step of its spin, keeping its angular + * momentum: L = I·ω with the old orientation, the turn + * q += ½·dt·(ω ⊗ q) and a normalisation, and ω read back out of L with the + * new one. That is the gyroscopic term of Euler's equations taken + * implicitly, as RigidBody.integrateOrientation takes it. */ +static void turn(F3dSlot *s, f3d_real dt) { + const f3d_real wx = s->spin.x, wy = s->spin.y, wz = s->spin.z; + if (wx == F3D_R(0.0) && wy == F3D_R(0.0) && wz == F3D_R(0.0)) return; + const F3dVec3 l = + f3d_sym_times(f3d_sym_turned(s->orientation, s->inertia), s->spin); + const f3d_real x = s->orientation.x, y = s->orientation.y; + const f3d_real z = s->orientation.z, w = s->orientation.w; + const f3d_real h = F3D_R(0.5) * dt; + const f3d_real nx = x + h * (w * wx + wy * z - wz * y); + const f3d_real ny = y + h * (w * wy + wz * x - wx * z); + const f3d_real nz = z + h * (w * wz + wx * y - wy * x); + const f3d_real nw = w - h * (wx * x + wy * y + wz * z); + const f3d_real length = f3d_sqrt(nx * nx + ny * ny + nz * nz + nw * nw); + s->orientation.x = nx / length; + s->orientation.y = ny / length; + s->orientation.z = nz / length; + s->orientation.w = nw / length; + const f3d_real keep = F3D_R(1.0) / (F3D_R(1.0) + dt * s->angular_damping); + const F3dVec3 o = f3d_sym_times( + f3d_sym_turned(s->orientation, s->inverse_inertia), l); + s->spin.x = o.x * keep; + s->spin.y = o.y * keep; + s->spin.z = o.z * keep; +} + +int f3d_turns(const F3dSlot *s) { + const F3dSym3 m = s->inverse_inertia; + return m.xx != F3D_R(0.0) || m.yy != F3D_R(0.0) || m.zz != F3D_R(0.0) || + m.xy != F3D_R(0.0) || m.xz != F3D_R(0.0) || m.yz != F3D_R(0.0); +} + +void f3d_integrate_velocity(const F3dWorld *world, F3dSlot *s, f3d_real h) { + const F3dVec3 g = world->s.gravity; + const f3d_real im = s->inverse_mass; + /* The wind's drag, ½ ρ C A |u| u against the velocity u through the + * air, with A a quarter of the surface: a convex body's projected area + * averaged over every way it can face, so a tumbling box needs no + * orientation to say how much wind it catches. Taken implicitly with + * |u| as the substep found it: u' = (u + a h) / (1 + k h). Stable for a + * leaf in a gale at any step, and its fixed point is k u = a exactly, so + * a falling body settles at its terminal speed and not beside it. */ + f3d_real keep = F3D_R(1.0); + f3d_real wind[3] = {F3D_R(0.0), F3D_R(0.0), F3D_R(0.0)}; + if (s->surface > F3D_R(0.0) && s->shape_drag > F3D_R(0.0)) { + f3d_world_sample_wind(world, s->position.x, s->position.y, s->position.z, + wind); + const f3d_real ux = s->velocity.x - wind[0]; + const f3d_real uy = s->velocity.y - wind[1]; + const f3d_real uz = s->velocity.z - wind[2]; + const f3d_real speed = f3d_sqrt(ux * ux + uy * uy + uz * uz); + const f3d_real k = F3D_R(0.5) * world->s.air_density * s->shape_drag * + (F3D_R(0.25) * s->surface) * speed * im; + keep = F3D_R(1.0) / (F3D_R(1.0) + k * h); + } + s->velocity.x = + wind[0] + (s->velocity.x - wind[0] + (g.x + s->force.x * im) * h) * keep; + s->velocity.y = + wind[1] + (s->velocity.y - wind[1] + (g.y + s->force.y * im) * h) * keep; + s->velocity.z = + wind[2] + (s->velocity.z - wind[2] + (g.z + s->force.z * im) * h) * keep; + if (s->linear_damping > F3D_R(0.0)) { + const f3d_real damp = F3D_R(1.0) / (F3D_R(1.0) + h * s->linear_damping); + s->velocity.x *= damp; + s->velocity.y *= damp; + s->velocity.z *= damp; + } + if (f3d_turns(s)) { + const F3dVec3 dw = f3d_sym_times( + f3d_sym_turned(s->orientation, s->inverse_inertia), + f3d_scale(s->torque, h)); + s->spin = f3d_add(s->spin, dw); + } +} + +/* The most a body turns in one substep, radians: an eighth of a turn, as + * Box2D v3 holds it (B2_MAX_ROTATION). */ +#define F3D_MAX_TURN (F3D_R(0.25) * F3D_PI) + +void f3d_integrate_position(F3dSlot *s, f3d_real h) { + s->position.x += s->velocity.x * h; + s->position.y += s->velocity.y * h; + s->position.z += s->velocity.z * h; + if (!f3d_turns(s)) return; + /* **No more than an eighth of a turn a substep.** A rod two metres long + * and two centimetres thick is five thousand times easier to turn about + * its length than across it, and a contact at its end with friction spins + * it about its length at thousands of radians a second — which a real + * pencil also does. Turned tens of radians in a substep, the first-order + * turn is no turn at all, the momentum read back through that tiny + * inertia grows, and the rod flew off at hundreds of millions of radians + * a second. Held to π/4 a substep — 188 rad/s at four substeps of a + * sixtieth — the turn stays a turn and the spin stays bounded. */ + const f3d_real most = F3D_MAX_TURN / h; + f3d_real w2 = f3d_dot(s->spin, s->spin); + if (w2 > most * most) s->spin = f3d_scale(s->spin, most / f3d_sqrt(w2)); + turn(s, h); + /* And after: the turn reads the spin back out of the momentum through + * the new orientation, and through a tiny inertia that can be far past + * what went in. */ + w2 = f3d_dot(s->spin, s->spin); + if (w2 > most * most) s->spin = f3d_scale(s->spin, most / f3d_sqrt(w2)); +} + +void f3d_finish_motion(const F3dWorld *world, F3dSlot *s, f3d_real dt) { + s->force = f3d_v3(F3D_R(0.0), F3D_R(0.0), F3D_R(0.0)); + s->torque = f3d_v3(F3D_R(0.0), F3D_R(0.0), F3D_R(0.0)); + if (s->type != F3D_BODY_DYNAMIC || (s->flags & F3D_FLAG_ASLEEP)) return; + /* How long it has been still: slower than the sleep speed, in m/s and in + * rad/s alike. Spin counts: a body turning on the spot is not at rest. + * Whether it sleeps is its island's to say, in the collision stage. */ + const f3d_real sleep2 = world->s.sleep_speed * world->s.sleep_speed; + const f3d_real v2 = f3d_dot(s->velocity, s->velocity); + const f3d_real w2 = f3d_dot(s->spin, s->spin); + if (v2 > sleep2 || w2 > sleep2) { + s->still = F3D_R(0.0); + } else { + s->still += dt; + } +} diff --git a/packages/flutter3d_physics_native/csrc/src/f3d_narrow.c b/packages/flutter3d_physics_native/csrc/src/f3d_narrow.c new file mode 100644 index 000000000..242b27776 --- /dev/null +++ b/packages/flutter3d_physics_native/csrc/src/f3d_narrow.c @@ -0,0 +1,670 @@ +/* + * The narrow phase — P9, phase 2: where two shapes touch, how deep, and + * which way out, for every pair of sphere, box and capsule, turned however + * they are turned. + * + * Every pair answers with a manifold: one normal, out of b and into a, and + * up to four points halfway between the two surfaces, each with its depth. + * A depth is positive inside and negative for a gap within the margin, for + * the reason flutter3d_physics' Contact.depth gives: a resting pile that + * only reported overlaps would fall a step, catch itself, and fall again. + * + * A box resting on a face needs its whole face, not a pin to balance on, so + * box against box clips the incident face to the reference face and keeps + * up to four points; a capsule lying along a box or another capsule keeps + * two. Each point carries the features that made it, so the solver can + * carry an impulse across steps. + */ +#include "f3d_internal.h" + +F3dMat3 f3d_mat_of(F3dQuat q) { + const f3d_real x = q.x, y = q.y, z = q.z, w = q.w; + F3dMat3 m; + m.c[0] = f3d_v3(F3D_R(1.0) - F3D_R(2.0) * (y * y + z * z), + F3D_R(2.0) * (x * y + z * w), F3D_R(2.0) * (x * z - y * w)); + m.c[1] = f3d_v3(F3D_R(2.0) * (x * y - z * w), + F3D_R(1.0) - F3D_R(2.0) * (x * x + z * z), + F3D_R(2.0) * (y * z + x * w)); + m.c[2] = f3d_v3(F3D_R(2.0) * (x * z + y * w), F3D_R(2.0) * (y * z - x * w), + F3D_R(1.0) - F3D_R(2.0) * (x * x + y * y)); + return m; +} + +static f3d_real component(F3dVec3 v, int i) { + return i == 0 ? v.x : (i == 1 ? v.y : v.z); +} + +/* v in the frame of [m], from the world's. */ +static F3dVec3 to_local(const F3dMat3 *m, F3dVec3 v) { + return f3d_v3(f3d_dot(m->c[0], v), f3d_dot(m->c[1], v), f3d_dot(m->c[2], v)); +} + +static F3dVec3 to_world(const F3dMat3 *m, F3dVec3 v) { + return f3d_add(f3d_add(f3d_scale(m->c[0], v.x), f3d_scale(m->c[1], v.y)), + f3d_scale(m->c[2], v.z)); +} + +static void emit(F3dManifold *out, F3dVec3 point, f3d_real depth, uint32_t id) { + if (out->count >= F3D_MANIFOLD_POINTS) return; + F3dContactPoint *p = &out->points[out->count++]; + p->point = point; + p->depth = depth; + p->id = id; +} + +/* ------------------------------------------------------------- segments */ + +/* A capsule's axis, end to end. */ +static void segment_of(const F3dPlaced *c, F3dVec3 *p0, F3dVec3 *p1) { + const F3dVec3 half = f3d_scale(c->axes.c[1], c->size.y); + *p0 = f3d_sub(c->at, half); + *p1 = f3d_add(c->at, half); +} + +/* How far along p0→p1 the point nearest [q] lies, nought to one. */ +static f3d_real nearest_on_segment(F3dVec3 p0, F3dVec3 p1, F3dVec3 q) { + const F3dVec3 d = f3d_sub(p1, p0); + const f3d_real dd = f3d_dot(d, d); + if (!(dd > F3D_R(0.0))) return F3D_R(0.0); + return f3d_clamp(f3d_dot(f3d_sub(q, p0), d) / dd, F3D_R(0.0), F3D_R(1.0)); +} + +/* The nearest pair of points on p0→p1 and q0→q1, as fractions along each: + * Ericson's closest points of two segments, in Real-Time Collision + * Detection, §5.1.9. */ +void f3d_nearest_of_segments(F3dVec3 p0, F3dVec3 p1, F3dVec3 q0, F3dVec3 q1, + f3d_real *s, f3d_real *t) { + const F3dVec3 d1 = f3d_sub(p1, p0), d2 = f3d_sub(q1, q0); + const F3dVec3 r = f3d_sub(p0, q0); + const f3d_real a = f3d_dot(d1, d1), e = f3d_dot(d2, d2); + const f3d_real f = f3d_dot(d2, r); + const f3d_real tiny = F3D_R(1e-12); + if (a <= tiny && e <= tiny) { + *s = *t = F3D_R(0.0); + return; + } + if (a <= tiny) { + *s = F3D_R(0.0); + *t = f3d_clamp(f / e, F3D_R(0.0), F3D_R(1.0)); + return; + } + const f3d_real c = f3d_dot(d1, r); + if (e <= tiny) { + *t = F3D_R(0.0); + *s = f3d_clamp(-c / a, F3D_R(0.0), F3D_R(1.0)); + return; + } + const f3d_real b = f3d_dot(d1, d2); + const f3d_real denom = a * e - b * b; + f3d_real ss = denom > tiny * a * e + ? f3d_clamp((b * f - c * e) / denom, F3D_R(0.0), F3D_R(1.0)) + : F3D_R(0.0); + f3d_real tt = (b * ss + f) / e; + if (tt < F3D_R(0.0)) { + tt = F3D_R(0.0); + ss = f3d_clamp(-c / a, F3D_R(0.0), F3D_R(1.0)); + } else if (tt > F3D_R(1.0)) { + tt = F3D_R(1.0); + ss = f3d_clamp((b - c) / a, F3D_R(0.0), F3D_R(1.0)); + } + *s = ss; + *t = tt; +} + +static F3dVec3 along(F3dVec3 p0, F3dVec3 p1, f3d_real t) { + return f3d_madd(p0, f3d_sub(p1, p0), t); +} + +/* ------------------------------------------------------- round shapes */ + +/* Two balls: the core of every pair with a round side. The normal is out + * of b, and concentric balls part upwards, as flutter3d_physics parts + * them. Fills the normal when [set_normal]; otherwise measures along the + * normal already there. */ +static int balls(F3dVec3 ca, f3d_real ra, F3dVec3 cb, f3d_real rb, + f3d_real margin, F3dManifold *out, uint32_t id) { + const F3dVec3 d = f3d_sub(ca, cb); + const f3d_real reach = ra + rb; + const f3d_real within = reach + margin; + const f3d_real d2 = f3d_dot(d, d); + if (d2 >= within * within) return 0; + const f3d_real distance = f3d_sqrt(d2); + const F3dVec3 n = distance > F3D_R(1e-9) * (F3D_R(1.0) + reach) + ? f3d_scale(d, F3D_R(1.0) / distance) + : f3d_v3(F3D_R(0.0), F3D_R(1.0), F3D_R(0.0)); + out->normal = n; + /* Halfway between a's deepest point and b's. */ + const F3dVec3 on_a = f3d_madd(ca, n, -ra); + const F3dVec3 on_b = f3d_madd(cb, n, rb); + emit(out, f3d_scale(f3d_add(on_a, on_b), F3D_R(0.5)), reach - distance, id); + return 1; +} + +static uint32_t sphere_sphere(const F3dPlaced *a, const F3dPlaced *b, + f3d_real margin, F3dManifold *out) { + return (uint32_t)balls(a->at, a->size.x, b->at, b->size.x, margin, out, 0); +} + +static uint32_t sphere_capsule(const F3dPlaced *sphere, + const F3dPlaced *capsule, f3d_real margin, + F3dManifold *out) { + F3dVec3 p0, p1; + segment_of(capsule, &p0, &p1); + const F3dVec3 q = along(p0, p1, nearest_on_segment(p0, p1, sphere->at)); + return (uint32_t)balls(sphere->at, sphere->size.x, q, capsule->size.x, + margin, out, 0); +} + +static uint32_t capsule_capsule(const F3dPlaced *a, const F3dPlaced *b, + f3d_real margin, F3dManifold *out) { + F3dVec3 a0, a1, b0, b1; + segment_of(a, &a0, &a1); + segment_of(b, &b0, &b1); + f3d_real s, t; + f3d_nearest_of_segments(a0, a1, b0, b1, &s, &t); + const f3d_real ra = a->size.x, rb = b->size.x; + if (!balls(along(a0, a1, s), ra, along(b0, b1, t), rb, margin, out, 0)) { + return 0; + } + /* Lying along each other, one point is a pin to roll on: two, at the ends + * of the stretch where they overlap, measured along the one normal. */ + const F3dVec3 da = f3d_sub(a1, a0), db = f3d_sub(b1, b0); + const f3d_real la = f3d_dot(da, da), lb = f3d_dot(db, db); + const F3dVec3 x = f3d_cross(da, db); + if (la <= F3D_R(1e-12) || lb <= F3D_R(1e-12) || + f3d_dot(x, x) > F3D_R(1e-4) * la * lb) { + return out->count; + } + f3d_real lo = f3d_dot(f3d_sub(b0, a0), da) / la; + f3d_real hi = f3d_dot(f3d_sub(b1, a0), da) / la; + if (lo > hi) { + const f3d_real swap = lo; + lo = hi; + hi = swap; + } + lo = f3d_max(lo, F3D_R(0.0)); + hi = f3d_min(hi, F3D_R(1.0)); + if (hi - lo <= F3D_R(1e-3)) return out->count; + const F3dVec3 n = out->normal; + out->count = 0; + const f3d_real ends[2] = {lo, hi}; + for (uint32_t i = 0; i < 2; i++) { + const F3dVec3 pa = along(a0, a1, ends[i]); + const F3dVec3 pb = along(b0, b1, nearest_on_segment(b0, b1, pa)); + const f3d_real depth = ra + rb - f3d_dot(f3d_sub(pa, pb), n); + if (depth <= -margin) continue; + const F3dVec3 on_a = f3d_madd(pa, n, -ra); + emit(out, f3d_madd(on_a, n, F3D_R(0.5) * depth), depth, 1u + i); + } + return out->count; +} + +/* --------------------------------------------------------- against a box */ + +/* A ball against a box: the box's nearest point, or with the centre inside + * it, its nearest face. */ +static int ball_box(F3dVec3 c, f3d_real r, const F3dPlaced *box, + f3d_real margin, F3dManifold *out, uint32_t id) { + const F3dVec3 h = box->size; + const F3dVec3 p = to_local(&box->axes, f3d_sub(c, box->at)); + const F3dVec3 q = f3d_v3(f3d_clamp(p.x, -h.x, h.x), f3d_clamp(p.y, -h.y, h.y), + f3d_clamp(p.z, -h.z, h.z)); + const F3dVec3 d = f3d_sub(p, q); + const f3d_real d2 = f3d_dot(d, d); + F3dVec3 nl, surface; + f3d_real depth; + if (d2 > F3D_R(1e-18)) { + const f3d_real reach = r + margin; + if (d2 >= reach * reach) return 0; + const f3d_real distance = f3d_sqrt(d2); + nl = f3d_scale(d, F3D_R(1.0) / distance); + surface = q; + depth = r - distance; + } else { + const f3d_real ox = h.x - f3d_abs(p.x), oy = h.y - f3d_abs(p.y); + const f3d_real oz = h.z - f3d_abs(p.z); + surface = p; + if (ox <= oy && ox <= oz) { + const f3d_real sign = p.x >= F3D_R(0.0) ? F3D_R(1.0) : F3D_R(-1.0); + nl = f3d_v3(sign, F3D_R(0.0), F3D_R(0.0)); + surface.x = sign * h.x; + depth = ox + r; + } else if (oy <= oz) { + const f3d_real sign = p.y >= F3D_R(0.0) ? F3D_R(1.0) : F3D_R(-1.0); + nl = f3d_v3(F3D_R(0.0), sign, F3D_R(0.0)); + surface.y = sign * h.y; + depth = oy + r; + } else { + const f3d_real sign = p.z >= F3D_R(0.0) ? F3D_R(1.0) : F3D_R(-1.0); + nl = f3d_v3(F3D_R(0.0), F3D_R(0.0), sign); + surface.z = sign * h.z; + depth = oz + r; + } + } + const F3dVec3 n = to_world(&box->axes, nl); + const F3dVec3 on_box = f3d_add(box->at, to_world(&box->axes, surface)); + const F3dVec3 on_ball = f3d_madd(c, n, -r); + out->normal = n; + emit(out, f3d_scale(f3d_add(on_box, on_ball), F3D_R(0.5)), depth, id); + return 1; +} + +static uint32_t sphere_box(const F3dPlaced *sphere, const F3dPlaced *box, + f3d_real margin, F3dManifold *out) { + return (uint32_t)ball_box(sphere->at, sphere->size.x, box, margin, out, 0); +} + +/* The box's furthest reach along [n]. */ +static f3d_real box_support(const F3dPlaced *box, F3dVec3 n) { + return f3d_dot(n, box->at) + + f3d_abs(f3d_dot(n, box->axes.c[0])) * box->size.x + + f3d_abs(f3d_dot(n, box->axes.c[1])) * box->size.y + + f3d_abs(f3d_dot(n, box->axes.c[2])) * box->size.z; +} + +/* Squared distance from [p] to the box, in the box's own frame. */ +static f3d_real box_gap2(const F3dPlaced *box, F3dVec3 p) { + const F3dVec3 l = to_local(&box->axes, f3d_sub(p, box->at)); + const F3dVec3 h = box->size; + const f3d_real dx = f3d_abs(l.x) - h.x, dy = f3d_abs(l.y) - h.y; + const f3d_real dz = f3d_abs(l.z) - h.z; + const f3d_real ex = f3d_max(dx, F3D_R(0.0)), ey = f3d_max(dy, F3D_R(0.0)); + const f3d_real ez = f3d_max(dz, F3D_R(0.0)); + return ex * ex + ey * ey + ez * ez; +} + +static uint32_t capsule_box(const F3dPlaced *capsule, const F3dPlaced *box, + f3d_real margin, F3dManifold *out) { + F3dVec3 p0, p1; + segment_of(capsule, &p0, &p1); + const f3d_real r = capsule->size.x; + /* The axis's nearest point to the box: the distance to a box is convex + * along a line, so a golden-section search finds its least to a part in + * ten million in thirty-two narrowings, and only adds and multiplies. */ + f3d_real lo = F3D_R(0.0), hi = F3D_R(1.0); + const f3d_real golden = F3D_R(0.6180339887498949); + f3d_real m1 = hi - golden * (hi - lo), m2 = lo + golden * (hi - lo); + f3d_real f1 = box_gap2(box, along(p0, p1, m1)); + f3d_real f2 = box_gap2(box, along(p0, p1, m2)); + for (int i = 0; i < 32; i++) { + if (f1 <= f2) { + hi = m2; + m2 = m1; + f2 = f1; + m1 = hi - golden * (hi - lo); + f1 = box_gap2(box, along(p0, p1, m1)); + } else { + lo = m1; + m1 = m2; + f1 = f2; + m2 = lo + golden * (hi - lo); + f2 = box_gap2(box, along(p0, p1, m2)); + } + } + /* The deepest of the two ends and that nearest point gives the normal. */ + const f3d_real candidates[3] = {F3D_R(0.0), F3D_R(1.0), + F3D_R(0.5) * (lo + hi)}; + F3dManifold best; + f3d_zero(&best, sizeof best); + int found = 0; + for (uint32_t i = 0; i < 3; i++) { + F3dManifold trial; + f3d_zero(&trial, sizeof trial); + if (!ball_box(along(p0, p1, candidates[i]), r, box, margin, &trial, i)) { + continue; + } + if (!found || trial.points[0].depth > best.points[0].depth) { + best = trial; + found = 1; + } + } + if (!found) return 0; + out->normal = best.normal; + emit(out, best.points[0].point, best.points[0].depth, best.points[0].id); + /* Lying on a face, the ends rest on it too: each end that stands over + * the face is measured along the face's normal, so a capsule on a floor + * has two points and does not roll about the one. */ + const F3dVec3 n = best.normal; + int face = -1; + for (int k = 0; k < 3; k++) { + if (f3d_abs(f3d_dot(n, box->axes.c[k])) > F3D_R(0.999)) face = k; + } + if (face < 0) return out->count; + const f3d_real top = box_support(box, n); + for (uint32_t i = 0; i < 2; i++) { + if (best.points[0].id == i) continue; + const F3dVec3 c = i == 0 ? p0 : p1; + const F3dVec3 l = to_local(&box->axes, f3d_sub(c, box->at)); + int over = 1; + for (int k = 0; k < 3; k++) { + if (k != face && f3d_abs(component(l, k)) > component(box->size, k)) { + over = 0; + } + } + if (!over) continue; + const f3d_real depth = top - (f3d_dot(n, c) - r); + if (depth <= -margin) continue; + const F3dVec3 on_capsule = f3d_madd(c, n, -r); + emit(out, f3d_madd(on_capsule, n, F3D_R(0.5) * depth), depth, i); + } + return out->count; +} + +/* ----------------------------------------------------------- box and box */ + +typedef struct ClipPoint { + F3dVec3 p; + uint32_t id; +} ClipPoint; + +/* Keeps the part of [in] on the inner side of the plane dot(n, x) ≤ d, + * Sutherland–Hodgman; a point made on the plane is named after the plane + * and the edge it cut. */ +static uint32_t clip(const ClipPoint *in, uint32_t count, F3dVec3 n, f3d_real d, + uint32_t plane, ClipPoint *out) { + uint32_t written = 0; + for (uint32_t i = 0; i < count; i++) { + const ClipPoint a = in[i]; + const ClipPoint b = in[(i + 1) % count]; + const f3d_real da = f3d_dot(n, a.p) - d; + const f3d_real db = f3d_dot(n, b.p) - d; + if (da <= F3D_R(0.0) && written < 8u) out[written++] = a; + /* A cut only where the edge truly crosses: an end on the plane is kept + * as it is, and cutting there too would add it twice and grow a convex + * quadrilateral past the eight points four cuts can make. */ + if (((da < F3D_R(0.0) && db > F3D_R(0.0)) || + (da > F3D_R(0.0) && db < F3D_R(0.0))) && + written < 8u) { + const f3d_real t = da / (da - db); + ClipPoint cut; + cut.p = along(a.p, b.p, t); + cut.id = 16u + plane * 4u + (a.id & 3u); + out[written++] = cut; + } + } + return written; +} + +/* Of up to eight points, the four that hold the face: the deepest, the one + * furthest from it, and the two that span the most area either side of the + * line between them. */ +static void reduce(ClipPoint *points, f3d_real *depths, uint32_t *count, + F3dVec3 n) { + if (*count <= F3D_MANIFOLD_POINTS) return; + uint32_t pick[4]; + pick[0] = 0; + for (uint32_t i = 1; i < *count; i++) { + if (depths[i] > depths[pick[0]]) pick[0] = i; + } + f3d_real best = F3D_R(-1.0); + pick[1] = pick[0]; + for (uint32_t i = 0; i < *count; i++) { + const F3dVec3 d = f3d_sub(points[i].p, points[pick[0]].p); + if (f3d_dot(d, d) > best) { + best = f3d_dot(d, d); + pick[1] = i; + } + } + const F3dVec3 line = f3d_sub(points[pick[1]].p, points[pick[0]].p); + f3d_real most = F3D_R(0.0), least = F3D_R(0.0); + pick[2] = pick[0]; + pick[3] = pick[1]; + for (uint32_t i = 0; i < *count; i++) { + const f3d_real area = f3d_dot( + f3d_cross(line, f3d_sub(points[i].p, points[pick[0]].p)), n); + if (area > most) { + most = area; + pick[2] = i; + } + if (area < least) { + least = area; + pick[3] = i; + } + } + ClipPoint kept[4]; + f3d_real kept_depths[4]; + uint32_t n_kept = 0; + for (uint32_t k = 0; k < 4; k++) { + int seen = 0; + for (uint32_t j = 0; j < k; j++) seen |= pick[j] == pick[k]; + if (seen) continue; + kept[n_kept] = points[pick[k]]; + kept_depths[n_kept] = depths[pick[k]]; + n_kept++; + } + for (uint32_t k = 0; k < n_kept; k++) { + points[k] = kept[k]; + depths[k] = kept_depths[k]; + } + *count = n_kept; +} + +static uint32_t box_box(const F3dPlaced *a, const F3dPlaced *b, + f3d_real margin, F3dManifold *out) { + const F3dVec3 d = f3d_sub(a->at, b->at); + const f3d_real ha[3] = {a->size.x, a->size.y, a->size.z}; + const f3d_real hb[3] = {b->size.x, b->size.y, b->size.z}; + /* The fifteen axes that can part two boxes: three faces of each and the + * nine crossings of their edges. The way out is the one they overlap + * least along. */ + f3d_real best_face = F3D_R(1e30), best_edge = F3D_R(1e30); + int face_axis = -1, edge_axis = -1; + F3dVec3 face_n = f3d_v3(0, 0, 0), edge_n = f3d_v3(0, 0, 0); + for (int k = 0; k < 15; k++) { + F3dVec3 axis; + if (k < 3) { + axis = a->axes.c[k]; + } else if (k < 6) { + axis = b->axes.c[k - 3]; + } else { + axis = f3d_cross(a->axes.c[(k - 6) / 3], b->axes.c[(k - 6) % 3]); + const f3d_real l2 = f3d_dot(axis, axis); + /* Parallel edges cross to nothing: the face axes already cover + * them. */ + if (l2 < F3D_R(1e-6)) continue; + axis = f3d_scale(axis, F3D_R(1.0) / f3d_sqrt(l2)); + } + f3d_real ra = F3D_R(0.0), rb = F3D_R(0.0); + for (int i = 0; i < 3; i++) { + ra += ha[i] * f3d_abs(f3d_dot(a->axes.c[i], axis)); + rb += hb[i] * f3d_abs(f3d_dot(b->axes.c[i], axis)); + } + const f3d_real along_d = f3d_dot(d, axis); + const f3d_real overlap = ra + rb - f3d_abs(along_d); + if (overlap <= -margin) return 0; + if (along_d < F3D_R(0.0)) axis = f3d_scale(axis, F3D_R(-1.0)); + if (k < 6) { + /* a's faces first, and b's only when clearly better: two faces + * nearly as good would otherwise trade places step to step, and a + * resting box would rock between them. */ + const f3d_real better = k < 3 ? overlap : overlap + F3D_R(1e-4); + if (better < best_face) { + best_face = better; + face_axis = k; + face_n = axis; + } + } else if (overlap < best_edge) { + best_edge = overlap; + edge_axis = k; + edge_n = axis; + } + } + if (face_axis >= 3) best_face -= F3D_R(1e-4); + /* An edge only when it is clearly the shorter way out: a face holds a + * resting box, an edge only touches. */ + if (edge_axis >= 0 && best_edge < F3D_R(0.95) * best_face - F3D_R(1e-3)) { + const int i = (edge_axis - 6) / 3, j = (edge_axis - 6) % 3; + const F3dVec3 n = edge_n; /* Out of b, into a. */ + F3dVec3 ca = a->at, cb = b->at; + for (int k = 0; k < 3; k++) { + if (k != i) { + const f3d_real s = f3d_dot(a->axes.c[k], n) > F3D_R(0.0) ? F3D_R(-1.0) + : F3D_R(1.0); + ca = f3d_madd(ca, a->axes.c[k], s * ha[k]); + } + if (k != j) { + const f3d_real s = f3d_dot(b->axes.c[k], n) > F3D_R(0.0) ? F3D_R(1.0) + : F3D_R(-1.0); + cb = f3d_madd(cb, b->axes.c[k], s * hb[k]); + } + } + const F3dVec3 a0 = f3d_madd(ca, a->axes.c[i], -ha[i]); + const F3dVec3 a1 = f3d_madd(ca, a->axes.c[i], ha[i]); + const F3dVec3 b0 = f3d_madd(cb, b->axes.c[j], -hb[j]); + const F3dVec3 b1 = f3d_madd(cb, b->axes.c[j], hb[j]); + f3d_real s, t; + f3d_nearest_of_segments(a0, a1, b0, b1, &s, &t); + const F3dVec3 pa = along(a0, a1, s), pb = along(b0, b1, t); + out->normal = n; + emit(out, f3d_scale(f3d_add(pa, pb), F3D_R(0.5)), best_edge, + 0x100u + (uint32_t)(edge_axis - 6)); + return out->count; + } + /* A face: the reference box's face along the axis, the incident box's + * face turned most against it, clipped to the reference face's sides. */ + const int ref_is_a = face_axis < 3; + const F3dPlaced *ref = ref_is_a ? a : b; + const F3dPlaced *inc = ref_is_a ? b : a; + const int rk = ref_is_a ? face_axis : face_axis - 3; + /* Out of the reference face, towards the other box. face_n is out of b + * into a. */ + const F3dVec3 nr = ref_is_a ? f3d_scale(face_n, F3D_R(-1.0)) : face_n; + const f3d_real hr[3] = {ref->size.x, ref->size.y, ref->size.z}; + const f3d_real hi[3] = {inc->size.x, inc->size.y, inc->size.z}; + int ik = 0; + f3d_real most = F3D_R(-1.0); + for (int k = 0; k < 3; k++) { + const f3d_real c = f3d_abs(f3d_dot(inc->axes.c[k], nr)); + if (c > most) { + most = c; + ik = k; + } + } + const f3d_real side = + f3d_dot(inc->axes.c[ik], nr) > F3D_R(0.0) ? F3D_R(-1.0) : F3D_R(1.0); + const F3dVec3 inc_center = f3d_madd(inc->at, inc->axes.c[ik], side * hi[ik]); + const int iu = (ik + 1) % 3, iv = (ik + 2) % 3; + ClipPoint poly[8], scratch[8]; + const f3d_real su[4] = {F3D_R(1.0), F3D_R(-1.0), F3D_R(-1.0), F3D_R(1.0)}; + const f3d_real sv[4] = {F3D_R(1.0), F3D_R(1.0), F3D_R(-1.0), F3D_R(-1.0)}; + for (uint32_t v = 0; v < 4; v++) { + poly[v].p = f3d_madd(f3d_madd(inc_center, inc->axes.c[iu], su[v] * hi[iu]), + inc->axes.c[iv], sv[v] * hi[iv]); + poly[v].id = v; + } + uint32_t count = 4; + const int ru = (rk + 1) % 3, rv = (rk + 2) % 3; + const F3dVec3 u = ref->axes.c[ru], v = ref->axes.c[rv]; + const f3d_real du = f3d_dot(u, ref->at), dv = f3d_dot(v, ref->at); + count = clip(poly, count, u, du + hr[ru], 0, scratch); + count = clip(scratch, count, f3d_scale(u, F3D_R(-1.0)), -du + hr[ru], 1, poly); + count = clip(poly, count, v, dv + hr[rv], 2, scratch); + count = clip(scratch, count, f3d_scale(v, F3D_R(-1.0)), -dv + hr[rv], 3, poly); + const f3d_real face_d = f3d_dot(nr, ref->at) + hr[rk]; + ClipPoint kept[8]; + f3d_real depths[8]; + uint32_t n_kept = 0; + for (uint32_t i = 0; i < count; i++) { + const f3d_real depth = face_d - f3d_dot(nr, poly[i].p); + if (depth <= -margin) continue; + kept[n_kept] = poly[i]; + depths[n_kept] = depth; + n_kept++; + } + if (n_kept == 0) return 0; + reduce(kept, depths, &n_kept, nr); + out->normal = face_n; + const uint32_t base = ((ref_is_a ? 0u : 1u) << 12) | ((uint32_t)rk << 8) | + ((uint32_t)ik << 6); + for (uint32_t i = 0; i < n_kept; i++) { + emit(out, f3d_madd(kept[i].p, nr, F3D_R(0.5) * depths[i]), depths[i], + base | kept[i].id); + } + return out->count; +} + +/* ---------------------------------------------------------------- pairs */ + +/* The same pair with a and b swapped: the normal turns round. */ +static uint32_t flipped(uint32_t count, F3dManifold *out) { + out->normal = f3d_scale(out->normal, F3D_R(-1.0)); + return count; +} + +/* Whether the pair has a closed form here: balls, unrounded boxes and + * capsules. Anything else goes to the general narrow phase. */ +static int classic(const F3dPlaced *p) { + return p->kind == F3D_SHAPE_SPHERE || p->kind == F3D_SHAPE_CAPSULE || + (p->kind == F3D_SHAPE_BOX && p->rounding == F3D_R(0.0)); +} + +uint32_t f3d_collide(const F3dPlaced *pa, const F3dPlaced *pb, f3d_real margin, + F3dManifold *out) { + out->count = 0; + out->touching = 0; + uint32_t count = 0; + if (pa->kind == F3D_SHAPE_POINT || pb->kind == F3D_SHAPE_POINT) return 0; + if (pa->kind == F3D_SHAPE_MESH || pb->kind == F3D_SHAPE_MESH) { + if (pa->kind == F3D_SHAPE_MESH && pb->kind == F3D_SHAPE_MESH) return 0; + if (pb->kind == F3D_SHAPE_MESH) { + count = f3d_collide_mesh(pb, pa, margin, out); + } else { + count = f3d_collide_mesh(pa, pb, margin, out); + out->normal = f3d_scale(out->normal, F3D_R(-1.0)); + } + out->count = count; + for (uint32_t i = 0; i < count; i++) { + if (out->points[i].depth >= -F3D_LINEAR_SLOP) out->touching = 1; + } + return count; + } + if (!classic(pa) || !classic(pb)) { + count = f3d_collide_convex(pa, pb, margin, out); + out->count = count; + for (uint32_t i = 0; i < count; i++) { + if (out->points[i].depth >= -F3D_LINEAR_SLOP) out->touching = 1; + } + return count; + } + /* A ball's or a capsule's rounding is more radius. */ + F3dPlaced ra = *pa, rb = *pb; + if (ra.kind != F3D_SHAPE_BOX) ra.size.x += ra.rounding; + if (rb.kind != F3D_SHAPE_BOX) rb.size.x += rb.rounding; + const F3dPlaced *a = &ra, *b = &rb; + switch (a->kind * 4u + b->kind) { + case F3D_SHAPE_SPHERE * 4u + F3D_SHAPE_SPHERE: + count = sphere_sphere(a, b, margin, out); + break; + case F3D_SHAPE_SPHERE * 4u + F3D_SHAPE_BOX: + count = sphere_box(a, b, margin, out); + break; + case F3D_SHAPE_BOX * 4u + F3D_SHAPE_SPHERE: + count = flipped(sphere_box(b, a, margin, out), out); + break; + case F3D_SHAPE_SPHERE * 4u + F3D_SHAPE_CAPSULE: + count = sphere_capsule(a, b, margin, out); + break; + case F3D_SHAPE_CAPSULE * 4u + F3D_SHAPE_SPHERE: + count = flipped(sphere_capsule(b, a, margin, out), out); + break; + case F3D_SHAPE_BOX * 4u + F3D_SHAPE_BOX: + count = box_box(a, b, margin, out); + break; + case F3D_SHAPE_CAPSULE * 4u + F3D_SHAPE_BOX: + count = capsule_box(a, b, margin, out); + break; + case F3D_SHAPE_BOX * 4u + F3D_SHAPE_CAPSULE: + count = flipped(capsule_box(b, a, margin, out), out); + break; + case F3D_SHAPE_CAPSULE * 4u + F3D_SHAPE_CAPSULE: + count = capsule_capsule(a, b, margin, out); + break; + default: + return 0; /* A point touches nothing. */ + } + out->count = count; + for (uint32_t i = 0; i < count; i++) { + if (out->points[i].depth >= -F3D_LINEAR_SLOP) out->touching = 1; + } + return count; +} diff --git a/packages/flutter3d_physics_native/csrc/src/f3d_particles.c b/packages/flutter3d_physics_native/csrc/src/f3d_particles.c new file mode 100644 index 000000000..9e1f11f05 --- /dev/null +++ b/packages/flutter3d_physics_native/csrc/src/f3d_particles.c @@ -0,0 +1,97 @@ +/* + * Particles on the CPU — P9, phase 10: the reference for the GPU's, and + * the fallback where there is none. See f3d_physics.h for what they do; + * the WGSL in f3d_gpu.c does the same, line for line. + */ +#include "f3d_internal.h" + +typedef struct Particle { + F3dVec3 position; + f3d_real life; + F3dVec3 velocity; + f3d_real reserved; +} Particle; + +struct F3dParticles { + Particle *slots; + uint32_t capacity; + /* The slot the next particle takes. */ + uint32_t next; +}; + +F3dParticles *f3d_particles_create(uint32_t capacity) { + if (capacity == 0 || capacity > (1u << 26)) return NULL; + F3dParticles *p = (F3dParticles *)f3d_alloc(sizeof(F3dParticles)); + if (p == NULL) return NULL; + p->slots = (Particle *)f3d_alloc((size_t)capacity * sizeof(Particle)); + if (p->slots == NULL) { + f3d_free(p); + return NULL; + } + f3d_zero(p->slots, (size_t)capacity * sizeof(Particle)); + p->capacity = capacity; + p->next = 0; + return p; +} + +void f3d_particles_destroy(F3dParticles *particles) { + if (particles == NULL) return; + f3d_free(particles->slots); + f3d_free(particles); +} + +uint32_t f3d_particles_capacity(const F3dParticles *particles) { + return particles->capacity; +} + +uint32_t f3d_particles_emit(F3dParticles *particles, const f3d_real *data, + uint32_t count) { + const uint32_t first = particles->next; + for (uint32_t i = 0; i < count; i++) { + Particle *s = &particles->slots[particles->next]; + const f3d_real *d = data + (size_t)i * 7u; + s->position = f3d_v3(d[0], d[1], d[2]); + s->velocity = f3d_v3(d[3], d[4], d[5]); + s->life = d[6]; + particles->next = (particles->next + 1u) % particles->capacity; + } + return first; +} + +void f3d_particles_step(F3dParticles *particles, const F3dParticleForces *f, + f3d_real dt) { + if (!(f3d_finite(dt) && dt > F3D_R(0.0))) return; + const F3dVec3 g = f3d_v3(f->gravity[0], f->gravity[1], f->gravity[2]); + const F3dVec3 w = f3d_v3(f->wind[0], f->wind[1], f->wind[2]); + const f3d_real keep = F3D_R(1.0) / (F3D_R(1.0) + f->drag * dt); + for (uint32_t i = 0; i < particles->capacity; i++) { + Particle *s = &particles->slots[i]; + if (!(s->life > F3D_R(0.0))) continue; + F3dVec3 v = f3d_madd(s->velocity, g, dt); + v = f3d_add(w, f3d_scale(f3d_sub(v, w), keep)); + F3dVec3 x = f3d_madd(s->position, v, dt); + if (x.y < f->floor_y) { + x.y = f->floor_y; + if (v.y < F3D_R(0.0)) v.y = -v.y * f->restitution; + const f3d_real slide = F3D_R(1.0) - f->friction; + v.x *= slide; + v.z *= slide; + } + s->position = x; + s->velocity = v; + s->life -= dt; + } +} + +uint32_t f3d_particles_read(const F3dParticles *particles, f3d_real *out, + uint32_t capacity) { + const uint32_t n = capacity < particles->capacity ? capacity : particles->capacity; + for (uint32_t i = 0; i < n; i++) { + const Particle *s = &particles->slots[i]; + out[i * 4u] = s->position.x; + out[i * 4u + 1u] = s->position.y; + out[i * 4u + 2u] = s->position.z; + out[i * 4u + 3u] = s->life; + } + return n; +} diff --git a/packages/flutter3d_physics_native/csrc/src/f3d_pool.c b/packages/flutter3d_physics_native/csrc/src/f3d_pool.c new file mode 100644 index 000000000..fe48f786a --- /dev/null +++ b/packages/flutter3d_physics_native/csrc/src/f3d_pool.c @@ -0,0 +1,365 @@ +/* + * A pool of worker threads — P9, phases 11 and 12: a world asked for more + * than one thread hands the work it can split to these. + * + * Work is split the same way every time: worker w of n takes items + * [count·w/n, count·(w+1)/n), and the calling thread is worker nought. Every + * pass that uses the pool puts its results where they belong by item, or + * sorts them by key afterwards, so the world steps to the same bits on one + * thread or many. + * + * POSIX threads where there are some, Windows' own where there are not. + * In the WebAssembly build, none — the pool is never made, and every pass + * runs on the caller — unless it is the threads build (F3D_WASM_THREADS): + * there the module cannot start a thread, so its host starts the workers — + * Web Workers, or node's — each an instance of the module on the same + * shared memory, each waiting in f3d_worker_main, and a pool takes as many + * as it needs of those that are waiting. + */ +#include "f3d_internal.h" + +#if defined(__wasm__) && !defined(F3D_WASM_THREADS) + +void f3d_barrier_init(F3dBarrier *b, uint32_t workers) { + b->arrived = 0; + b->sense = 0; + b->workers = workers; +} + +void f3d_barrier_wait(F3dBarrier *b, uint32_t *sense) { + (void)b; + (void)sense; +} + +F3dPool *f3d_pool_create(uint32_t threads) { + (void)threads; + return NULL; +} + +void f3d_pool_destroy(F3dPool *pool) { (void)pool; } + +uint32_t f3d_pool_size(const F3dPool *pool) { + (void)pool; + return 1u; +} + +void f3d_pool_run(F3dPool *pool, uint32_t count, F3dTask task, void *context) { + (void)pool; + if (count > 0) task(context, 0, 0, count); +} + +#else + +/* ---------------------------------------------------------------- atomics */ + +/* Atomics as the compilers spell them: the GCC and Clang builtins, or the + * Interlocked functions and fences on MSVC. */ +#if defined(_MSC_VER) && !defined(__clang__) +#include +static uint32_t f3d_atomic_add(volatile uint32_t *p, uint32_t v) { + return (uint32_t)_InterlockedExchangeAdd((volatile long *)p, (long)v); +} +static uint32_t f3d_atomic_load(const volatile uint32_t *p) { + const uint32_t v = *p; + _ReadWriteBarrier(); + return v; +} +static void f3d_atomic_store(volatile uint32_t *p, uint32_t v) { + _ReadWriteBarrier(); + _InterlockedExchange((volatile long *)p, (long)v); +} +#else +static uint32_t f3d_atomic_add(volatile uint32_t *p, uint32_t v) { + return __atomic_fetch_add(p, v, __ATOMIC_ACQ_REL); +} +static uint32_t f3d_atomic_load(const volatile uint32_t *p) { + return __atomic_load_n(p, __ATOMIC_ACQUIRE); +} +static void f3d_atomic_store(volatile uint32_t *p, uint32_t v) { + __atomic_store_n(p, v, __ATOMIC_RELEASE); +} +#endif + +#if defined(__wasm__) +/* A browser's main thread may not block; every waiter spins. */ +static void yield_core(void) {} +#elif defined(_WIN32) +#include +static void yield_core(void) { SwitchToThread(); } +#else +#include +#include +static void yield_core(void) { sched_yield(); } +#endif + +/* ---------------------------------------------------------------- barrier */ + +void f3d_barrier_init(F3dBarrier *b, uint32_t workers) { + b->arrived = 0; + b->sense = 0; + b->workers = workers; +} + +void f3d_barrier_wait(F3dBarrier *b, uint32_t *sense) { + if (b->workers < 2u) return; + const uint32_t mine = *sense ^ 1u; + *sense = mine; + if (f3d_atomic_add(&b->arrived, 1u) == b->workers - 1u) { + f3d_atomic_store(&b->arrived, 0); + f3d_atomic_store(&b->sense, mine); + return; + } + /* Gives the core up after a while: a waiter is likely sharing it with a + * thread it waits for. */ + for (uint32_t spins = 0; f3d_atomic_load(&b->sense) != mine; spins++) { + if (spins > 4096u) yield_core(); + } +} + +static void run_share(uint32_t size, uint32_t index, F3dTask task, void *context, + uint32_t count) { + const uint32_t begin = (uint32_t)((uint64_t)count * index / size); + const uint32_t end = (uint32_t)((uint64_t)count * (index + 1u) / size); + if (begin < end) task(context, index, begin, end); +} + +#if defined(__wasm__) + +/* ------------------------------------------------------ the module's own */ + +/* The workers waiting in f3d_worker_main, and the one pass at a time they + * share: a module has one caller, so its pools take turns. */ +static volatile uint32_t g_ready; + +static struct { + /* Bumped for each pass, waited on by the workers. */ + volatile uint32_t pass; + volatile uint32_t pending; + F3dTask task; + void *context; + uint32_t count; + uint32_t size; +} g_job; + +struct F3dPool { + uint32_t size; +}; + +F3D_API uint32_t f3d_wasm_workers_ready(void) { return f3d_atomic_load(&g_ready); } + +/* Where the host's worker [index] — counted from nought, the pool's worker + * index + 1 — waits for its share of each pass, and never comes back. */ +F3D_API void f3d_worker_main(uint32_t index) { + uint32_t seen = f3d_atomic_load(&g_job.pass); + f3d_atomic_add(&g_ready, 1u); + for (;;) { + uint32_t pass; + while ((pass = f3d_atomic_load(&g_job.pass)) == seen) { + __builtin_wasm_memory_atomic_wait32((int *)&g_job.pass, (int)seen, -1); + } + seen = pass; + const uint32_t w = index + 1u; + if (w < g_job.size) { + run_share(g_job.size, w, g_job.task, g_job.context, g_job.count); + f3d_atomic_add(&g_job.pending, (uint32_t)-1); + } + } +} + +F3dPool *f3d_pool_create(uint32_t threads) { + if (threads < 2u || threads > F3D_MAX_THREADS) return NULL; + if (threads - 1u > f3d_atomic_load(&g_ready)) return NULL; + F3dPool *pool = (F3dPool *)f3d_alloc(sizeof(F3dPool)); + if (pool == NULL) return NULL; + pool->size = threads; + return pool; +} + +void f3d_pool_destroy(F3dPool *pool) { f3d_free(pool); } + +uint32_t f3d_pool_size(const F3dPool *pool) { return pool == NULL ? 1u : pool->size; } + +void f3d_pool_run(F3dPool *pool, uint32_t count, F3dTask task, void *context) { + if (pool == NULL || count <= 1u) { + if (count > 0) task(context, 0, 0, count); + return; + } + g_job.task = task; + g_job.context = context; + g_job.count = count; + g_job.size = pool->size; + f3d_atomic_store(&g_job.pending, pool->size - 1u); + f3d_atomic_add(&g_job.pass, 1u); + __builtin_wasm_memory_atomic_notify((int *)&g_job.pass, 0xffffffffu); + run_share(pool->size, 0, task, context, count); + while (f3d_atomic_load(&g_job.pending) != 0) { + } +} + +#else + +/* --------------------------------------------------------------- threads */ + +#if defined(_WIN32) +typedef HANDLE Thread; +typedef SRWLOCK Lock; +typedef CONDITION_VARIABLE Signal; +#define LOCK_INIT(l) InitializeSRWLock(l) +#define LOCK(l) AcquireSRWLockExclusive(l) +#define UNLOCK(l) ReleaseSRWLockExclusive(l) +#define LOCK_FREE(l) ((void)(l)) +#define SIGNAL_INIT(c) InitializeConditionVariable(c) +#define SIGNAL_WAIT(c, l) SleepConditionVariableSRW(c, l, INFINITE, 0) +#define SIGNAL_ALL(c) WakeAllConditionVariable(c) +#define SIGNAL_ONE(c) WakeConditionVariable(c) +#define SIGNAL_FREE(c) ((void)(c)) +#else +typedef pthread_t Thread; +typedef pthread_mutex_t Lock; +typedef pthread_cond_t Signal; +#define LOCK_INIT(l) pthread_mutex_init(l, NULL) +#define LOCK(l) pthread_mutex_lock(l) +#define UNLOCK(l) pthread_mutex_unlock(l) +#define LOCK_FREE(l) pthread_mutex_destroy(l) +#define SIGNAL_INIT(c) pthread_cond_init(c, NULL) +#define SIGNAL_WAIT(c, l) pthread_cond_wait(c, l) +#define SIGNAL_ALL(c) pthread_cond_broadcast(c) +#define SIGNAL_ONE(c) pthread_cond_signal(c) +#define SIGNAL_FREE(c) pthread_cond_destroy(c) +#endif + +typedef struct Worker { + F3dPool *pool; + uint32_t index; + Thread thread; +} Worker; + +struct F3dPool { + uint32_t size; + Worker *workers; + Lock lock; + Signal start; + Signal done; + /* Bumped for every pass, so a worker knows a pass from the last one. */ + uint64_t pass; + uint32_t pending; + int quit; + F3dTask task; + void *context; + uint32_t count; +}; + +static void work(Worker *w) { + F3dPool *pool = w->pool; + uint64_t seen = 0; + for (;;) { + LOCK(&pool->lock); + while (pool->pass == seen && !pool->quit) SIGNAL_WAIT(&pool->start, &pool->lock); + if (pool->quit) { + UNLOCK(&pool->lock); + return; + } + seen = pool->pass; + const F3dTask task = pool->task; + void *context = pool->context; + const uint32_t count = pool->count; + UNLOCK(&pool->lock); + run_share(pool->size, w->index, task, context, count); + LOCK(&pool->lock); + if (--pool->pending == 0) SIGNAL_ONE(&pool->done); + UNLOCK(&pool->lock); + } +} + +#if defined(_WIN32) +static DWORD WINAPI thread_main(LPVOID arg) { + work((Worker *)arg); + return 0; +} +#else +static void *thread_main(void *arg) { + work((Worker *)arg); + return NULL; +} +#endif + +F3dPool *f3d_pool_create(uint32_t threads) { + if (threads < 2u || threads > F3D_MAX_THREADS) return NULL; + F3dPool *pool = (F3dPool *)f3d_alloc(sizeof(F3dPool)); + if (pool == NULL) return NULL; + f3d_zero(pool, sizeof *pool); + pool->workers = (Worker *)f3d_alloc((size_t)threads * sizeof(Worker)); + if (pool->workers == NULL) { + f3d_free(pool); + return NULL; + } + pool->size = threads; + LOCK_INIT(&pool->lock); + SIGNAL_INIT(&pool->start); + SIGNAL_INIT(&pool->done); + /* Worker nought is whoever runs a pass; the rest are threads. */ + uint32_t started = 1; + for (uint32_t i = 1; i < threads; i++) { + Worker *w = &pool->workers[i]; + w->pool = pool; + w->index = i; +#if defined(_WIN32) + w->thread = CreateThread(NULL, 0, thread_main, w, 0, NULL); + if (w->thread == NULL) break; +#else + if (pthread_create(&w->thread, NULL, thread_main, w) != 0) break; +#endif + started++; + } + if (started < threads) { + pool->size = started; + f3d_pool_destroy(pool); + return NULL; + } + return pool; +} + +void f3d_pool_destroy(F3dPool *pool) { + if (pool == NULL) return; + LOCK(&pool->lock); + pool->quit = 1; + SIGNAL_ALL(&pool->start); + UNLOCK(&pool->lock); + for (uint32_t i = 1; i < pool->size; i++) { +#if defined(_WIN32) + WaitForSingleObject(pool->workers[i].thread, INFINITE); + CloseHandle(pool->workers[i].thread); +#else + pthread_join(pool->workers[i].thread, NULL); +#endif + } + SIGNAL_FREE(&pool->start); + SIGNAL_FREE(&pool->done); + LOCK_FREE(&pool->lock); + f3d_free(pool->workers); + f3d_free(pool); +} + +uint32_t f3d_pool_size(const F3dPool *pool) { return pool == NULL ? 1u : pool->size; } + +void f3d_pool_run(F3dPool *pool, uint32_t count, F3dTask task, void *context) { + if (pool != NULL && count > 1u) { + LOCK(&pool->lock); + pool->task = task; + pool->context = context; + pool->count = count; + pool->pending = pool->size - 1u; + pool->pass++; + SIGNAL_ALL(&pool->start); + UNLOCK(&pool->lock); + run_share(pool->size, 0, task, context, count); + LOCK(&pool->lock); + while (pool->pending > 0) SIGNAL_WAIT(&pool->done, &pool->lock); + UNLOCK(&pool->lock); + return; + } + if (count > 0) task(context, 0, 0, count); +} + +#endif +#endif diff --git a/packages/flutter3d_physics_native/csrc/src/f3d_query.c b/packages/flutter3d_physics_native/csrc/src/f3d_query.c new file mode 100644 index 000000000..d69e54e58 --- /dev/null +++ b/packages/flutter3d_physics_native/csrc/src/f3d_query.c @@ -0,0 +1,682 @@ +/* + * Queries and characters — P9, phase 9: rays, shape overlaps, shape casts, + * and a character controller made of them. + * + * A ray walks the broadphase tree nearest box first and cuts what is left + * at each hit. A ball and an unrounded box it meets in closed form, a mesh + * triangle by triangle (Möller and Trumbore), and every other shape by + * conservative advancement: a point on the ray is never nearer the shape + * than its distance, so stepping that far along the ray cannot step past + * it. A cast moves a shape the same way against what is near its path. An + * overlap asks the narrow phase whether anything touches. + * + * The character is a kinematic capsule: it is moved by casting it along + * the move and sliding what is left along what it met, as many characters + * have been since Quake. Ground is what is flat enough to stand on; a wall + * is slid along and not climbed; a step is climbed by lifting, moving and + * setting down; and walking down a slope it keeps to the ground. + */ +#include "f3d_internal.h" + +/* How close is touching, for rays and casts, m. */ +#define F3D_QUERY_TOLERANCE F3D_R(1e-4) +/* How far a character keeps from what it meets, m. */ +#define F3D_CHARACTER_SKIN F3D_R(0.01) + +static int sees(const F3dWorld *world, const F3dSlot *s, uint32_t mask, + F3dBody ignore) { + if (!s->live || s->shape == F3D_SHAPE_POINT) return 0; + if (!(s->layer & mask)) return 0; + return ignore == 0 || f3d_handle_of(world, s) != ignore; +} + +static F3dQuat unit(f3d_real x, f3d_real y, f3d_real z, f3d_real w) { + const f3d_real len = f3d_sqrt(x * x + y * y + z * z + w * w); + F3dQuat q; + if (!(len > F3D_R(0.0)) || !f3d_finite(len)) { + q.x = q.y = q.z = F3D_R(0.0); + q.w = F3D_R(1.0); + return q; + } + q.x = x / len; + q.y = y / len; + q.z = z / len; + q.w = w / len; + return q; +} + +/* A shape a query asks about; 0 for a kind or size it does not take. */ +static int query_shape(F3dShapeKind kind, f3d_real a, f3d_real b, f3d_real c, + f3d_real rounding, F3dVec3 at, F3dQuat q, + F3dPlaced *out) { + f3d_zero(out, sizeof *out); + if (!(f3d_finite(rounding) && rounding >= F3D_R(0.0))) return 0; + if (!(f3d_finite(a) && f3d_finite(b) && f3d_finite(c))) return 0; + switch (kind) { + case F3D_SHAPE_SPHERE: + if (!(a > F3D_R(0.0))) return 0; + break; + case F3D_SHAPE_BOX: + if (!(a > F3D_R(0.0) && b > F3D_R(0.0) && c > F3D_R(0.0))) return 0; + break; + case F3D_SHAPE_CAPSULE: + if (!(a > F3D_R(0.0) && b >= F3D_R(0.0))) return 0; + break; + case F3D_SHAPE_CYLINDER: + case F3D_SHAPE_CONE: + if (!(a > F3D_R(0.0) && b > F3D_R(0.0))) return 0; + break; + default: + return 0; + } + out->kind = kind; + out->size = f3d_v3(a, b, c); + out->at = at; + out->axes = f3d_mat_of(q); + out->rounding = rounding; + return 1; +} + +/* How far a query shape reaches from its centre. */ +static f3d_real reach_of(const F3dPlaced *p) { + const F3dVec3 s = p->size; + f3d_real r; + switch (p->kind) { + case F3D_SHAPE_SPHERE: + r = s.x; + break; + case F3D_SHAPE_BOX: + r = f3d_sqrt(f3d_dot(s, s)); + break; + case F3D_SHAPE_CAPSULE: + r = s.x + s.y; + break; + case F3D_SHAPE_CYLINDER: + r = f3d_sqrt(s.x * s.x + s.y * s.y); + break; + default: /* A cone: its apex, or its rim. */ + r = f3d_max(F3D_R(0.75) * s.y, + f3d_sqrt(s.x * s.x + F3D_R(0.0625) * s.y * s.y)); + break; + } + return r + p->rounding; +} + +/* The deepest point of a manifold, and its depth. */ +static f3d_real deepest(const F3dManifold *m, F3dVec3 *point) { + uint32_t best = 0; + for (uint32_t k = 1; k < m->count; k++) { + if (m->points[k].depth > m->points[best].depth) best = k; + } + if (point != NULL) *point = m->points[best].point; + return m->points[best].depth; +} + +/* ------------------------------------------------------------------- rays */ + +/* A ray and the nearest triangle of a mesh it meets from the front. */ +typedef struct MeshRay { + const F3dPlaced *mesh; + F3dVec3 o, u; /* In the mesh's frame. */ + f3d_real best; + F3dVec3 normal; /* In the mesh's frame. */ + int found; +} MeshRay; + +static F3dVec3 mesh_vertex(const F3dPlaced *p, uint32_t i) { + return f3d_v3(p->vertices[i * 3u], p->vertices[i * 3u + 1u], + p->vertices[i * 3u + 2u]); +} + +static int ray_triangle(void *context, int32_t leaf) { + MeshRay *r = (MeshRay *)context; + const uint32_t t = r->mesh->mesh_tree->nodes[leaf].slot; + const uint32_t *tri = &r->mesh->triangles[t * 3u]; + const F3dVec3 a = mesh_vertex(r->mesh, tri[0]); + const F3dVec3 e1 = f3d_sub(mesh_vertex(r->mesh, tri[1]), a); + const F3dVec3 e2 = f3d_sub(mesh_vertex(r->mesh, tri[2]), a); + const F3dVec3 n = f3d_cross(e1, e2); + /* From the front only: one sided, as the mesh is to bodies. */ + if (!(f3d_dot(n, r->u) < F3D_R(0.0))) return 1; + const F3dVec3 p = f3d_cross(r->u, e2); + const f3d_real det = f3d_dot(e1, p); + if (f3d_abs(det) <= F3D_R(1e-20)) return 1; + const f3d_real inv = F3D_R(1.0) / det; + const F3dVec3 s = f3d_sub(r->o, a); + const f3d_real u = f3d_dot(s, p) * inv; + if (u < F3D_R(0.0) || u > F3D_R(1.0)) return 1; + const F3dVec3 q = f3d_cross(s, e1); + const f3d_real v = f3d_dot(r->u, q) * inv; + if (v < F3D_R(0.0) || u + v > F3D_R(1.0)) return 1; + const f3d_real dist = f3d_dot(e2, q) * inv; + if (dist < F3D_R(0.0) || dist >= r->best) return 1; + r->best = dist; + r->normal = f3d_scale(n, F3D_R(1.0) / f3d_sqrt(f3d_dot(n, n))); + r->found = 1; + return 1; +} + +/* Where the ray from [o] along the unit [u] first meets the body within + * [limit]: 1, the distance and the normal there; 0 when it misses or starts + * inside. */ +static int ray_body(const F3dWorld *world, const F3dSlot *s, F3dVec3 o, + F3dVec3 u, f3d_real limit, f3d_real *dist, F3dVec3 *normal) { + const F3dPlaced p = f3d_placed_of(world, s); + if (p.kind == F3D_SHAPE_SPHERE) { + const f3d_real r = p.size.x + p.rounding; + const F3dVec3 m = f3d_sub(o, p.at); + const f3d_real b = f3d_dot(m, u); + const f3d_real c = f3d_dot(m, m) - r * r; + if (c <= F3D_R(0.0) || b > F3D_R(0.0)) return 0; + /* r² less the ray's nearest distance to the centre, squared: the same + * as b² − c, without subtracting two large numbers to get a small one — + * which cost two millimetres at thirty metres in floats. */ + const F3dVec3 across = f3d_madd(m, u, -b); + const f3d_real disc = r * r - f3d_dot(across, across); + if (disc < F3D_R(0.0)) return 0; + const f3d_real t = -b - f3d_sqrt(disc); + if (t > limit) return 0; + *dist = t; + *normal = f3d_scale(f3d_sub(f3d_madd(o, u, t), p.at), F3D_R(1.0) / r); + return 1; + } + if (p.kind == F3D_SHAPE_BOX && p.rounding == F3D_R(0.0)) { + const F3dVec3 rel = f3d_sub(o, p.at); + const f3d_real ol[3] = {f3d_dot(p.axes.c[0], rel), f3d_dot(p.axes.c[1], rel), + f3d_dot(p.axes.c[2], rel)}; + const f3d_real ul[3] = {f3d_dot(p.axes.c[0], u), f3d_dot(p.axes.c[1], u), + f3d_dot(p.axes.c[2], u)}; + const f3d_real h[3] = {p.size.x, p.size.y, p.size.z}; + f3d_real lo = F3D_R(-1e30), hi = limit; + int axis = -1; + f3d_real sign = F3D_R(0.0); + for (int k = 0; k < 3; k++) { + if (f3d_abs(ul[k]) <= F3D_R(1e-20)) { + if (ol[k] < -h[k] || ol[k] > h[k]) return 0; + continue; + } + const f3d_real inv = F3D_R(1.0) / ul[k]; + f3d_real t0 = (-h[k] - ol[k]) * inv, t1 = (h[k] - ol[k]) * inv; + f3d_real s0 = F3D_R(-1.0); + if (t0 > t1) { + const f3d_real t = t0; + t0 = t1; + t1 = t; + s0 = F3D_R(1.0); + } + if (t0 > lo) { + lo = t0; + axis = k; + sign = s0; + } + hi = f3d_min(hi, t1); + if (lo > hi) return 0; + } + /* Starting inside, or behind it. */ + if (axis < 0 || lo < F3D_R(0.0)) return 0; + *dist = lo; + *normal = f3d_scale(p.axes.c[axis], sign); + return 1; + } + if (p.kind == F3D_SHAPE_MESH) { + if (p.mesh == NULL || p.mesh_tree == NULL) return 0; + MeshRay r; + r.mesh = &p; + const F3dVec3 rel = f3d_sub(o, p.at); + r.o = f3d_v3(f3d_dot(p.axes.c[0], rel), f3d_dot(p.axes.c[1], rel), + f3d_dot(p.axes.c[2], rel)); + r.u = f3d_v3(f3d_dot(p.axes.c[0], u), f3d_dot(p.axes.c[1], u), + f3d_dot(p.axes.c[2], u)); + r.best = limit; + r.found = 0; + f3d_real cut = limit; + f3d_tree_ray(p.mesh_tree, r.o, r.u, &cut, ray_triangle, &r); + if (!r.found) return 0; + *dist = r.best; + *normal = f3d_add(f3d_add(f3d_scale(p.axes.c[0], r.normal.x), + f3d_scale(p.axes.c[1], r.normal.y)), + f3d_scale(p.axes.c[2], r.normal.z)); + return 1; + } + /* Conservative advancement of a point along the ray. */ + F3dPlaced probe; + f3d_zero(&probe, sizeof probe); + probe.kind = F3D_SHAPE_SPHERE; + probe.axes.c[0] = f3d_v3(F3D_R(1.0), F3D_R(0.0), F3D_R(0.0)); + probe.axes.c[1] = f3d_v3(F3D_R(0.0), F3D_R(1.0), F3D_R(0.0)); + probe.axes.c[2] = f3d_v3(F3D_R(0.0), F3D_R(0.0), F3D_R(1.0)); + f3d_real t = F3D_R(0.0); + for (int iteration = 0; iteration < 64; iteration++) { + probe.at = f3d_madd(o, u, t); + F3dManifold m; + f3d_zero(&m, sizeof m); + if (f3d_collide(&probe, &p, limit - t + F3D_QUERY_TOLERANCE, &m) == 0) { + return 0; + } + const f3d_real gap = -deepest(&m, NULL); + if (gap <= F3D_QUERY_TOLERANCE) { + if (iteration == 0) return 0; /* It starts inside. */ + *dist = t; + *normal = m.normal; + return 1; + } + t += gap; + if (t > limit) return 0; + } + return 0; +} + +typedef struct RayWalk { + const F3dWorld *world; + F3dVec3 o, u; + uint32_t mask; + F3dBody ignore; + /* Nearest: the best so far and the limit it cuts. */ + f3d_real *limit; + F3dBody body; + f3d_real distance; + F3dVec3 normal; + /* All: written nearest first, up to capacity; counted all. */ + F3dBody *bodies; + f3d_real *hits; + uint32_t capacity, count; + int all; +} RayWalk; + +static void write_hit(f3d_real *hit, F3dVec3 point, F3dVec3 normal, + f3d_real value) { + hit[0] = point.x; + hit[1] = point.y; + hit[2] = point.z; + hit[3] = normal.x; + hit[4] = normal.y; + hit[5] = normal.z; + hit[6] = value; +} + +static int ray_leaf(void *context, int32_t leaf) { + RayWalk *r = (RayWalk *)context; + const uint32_t slot = r->world->tree.nodes[leaf].slot; + const F3dSlot *s = &r->world->slots[slot]; + if (!sees(r->world, s, r->mask, r->ignore)) return 1; + f3d_real d; + F3dVec3 n; + if (!ray_body(r->world, s, r->o, r->u, *r->limit, &d, &n)) return 1; + if (!r->all) { + /* Equal distances go to the lower slot, so the tree's shape does not + * choose. */ + const F3dBody h = f3d_handle_of(r->world, s); + if (r->body == 0 || d < r->distance || + (d == r->distance && (uint32_t)h < (uint32_t)r->body)) { + r->body = h; + r->distance = d; + r->normal = n; + *r->limit = d; + } + return 1; + } + /* Kept nearest first, ties by slot: an insertion into what is written. */ + const uint32_t room = r->capacity; + uint32_t at = r->count < room ? r->count : room; + while (at > 0) { + const f3d_real prev = r->hits[(at - 1u) * F3D_HIT_FLOATS + 6u]; + if (prev < d || (prev == d && (uint32_t)r->bodies[at - 1u] < slot)) break; + at--; + } + if (at < room) { + const uint32_t last = r->count < room ? r->count : room - 1u; + for (uint32_t k = last; k > at; k--) { + r->bodies[k] = r->bodies[k - 1u]; + for (uint32_t f = 0; f < F3D_HIT_FLOATS; f++) { + r->hits[k * F3D_HIT_FLOATS + f] = r->hits[(k - 1u) * F3D_HIT_FLOATS + f]; + } + } + r->bodies[at] = f3d_handle_of(r->world, s); + write_hit(&r->hits[at * F3D_HIT_FLOATS], f3d_madd(r->o, r->u, d), n, d); + } + r->count++; + return 1; +} + +static int unit_ray(f3d_real dx, f3d_real dy, f3d_real dz, F3dVec3 *u) { + const f3d_real len = f3d_sqrt(dx * dx + dy * dy + dz * dz); + if (!(len > F3D_R(0.0)) || !f3d_finite(len)) return 0; + *u = f3d_v3(dx / len, dy / len, dz / len); + return 1; +} + +int f3d_world_ray_cast(F3dWorld *world, f3d_real ox, f3d_real oy, f3d_real oz, + f3d_real dx, f3d_real dy, f3d_real dz, + f3d_real max_distance, uint32_t mask, F3dBody ignore, + F3dBody *body, f3d_real *hit) { + F3dVec3 u; + if (!unit_ray(dx, dy, dz, &u) || !(max_distance > F3D_R(0.0))) return 0; + f3d_update_proxies(world, F3D_R(0.0)); + f3d_build_mesh_trees(world); + RayWalk r; + f3d_zero(&r, sizeof r); + r.world = world; + r.o = f3d_v3(ox, oy, oz); + r.u = u; + r.mask = mask; + r.ignore = ignore; + f3d_real limit = max_distance; + r.limit = &limit; + f3d_tree_ray(&world->tree, r.o, r.u, &limit, ray_leaf, &r); + if (r.body == 0) return 0; + *body = r.body; + write_hit(hit, f3d_madd(r.o, u, r.distance), r.normal, r.distance); + return 1; +} + +uint32_t f3d_world_ray_cast_all(F3dWorld *world, f3d_real ox, f3d_real oy, + f3d_real oz, f3d_real dx, f3d_real dy, + f3d_real dz, f3d_real max_distance, + uint32_t mask, F3dBody ignore, F3dBody *bodies, + f3d_real *hits, uint32_t capacity) { + F3dVec3 u; + if (!unit_ray(dx, dy, dz, &u) || !(max_distance > F3D_R(0.0))) return 0; + f3d_update_proxies(world, F3D_R(0.0)); + f3d_build_mesh_trees(world); + RayWalk r; + f3d_zero(&r, sizeof r); + r.world = world; + r.o = f3d_v3(ox, oy, oz); + r.u = u; + r.mask = mask; + r.ignore = ignore; + f3d_real limit = max_distance; + r.limit = &limit; + r.all = 1; + r.bodies = bodies; + r.hits = hits; + r.capacity = capacity; + f3d_tree_ray(&world->tree, r.o, r.u, &limit, ray_leaf, &r); + return r.count; +} + +/* --------------------------------------------------------------- overlaps */ + +typedef struct Near { + const F3dWorld *world; + uint32_t slots[512]; + uint32_t count; +} Near; + +static int near_leaf(void *context, int32_t leaf) { + Near *n = (Near *)context; + if (n->count < 512u) n->slots[n->count++] = n->world->tree.nodes[leaf].slot; + return n->count < 512u; +} + +/* The slots near [box], in slot order. */ +static void gather(const F3dWorld *world, F3dBox box, Near *n) { + n->world = world; + n->count = 0; + f3d_tree_query(&world->tree, box, near_leaf, n); + for (uint32_t i = 1; i < n->count; i++) { + const uint32_t v = n->slots[i]; + uint32_t j = i; + while (j > 0 && n->slots[j - 1] > v) { + n->slots[j] = n->slots[j - 1]; + j--; + } + n->slots[j] = v; + } +} + +static F3dBox around(F3dVec3 a, F3dVec3 b, f3d_real r) { + F3dBox box; + box.lo = f3d_v3(f3d_min(a.x, b.x) - r, f3d_min(a.y, b.y) - r, + f3d_min(a.z, b.z) - r); + box.hi = f3d_v3(f3d_max(a.x, b.x) + r, f3d_max(a.y, b.y) + r, + f3d_max(a.z, b.z) + r); + return box; +} + +uint32_t f3d_world_overlap_shape(F3dWorld *world, F3dShapeKind kind, f3d_real a, + f3d_real b, f3d_real c, f3d_real rounding, + f3d_real px, f3d_real py, f3d_real pz, + f3d_real qx, f3d_real qy, f3d_real qz, + f3d_real qw, uint32_t mask, F3dBody ignore, + F3dBody *out, uint32_t capacity) { + F3dPlaced q; + const F3dVec3 at = f3d_v3(px, py, pz); + if (!query_shape(kind, a, b, c, rounding, at, unit(qx, qy, qz, qw), &q)) { + return 0; + } + f3d_update_proxies(world, F3D_R(0.0)); + f3d_build_mesh_trees(world); + Near n; + gather(world, around(at, at, reach_of(&q)), &n); + uint32_t count = 0; + for (uint32_t i = 0; i < n.count; i++) { + const F3dSlot *s = &world->slots[n.slots[i]]; + if (!sees(world, s, mask, ignore)) continue; + const F3dPlaced p = f3d_placed_of(world, s); + F3dManifold m; + f3d_zero(&m, sizeof m); + if (f3d_collide(&q, &p, F3D_R(0.0), &m) == 0) continue; + if (deepest(&m, NULL) < F3D_R(0.0)) continue; + if (count < capacity) out[count] = f3d_handle_of(world, s); + count++; + } + return count; +} + +/* ------------------------------------------------------------------ casts */ + +/* The first fraction of [move] at which [q] touches [other]: 1 and the + * fraction, the normal out of [other] and the point; 0 when it does not + * within the move. Overlapping at the start, nought. */ +static int cast_against(const F3dPlaced *q, F3dVec3 move, const F3dPlaced *other, + f3d_real *fraction, F3dVec3 *normal, F3dVec3 *point) { + const f3d_real length = f3d_sqrt(f3d_dot(move, move)); + F3dPlaced at = *q; + const F3dVec3 start = q->at; + f3d_real t = F3D_R(0.0); + for (int iteration = 0; iteration < 64; iteration++) { + at.at = f3d_madd(start, move, t); + F3dManifold m; + f3d_zero(&m, sizeof m); + const f3d_real reach = (F3D_R(1.0) - t) * length + F3D_QUERY_TOLERANCE; + if (f3d_collide(&at, other, reach, &m) == 0) return 0; + F3dVec3 p; + const f3d_real gap = -deepest(&m, &p); + if (gap <= F3D_QUERY_TOLERANCE) { + *fraction = t; + *normal = m.normal; + *point = p; + return 1; + } + const f3d_real closing = -f3d_dot(move, m.normal); + if (!(closing > F3D_R(1e-12) * length)) return 0; + t += (gap - F3D_R(0.5) * F3D_QUERY_TOLERANCE) / closing; + if (t > F3D_R(1.0)) return 0; + } + return 0; +} + +/* The nearest cast hit of [q] moved by [move] against what the world holds: + * 1 when there is one. */ +static int cast_world(F3dWorld *world, const F3dPlaced *q, F3dVec3 move, + uint32_t mask, F3dBody ignore, F3dBody *body, + f3d_real *fraction, F3dVec3 *normal, F3dVec3 *point) { + Near n; + gather(world, around(q->at, f3d_add(q->at, move), reach_of(q) + F3D_QUERY_TOLERANCE), + &n); + int found = 0; + for (uint32_t i = 0; i < n.count; i++) { + const F3dSlot *s = &world->slots[n.slots[i]]; + if (!sees(world, s, mask, ignore)) continue; + const F3dPlaced p = f3d_placed_of(world, s); + f3d_real t; + F3dVec3 nn, pp; + if (!cast_against(q, move, &p, &t, &nn, &pp)) continue; + if (found && t >= *fraction) continue; + found = 1; + *body = f3d_handle_of(world, s); + *fraction = t; + *normal = nn; + *point = pp; + } + return found; +} + +int f3d_world_cast_shape(F3dWorld *world, F3dShapeKind kind, f3d_real a, + f3d_real b, f3d_real c, f3d_real rounding, f3d_real px, + f3d_real py, f3d_real pz, f3d_real qx, f3d_real qy, + f3d_real qz, f3d_real qw, f3d_real tx, f3d_real ty, + f3d_real tz, uint32_t mask, F3dBody ignore, + F3dBody *body, f3d_real *hit) { + F3dPlaced q; + if (!query_shape(kind, a, b, c, rounding, f3d_v3(px, py, pz), + unit(qx, qy, qz, qw), &q)) { + return 0; + } + if (!(f3d_finite(tx) && f3d_finite(ty) && f3d_finite(tz))) return 0; + f3d_update_proxies(world, F3D_R(0.0)); + f3d_build_mesh_trees(world); + f3d_real t; + F3dVec3 n, p; + if (!cast_world(world, &q, f3d_v3(tx, ty, tz), mask, ignore, body, &t, &n, &p)) { + return 0; + } + write_hit(hit, p, n, t); + return 1; +} + +/* ------------------------------------------------------------- characters */ + +typedef struct Character { + F3dWorld *world; + F3dPlaced shape; + uint32_t mask; + F3dBody ignore; +} Character; + +/* Moves the character by as much of [move] as it can, stopping its skin + * short of what it meets; 1 and the hit when it met something. */ +static int advance(Character *c, F3dVec3 move, F3dVec3 *normal, F3dBody *body) { + const f3d_real length = f3d_sqrt(f3d_dot(move, move)); + if (!(length > F3D_R(1e-9))) return 0; + f3d_real t; + F3dVec3 p; + if (!cast_world(c->world, &c->shape, move, c->mask, c->ignore, body, &t, + normal, &p)) { + c->shape.at = f3d_add(c->shape.at, move); + return 0; + } + const f3d_real keep = f3d_max(t - F3D_CHARACTER_SKIN / length, F3D_R(0.0)); + c->shape.at = f3d_madd(c->shape.at, move, keep); + return 1; +} + +uint32_t f3d_world_move_character(F3dWorld *world, f3d_real radius, + f3d_real half_height, f3d_real *position, + f3d_real dx, f3d_real dy, f3d_real dz, + f3d_real max_slope_cos, f3d_real step_height, + uint32_t mask, F3dBody ignore, + F3dBody *ground_body, f3d_real *ground) { + *ground_body = 0; + ground[0] = ground[1] = ground[2] = F3D_R(0.0); + if (!(f3d_finite(radius) && radius > F3D_R(0.0))) return 0; + if (!(f3d_finite(half_height) && half_height >= F3D_R(0.0))) return 0; + if (!(f3d_finite(dx) && f3d_finite(dy) && f3d_finite(dz))) return 0; + if (!f3d_finite(step_height) || !f3d_finite(max_slope_cos)) return 0; + f3d_update_proxies(world, F3D_R(0.0)); + f3d_build_mesh_trees(world); + Character c; + c.world = world; + c.mask = mask; + c.ignore = ignore; + const F3dQuat upright = {F3D_R(0.0), F3D_R(0.0), F3D_R(0.0), F3D_R(1.0)}; + query_shape(F3D_SHAPE_CAPSULE, radius, half_height, F3D_R(0.0), F3D_R(0.0), + f3d_v3(position[0], position[1], position[2]), upright, &c.shape); + uint32_t flags = 0; + F3dVec3 left = f3d_v3(dx, dy, dz); + F3dVec3 blocked = f3d_v3(F3D_R(0.0), F3D_R(0.0), F3D_R(0.0)); + /* Cast and slide, up to four times: a corner is two walls and a floor. */ + for (int i = 0; i < 4 && f3d_dot(left, left) > F3D_R(1e-14); i++) { + const F3dVec3 from = c.shape.at; + F3dVec3 n; + F3dBody hit; + if (!advance(&c, left, &n, &hit)) break; + left = f3d_sub(left, f3d_sub(c.shape.at, from)); + if (n.y >= max_slope_cos) { + flags |= F3D_CHARACTER_GROUNDED; + *ground_body = hit; + ground[0] = n.x; + ground[1] = n.y; + ground[2] = n.z; + } else if (n.y <= -max_slope_cos) { + flags |= F3D_CHARACTER_CEILING; + } else { + /* A wall, or a slope too steep to stand on: met as a wall, upright, + * so sliding along it does not climb it. What it stopped of the + * level move is what a step might take. */ + flags |= F3D_CHARACTER_WALL; + if (blocked.x == F3D_R(0.0) && blocked.z == F3D_R(0.0)) { + blocked = f3d_v3(left.x, F3D_R(0.0), left.z); + } + const f3d_real flat = f3d_sqrt(n.x * n.x + n.z * n.z); + if (flat > F3D_R(1e-9)) { + n = f3d_v3(n.x / flat, F3D_R(0.0), n.z / flat); + } + } + const f3d_real into = f3d_dot(left, n); + if (into < F3D_R(0.0)) left = f3d_madd(left, n, -into); + } + /* A step: lifted by its height, moved on, set down onto ground. Kept only + * when it lands on ground and got further. */ + if ((flags & F3D_CHARACTER_WALL) && step_height > F3D_R(0.0) && + f3d_dot(blocked, blocked) > F3D_R(1e-12)) { + const F3dVec3 before = c.shape.at; + F3dVec3 n; + F3dBody hit; + advance(&c, f3d_v3(F3D_R(0.0), step_height, F3D_R(0.0)), &n, &hit); + const F3dVec3 lifted = c.shape.at; + advance(&c, blocked, &n, &hit); + const F3dVec3 on = c.shape.at; + const f3d_real moved2 = (on.x - lifted.x) * (on.x - lifted.x) + + (on.z - lifted.z) * (on.z - lifted.z); + const int landed = advance( + &c, + f3d_v3(F3D_R(0.0), -(lifted.y - before.y) - F3D_CHARACTER_SKIN, + F3D_R(0.0)), + &n, &hit); + if (landed && n.y >= max_slope_cos && moved2 > F3D_R(1e-8)) { + flags |= F3D_CHARACTER_STEPPED | F3D_CHARACTER_GROUNDED; + *ground_body = hit; + ground[0] = n.x; + ground[1] = n.y; + ground[2] = n.z; + } else { + c.shape.at = before; + } + } + /* Keeping to the ground: not moving up and not on ground, it looks down + * as far as a step, or its skin twice over, and settles on ground there — + * walking down a slope or off a kerb, not floating off it. */ + if (!(flags & F3D_CHARACTER_GROUNDED) && dy <= F3D_R(0.0)) { + const F3dVec3 before = c.shape.at; + const f3d_real reach = + f3d_max(step_height, F3D_R(2.0) * F3D_CHARACTER_SKIN); + F3dVec3 n; + F3dBody hit; + if (advance(&c, f3d_v3(F3D_R(0.0), -reach, F3D_R(0.0)), &n, &hit) && + n.y >= max_slope_cos) { + flags |= F3D_CHARACTER_GROUNDED; + *ground_body = hit; + ground[0] = n.x; + ground[1] = n.y; + ground[2] = n.z; + } else { + c.shape.at = before; + } + } + position[0] = c.shape.at.x; + position[1] = c.shape.at.y; + position[2] = c.shape.at.z; + return flags; +} diff --git a/packages/flutter3d_physics_native/csrc/src/f3d_snapshot.c b/packages/flutter3d_physics_native/csrc/src/f3d_snapshot.c new file mode 100644 index 000000000..00bc0216c --- /dev/null +++ b/packages/flutter3d_physics_native/csrc/src/f3d_snapshot.c @@ -0,0 +1,345 @@ +/* + * The world's whole state in one buffer, and back — P9. + * + * A header, the world's plain state, the arena's slots up to its high-water + * mark, the wind grid and the events waiting. Slots are copied whole: they + * are zeroed when taken, so their padding is the same bytes every time, and + * a snapshot of the same world is the same bytes. The header says which + * build wrote it — the real's size and the layouts' — so a snapshot is + * refused by a build that would read it wrongly, rather than misread. + */ +#include "f3d_internal.h" + +#define F3D_SNAPSHOT_MAGIC 0x53443346u /* "F3DS", little-endian. */ +#define F3D_SNAPSHOT_VERSION 8u + +typedef struct F3dSnapshotHeader { + uint32_t magic; + uint32_t version; + uint32_t real_bytes; + uint32_t state_bytes; + uint32_t slot_bytes; + uint32_t event_bytes; + uint32_t manifold_bytes; + uint32_t hull_bytes; + uint32_t mesh_bytes; + uint32_t joint_bytes; +} F3dSnapshotHeader; + +static uint64_t grid_reals(const F3dWorldState *s) { + return (uint64_t)s->grid_n[0] * s->grid_n[1] * s->grid_n[2] * 3u; +} + +static uint64_t size_of(const F3dWorldState *s) { + return (uint64_t)sizeof(F3dSnapshotHeader) + sizeof(F3dWorldState) + + (uint64_t)s->used * sizeof(F3dSlot) + + grid_reals(s) * sizeof(f3d_real) + + (uint64_t)s->events_count * sizeof(F3dEventRecord) + + (uint64_t)s->manifold_count * sizeof(F3dManifold) + + (uint64_t)s->hull_count * sizeof(F3dHull) + + (uint64_t)s->hull_vertex_count * 3u * sizeof(f3d_real) + + (uint64_t)s->hull_triangle_count * 3u * sizeof(uint32_t) + + (uint64_t)s->mesh_count * sizeof(F3dMesh) + + (uint64_t)s->mesh_vertex_count * 3u * sizeof(f3d_real) + + (uint64_t)s->mesh_triangle_count * (3u * sizeof(uint32_t) + 1u) + + (uint64_t)s->joint_used * sizeof(F3dJointSlot); +} + +uint32_t f3d_world_snapshot_size(const F3dWorld *world) { + const uint64_t size = size_of(&world->s); + return size > UINT32_MAX ? 0u : (uint32_t)size; +} + +uint32_t f3d_world_snapshot_write(const F3dWorld *world, uint8_t *buffer, + uint32_t size) { + const uint32_t needed = f3d_world_snapshot_size(world); + if (needed == 0 || size < needed || buffer == NULL) return 0; + F3dSnapshotHeader header; + f3d_zero(&header, sizeof header); + header.magic = F3D_SNAPSHOT_MAGIC; + header.version = F3D_SNAPSHOT_VERSION; + header.real_bytes = (uint32_t)sizeof(f3d_real); + header.state_bytes = (uint32_t)sizeof(F3dWorldState); + header.slot_bytes = (uint32_t)sizeof(F3dSlot); + header.event_bytes = (uint32_t)sizeof(F3dEventRecord); + header.manifold_bytes = (uint32_t)sizeof(F3dManifold); + header.hull_bytes = (uint32_t)sizeof(F3dHull); + header.mesh_bytes = (uint32_t)sizeof(F3dMesh); + header.joint_bytes = (uint32_t)sizeof(F3dJointSlot); + uint8_t *at = buffer; + f3d_copy(at, &header, sizeof header); + at += sizeof header; + /* The events go out oldest first from nought, so the same queue is the + * same bytes wherever its ring happened to start. */ + /* Copied by bytes, not assigned: an assignment need not carry the + * padding, and the padding is part of what makes two snapshots of one + * world the same bytes. */ + F3dWorldState state; + f3d_copy(&state, &world->s, sizeof state); + state.events_head = 0; + f3d_copy(at, &state, sizeof state); + at += sizeof state; + if (world->s.used > 0) { + f3d_copy(at, world->slots, (size_t)world->s.used * sizeof(F3dSlot)); + at += (size_t)world->s.used * sizeof(F3dSlot); + } + const uint64_t reals = grid_reals(&world->s); + if (reals > 0) { + f3d_copy(at, world->grid, (size_t)reals * sizeof(f3d_real)); + at += (size_t)reals * sizeof(f3d_real); + } + for (uint32_t i = 0; i < world->s.events_count; i++) { + const uint32_t from = (world->s.events_head + i) % F3D_EVENT_CAPACITY; + f3d_copy(at, &world->events[from], sizeof(F3dEventRecord)); + at += sizeof(F3dEventRecord); + } + /* The contacts: what the next step compares against to say what began + * and ended, and what two sleeping bodies keep. */ + if (world->s.manifold_count > 0) { + f3d_copy(at, world->manifolds, + (size_t)world->s.manifold_count * sizeof(F3dManifold)); + at += (size_t)world->s.manifold_count * sizeof(F3dManifold); + } + /* The hulls the bodies are shaped as. */ + if (world->s.hull_count > 0) { + f3d_copy(at, world->hulls, (size_t)world->s.hull_count * sizeof(F3dHull)); + at += (size_t)world->s.hull_count * sizeof(F3dHull); + f3d_copy(at, world->hull_vertices, + (size_t)world->s.hull_vertex_count * 3u * sizeof(f3d_real)); + at += (size_t)world->s.hull_vertex_count * 3u * sizeof(f3d_real); + f3d_copy(at, world->hull_triangles, + (size_t)world->s.hull_triangle_count * 3u * sizeof(uint32_t)); + at += (size_t)world->s.hull_triangle_count * 3u * sizeof(uint32_t); + } + /* The meshes. */ + if (world->s.mesh_count > 0) { + f3d_copy(at, world->meshes, (size_t)world->s.mesh_count * sizeof(F3dMesh)); + at += (size_t)world->s.mesh_count * sizeof(F3dMesh); + f3d_copy(at, world->mesh_vertices, + (size_t)world->s.mesh_vertex_count * 3u * sizeof(f3d_real)); + at += (size_t)world->s.mesh_vertex_count * 3u * sizeof(f3d_real); + f3d_copy(at, world->mesh_triangles, + (size_t)world->s.mesh_triangle_count * 3u * sizeof(uint32_t)); + at += (size_t)world->s.mesh_triangle_count * 3u * sizeof(uint32_t); + f3d_copy(at, world->mesh_edges, world->s.mesh_triangle_count); + at += world->s.mesh_triangle_count; + } + /* The joints, warm-start impulses and all. */ + if (world->s.joint_used > 0) { + f3d_copy(at, world->joints, + (size_t)world->s.joint_used * sizeof(F3dJointSlot)); + } + return needed; +} + +int f3d_world_restore(F3dWorld *world, const uint8_t *buffer, uint32_t size) { + if (buffer == NULL || size < sizeof(F3dSnapshotHeader)) return 0; + F3dSnapshotHeader header; + f3d_copy(&header, buffer, sizeof header); + if (header.magic != F3D_SNAPSHOT_MAGIC || + header.version != F3D_SNAPSHOT_VERSION || + header.real_bytes != sizeof(f3d_real) || + header.state_bytes != sizeof(F3dWorldState) || + header.slot_bytes != sizeof(F3dSlot) || + header.event_bytes != sizeof(F3dEventRecord) || + header.manifold_bytes != sizeof(F3dManifold) || + header.hull_bytes != sizeof(F3dHull) || + header.mesh_bytes != sizeof(F3dMesh) || + header.joint_bytes != sizeof(F3dJointSlot)) { + return 0; + } + if (size < sizeof header + sizeof(F3dWorldState)) return 0; + F3dWorldState state; + f3d_copy(&state, buffer + sizeof header, sizeof state); + if (size_of(&state) != size) return 0; + if (state.events_count > F3D_EVENT_CAPACITY || state.live > state.used || + state.free_head > state.used || state.events_head != 0) { + return 0; + } + const uint8_t *at = buffer + sizeof header + sizeof state; + /* Everything allocated before anything is replaced, so a restore that + * runs out of memory leaves the world as it was. */ + F3dSlot *slots = NULL; + const uint32_t capacity = state.used; + if (capacity > 0) { + slots = (F3dSlot *)f3d_alloc((size_t)capacity * sizeof(F3dSlot)); + if (slots == NULL) return 0; + } + const uint64_t reals = grid_reals(&state); + f3d_real *grid = NULL; + if (reals > 0) { + grid = (f3d_real *)f3d_alloc((size_t)reals * sizeof(f3d_real)); + if (grid == NULL) { + f3d_free(slots); + return 0; + } + } + F3dEventRecord *events = NULL; + if (state.events_count > 0) { + events = (F3dEventRecord *)f3d_alloc((size_t)F3D_EVENT_CAPACITY * + sizeof(F3dEventRecord)); + if (events == NULL) { + f3d_free(slots); + f3d_free(grid); + return 0; + } + f3d_zero(events, (size_t)F3D_EVENT_CAPACITY * sizeof(F3dEventRecord)); + } + if (capacity > 0) { + f3d_copy(slots, at, (size_t)capacity * sizeof(F3dSlot)); + at += (size_t)capacity * sizeof(F3dSlot); + } + if (reals > 0) { + f3d_copy(grid, at, (size_t)reals * sizeof(f3d_real)); + at += (size_t)reals * sizeof(f3d_real); + } + F3dManifold *manifolds = NULL; + if (state.manifold_count > 0) { + manifolds = (F3dManifold *)f3d_alloc((size_t)state.manifold_count * + sizeof(F3dManifold)); + if (manifolds == NULL) { + f3d_free(slots); + f3d_free(grid); + f3d_free(events); + return 0; + } + } + F3dHull *hulls = NULL; + f3d_real *hull_vertices = NULL; + uint32_t *hull_triangles = NULL; + if (state.hull_count > 0) { + hulls = (F3dHull *)f3d_alloc((size_t)state.hull_count * sizeof(F3dHull)); + hull_vertices = (f3d_real *)f3d_alloc( + (size_t)state.hull_vertex_count * 3u * sizeof(f3d_real) + 1u); + hull_triangles = (uint32_t *)f3d_alloc( + (size_t)state.hull_triangle_count * 3u * sizeof(uint32_t) + 1u); + if (hulls == NULL || hull_vertices == NULL || hull_triangles == NULL) { + f3d_free(hulls); + f3d_free(hull_vertices); + f3d_free(hull_triangles); + f3d_free(slots); + f3d_free(grid); + f3d_free(events); + f3d_free(manifolds); + return 0; + } + } + F3dMesh *meshes = NULL; + f3d_real *mesh_vertices = NULL; + uint32_t *mesh_triangles = NULL; + uint8_t *mesh_edges = NULL; + if (state.mesh_count > 0) { + meshes = (F3dMesh *)f3d_alloc((size_t)state.mesh_count * sizeof(F3dMesh)); + mesh_vertices = (f3d_real *)f3d_alloc( + (size_t)state.mesh_vertex_count * 3u * sizeof(f3d_real) + 1u); + mesh_triangles = (uint32_t *)f3d_alloc( + (size_t)state.mesh_triangle_count * 3u * sizeof(uint32_t) + 1u); + mesh_edges = (uint8_t *)f3d_alloc((size_t)state.mesh_triangle_count + 1u); + if (meshes == NULL || mesh_vertices == NULL || mesh_triangles == NULL || + mesh_edges == NULL) { + f3d_free(meshes); + f3d_free(mesh_vertices); + f3d_free(mesh_triangles); + f3d_free(mesh_edges); + f3d_free(hulls); + f3d_free(hull_vertices); + f3d_free(hull_triangles); + f3d_free(slots); + f3d_free(grid); + f3d_free(events); + f3d_free(manifolds); + return 0; + } + } + F3dJointSlot *joints = NULL; + if (state.joint_used > 0) { + joints = (F3dJointSlot *)f3d_alloc((size_t)state.joint_used * + sizeof(F3dJointSlot)); + if (joints == NULL) { + f3d_free(meshes); + f3d_free(mesh_vertices); + f3d_free(mesh_triangles); + f3d_free(mesh_edges); + f3d_free(hulls); + f3d_free(hull_vertices); + f3d_free(hull_triangles); + f3d_free(slots); + f3d_free(grid); + f3d_free(events); + f3d_free(manifolds); + return 0; + } + } + if (events != NULL) { + f3d_copy(events, at, (size_t)state.events_count * sizeof(F3dEventRecord)); + at += (size_t)state.events_count * sizeof(F3dEventRecord); + } + if (manifolds != NULL) { + f3d_copy(manifolds, at, + (size_t)state.manifold_count * sizeof(F3dManifold)); + at += (size_t)state.manifold_count * sizeof(F3dManifold); + } + if (hulls != NULL) { + f3d_copy(hulls, at, (size_t)state.hull_count * sizeof(F3dHull)); + at += (size_t)state.hull_count * sizeof(F3dHull); + f3d_copy(hull_vertices, at, + (size_t)state.hull_vertex_count * 3u * sizeof(f3d_real)); + at += (size_t)state.hull_vertex_count * 3u * sizeof(f3d_real); + f3d_copy(hull_triangles, at, + (size_t)state.hull_triangle_count * 3u * sizeof(uint32_t)); + at += (size_t)state.hull_triangle_count * 3u * sizeof(uint32_t); + } + if (meshes != NULL) { + f3d_copy(meshes, at, (size_t)state.mesh_count * sizeof(F3dMesh)); + at += (size_t)state.mesh_count * sizeof(F3dMesh); + f3d_copy(mesh_vertices, at, + (size_t)state.mesh_vertex_count * 3u * sizeof(f3d_real)); + at += (size_t)state.mesh_vertex_count * 3u * sizeof(f3d_real); + f3d_copy(mesh_triangles, at, + (size_t)state.mesh_triangle_count * 3u * sizeof(uint32_t)); + at += (size_t)state.mesh_triangle_count * 3u * sizeof(uint32_t); + f3d_copy(mesh_edges, at, state.mesh_triangle_count); + at += state.mesh_triangle_count; + } + if (joints != NULL) { + f3d_copy(joints, at, (size_t)state.joint_used * sizeof(F3dJointSlot)); + } + f3d_free(world->slots); + f3d_free(world->manifolds); + f3d_free(world->grid); + f3d_free(world->events); + f3d_free(world->hulls); + f3d_free(world->hull_vertices); + f3d_free(world->hull_triangles); + f3d_copy(&world->s, &state, sizeof state); + world->hulls = hulls; + world->hull_vertices = hull_vertices; + world->hull_triangles = hull_triangles; + f3d_free(world->meshes); + f3d_free(world->mesh_vertices); + f3d_free(world->mesh_triangles); + f3d_free(world->mesh_edges); + world->meshes = meshes; + world->mesh_vertices = mesh_vertices; + world->mesh_triangles = mesh_triangles; + world->mesh_edges = mesh_edges; + /* The meshes' trees are built again from them by the next step. */ + f3d_clear_mesh_trees(world); + f3d_free(world->joints); + world->joints = joints; + world->joint_capacity = state.joint_used; + world->joined_stale = 1; + world->slots = slots; + world->capacity = capacity; + world->grid = grid; + world->events = events; + world->manifolds = manifolds; + world->manifold_capacity = state.manifold_count; + /* The tree is not in a snapshot: built again by the next step. */ + f3d_tree_clear(&world->tree); + f3d_free(world->proxies); + world->proxies = NULL; + world->proxy_capacity = 0; + world->pairs_ready = 0; + return 1; +} diff --git a/packages/flutter3d_physics_native/csrc/src/f3d_solve.c b/packages/flutter3d_physics_native/csrc/src/f3d_solve.c new file mode 100644 index 000000000..1ca845ca5 --- /dev/null +++ b/packages/flutter3d_physics_native/csrc/src/f3d_solve.c @@ -0,0 +1,1058 @@ +/* + * The solver — P9, phase 3: soft contacts solved in substeps, as Box2D v3 + * solves them (Catto, "Solver2D", 2024). + * + * A step of dt is cut into substeps of h. Each substep integrates the + * velocities, applies what each contact pushed with last time (the warm + * start), solves the contacts with a soft bias that pushes overlap out, + * moves the bodies, and solves them once more with no bias, so the push + * that took the overlap out does not stay on as speed. After the last + * substep, restitution; then the impulses are kept for the next step. + * + * **Soft, not stiff.** A contact is a spring of a frequency the substep + * can carry — at most a quarter of the substep rate, and thirty hertz — and + * heavily damped, so a pile settles instead of ringing; and the speed it + * pushes overlap out at is held to three metres a second, so a box spawned + * inside another comes out instead of being fired out. + * + * Every contact is solved in the order of its pair, and every point in its + * manifold's order, so the same world solves to the same bits everywhere. + */ +#include "f3d_internal.h" + +/* The fastest overlap is pushed out at, m/s. */ +#define F3D_MAX_PUSH F3D_R(3.0) +/* Below this approach, m/s, nothing bounces: restitution on a resting + * contact would have it chatter. */ +#define F3D_RESTITUTION_THRESHOLD F3D_R(1.0) +/* The contact spring's damping ratio, heavily over critical. */ +#define F3D_CONTACT_DAMPING F3D_R(10.0) + +int f3d_world_set_substeps(F3dWorld *world, uint32_t substeps) { + if (substeps == 0 || substeps > 64) return 0; + world->s.substeps = substeps; + return 1; +} + +int f3d_body_set_friction(F3dWorld *world, F3dBody body, f3d_real friction) { + F3dSlot *s = f3d_slot_of(world, body); + if (s == NULL || !(f3d_finite(friction) && friction >= F3D_R(0.0))) return 0; + s->friction = friction; + return 1; +} + +int f3d_body_set_restitution(F3dWorld *world, F3dBody body, + f3d_real restitution) { + F3dSlot *s = f3d_slot_of(world, body); + if (s == NULL || !(f3d_finite(restitution) && restitution >= F3D_R(0.0) && + restitution <= F3D_R(1.0))) { + return 0; + } + s->restitution = restitution; + return 1; +} + +typedef struct SolverPoint { + /* From each centre to the point at the step's start, in the world. */ + F3dVec3 ra, rb; + /* Separation at the step's start: minus the depth. */ + f3d_real base; + f3d_real normal_mass; + f3d_real tangent_mass[2]; + /* The speed they approached at before the solve, for restitution. */ + f3d_real approach; + f3d_real most; +} SolverPoint; + +typedef struct SolverContact { + uint32_t a, b; + uint32_t manifold; + uint32_t count; + F3dVec3 normal, tangent[2]; + f3d_real friction, restitution; + SolverPoint p[F3D_MANIFOLD_POINTS]; +} SolverContact; + +typedef F3dSolverBody SolverBody; + +/* A body's turn since the step began, as a small rotation vector: twice the + * vector part of q · q₀⁻¹. */ +static F3dVec3 turned_since(F3dQuat q, F3dQuat q0) { + const f3d_real x = q.w * -q0.x + q.x * q0.w + q.y * -q0.z - q.z * -q0.y; + const f3d_real y = q.w * -q0.y - q.x * -q0.z + q.y * q0.w + q.z * -q0.x; + const f3d_real z = q.w * -q0.z + q.x * -q0.y - q.y * -q0.x + q.z * q0.w; + const f3d_real w = q.w * q0.w + q.x * q0.x + q.y * q0.y + q.z * q0.z; + const f3d_real k = w < F3D_R(0.0) ? F3D_R(-2.0) : F3D_R(2.0); + return f3d_v3(x * k, y * k, z * k); +} + +static int moves(const F3dSlot *s) { + return s->live && s->type == F3D_BODY_DYNAMIC && + !(s->flags & F3D_FLAG_ASLEEP); +} + +/* A unit vector square to [n], fixed by n alone. */ +static F3dVec3 square_to(F3dVec3 n) { + const F3dVec3 axis = f3d_abs(n.x) < F3D_R(0.57735) + ? f3d_v3(F3D_R(1.0), F3D_R(0.0), F3D_R(0.0)) + : f3d_v3(F3D_R(0.0), F3D_R(1.0), F3D_R(0.0)); + const F3dVec3 t = f3d_cross(n, axis); + return f3d_scale(t, F3D_R(1.0) / f3d_sqrt(f3d_dot(t, t))); +} + +/* 1 / (J M⁻¹ Jᵀ) along [d] at anchors ra, rb. */ +static f3d_real mass_along(const SolverBody *a, const SolverBody *b, + F3dVec3 ra, F3dVec3 rb, F3dVec3 d) { + const F3dVec3 ca = f3d_cross(ra, d), cb = f3d_cross(rb, d); + const f3d_real k = + a->inverse_mass + b->inverse_mass + + f3d_dot(ca, f3d_sym_times(a->inverse_inertia, ca)) + + f3d_dot(cb, f3d_sym_times(b->inverse_inertia, cb)); + return k > F3D_R(0.0) ? F3D_R(1.0) / k : F3D_R(0.0); +} + +/* The velocity of a's point relative to b's. */ +static F3dVec3 relative(const F3dSlot *a, const F3dSlot *b, F3dVec3 ra, + F3dVec3 rb) { + const F3dVec3 va = f3d_add(a->velocity, f3d_cross(a->spin, ra)); + const F3dVec3 vb = f3d_add(b->velocity, f3d_cross(b->spin, rb)); + return f3d_sub(va, vb); +} + +/* What the contacts read and write of each body, packed tight: its + * velocity and spin, and how far it has moved and turned since the step + * began — the turn worked out once a body, not once a contact. Gathered + * from the slots before each stage of contacts and scattered back after, + * so the joints and the integration between stages see the slots. */ +typedef struct Motion { + F3dVec3 v, w; + F3dVec3 moved, turned; +} Motion; + +static void gather(const F3dWorld *world, const SolverBody *bodies, Motion *motion, + uint32_t begin, uint32_t end) { + for (uint32_t i = begin; i < end; i++) { + const F3dSlot *s = &world->slots[i]; + if (!s->live) continue; + Motion *m = &motion[i]; + m->v = s->velocity; + m->w = s->spin; + m->moved = f3d_sub(s->position, bodies[i].start); + m->turned = turned_since(s->orientation, bodies[i].turn); + } +} + +static void scatter(F3dWorld *world, const Motion *motion, uint32_t begin, uint32_t end) { + for (uint32_t i = begin; i < end; i++) { + F3dSlot *s = &world->slots[i]; + if (!moves(s)) continue; + s->velocity = motion[i].v; + s->spin = motion[i].w; + } +} + +static F3dVec3 relative_motion(const Motion *a, const Motion *b, F3dVec3 ra, F3dVec3 rb) { + const F3dVec3 va = f3d_add(a->v, f3d_cross(a->w, ra)); + const F3dVec3 vb = f3d_add(b->v, f3d_cross(b->w, rb)); + return f3d_sub(va, vb); +} + +/* An impulse [p] at the point: into a, out of b. A body that does not + * move is left untouched rather than given nothing: in the fast mode a + * fixed floor is in many contacts of one colour at once, and nothing may + * write it. */ +static void push(Motion *a, Motion *b, const SolverBody *ba, const SolverBody *bb, + F3dVec3 ra, F3dVec3 rb, F3dVec3 p) { + if (ba->inverse_mass > F3D_R(0.0)) { + a->v = f3d_madd(a->v, p, ba->inverse_mass); + a->w = f3d_add(a->w, f3d_sym_times(ba->inverse_inertia, f3d_cross(ra, p))); + } + if (bb->inverse_mass > F3D_R(0.0)) { + b->v = f3d_madd(b->v, p, -bb->inverse_mass); + b->w = f3d_sub(b->w, f3d_sym_times(bb->inverse_inertia, f3d_cross(rb, p))); + } +} + +typedef struct Softness { + f3d_real bias_rate, mass_scale, impulse_scale; +} Softness; + +/* A spring of [hertz] and damping [zeta] as a substep of [h] carries it: + * Catto's soft step, the spring and damper folded into the impulse. */ +static Softness soft(f3d_real hertz, f3d_real zeta, f3d_real h) { + const f3d_real omega = F3D_R(2.0) * F3D_PI * hertz; + const f3d_real a1 = F3D_R(2.0) * zeta + h * omega; + const f3d_real a2 = h * omega * a1; + const f3d_real a3 = F3D_R(1.0) / (F3D_R(1.0) + a2); + Softness s; + s.bias_rate = omega / a1; + s.mass_scale = a2 * a3; + s.impulse_scale = a3; + return s; +} + +static void solve_contact(F3dWorld *world, SolverContact *sc, const SolverBody *bodies, + Motion *motion, Softness softness, f3d_real inv_h, int use_bias) { + { + F3dManifold *m = &world->manifolds[sc->manifold]; + Motion *a = &motion[sc->a]; + Motion *b = &motion[sc->b]; + const SolverBody *ba = &bodies[sc->a], *bb = &bodies[sc->b]; + const F3dVec3 turned_a = a->turned; + const F3dVec3 turned_b = b->turned; + const F3dVec3 n = sc->normal; + const F3dVec3 da = a->moved; + const F3dVec3 db = b->moved; + for (uint32_t k = 0; k < sc->count; k++) { + SolverPoint *p = &sc->p[k]; + F3dContactPoint *mp = &m->points[k]; + /* Where the two points are now, against where they started. */ + /* How the two points have moved since the step began: each body's + * shift, and its turn taken to first order, Δφ × r. Not the anchor + * turned whole with the body: a rolling ball touches the floor at a + * new point of itself each moment, and the old point carried round + * would read a gap where it rests — a ball rolling at ten metres a + * second sank two centimetres. */ + const F3dVec3 moved = + f3d_sub(f3d_add(da, f3d_cross(turned_a, p->ra)), + f3d_add(db, f3d_cross(turned_b, p->rb))); + const f3d_real s = p->base + f3d_dot(moved, n); + f3d_real bias = F3D_R(0.0), mass_scale = F3D_R(1.0); + f3d_real impulse_scale = F3D_R(0.0); + if (s > F3D_R(0.0)) { + /* Apart: they may close the gap this substep, and no more. */ + bias = s * inv_h; + } else if (use_bias) { + bias = f3d_max(softness.bias_rate * f3d_min(s + F3D_LINEAR_SLOP, + F3D_R(0.0)), + -F3D_MAX_PUSH); + mass_scale = softness.mass_scale; + impulse_scale = softness.impulse_scale; + } + const f3d_real vn = f3d_dot(relative_motion(a, b, p->ra, p->rb), n); + const f3d_real lambda = -p->normal_mass * mass_scale * (vn + bias) - + impulse_scale * mp->normal_impulse; + const f3d_real total = f3d_max(mp->normal_impulse + lambda, F3D_R(0.0)); + const f3d_real delta = total - mp->normal_impulse; + mp->normal_impulse = total; + p->most = f3d_max(p->most, total); + push(a, b, ba, bb, p->ra, p->rb, f3d_scale(n, delta)); + } + /* Friction, inside Coulomb's circle of what the normal pushes with. */ + for (uint32_t k = 0; k < sc->count; k++) { + SolverPoint *p = &sc->p[k]; + F3dContactPoint *mp = &m->points[k]; + const F3dVec3 v = relative_motion(a, b, p->ra, p->rb); + const f3d_real t0 = mp->tangent_impulse[0] - + p->tangent_mass[0] * f3d_dot(v, sc->tangent[0]); + const f3d_real t1 = mp->tangent_impulse[1] - + p->tangent_mass[1] * f3d_dot(v, sc->tangent[1]); + const f3d_real limit = sc->friction * mp->normal_impulse; + const f3d_real length = f3d_sqrt(t0 * t0 + t1 * t1); + const f3d_real keep = + length > limit && length > F3D_R(0.0) ? limit / length : F3D_R(1.0); + const f3d_real n0 = t0 * keep, n1 = t1 * keep; + const F3dVec3 dp = + f3d_add(f3d_scale(sc->tangent[0], n0 - mp->tangent_impulse[0]), + f3d_scale(sc->tangent[1], n1 - mp->tangent_impulse[1])); + mp->tangent_impulse[0] = n0; + mp->tangent_impulse[1] = n1; + push(a, b, ba, bb, p->ra, p->rb, dp); + } + } +} + +static void solve(F3dWorld *world, SolverContact *contacts, uint32_t count, + const SolverBody *bodies, Motion *motion, Softness softness, f3d_real h, + int use_bias) { + const f3d_real inv_h = F3D_R(1.0) / h; + gather(world, bodies, motion, 0, world->s.used); + for (uint32_t c = 0; c < count; c++) { + solve_contact(world, &contacts[c], bodies, motion, softness, inv_h, use_bias); + } + scatter(world, motion, 0, world->s.used); +} + +static void warm_contact(F3dWorld *world, const SolverContact *sc, const SolverBody *bodies, + Motion *motion) { + { + const F3dManifold *m = &world->manifolds[sc->manifold]; + Motion *a = &motion[sc->a]; + Motion *b = &motion[sc->b]; + for (uint32_t k = 0; k < sc->count; k++) { + const F3dContactPoint *mp = &m->points[k]; + const F3dVec3 p = + f3d_add(f3d_scale(sc->normal, mp->normal_impulse), + f3d_add(f3d_scale(sc->tangent[0], mp->tangent_impulse[0]), + f3d_scale(sc->tangent[1], mp->tangent_impulse[1]))); + push(a, b, &bodies[sc->a], &bodies[sc->b], sc->p[k].ra, sc->p[k].rb, p); + } + } +} + +static void warm_start(F3dWorld *world, const SolverContact *contacts, + uint32_t count, const SolverBody *bodies, Motion *motion) { + gather(world, bodies, motion, 0, world->s.used); + for (uint32_t c = 0; c < count; c++) warm_contact(world, &contacts[c], bodies, motion); + scatter(world, motion, 0, world->s.used); +} + +/* Restitution, once the substeps are done: a contact that was closing + * faster than the threshold, and was pushed on, gets back its share of the + * approach speed. */ +static void restitute_contact(F3dWorld *world, SolverContact *sc, const SolverBody *bodies, + Motion *motion) { + if (sc->restitution == F3D_R(0.0)) return; + { + F3dManifold *m = &world->manifolds[sc->manifold]; + Motion *a = &motion[sc->a]; + Motion *b = &motion[sc->b]; + for (uint32_t k = 0; k < sc->count; k++) { + SolverPoint *p = &sc->p[k]; + F3dContactPoint *mp = &m->points[k]; + if (p->approach > -F3D_RESTITUTION_THRESHOLD || p->most == F3D_R(0.0)) { + continue; + } + const f3d_real vn = f3d_dot(relative_motion(a, b, p->ra, p->rb), sc->normal); + const f3d_real lambda = + -p->normal_mass * (vn + sc->restitution * p->approach); + const f3d_real total = f3d_max(mp->normal_impulse + lambda, F3D_R(0.0)); + const f3d_real delta = total - mp->normal_impulse; + mp->normal_impulse = total; + push(a, b, &bodies[sc->a], &bodies[sc->b], p->ra, p->rb, + f3d_scale(sc->normal, delta)); + } + } +} + +static void restitute(F3dWorld *world, SolverContact *contacts, + uint32_t count, const SolverBody *bodies, Motion *motion) { + gather(world, bodies, motion, 0, world->s.used); + for (uint32_t c = 0; c < count; c++) restitute_contact(world, &contacts[c], bodies, motion); + scatter(world, motion, 0, world->s.used); +} + +/* ------------------------------------------------------------- wide solve */ + +/* The fast mode solves a colour's contacts F3D_LANES at a time: a batch of + * contacts that share no moving body, each in a lane of a vector, every + * lane doing what the scalar functions above do, operation for operation, + * so a lane's bits are a contact's bits solved alone. What a scalar + * contact decides with a branch, a lane decides with a mask; a lane with + * fewer points, and a body that does not move, is masked out, not skipped. + * Vectors as GCC and Clang build them; elsewhere — MSVC — the same + * operations a lane at a time, to the same bits. */ +#define F3D_LANES 4 + +#if (defined(__clang__) || defined(__GNUC__)) && !defined(F3D_NO_SIMD) +#ifdef F3D_REAL_DOUBLE +typedef int64_t WLaneInt; +#else +typedef int32_t WLaneInt; +#endif +typedef f3d_real WReal __attribute__((vector_size(F3D_LANES * sizeof(f3d_real)))); +typedef WLaneInt WMask __attribute__((vector_size(F3D_LANES * sizeof(f3d_real)))); +#define W_LANE(v, l) ((v)[l]) +static inline WReal w_add(WReal a, WReal b) { return a + b; } +static inline WReal w_sub(WReal a, WReal b) { return a - b; } +static inline WReal w_mul(WReal a, WReal b) { return a * b; } +static inline WReal w_div(WReal a, WReal b) { return a / b; } +static inline WReal w_neg(WReal a) { return -a; } +static inline WReal w_splat(f3d_real x) { return (WReal){0} + x; } +static inline WMask w_gt(WReal a, WReal b) { return (WMask)(a > b); } +static inline WMask w_lt(WReal a, WReal b) { return (WMask)(a < b); } +static inline WMask w_eq(WReal a, WReal b) { return (WMask)(a == b); } +static inline WMask w_and(WMask a, WMask b) { return a & b; } +static inline WMask w_not(WMask a) { return ~a; } +static inline WReal w_sel(WMask m, WReal a, WReal b) { + return (WReal)(((WMask)a & m) | ((WMask)b & ~m)); +} +#else +typedef struct WReal { + f3d_real l[F3D_LANES]; +} WReal; +typedef struct WMask { + int l[F3D_LANES]; +} WMask; +#define W_LANE(v, i) ((v).l[i]) +#define W_EACH(op) \ + WReal r; \ + for (int i = 0; i < F3D_LANES; i++) r.l[i] = (op); \ + return r +#define W_TEST(op) \ + WMask r; \ + for (int i = 0; i < F3D_LANES; i++) r.l[i] = (op); \ + return r +static inline WReal w_add(WReal a, WReal b) { W_EACH(a.l[i] + b.l[i]); } +static inline WReal w_sub(WReal a, WReal b) { W_EACH(a.l[i] - b.l[i]); } +static inline WReal w_mul(WReal a, WReal b) { W_EACH(a.l[i] * b.l[i]); } +static inline WReal w_div(WReal a, WReal b) { W_EACH(a.l[i] / b.l[i]); } +static inline WReal w_neg(WReal a) { W_EACH(-a.l[i]); } +static inline WReal w_splat(f3d_real x) { W_EACH(x); } +static inline WMask w_gt(WReal a, WReal b) { W_TEST(a.l[i] > b.l[i]); } +static inline WMask w_lt(WReal a, WReal b) { W_TEST(a.l[i] < b.l[i]); } +static inline WMask w_eq(WReal a, WReal b) { W_TEST(a.l[i] == b.l[i]); } +static inline WMask w_and(WMask a, WMask b) { W_TEST(a.l[i] && b.l[i]); } +static inline WMask w_not(WMask a) { W_TEST(!a.l[i]); } +static inline WReal w_sel(WMask m, WReal a, WReal b) { W_EACH(m.l[i] ? a.l[i] : b.l[i]); } +#endif + +static inline WReal w_sqrt(WReal a) { + WReal r = a; + for (int i = 0; i < F3D_LANES; i++) W_LANE(r, i) = f3d_sqrt(W_LANE(a, i)); + return r; +} + +/* f3d_max and f3d_min, a lane at a time. */ +static inline WReal w_max(WReal a, WReal b) { return w_sel(w_gt(a, b), a, b); } +static inline WReal w_min(WReal a, WReal b) { return w_sel(w_lt(a, b), a, b); } + +typedef struct WVec3 { + WReal x, y, z; +} WVec3; + +typedef struct WSym3 { + WReal xx, yy, zz, xy, xz, yz; +} WSym3; + +static inline WVec3 wv(WReal x, WReal y, WReal z) { + WVec3 v; + v.x = x; + v.y = y; + v.z = z; + return v; +} +static inline WVec3 wv_add(WVec3 a, WVec3 b) { + return wv(w_add(a.x, b.x), w_add(a.y, b.y), w_add(a.z, b.z)); +} +static inline WVec3 wv_sub(WVec3 a, WVec3 b) { + return wv(w_sub(a.x, b.x), w_sub(a.y, b.y), w_sub(a.z, b.z)); +} +static inline WVec3 wv_scale(WVec3 a, WReal k) { + return wv(w_mul(a.x, k), w_mul(a.y, k), w_mul(a.z, k)); +} +static inline WVec3 wv_madd(WVec3 a, WVec3 b, WReal k) { + return wv(w_add(a.x, w_mul(b.x, k)), w_add(a.y, w_mul(b.y, k)), w_add(a.z, w_mul(b.z, k))); +} +static inline WReal wv_dot(WVec3 a, WVec3 b) { + return w_add(w_add(w_mul(a.x, b.x), w_mul(a.y, b.y)), w_mul(a.z, b.z)); +} +static inline WVec3 wv_cross(WVec3 a, WVec3 b) { + return wv(w_sub(w_mul(a.y, b.z), w_mul(a.z, b.y)), w_sub(w_mul(a.z, b.x), w_mul(a.x, b.z)), + w_sub(w_mul(a.x, b.y), w_mul(a.y, b.x))); +} +static inline WVec3 wsym_times(WSym3 m, WVec3 v) { + return wv(w_add(w_add(w_mul(m.xx, v.x), w_mul(m.xy, v.y)), w_mul(m.xz, v.z)), + w_add(w_add(w_mul(m.xy, v.x), w_mul(m.yy, v.y)), w_mul(m.yz, v.z)), + w_add(w_add(w_mul(m.xz, v.x), w_mul(m.yz, v.y)), w_mul(m.zz, v.z))); +} +static inline WVec3 wv_sel(WMask m, WVec3 a, WVec3 b) { + return wv(w_sel(m, a.x, b.x), w_sel(m, a.y, b.y), w_sel(m, a.z, b.z)); +} + +static inline void wv_set(WVec3 *v, int l, F3dVec3 s) { + W_LANE(v->x, l) = s.x; + W_LANE(v->y, l) = s.y; + W_LANE(v->z, l) = s.z; +} +static inline F3dVec3 wv_get(WVec3 v, int l) { + return f3d_v3(W_LANE(v.x, l), W_LANE(v.y, l), W_LANE(v.z, l)); +} + +typedef struct WidePoint { + WVec3 ra, rb; + WReal base, normal_mass, tangent_mass0, tangent_mass1, approach, most; + WReal normal_impulse, tangent_impulse0, tangent_impulse1; + /* The lanes whose contact has this point. */ + WMask live; +} WidePoint; + +typedef struct WideBatch { + /* Each lane's contact, its bodies, and how many lanes are filled. */ + uint32_t contact[F3D_LANES]; + uint32_t a[F3D_LANES], b[F3D_LANES]; + uint32_t lanes, points; + WVec3 normal, tangent0, tangent1; + WReal friction, restitution; + WReal mass_a, mass_b; + WSym3 inertia_a, inertia_b; + /* The lanes whose body moves, and so is pushed. */ + WMask moves_a, moves_b; + WidePoint p[F3D_MANIFOLD_POINTS]; +} WideBatch; + +/* Lane [l] of [batch] made from contact [c]: its normal and tangents, its + * points and their impulses so far, and its bodies' masses. */ +static void wide_fill_lane(WideBatch *w, int l, const F3dWorld *world, const SolverBody *bodies, + const SolverContact *contacts, uint32_t c) { + const SolverContact *sc = &contacts[c]; + const F3dManifold *m = &world->manifolds[sc->manifold]; + const SolverBody *ba = &bodies[sc->a], *bb = &bodies[sc->b]; + w->contact[l] = c; + w->a[l] = sc->a; + w->b[l] = sc->b; + wv_set(&w->normal, l, sc->normal); + wv_set(&w->tangent0, l, sc->tangent[0]); + wv_set(&w->tangent1, l, sc->tangent[1]); + W_LANE(w->friction, l) = sc->friction; + W_LANE(w->restitution, l) = sc->restitution; + W_LANE(w->mass_a, l) = ba->inverse_mass; + W_LANE(w->mass_b, l) = bb->inverse_mass; + W_LANE(w->inertia_a.xx, l) = ba->inverse_inertia.xx; + W_LANE(w->inertia_a.yy, l) = ba->inverse_inertia.yy; + W_LANE(w->inertia_a.zz, l) = ba->inverse_inertia.zz; + W_LANE(w->inertia_a.xy, l) = ba->inverse_inertia.xy; + W_LANE(w->inertia_a.xz, l) = ba->inverse_inertia.xz; + W_LANE(w->inertia_a.yz, l) = ba->inverse_inertia.yz; + W_LANE(w->inertia_b.xx, l) = bb->inverse_inertia.xx; + W_LANE(w->inertia_b.yy, l) = bb->inverse_inertia.yy; + W_LANE(w->inertia_b.zz, l) = bb->inverse_inertia.zz; + W_LANE(w->inertia_b.xy, l) = bb->inverse_inertia.xy; + W_LANE(w->inertia_b.xz, l) = bb->inverse_inertia.xz; + W_LANE(w->inertia_b.yz, l) = bb->inverse_inertia.yz; + W_LANE(w->moves_a, l) = ba->inverse_mass > F3D_R(0.0) ? -1 : 0; + W_LANE(w->moves_b, l) = bb->inverse_mass > F3D_R(0.0) ? -1 : 0; + if (sc->count > w->points) w->points = sc->count; + for (uint32_t k = 0; k < F3D_MANIFOLD_POINTS; k++) { + WidePoint *wp = &w->p[k]; + if (k >= sc->count) { + W_LANE(wp->live, l) = 0; + continue; + } + const SolverPoint *p = &sc->p[k]; + const F3dContactPoint *mp = &m->points[k]; + W_LANE(wp->live, l) = -1; + wv_set(&wp->ra, l, p->ra); + wv_set(&wp->rb, l, p->rb); + W_LANE(wp->base, l) = p->base; + W_LANE(wp->normal_mass, l) = p->normal_mass; + W_LANE(wp->tangent_mass0, l) = p->tangent_mass[0]; + W_LANE(wp->tangent_mass1, l) = p->tangent_mass[1]; + W_LANE(wp->approach, l) = p->approach; + W_LANE(wp->most, l) = p->most; + W_LANE(wp->normal_impulse, l) = mp->normal_impulse; + W_LANE(wp->tangent_impulse0, l) = mp->tangent_impulse[0]; + W_LANE(wp->tangent_impulse1, l) = mp->tangent_impulse[1]; + } +} + +/* A batch of the contacts [first, first + n) of [order], n at most + * F3D_LANES; an empty lane repeats the first lane's bodies, read and never + * written, and has no points. */ +static void wide_fill(WideBatch *w, const F3dWorld *world, const SolverBody *bodies, + const SolverContact *contacts, const uint32_t *order, uint32_t first, + uint32_t n) { + f3d_zero(w, sizeof *w); + w->lanes = n; + for (uint32_t l = 0; l < F3D_LANES; l++) { + if (l < n) { + wide_fill_lane(w, (int)l, world, bodies, contacts, order[first + l]); + continue; + } + w->contact[l] = UINT32_MAX; + w->a[l] = w->a[0]; + w->b[l] = w->b[0]; + W_LANE(w->moves_a, l) = 0; + W_LANE(w->moves_b, l) = 0; + for (uint32_t k = 0; k < F3D_MANIFOLD_POINTS; k++) W_LANE(w->p[k].live, l) = 0; + } +} + +/* The batch's impulses back into its manifolds, once the step is done. */ +static void wide_store(const WideBatch *w, F3dWorld *world, const SolverContact *contacts) { + for (uint32_t l = 0; l < w->lanes; l++) { + const SolverContact *sc = &contacts[w->contact[l]]; + F3dManifold *m = &world->manifolds[sc->manifold]; + for (uint32_t k = 0; k < sc->count; k++) { + m->points[k].normal_impulse = W_LANE(w->p[k].normal_impulse, l); + m->points[k].tangent_impulse[0] = W_LANE(w->p[k].tangent_impulse0, l); + m->points[k].tangent_impulse[1] = W_LANE(w->p[k].tangent_impulse1, l); + } + } +} + +/* The two sides' motion, a lane a contact. */ +typedef struct WideMotion { + WVec3 va, wa, vb, wb; + WVec3 moved_a, turned_a, moved_b, turned_b; +} WideMotion; + +static void wide_load(const WideBatch *w, const Motion *motion, WideMotion *m) { + for (int l = 0; l < F3D_LANES; l++) { + const Motion *a = &motion[w->a[l]], *b = &motion[w->b[l]]; + wv_set(&m->va, l, a->v); + wv_set(&m->wa, l, a->w); + wv_set(&m->moved_a, l, a->moved); + wv_set(&m->turned_a, l, a->turned); + wv_set(&m->vb, l, b->v); + wv_set(&m->wb, l, b->w); + wv_set(&m->moved_b, l, b->moved); + wv_set(&m->turned_b, l, b->turned); + } +} + +/* Back into the motion the lanes whose bodies move: no two lanes of a + * batch share one. */ +static void wide_save(const WideBatch *w, const WideMotion *m, Motion *motion) { + for (int l = 0; l < F3D_LANES; l++) { + if (W_LANE(w->moves_a, l)) { + motion[w->a[l]].v = wv_get(m->va, l); + motion[w->a[l]].w = wv_get(m->wa, l); + } + if (W_LANE(w->moves_b, l)) { + motion[w->b[l]].v = wv_get(m->vb, l); + motion[w->b[l]].w = wv_get(m->wb, l); + } + } +} + +static WVec3 wide_relative(const WideMotion *m, WVec3 ra, WVec3 rb) { + const WVec3 va = wv_add(m->va, wv_cross(m->wa, ra)); + const WVec3 vb = wv_add(m->vb, wv_cross(m->wb, rb)); + return wv_sub(va, vb); +} + +/* push(), for the lanes in [on]. */ +static void wide_push(const WideBatch *w, WideMotion *m, WMask on, WVec3 ra, WVec3 rb, WVec3 p) { + const WMask a = w_and(on, w->moves_a), b = w_and(on, w->moves_b); + m->va = wv_sel(a, wv_madd(m->va, p, w->mass_a), m->va); + m->wa = wv_sel(a, wv_add(m->wa, wsym_times(w->inertia_a, wv_cross(ra, p))), m->wa); + m->vb = wv_sel(b, wv_madd(m->vb, p, w_neg(w->mass_b)), m->vb); + m->wb = wv_sel(b, wv_sub(m->wb, wsym_times(w->inertia_b, wv_cross(rb, p))), m->wb); +} + +/* warm_contact(), a batch at once. */ +static void wide_warm(WideBatch *w, Motion *motion) { + WideMotion m; + wide_load(w, motion, &m); + for (uint32_t k = 0; k < w->points; k++) { + const WidePoint *p = &w->p[k]; + const WVec3 push = + wv_add(wv_scale(w->normal, p->normal_impulse), + wv_add(wv_scale(w->tangent0, p->tangent_impulse0), + wv_scale(w->tangent1, p->tangent_impulse1))); + wide_push(w, &m, p->live, p->ra, p->rb, push); + } + wide_save(w, &m, motion); +} + +/* solve_contact(), a batch at once. */ +static void wide_solve(WideBatch *w, Motion *motion, Softness softness, f3d_real inv_h, + int use_bias) { + WideMotion m; + wide_load(w, motion, &m); + const WReal zero = w_splat(F3D_R(0.0)), one = w_splat(F3D_R(1.0)); + const WReal rate = w_splat(softness.bias_rate); + const WReal mass_scale_soft = w_splat(softness.mass_scale); + const WReal impulse_scale_soft = w_splat(softness.impulse_scale); + const WReal slop = w_splat(F3D_LINEAR_SLOP), most_push = w_splat(-F3D_MAX_PUSH); + const WReal per_h = w_splat(inv_h); + for (uint32_t k = 0; k < w->points; k++) { + WidePoint *p = &w->p[k]; + const WVec3 moved = wv_sub(wv_add(m.moved_a, wv_cross(m.turned_a, p->ra)), + wv_add(m.moved_b, wv_cross(m.turned_b, p->rb))); + const WReal s = w_add(p->base, wv_dot(moved, w->normal)); + const WMask apart = w_gt(s, zero); + WReal bias = w_sel(apart, w_mul(s, per_h), zero); + WReal mass_scale = one, impulse_scale = zero; + if (use_bias) { + const WReal soft_bias = w_max(w_mul(rate, w_min(w_add(s, slop), zero)), most_push); + bias = w_sel(apart, bias, soft_bias); + mass_scale = w_sel(apart, one, mass_scale_soft); + impulse_scale = w_sel(apart, zero, impulse_scale_soft); + } + const WReal vn = wv_dot(wide_relative(&m, p->ra, p->rb), w->normal); + const WReal lambda = w_sub(w_mul(w_mul(w_neg(p->normal_mass), mass_scale), w_add(vn, bias)), + w_mul(impulse_scale, p->normal_impulse)); + const WReal total = w_max(w_add(p->normal_impulse, lambda), zero); + const WReal delta = w_sub(total, p->normal_impulse); + p->normal_impulse = w_sel(p->live, total, p->normal_impulse); + p->most = w_sel(p->live, w_max(p->most, total), p->most); + wide_push(w, &m, p->live, p->ra, p->rb, wv_scale(w->normal, delta)); + } + for (uint32_t k = 0; k < w->points; k++) { + WidePoint *p = &w->p[k]; + const WVec3 v = wide_relative(&m, p->ra, p->rb); + const WReal t0 = w_sub(p->tangent_impulse0, w_mul(p->tangent_mass0, wv_dot(v, w->tangent0))); + const WReal t1 = w_sub(p->tangent_impulse1, w_mul(p->tangent_mass1, wv_dot(v, w->tangent1))); + const WReal limit = w_mul(w->friction, p->normal_impulse); + const WReal length = w_sqrt(w_add(w_mul(t0, t0), w_mul(t1, t1))); + const WMask clamp = w_and(w_gt(length, limit), w_gt(length, zero)); + const WReal keep = w_sel(clamp, w_div(limit, length), one); + const WReal n0 = w_mul(t0, keep), n1 = w_mul(t1, keep); + const WVec3 push = wv_add(wv_scale(w->tangent0, w_sub(n0, p->tangent_impulse0)), + wv_scale(w->tangent1, w_sub(n1, p->tangent_impulse1))); + p->tangent_impulse0 = w_sel(p->live, n0, p->tangent_impulse0); + p->tangent_impulse1 = w_sel(p->live, n1, p->tangent_impulse1); + wide_push(w, &m, p->live, p->ra, p->rb, push); + } + wide_save(w, &m, motion); +} + +/* restitute_contact(), a batch at once. */ +static void wide_restitute(WideBatch *w, Motion *motion) { + WideMotion m; + wide_load(w, motion, &m); + const WReal zero = w_splat(F3D_R(0.0)); + const WReal threshold = w_splat(-F3D_RESTITUTION_THRESHOLD); + const WMask bounces = w_not(w_eq(w->restitution, zero)); + for (uint32_t k = 0; k < w->points; k++) { + WidePoint *p = &w->p[k]; + const WMask on = w_and(w_and(p->live, bounces), + w_and(w_not(w_gt(p->approach, threshold)), w_not(w_eq(p->most, zero)))); + const WReal vn = wv_dot(wide_relative(&m, p->ra, p->rb), w->normal); + const WReal lambda = + w_mul(w_neg(p->normal_mass), w_add(vn, w_mul(w->restitution, p->approach))); + const WReal total = w_max(w_add(p->normal_impulse, lambda), zero); + const WReal delta = w_sub(total, p->normal_impulse); + p->normal_impulse = w_sel(on, total, p->normal_impulse); + wide_push(w, &m, on, p->ra, p->rb, wv_scale(w->normal, delta)); + } + wide_save(w, &m, motion); +} + +/* ------------------------------------------------------------ fast mode */ + +/* Colours a contact may take; one that finds them all taken by its bodies + * goes to the overflow, solved on one thread after the colours. */ +#define F3D_COLOURS 64u + +/* A step of the fast mode: the contacts in colour order, and what every + * worker needs to walk the step's stages with the rest. */ +typedef struct FastStep { + F3dWorld *world; + const SolverBody *bodies; + Motion *motion; + SolverContact *contacts; + /* contacts[order[k]] for k from group_start[g] to group_start[g + 1] are + * group g's; the last group, F3D_COLOURS, is the overflow. */ + const uint32_t *order; + uint32_t group_start[F3D_COLOURS + 2u]; + /* Colour g's contacts in batches: batches[batch_start[g]] up to + * batches[batch_start[g + 1]]. The overflow has none. */ + WideBatch *batches; + uint32_t batch_start[F3D_COLOURS + 1u]; + Softness softness; + f3d_real h; + uint32_t substeps, used, row; + uint32_t workers; + F3dBarrier barrier; +} FastStep; + +/* Worker [w]'s share of [n] items: [*begin, *end). */ +static void share_of(uint32_t n, uint32_t w, uint32_t workers, uint32_t *begin, + uint32_t *end) { + *begin = (uint32_t)((uint64_t)n * w / workers); + *end = (uint32_t)((uint64_t)n * (w + 1u) / workers); +} + +typedef enum ContactStage { WARM, SOLVE, RELAX, RESTITUTE } ContactStage; + +/* Every group of contacts through one stage: a colour shared out among the + * workers, the overflow on worker nought, a barrier after each. */ +static void contact_stage(FastStep *f, uint32_t w, uint32_t *sense, ContactStage stage) { + const f3d_real inv_h = F3D_R(1.0) / f->h; + uint32_t mine = 0, mine_end = 0; + share_of(f->used, w, f->workers, &mine, &mine_end); + gather(f->world, f->bodies, f->motion, mine, mine_end); + f3d_barrier_wait(&f->barrier, sense); + for (uint32_t g = 0; g <= F3D_COLOURS; g++) { + const uint32_t first = f->group_start[g], n = f->group_start[g + 1u] - first; + if (n == 0) continue; + if (g < F3D_COLOURS) { + const uint32_t batch = f->batch_start[g]; + uint32_t begin = 0, end = 0; + share_of(f->batch_start[g + 1u] - batch, w, f->workers, &begin, &end); + for (uint32_t k = batch + begin; k < batch + end; k++) { + WideBatch *wb = &f->batches[k]; + switch (stage) { + case WARM: wide_warm(wb, f->motion); break; + case SOLVE: wide_solve(wb, f->motion, f->softness, inv_h, 1); break; + case RELAX: wide_solve(wb, f->motion, f->softness, inv_h, 0); break; + case RESTITUTE: wide_restitute(wb, f->motion); break; + } + } + f3d_barrier_wait(&f->barrier, sense); + continue; + } + /* The overflow, whose contacts may share a body: on worker nought, one + * after another. */ + const uint32_t begin = 0, end = w == 0 ? n : 0; + for (uint32_t k = begin; k < end; k++) { + SolverContact *sc = &f->contacts[f->order[first + k]]; + switch (stage) { + case WARM: warm_contact(f->world, sc, f->bodies, f->motion); break; + case SOLVE: solve_contact(f->world, sc, f->bodies, f->motion, f->softness, inv_h, 1); break; + case RELAX: solve_contact(f->world, sc, f->bodies, f->motion, f->softness, inv_h, 0); break; + case RESTITUTE: restitute_contact(f->world, sc, f->bodies, f->motion); break; + } + } + f3d_barrier_wait(&f->barrier, sense); + } + scatter(f->world, f->motion, mine, mine_end); + f3d_barrier_wait(&f->barrier, sense); +} + +/* One worker's walk through the step: the same stages in the same order as + * the deterministic mode's, each body's motion on whichever worker has it, + * joints on worker nought, contacts a colour at a time. */ +static void fast_walk(void *context, uint32_t w, uint32_t begin_unused, uint32_t end_unused) { + (void)begin_unused; + (void)end_unused; + FastStep *f = (FastStep *)context; + F3dWorld *world = f->world; + uint32_t sense = 0, begin = 0, end = 0; + share_of(f->used, w, f->workers, &begin, &end); + for (uint32_t step = 0; step < f->substeps; step++) { + for (uint32_t i = begin; i < end; i++) { + F3dSlot *s = &world->slots[i]; + if (moves(s)) f3d_integrate_velocity(world, s, f->h); + } + f3d_barrier_wait(&f->barrier, &sense); + if (w == 0) f3d_warm_joints(world, f->bodies); + f3d_barrier_wait(&f->barrier, &sense); + contact_stage(f, w, &sense, WARM); + if (w == 0) f3d_solve_joints(world, f->bodies, f->h, 1); + f3d_barrier_wait(&f->barrier, &sense); + contact_stage(f, w, &sense, SOLVE); + for (uint32_t i = begin; i < end; i++) { + F3dSlot *s = &world->slots[i]; + if (!moves(s)) continue; + f3d_integrate_position(s, f->h); + if (f->bodies[i].bullet >= 0) { + const size_t at = (size_t)f->bodies[i].bullet * f->row + step + 1u; + world->bullet_at[at] = s->position; + world->bullet_turn[at] = s->orientation; + } + } + f3d_barrier_wait(&f->barrier, &sense); + if (w == 0) f3d_solve_joints(world, f->bodies, f->h, 0); + f3d_barrier_wait(&f->barrier, &sense); + contact_stage(f, w, &sense, RELAX); + } + contact_stage(f, w, &sense, RESTITUTE); +} + +/* The lowest bit not set in [taken], or F3D_COLOURS for none. */ +static uint32_t free_colour(uint64_t taken) { + for (uint32_t k = 0; k < F3D_COLOURS; k++) { + if (!((taken >> k) & 1u)) return k; + } + return F3D_COLOURS; +} + +/* The fast mode's substeps and restitution, in place of the deterministic + * mode's. Colours greedily in contact order — a contact takes the lowest + * colour neither of its moving bodies has — so the colouring, and so the + * bits, depend on the world alone and not on the threads. */ +static int fast_solve(F3dWorld *world, const SolverBody *bodies, Motion *motion, + SolverContact *contacts, uint32_t count, Softness softness, f3d_real h, + uint32_t row, uint8_t *room) { + const uint32_t used = world->s.used; + uint64_t *taken = (uint64_t *)room; + uint32_t *colour = (uint32_t *)(taken + used); + uint32_t *order = colour + count; + FastStep f; + f3d_zero(&f, sizeof f); + for (uint32_t i = 0; i < used; i++) taken[i] = 0; + for (uint32_t c = 0; c < count; c++) { + const uint32_t a = contacts[c].a, b = contacts[c].b; + const int ma = bodies[a].inverse_mass > F3D_R(0.0); + const int mb = bodies[b].inverse_mass > F3D_R(0.0); + const uint32_t k = free_colour((ma ? taken[a] : 0u) | (mb ? taken[b] : 0u)); + if (k < F3D_COLOURS) { + if (ma) taken[a] |= (uint64_t)1 << k; + if (mb) taken[b] |= (uint64_t)1 << k; + } + colour[c] = k; + f.group_start[k + 1u]++; + } + for (uint32_t g = 0; g <= F3D_COLOURS; g++) f.group_start[g + 1u] += f.group_start[g]; + uint32_t fill[F3D_COLOURS + 1u]; + for (uint32_t g = 0; g <= F3D_COLOURS; g++) fill[g] = f.group_start[g]; + /* The overflow in contact order: its contacts may share a body, and are + * solved one after another. Within a colour, the contacts of one point + * first, then two, and so on, so a batch's lanes mostly have as many + * points as each other: a colour's contacts share no moving body, and + * the order they are solved in changes nothing. */ + for (uint32_t c = 0; c < count; c++) { + if (colour[c] == F3D_COLOURS) order[fill[F3D_COLOURS]++] = c; + } + for (uint32_t points = 1; points <= F3D_MANIFOLD_POINTS; points++) { + for (uint32_t c = 0; c < count; c++) { + const uint32_t n = contacts[c].count > 1u ? contacts[c].count : 1u; + if (colour[c] < F3D_COLOURS && n == points) order[fill[colour[c]]++] = c; + } + } + /* Each colour in batches of F3D_LANES, in that order, aligned for the + * vectors past the order. */ + uintptr_t at = (uintptr_t)(order + count); + at = (at + 63u) & ~(uintptr_t)63u; + f.batches = (WideBatch *)at; + uint32_t batches = 0; + for (uint32_t g = 0; g < F3D_COLOURS; g++) { + f.batch_start[g] = batches; + for (uint32_t k = f.group_start[g]; k < f.group_start[g + 1u]; k += F3D_LANES) { + const uint32_t n = f.group_start[g + 1u] - k < F3D_LANES ? f.group_start[g + 1u] - k + : F3D_LANES; + wide_fill(&f.batches[batches++], world, bodies, contacts, order, k, n); + } + } + f.batch_start[F3D_COLOURS] = batches; + f.world = world; + f.bodies = bodies; + f.motion = motion; + f.contacts = contacts; + f.order = order; + f.softness = softness; + f.h = h; + f.substeps = world->s.substeps; + f.used = used; + f.row = row; + f.workers = f3d_pool_size(world->pool); + f3d_barrier_init(&f.barrier, f.workers); + f3d_pool_run(world->pool, f.workers, fast_walk, &f); + for (uint32_t k = 0; k < batches; k++) wide_store(&f.batches[k], world, contacts); + return 1; +} + +void f3d_step_solve(F3dWorld *world, f3d_real dt) { + const uint32_t used = world->s.used; + const uint32_t substeps = world->s.substeps; + const f3d_real h = dt / (f3d_real)substeps; + /* The bodies, then the contacts, in the world's scratch. */ + const size_t body_bytes = ((size_t)used * sizeof(SolverBody) + 15u) & + ~(size_t)15u; + const size_t motion_bytes = ((size_t)used * sizeof(Motion) + 15u) & ~(size_t)15u; + const size_t contact_bytes = ((size_t)world->s.manifold_count * sizeof(SolverContact) + 15u) & + ~(size_t)15u; + /* The fast mode's colouring after them: a mask a body, two words a + * contact, and the batches. */ + const size_t fast_bytes = + world->s.fast ? (size_t)used * sizeof(uint64_t) + (size_t)world->s.manifold_count * 8u + + ((size_t)world->s.manifold_count / F3D_LANES + F3D_COLOURS + 1u) * + sizeof(WideBatch) + + 64u + : 0u; + uint8_t *base = + (uint8_t *)f3d_scratch(world, body_bytes + motion_bytes + contact_bytes + fast_bytes); + if (base == NULL && used > 0) return; + SolverBody *bodies = (SolverBody *)base; + Motion *motion = (Motion *)(base + body_bytes); + SolverContact *contacts = (SolverContact *)(base + body_bytes + motion_bytes); + for (uint32_t i = 0; i < used; i++) { + const F3dSlot *s = &world->slots[i]; + SolverBody *sb = &bodies[i]; + f3d_zero(sb, sizeof *sb); + if (!s->live) continue; + sb->start = s->position; + sb->turn = s->orientation; + sb->bullet = -1; + if (moves(s)) { + sb->inverse_mass = s->inverse_mass; + sb->inverse_inertia = f3d_sym_turned(s->orientation, s->inverse_inertia); + } + } + /* Each manifold the solver can act on — one of its bodies awake and + * dynamic — made ready: anchors, masses, and the approach speed. */ + uint32_t count = 0; + for (uint32_t i = 0; i < world->s.manifold_count; i++) { + F3dManifold *m = &world->manifolds[i]; + const uint32_t a = (uint32_t)(m->a & 0xffffffffu); + const uint32_t b = (uint32_t)(m->b & 0xffffffffu); + const F3dSlot *sa = &world->slots[a], *sb = &world->slots[b]; + if (!moves(sa) && !moves(sb)) continue; + SolverContact *sc = &contacts[count++]; + sc->a = a; + sc->b = b; + sc->manifold = i; + sc->count = m->count; + sc->normal = m->normal; + sc->tangent[0] = square_to(m->normal); + sc->tangent[1] = f3d_cross(m->normal, sc->tangent[0]); + /* Friction the geometric mean, so either surface can make it slippery; + * restitution the larger, so either can make it bounce. */ + sc->friction = f3d_sqrt(sa->friction * sb->friction); + sc->restitution = f3d_max(sa->restitution, sb->restitution); + for (uint32_t k = 0; k < m->count; k++) { + SolverPoint *p = &sc->p[k]; + const F3dContactPoint *mp = &m->points[k]; + p->ra = f3d_sub(mp->point, sa->position); + p->rb = f3d_sub(mp->point, sb->position); + p->base = -mp->depth; + p->normal_mass = + mass_along(&bodies[a], &bodies[b], p->ra, p->rb, m->normal); + p->tangent_mass[0] = + mass_along(&bodies[a], &bodies[b], p->ra, p->rb, sc->tangent[0]); + p->tangent_mass[1] = + mass_along(&bodies[a], &bodies[b], p->ra, p->rb, sc->tangent[1]); + p->approach = f3d_dot(relative(sa, sb, p->ra, p->rb), m->normal); + p->most = F3D_R(0.0); + } + } + /* A quarter of the substep rate, and no more than thirty hertz. */ + const f3d_real hertz = f3d_min(F3D_R(30.0), F3D_R(0.25) / h); + const Softness softness = soft(hertz, F3D_CONTACT_DAMPING, h); + /* The bullets' paths: where each stands before the first substep and + * after every one, so the sweep follows a turn of more than half a + * revolution in a step, which its two ends alone would read the short + * way round. */ + uint32_t bullets = 0; + for (uint32_t i = 0; i < used; i++) { + const F3dSlot *s = &world->slots[i]; + if (moves(s) && (s->flags & F3D_FLAG_BULLET)) bodies[i].bullet = (int32_t)bullets++; + } + const uint32_t row = substeps + 1u; + if (bullets * row > world->bullet_capacity) { + F3dVec3 *at = (F3dVec3 *)f3d_realloc(world->bullet_at, + (size_t)bullets * row * sizeof(F3dVec3)); + if (at != NULL) world->bullet_at = at; + F3dQuat *turn = (F3dQuat *)f3d_realloc( + world->bullet_turn, (size_t)bullets * row * sizeof(F3dQuat)); + if (turn != NULL) world->bullet_turn = turn; + if (at != NULL && turn != NULL) { + world->bullet_capacity = bullets * row; + } else { + for (uint32_t i = 0; i < used; i++) bodies[i].bullet = -1; + bullets = 0; + } + } + for (uint32_t i = 0; i < used && bullets > 0; i++) { + if (bodies[i].bullet < 0) continue; + const size_t at = (size_t)bodies[i].bullet * row; + world->bullet_at[at] = world->slots[i].position; + world->bullet_turn[at] = world->slots[i].orientation; + } + if (world->s.fast) { + fast_solve(world, bodies, motion, contacts, count, softness, h, row, + base + body_bytes + motion_bytes + contact_bytes); + } + for (uint32_t step = 0; step < substeps && !world->s.fast; step++) { + for (uint32_t i = 0; i < used; i++) { + F3dSlot *s = &world->slots[i]; + if (moves(s)) f3d_integrate_velocity(world, s, h); + } + f3d_warm_joints(world, bodies); + warm_start(world, contacts, count, bodies, motion); + f3d_solve_joints(world, bodies, h, 1); + solve(world, contacts, count, bodies, motion, softness, h, 1); + for (uint32_t i = 0; i < used; i++) { + F3dSlot *s = &world->slots[i]; + if (!moves(s)) continue; + f3d_integrate_position(s, h); + if (bodies[i].bullet >= 0) { + const size_t at = (size_t)bodies[i].bullet * row + step + 1u; + world->bullet_at[at] = s->position; + world->bullet_turn[at] = s->orientation; + } + } + f3d_solve_joints(world, bodies, h, 0); + solve(world, contacts, count, bodies, motion, softness, h, 0); + } + if (!world->s.fast) restitute(world, contacts, count, bodies, motion); + f3d_step_continuous(world, bodies); + for (uint32_t i = 0; i < used; i++) { + F3dSlot *s = &world->slots[i]; + if (s->live) f3d_finish_motion(world, s, dt); + } +} diff --git a/packages/flutter3d_physics_native/csrc/src/f3d_tree.c b/packages/flutter3d_physics_native/csrc/src/f3d_tree.c new file mode 100644 index 000000000..151fcc35e --- /dev/null +++ b/packages/flutter3d_physics_native/csrc/src/f3d_tree.c @@ -0,0 +1,573 @@ +/* + * The broadphase tree — P9, phase 4: a dynamic tree of boxes, as Box2D's + * b2DynamicTree builds it, in three dimensions. + * + * Each body with a shape has a leaf holding a fat box — its own box grown + * by F3D_FAT_MARGIN — so a body moving a little stays in its leaf and the + * tree is not touched. A leaf goes in beside the node that costs least by + * surface area, which is what a ray or a box searching the tree pays for, + * and every node on the way back up is balanced by a rotation when one + * side is two deeper than the other. + * + * The tree's shape depends on the order bodies arrive in, but nothing that + * leaves the core does: the pairs it finds are sorted by slot, and a + * contact depends only on the two shapes. So the tree is left out of a + * snapshot and built again after a restore, and the restored world still + * steps to the same bytes. + */ +#include "f3d_internal.h" + +static F3dBox merge(F3dBox a, F3dBox b) { + F3dBox m; + m.lo = f3d_v3(f3d_min(a.lo.x, b.lo.x), f3d_min(a.lo.y, b.lo.y), + f3d_min(a.lo.z, b.lo.z)); + m.hi = f3d_v3(f3d_max(a.hi.x, b.hi.x), f3d_max(a.hi.y, b.hi.y), + f3d_max(a.hi.z, b.hi.z)); + return m; +} + +/* Half the surface area, which orders boxes as the area does. */ +static f3d_real area(F3dBox b) { + const f3d_real x = b.hi.x - b.lo.x, y = b.hi.y - b.lo.y; + const f3d_real z = b.hi.z - b.lo.z; + return x * y + y * z + z * x; +} + +static int contains(F3dBox outer, F3dBox inner) { + return outer.lo.x <= inner.lo.x && outer.lo.y <= inner.lo.y && + outer.lo.z <= inner.lo.z && inner.hi.x <= outer.hi.x && + inner.hi.y <= outer.hi.y && inner.hi.z <= outer.hi.z; +} + +static int is_leaf(const F3dTreeNode *n) { return n->child1 == -1; } + +static int32_t max_i(int32_t a, int32_t b) { return a > b ? a : b; } + +static int32_t allocate(F3dTree *tree) { + if (tree->free_list == -1) { + const uint32_t grown = tree->capacity == 0 ? 64u : tree->capacity * 2u; + if (grown <= tree->capacity || grown > (uint32_t)INT32_MAX) return -1; + F3dTreeNode *nodes = (F3dTreeNode *)f3d_realloc( + tree->nodes, (size_t)grown * sizeof(F3dTreeNode)); + if (nodes == NULL) return -1; + for (uint32_t i = tree->capacity; i < grown; i++) { + f3d_zero(&nodes[i], sizeof nodes[i]); + nodes[i].parent = i + 1u < grown ? (int32_t)(i + 1u) : -1; + nodes[i].height = -1; + } + tree->nodes = nodes; + tree->free_list = (int32_t)tree->capacity; + tree->capacity = grown; + } + const int32_t id = tree->free_list; + F3dTreeNode *n = &tree->nodes[id]; + tree->free_list = n->parent; + n->parent = -1; + n->child1 = n->child2 = -1; + n->height = 0; + n->slot = 0; + return id; +} + +static void release(F3dTree *tree, int32_t id) { + tree->nodes[id].parent = tree->free_list; + tree->nodes[id].height = -1; + tree->free_list = id; +} + +/* One rotation at [a] when one child is two deeper than the other; returns + * the node now where [a] was. Erin Catto's, from Box2D. */ +static int32_t balance(F3dTree *tree, int32_t ia) { + F3dTreeNode *nodes = tree->nodes; + F3dTreeNode *a = &nodes[ia]; + if (is_leaf(a) || a->height < 2) return ia; + const int32_t ib = a->child1, ic = a->child2; + F3dTreeNode *b = &nodes[ib], *c = &nodes[ic]; + const int32_t lean = c->height - b->height; + if (lean > 1) { + /* Raise c. */ + const int32_t i_f = c->child1, ig = c->child2; + F3dTreeNode *f = &nodes[i_f], *g = &nodes[ig]; + c->child1 = ia; + c->parent = a->parent; + a->parent = ic; + if (c->parent != -1) { + if (nodes[c->parent].child1 == ia) { + nodes[c->parent].child1 = ic; + } else { + nodes[c->parent].child2 = ic; + } + } else { + tree->root = ic; + } + if (f->height > g->height) { + c->child2 = i_f; + a->child2 = ig; + g->parent = ia; + a->box = merge(b->box, g->box); + c->box = merge(a->box, f->box); + a->height = 1 + max_i(b->height, g->height); + c->height = 1 + max_i(a->height, f->height); + } else { + c->child2 = ig; + a->child2 = i_f; + f->parent = ia; + a->box = merge(b->box, f->box); + c->box = merge(a->box, g->box); + a->height = 1 + max_i(b->height, f->height); + c->height = 1 + max_i(a->height, g->height); + } + return ic; + } + if (lean < -1) { + /* Raise b. */ + const int32_t id = b->child1, ie = b->child2; + F3dTreeNode *d = &nodes[id], *e = &nodes[ie]; + b->child1 = ia; + b->parent = a->parent; + a->parent = ib; + if (b->parent != -1) { + if (nodes[b->parent].child1 == ia) { + nodes[b->parent].child1 = ib; + } else { + nodes[b->parent].child2 = ib; + } + } else { + tree->root = ib; + } + if (d->height > e->height) { + b->child2 = id; + a->child1 = ie; + e->parent = ia; + a->box = merge(c->box, e->box); + b->box = merge(a->box, d->box); + a->height = 1 + max_i(c->height, e->height); + b->height = 1 + max_i(a->height, d->height); + } else { + b->child2 = ie; + a->child1 = id; + d->parent = ia; + a->box = merge(c->box, d->box); + b->box = merge(a->box, e->box); + a->height = 1 + max_i(c->height, d->height); + b->height = 1 + max_i(a->height, e->height); + } + return ib; + } + return ia; +} + +/* Refits and balances from [index] up to the root. */ +static void climb(F3dTree *tree, int32_t index) { + while (index != -1) { + index = balance(tree, index); + F3dTreeNode *n = &tree->nodes[index]; + const F3dTreeNode *c1 = &tree->nodes[n->child1]; + const F3dTreeNode *c2 = &tree->nodes[n->child2]; + n->height = 1 + max_i(c1->height, c2->height); + n->box = merge(c1->box, c2->box); + index = n->parent; + } +} + +int32_t f3d_tree_insert(F3dTree *tree, F3dBox box, uint32_t slot) { + const int32_t leaf = allocate(tree); + if (leaf == -1) return -1; + tree->nodes[leaf].box = box; + tree->nodes[leaf].slot = slot; + tree->leaves++; + if (tree->root == -1) { + tree->root = leaf; + return leaf; + } + /* The sibling: down from the root, at each node weighing a new parent + * here against going on into either child, by the surface area each adds + * — what goes in costs its own area, and every ancestor it widens costs + * what it widens. */ + int32_t index = tree->root; + while (!is_leaf(&tree->nodes[index])) { + const F3dTreeNode *n = &tree->nodes[index]; + const f3d_real here = area(n->box); + const f3d_real joined = area(merge(n->box, box)); + const f3d_real cost = F3D_R(2.0) * joined; + const f3d_real inherited = F3D_R(2.0) * (joined - here); + f3d_real child_cost[2]; + const int32_t children[2] = {n->child1, n->child2}; + for (int k = 0; k < 2; k++) { + const F3dTreeNode *c = &tree->nodes[children[k]]; + const f3d_real grown = area(merge(c->box, box)); + child_cost[k] = (is_leaf(c) ? grown : grown - area(c->box)) + inherited; + } + if (cost < child_cost[0] && cost < child_cost[1]) break; + index = child_cost[0] <= child_cost[1] ? children[0] : children[1]; + } + const int32_t sibling = index; + const int32_t old_parent = tree->nodes[sibling].parent; + const int32_t parent = allocate(tree); + if (parent == -1) { + release(tree, leaf); + tree->leaves--; + return -1; + } + F3dTreeNode *nodes = tree->nodes; /* allocate may have moved them */ + nodes[parent].parent = old_parent; + nodes[parent].box = merge(box, nodes[sibling].box); + nodes[parent].height = nodes[sibling].height + 1; + nodes[parent].child1 = sibling; + nodes[parent].child2 = leaf; + nodes[sibling].parent = parent; + nodes[leaf].parent = parent; + if (old_parent != -1) { + if (nodes[old_parent].child1 == sibling) { + nodes[old_parent].child1 = parent; + } else { + nodes[old_parent].child2 = parent; + } + } else { + tree->root = parent; + } + climb(tree, nodes[leaf].parent); + return leaf; +} + +void f3d_tree_remove(F3dTree *tree, int32_t leaf) { + F3dTreeNode *nodes = tree->nodes; + tree->leaves--; + if (leaf == tree->root) { + tree->root = -1; + release(tree, leaf); + return; + } + const int32_t parent = nodes[leaf].parent; + const int32_t grand = nodes[parent].parent; + const int32_t sibling = + nodes[parent].child1 == leaf ? nodes[parent].child2 : nodes[parent].child1; + if (grand != -1) { + if (nodes[grand].child1 == parent) { + nodes[grand].child1 = sibling; + } else { + nodes[grand].child2 = sibling; + } + nodes[sibling].parent = grand; + release(tree, parent); + climb(tree, grand); + } else { + tree->root = sibling; + nodes[sibling].parent = -1; + release(tree, parent); + } + release(tree, leaf); +} + +void f3d_tree_clear(F3dTree *tree) { + f3d_free(tree->nodes); + f3d_zero(tree, sizeof *tree); + tree->root = -1; + tree->free_list = -1; +} + +void f3d_tree_query(const F3dTree *tree, F3dBox box, + int (*visit)(void *context, int32_t leaf), void *context) { + if (tree->root == -1) return; + /* A balanced tree of every body a computer can hold is under a hundred + * deep, and a depth-first walk keeps one more than the depth waiting. */ + int32_t stack[256]; + int top = 0; + stack[top++] = tree->root; + while (top > 0) { + const int32_t id = stack[--top]; + const F3dTreeNode *n = &tree->nodes[id]; + if (!f3d_box_overlap(n->box, box)) continue; + if (is_leaf(n)) { + if (!visit(context, id)) return; + } else if (top + 2 <= 256) { + stack[top++] = n->child2; + stack[top++] = n->child1; + } + } +} + +/* Where the segment enters [b], as a fraction of [dir], or −1 when it + * misses it within [limit]: the slab test, an axis at a time. */ +static f3d_real ray_box(F3dBox b, F3dVec3 o, F3dVec3 inv, int parallel[3], + f3d_real limit) { + f3d_real lo = F3D_R(0.0), hi = limit; + const f3d_real os[3] = {o.x, o.y, o.z}; + const f3d_real is[3] = {inv.x, inv.y, inv.z}; + const f3d_real bl[3] = {b.lo.x, b.lo.y, b.lo.z}; + const f3d_real bh[3] = {b.hi.x, b.hi.y, b.hi.z}; + for (int k = 0; k < 3; k++) { + if (parallel[k]) { + if (os[k] < bl[k] || os[k] > bh[k]) return F3D_R(-1.0); + continue; + } + f3d_real t0 = (bl[k] - os[k]) * is[k], t1 = (bh[k] - os[k]) * is[k]; + if (t0 > t1) { + const f3d_real t = t0; + t0 = t1; + t1 = t; + } + lo = f3d_max(lo, t0); + hi = f3d_min(hi, t1); + if (lo > hi) return F3D_R(-1.0); + } + return lo; +} + +void f3d_tree_ray(const F3dTree *tree, F3dVec3 origin, F3dVec3 dir, + f3d_real *limit, + int (*visit)(void *context, int32_t leaf), void *context) { + if (tree->root == -1) return; + int parallel[3] = {dir.x == F3D_R(0.0), dir.y == F3D_R(0.0), + dir.z == F3D_R(0.0)}; + const F3dVec3 inv = f3d_v3(parallel[0] ? F3D_R(0.0) : F3D_R(1.0) / dir.x, + parallel[1] ? F3D_R(0.0) : F3D_R(1.0) / dir.y, + parallel[2] ? F3D_R(0.0) : F3D_R(1.0) / dir.z); + int32_t stack[256]; + int top = 0; + stack[top++] = tree->root; + while (top > 0) { + const int32_t id = stack[--top]; + const F3dTreeNode *n = &tree->nodes[id]; + if (ray_box(n->box, origin, inv, parallel, *limit) < F3D_R(0.0)) continue; + if (n->child1 == -1) { + if (!visit(context, id)) return; + continue; + } + if (top + 2 > 256) continue; + /* The nearer child last onto the stack, so it is walked first and a + * near hit cuts the far one short. */ + const f3d_real t1 = ray_box(tree->nodes[n->child1].box, origin, inv, + parallel, *limit); + const f3d_real t2 = ray_box(tree->nodes[n->child2].box, origin, inv, + parallel, *limit); + if (t1 >= F3D_R(0.0) && t2 >= F3D_R(0.0) && t2 < t1) { + stack[top++] = n->child1; + stack[top++] = n->child2; + } else { + stack[top++] = n->child2; + stack[top++] = n->child1; + } + } +} + +F3dBox f3d_box_of(const F3dWorld *world, const F3dSlot *s, f3d_real margin) { + const F3dMat3 m = f3d_mat_of(s->orientation); + F3dVec3 reach; + switch (s->shape) { + case F3D_SHAPE_SPHERE: + reach = f3d_v3(s->size.x, s->size.x, s->size.x); + break; + case F3D_SHAPE_CAPSULE: { + const F3dVec3 axis = m.c[1]; + reach = f3d_v3(f3d_abs(axis.x) * s->size.y + s->size.x, + f3d_abs(axis.y) * s->size.y + s->size.x, + f3d_abs(axis.z) * s->size.y + s->size.x); + break; + } + case F3D_SHAPE_CYLINDER: + case F3D_SHAPE_CONE: { + /* A disc of radius r reaches r √(1 − a²) along a world axis whose + * share of its own axis is a; the cylinder's and the cone's ends are + * discs. Bounded here by the box round the shape's own box, which is + * at most a few per cent larger and needs no root per axis. */ + const f3d_real r = s->size.x; + const f3d_real h = s->shape == F3D_SHAPE_CYLINDER + ? s->size.y + : F3D_R(0.75) * s->size.y; + const f3d_real hl[3] = {r, h, r}; + reach = f3d_v3(F3D_R(0.0), F3D_R(0.0), F3D_R(0.0)); + for (int k = 0; k < 3; k++) { + reach.x += f3d_abs(m.c[k].x) * hl[k]; + reach.y += f3d_abs(m.c[k].y) * hl[k]; + reach.z += f3d_abs(m.c[k].z) * hl[k]; + } + break; + } + case F3D_SHAPE_HULL: + case F3D_SHAPE_MESH: { + /* The hull's or mesh's own box, turned: its centre may sit off the + * origin. */ + F3dVec3 blo, bhi; + if (s->shape == F3D_SHAPE_HULL && s->hull != 0 && + s->hull <= world->s.hull_count) { + blo = world->hulls[s->hull - 1u].lo; + bhi = world->hulls[s->hull - 1u].hi; + } else if (s->shape == F3D_SHAPE_MESH && s->hull != 0 && + s->hull <= world->s.mesh_count) { + blo = world->meshes[s->hull - 1u].lo; + bhi = world->meshes[s->hull - 1u].hi; + } else { + reach = f3d_v3(F3D_R(0.0), F3D_R(0.0), F3D_R(0.0)); + break; + } + const F3dVec3 c = f3d_scale(f3d_add(blo, bhi), F3D_R(0.5)); + const F3dVec3 hh = f3d_scale(f3d_sub(bhi, blo), F3D_R(0.5)); + const f3d_real hl[3] = {hh.x, hh.y, hh.z}; + const F3dVec3 shift = f3d_add( + f3d_add(f3d_scale(m.c[0], c.x), f3d_scale(m.c[1], c.y)), + f3d_scale(m.c[2], c.z)); + reach = f3d_v3(F3D_R(0.0), F3D_R(0.0), F3D_R(0.0)); + for (int k = 0; k < 3; k++) { + reach.x += f3d_abs(m.c[k].x) * hl[k]; + reach.y += f3d_abs(m.c[k].y) * hl[k]; + reach.z += f3d_abs(m.c[k].z) * hl[k]; + } + const f3d_real grow = margin + s->rounding; + reach = f3d_add(reach, f3d_v3(grow, grow, grow)); + F3dBox b; + b.lo = f3d_sub(f3d_add(s->position, shift), reach); + b.hi = f3d_add(f3d_add(s->position, shift), reach); + return b; + } + default: { + const f3d_real h[3] = {s->size.x, s->size.y, s->size.z}; + reach = f3d_v3(F3D_R(0.0), F3D_R(0.0), F3D_R(0.0)); + for (int k = 0; k < 3; k++) { + reach.x += f3d_abs(m.c[k].x) * h[k]; + reach.y += f3d_abs(m.c[k].y) * h[k]; + reach.z += f3d_abs(m.c[k].z) * h[k]; + } + break; + } + } + const f3d_real grow = margin + s->rounding; + reach = f3d_add(reach, f3d_v3(grow, grow, grow)); + F3dBox b; + b.lo = f3d_sub(s->position, reach); + b.hi = f3d_add(s->position, reach); + return b; +} + +/* Room for a leaf index per slot of the arena. */ +static int reserve_proxies(F3dWorld *world) { + if (world->s.used <= world->proxy_capacity) return 1; + uint32_t grown = world->proxy_capacity == 0 ? 64u : world->proxy_capacity; + while (grown < world->s.used) grown *= 2u; + int32_t *proxies = + (int32_t *)f3d_realloc(world->proxies, (size_t)grown * sizeof(int32_t)); + if (proxies == NULL) return 0; + for (uint32_t i = world->proxy_capacity; i < grown; i++) proxies[i] = -1; + world->proxies = proxies; + world->proxy_capacity = grown; + return 1; +} + +/* Slot [i]'s leaf went in or came out: its pairs are to be found again. + * When the list cannot grow, every pair is. */ +static void note_moved(F3dWorld *world, uint32_t i) { + if (!world->pairs_ready) return; + if (world->moved_count == world->moved_capacity) { + const uint32_t grown = world->moved_capacity == 0 ? 64u : world->moved_capacity * 2u; + uint32_t *moved = (uint32_t *)f3d_realloc(world->moved, (size_t)grown * sizeof(uint32_t)); + if (moved == NULL) { + world->pairs_ready = 0; + return; + } + world->moved = moved; + world->moved_capacity = grown; + } + world->moved[world->moved_count++] = i; +} + +void f3d_update_proxies(F3dWorld *world, f3d_real dt) { + if (!reserve_proxies(world)) return; + F3dTree *tree = &world->tree; + if (tree->capacity == 0 && tree->root == 0 && tree->free_list == 0) { + tree->root = -1; + tree->free_list = -1; + } + /* Half the contact margin on each body's side, so two leaves that do not + * overlap are further apart than a contact reaches. */ + const f3d_real half = F3D_R(0.5) * world->s.contact_margin; + for (uint32_t i = 0; i < world->s.used; i++) { + const F3dSlot *s = &world->slots[i]; + int32_t *leaf = &world->proxies[i]; + const int wanted = s->live && s->shape != F3D_SHAPE_POINT; + if (!wanted) { + if (*leaf != -1) { + f3d_tree_remove(tree, *leaf); + *leaf = -1; + note_moved(world, i); + } + continue; + } + const F3dBox tight = f3d_swept_box(world, s, half, dt); + if (*leaf != -1) { + /* A slot taken by a new body keeps its old leaf only if the box still + * fits: the leaf holds no handle, so it cannot go stale otherwise. */ + if (contains(tree->nodes[*leaf].box, tight)) continue; + f3d_tree_remove(tree, *leaf); + *leaf = -1; + } + F3dBox fat = tight; + const F3dVec3 grow = f3d_v3(F3D_FAT_MARGIN, F3D_FAT_MARGIN, F3D_FAT_MARGIN); + fat.lo = f3d_sub(fat.lo, grow); + fat.hi = f3d_add(fat.hi, grow); + *leaf = f3d_tree_insert(tree, fat, i); + note_moved(world, i); + } +} + +typedef struct Found { + const F3dWorld *world; + F3dBox box; + F3dBody *out; + uint32_t capacity; + uint32_t count; +} Found; + +static int found_one(void *context, int32_t leaf) { + Found *f = (Found *)context; + const uint32_t slot = f->world->tree.nodes[leaf].slot; + const F3dSlot *s = &f->world->slots[slot]; + if (!f3d_box_overlap(f3d_box_of(f->world, s, F3D_R(0.0)), f->box)) return 1; + /* Kept in slot order as they come, so the answer does not depend on the + * tree's shape: an insertion into the sorted part found so far. */ + const F3dBody handle = f3d_handle_of(f->world, s); + uint32_t at = f->count < f->capacity ? f->count : f->capacity; + while (at > 0 && (uint32_t)(f->out[at - 1] & 0xffffffffu) > slot) at--; + if (at < f->capacity) { + const uint32_t last = f->count < f->capacity ? f->count : f->capacity - 1u; + for (uint32_t k = last; k > at; k--) f->out[k] = f->out[k - 1]; + f->out[at] = handle; + } + f->count++; + return 1; +} + +F3dBox f3d_swept_box(const F3dWorld *world, const F3dSlot *s, f3d_real margin, + f3d_real dt) { + F3dBox b = f3d_box_of(world, s, margin); + if (!world->s.speculative || s->type != F3D_BODY_DYNAMIC || + (s->flags & F3D_FLAG_ASLEEP)) { + return b; + } + const F3dVec3 move = f3d_scale(s->velocity, dt); + b.lo = f3d_v3(f3d_min(b.lo.x, b.lo.x + move.x), f3d_min(b.lo.y, b.lo.y + move.y), + f3d_min(b.lo.z, b.lo.z + move.z)); + b.hi = f3d_v3(f3d_max(b.hi.x, b.hi.x + move.x), f3d_max(b.hi.y, b.hi.y + move.y), + f3d_max(b.hi.z, b.hi.z + move.z)); + return b; +} + +f3d_real f3d_reach_of(const F3dWorld *world, const F3dSlot *s) { + const F3dBox b = f3d_box_of(world, s, F3D_R(0.0)); + const F3dVec3 half = f3d_scale(f3d_sub(b.hi, b.lo), F3D_R(0.5)); + return f3d_sqrt(f3d_dot(half, half)); +} + +uint32_t f3d_world_query_box(F3dWorld *world, f3d_real lx, f3d_real ly, + f3d_real lz, f3d_real hx, f3d_real hy, + f3d_real hz, F3dBody *out, uint32_t capacity) { + f3d_update_proxies(world, F3D_R(0.0)); + Found f; + f.world = world; + f.box.lo = f3d_v3(lx, ly, lz); + f.box.hi = f3d_v3(hx, hy, hz); + f.out = out; + f.capacity = capacity; + f.count = 0; + f3d_tree_query(&world->tree, f.box, found_one, &f); + return f.count; +} diff --git a/packages/flutter3d_physics_native/csrc/src/f3d_world.c b/packages/flutter3d_physics_native/csrc/src/f3d_world.c new file mode 100644 index 000000000..46bcf16d4 --- /dev/null +++ b/packages/flutter3d_physics_native/csrc/src/f3d_world.c @@ -0,0 +1,749 @@ +/* + * The world and its bodies — P9: an arena of bodies named by generational + * handles, the world's air, wind and origin, the bodies' accessors and the + * event queue. The step is in f3d_motion.c and f3d_heat.c, snapshots in + * f3d_snapshot.c. + */ +#include "f3d_internal.h" + +/* Room for this many bodies before the arena first grows. */ +#define F3D_INITIAL_CAPACITY 64u + +uint32_t f3d_abi_version(void) { return F3D_ABI_VERSION; } + +uint32_t f3d_real_bytes(void) { return (uint32_t)sizeof(f3d_real); } + +void *f3d_buffer_alloc(uint32_t bytes) { return f3d_alloc(bytes); } + +void f3d_buffer_free(void *buffer) { f3d_free(buffer); } + +F3dWorld *f3d_world_create(void) { + F3dWorld *world = (F3dWorld *)f3d_alloc(sizeof(F3dWorld)); + if (world == NULL) return NULL; + f3d_zero(world, sizeof(F3dWorld)); + world->s.gravity.y = F3D_R(-9.81); + world->s.air_temperature = F3D_R(293.15); + world->s.air_density = F3D_R(1.204); + world->s.sleep_speed = F3D_R(0.05); + world->s.sleep_time = F3D_R(0.5); + world->s.contact_margin = F3D_R(0.02); + world->s.substeps = 4u; + world->s.speculative = 1u; + world->tree.root = -1; + world->tree.free_list = -1; + return world; +} + +void f3d_world_destroy(F3dWorld *world) { + if (world == NULL) return; + f3d_free(world->slots); + f3d_free(world->grid); + f3d_free(world->events); + f3d_free(world->manifolds); + f3d_free(world->next_manifolds); + f3d_free(world->scratch); + f3d_tree_clear(&world->tree); + f3d_free(world->proxies); + f3d_free(world->hulls); + f3d_free(world->hull_vertices); + f3d_free(world->hull_triangles); + f3d_free(world->meshes); + f3d_free(world->mesh_vertices); + f3d_free(world->mesh_triangles); + f3d_free(world->mesh_edges); + f3d_clear_mesh_trees(world); + f3d_free(world->joints); + f3d_free(world->joined); + f3d_free(world->bullet_at); + f3d_free(world->bullet_turn); + f3d_free(world->pairs); + f3d_free(world->moved); + f3d_free(world->swept); + f3d_pool_destroy(world->pool); + for (uint32_t i = 0; i < F3D_MAX_THREADS; i++) f3d_free(world->lanes[i].items); + f3d_free(world); +} + +int f3d_world_set_threads(F3dWorld *world, uint32_t threads) { + if (threads == 0 || threads > F3D_MAX_THREADS) return 0; + if (threads == f3d_pool_size(world->pool)) return 1; + F3dPool *pool = threads > 1u ? f3d_pool_create(threads) : NULL; + if (threads > 1u && pool == NULL) return 0; + f3d_pool_destroy(world->pool); + world->pool = pool; + return 1; +} + +uint32_t f3d_world_threads(const F3dWorld *world) { return f3d_pool_size(world->pool); } + +int f3d_world_set_fast(F3dWorld *world, int fast) { + world->s.fast = fast ? 1u : 0u; + return 1; +} + +int f3d_world_fast(const F3dWorld *world) { return world->s.fast != 0; } + +void f3d_world_set_gravity(F3dWorld *world, f3d_real x, f3d_real y, + f3d_real z) { + world->s.gravity.x = x; + world->s.gravity.y = y; + world->s.gravity.z = z; +} + +void f3d_world_get_gravity(const F3dWorld *world, f3d_real *out) { + out[0] = world->s.gravity.x; + out[1] = world->s.gravity.y; + out[2] = world->s.gravity.z; +} + +int f3d_world_set_air(F3dWorld *world, f3d_real temperature, + f3d_real density) { + if (!(f3d_finite(temperature) && temperature > F3D_R(0.0))) return 0; + if (!(f3d_finite(density) && density > F3D_R(0.0))) return 0; + world->s.air_temperature = temperature; + world->s.air_density = density; + return 1; +} + +void f3d_world_get_air(const F3dWorld *world, f3d_real *out) { + out[0] = world->s.air_temperature; + out[1] = world->s.air_density; +} + + +/* A change of wind wakes every body that feels it: asleep, it would hang + * in the new wind as if the old one still blew. */ +static void wake_in_wind(F3dWorld *world) { + for (uint32_t i = 0; i < world->s.used; i++) { + F3dSlot *s = &world->slots[i]; + if (s->live && s->surface > F3D_R(0.0) && s->shape_drag > F3D_R(0.0)) { + f3d_wake(world, s); + } + } +} + +void f3d_world_set_wind(F3dWorld *world, f3d_real x, f3d_real y, f3d_real z) { + world->s.wind.x = x; + world->s.wind.y = y; + world->s.wind.z = z; + wake_in_wind(world); +} + +int f3d_world_set_wind_grid(F3dWorld *world, f3d_real ox, f3d_real oy, + f3d_real oz, f3d_real cell, uint32_t nx, + uint32_t ny, uint32_t nz, + const f3d_real *velocities) { + if (velocities == NULL) { + f3d_free(world->grid); + world->grid = NULL; + world->s.grid_n[0] = world->s.grid_n[1] = world->s.grid_n[2] = 0; + wake_in_wind(world); + return 1; + } + if (nx == 0 || ny == 0 || nz == 0) return 0; + if (!(f3d_finite(cell) && cell > F3D_R(0.0))) return 0; + if (!f3d_finite(ox) || !f3d_finite(oy) || !f3d_finite(oz)) return 0; + const uint64_t count = (uint64_t)nx * ny * nz * 3u; + if (count * sizeof(f3d_real) > (uint64_t)UINT32_MAX) return 0; + f3d_real *grid = (f3d_real *)f3d_alloc((size_t)count * sizeof(f3d_real)); + if (grid == NULL) return 0; + f3d_copy(grid, velocities, (size_t)count * sizeof(f3d_real)); + f3d_free(world->grid); + world->grid = grid; + world->s.grid_origin.x = ox; + world->s.grid_origin.y = oy; + world->s.grid_origin.z = oz; + world->s.grid_cell = cell; + world->s.grid_n[0] = nx; + world->s.grid_n[1] = ny; + world->s.grid_n[2] = nz; + wake_in_wind(world); + return 1; +} + +/* Where [p] falls along one axis of [n] samples: the lower sample and the + * share of the way to the next, held at the ends. */ +static void grid_axis(f3d_real p, uint32_t n, uint32_t *at, f3d_real *t) { + if (n == 1 || !(p > F3D_R(0.0))) { + *at = 0; + *t = F3D_R(0.0); + return; + } + const f3d_real last = (f3d_real)(n - 1); + if (p >= last) { + *at = n - 2; + *t = F3D_R(1.0); + return; + } + uint32_t i = (uint32_t)p; + if (i > n - 2) i = n - 2; + *at = i; + *t = p - (f3d_real)i; +} + +void f3d_world_sample_wind(const F3dWorld *world, f3d_real x, f3d_real y, + f3d_real z, f3d_real *out) { + out[0] = world->s.wind.x; + out[1] = world->s.wind.y; + out[2] = world->s.wind.z; + const uint32_t *n = world->s.grid_n; + if (world->grid == NULL || n[0] == 0) return; + const f3d_real inv = F3D_R(1.0) / world->s.grid_cell; + uint32_t i, j, k; + f3d_real tx, ty, tz; + grid_axis((x - world->s.grid_origin.x) * inv, n[0], &i, &tx); + grid_axis((y - world->s.grid_origin.y) * inv, n[1], &j, &ty); + grid_axis((z - world->s.grid_origin.z) * inv, n[2], &k, &tz); + const uint32_t di = n[0] > 1 ? 1u : 0u; + const uint32_t dj = n[1] > 1 ? 1u : 0u; + const uint32_t dk = n[2] > 1 ? 1u : 0u; + for (uint32_t c = 0; c < 3; c++) { + f3d_real sum = F3D_R(0.0); + for (uint32_t corner = 0; corner < 8; corner++) { + const uint32_t ci = i + ((corner & 1u) ? di : 0u); + const uint32_t cj = j + ((corner & 2u) ? dj : 0u); + const uint32_t ck = k + ((corner & 4u) ? dk : 0u); + const f3d_real w = ((corner & 1u) ? tx : F3D_R(1.0) - tx) * + ((corner & 2u) ? ty : F3D_R(1.0) - ty) * + ((corner & 4u) ? tz : F3D_R(1.0) - tz); + const size_t at = ((size_t)ck * n[1] + cj) * n[0] + ci; + sum += w * world->grid[at * 3u + c]; + } + out[c] += sum; + } +} + +int f3d_world_set_sleep(F3dWorld *world, f3d_real speed, f3d_real time) { + if (!(f3d_finite(speed) && speed >= F3D_R(0.0))) return 0; + if (!(f3d_finite(time) && time >= F3D_R(0.0))) return 0; + world->s.sleep_speed = speed; + world->s.sleep_time = time; + return 1; +} + +void f3d_world_get_origin(const F3dWorld *world, double *out) { + out[0] = world->s.origin[0]; + out[1] = world->s.origin[1]; + out[2] = world->s.origin[2]; +} + +void f3d_world_shift_origin(F3dWorld *world, double dx, double dy, double dz) { + if (!(dx - dx == 0.0 && dy - dy == 0.0 && dz - dz == 0.0)) return; + world->s.origin[0] += dx; + world->s.origin[1] += dy; + world->s.origin[2] += dz; + /* Each position moved in doubles and rounded once, so a body far from + * the old origin lands as near the new one as a real can say. */ + for (uint32_t i = 0; i < world->s.used; i++) { + F3dSlot *s = &world->slots[i]; + if (!s->live) continue; + s->position.x = (f3d_real)((double)s->position.x - dx); + s->position.y = (f3d_real)((double)s->position.y - dy); + s->position.z = (f3d_real)((double)s->position.z - dz); + } + world->s.grid_origin.x = (f3d_real)((double)world->s.grid_origin.x - dx); + world->s.grid_origin.y = (f3d_real)((double)world->s.grid_origin.y - dy); + world->s.grid_origin.z = (f3d_real)((double)world->s.grid_origin.z - dz); +} + +uint32_t f3d_world_body_count(const F3dWorld *world) { return world->s.live; } + +static F3dBody handle_of(uint32_t slot, uint32_t generation) { + return ((uint64_t)generation << 32) | (uint64_t)slot; +} + +F3dBody f3d_handle_of(const F3dWorld *world, const F3dSlot *slot) { + return handle_of((uint32_t)(slot - world->slots), slot->generation); +} + +F3dSlot *f3d_slot_of(const F3dWorld *world, F3dBody body) { + const uint32_t slot = (uint32_t)(body & 0xffffffffu); + const uint32_t generation = (uint32_t)(body >> 32); + if (generation == 0 || slot >= world->s.used) return NULL; + F3dSlot *s = &world->slots[slot]; + return s->live && s->generation == generation ? s : NULL; +} + +void f3d_push_event(F3dWorld *world, F3dBody body, uint32_t kind) { + f3d_push_pair_event(world, body, 0, kind); +} + +void f3d_push_pair_event(F3dWorld *world, F3dBody body, F3dBody other, + uint32_t kind) { + if (world->events == NULL) { + world->events = (F3dEventRecord *)f3d_alloc( + (size_t)F3D_EVENT_CAPACITY * sizeof(F3dEventRecord)); + if (world->events == NULL) { + world->s.events_dropped++; + return; + } + f3d_zero(world->events, + (size_t)F3D_EVENT_CAPACITY * sizeof(F3dEventRecord)); + } + if (world->s.events_count == F3D_EVENT_CAPACITY) { + world->s.events_dropped++; + return; + } + const uint32_t at = + (world->s.events_head + world->s.events_count) % F3D_EVENT_CAPACITY; + world->events[at].body = body; + world->events[at].other = other; + world->events[at].kind = kind; + world->s.events_count++; +} + +uint32_t f3d_world_read_events(F3dWorld *world, F3dBody *bodies, + F3dBody *others, uint32_t *kinds, + uint32_t capacity) { + uint32_t read = 0; + while (read < capacity && world->s.events_count > 0) { + const F3dEventRecord *e = &world->events[world->s.events_head]; + bodies[read] = e->body; + if (others != NULL) others[read] = e->other; + kinds[read] = e->kind; + world->s.events_head = (world->s.events_head + 1u) % F3D_EVENT_CAPACITY; + world->s.events_count--; + read++; + } + return read; +} + +uint32_t f3d_world_events_dropped(const F3dWorld *world) { + return world->s.events_dropped; +} + +/* A free slot's index, growing the arena when none is free; or UINT32_MAX + * when it cannot grow. */ +static uint32_t take_slot(F3dWorld *world) { + if (world->s.free_head != 0) { + const uint32_t slot = world->s.free_head - 1; + world->s.free_head = world->slots[slot].next_free; + return slot; + } + if (world->s.used == world->capacity) { + const uint32_t grown = world->capacity == 0 ? F3D_INITIAL_CAPACITY + : world->capacity * 2u; + if (grown <= world->capacity) return UINT32_MAX; + F3dSlot *slots = + (F3dSlot *)f3d_realloc(world->slots, (size_t)grown * sizeof(F3dSlot)); + if (slots == NULL) return UINT32_MAX; + f3d_zero(slots + world->capacity, + (size_t)(grown - world->capacity) * sizeof(F3dSlot)); + world->slots = slots; + world->capacity = grown; + } + return world->s.used++; +} + +F3dBody f3d_body_create(F3dWorld *world, F3dBodyType type, f3d_real px, + f3d_real py, f3d_real pz, f3d_real mass) { + if (type != F3D_BODY_DYNAMIC && type != F3D_BODY_FIXED) return 0; + if (!f3d_finite(px) || !f3d_finite(py) || !f3d_finite(pz)) return 0; + if (type == F3D_BODY_DYNAMIC && !(f3d_finite(mass) && mass > F3D_R(0.0))) { + return 0; + } + if (!(f3d_finite(mass) && mass >= F3D_R(0.0))) mass = F3D_R(0.0); + const uint32_t slot = take_slot(world); + if (slot == UINT32_MAX) return 0; + F3dSlot *s = &world->slots[slot]; + uint32_t generation = s->generation + 1u; + if (generation == 0) generation = 1; /* Wrapped: nought is never a handle. */ + f3d_zero(s, sizeof(F3dSlot)); + s->generation = generation; + s->live = 1; + s->type = (uint8_t)type; + s->shape = F3D_SHAPE_POINT; + s->position.x = px; + s->position.y = py; + s->position.z = pz; + s->orientation.w = F3D_R(1.0); + s->mass = mass; + f3d_material_preset(F3D_MATERIAL_INERT, &s->material); + s->temperature = world->s.air_temperature; + s->friction = F3D_R(0.6); + s->layer = 1u; + s->mask = UINT32_MAX; + f3d_refresh_mass(world, s); + world->s.live++; + return handle_of(slot, generation); +} + +int f3d_body_destroy(F3dWorld *world, F3dBody body) { + F3dSlot *s = f3d_slot_of(world, body); + if (s == NULL) return 0; + f3d_unjoin(world, (uint32_t)(s - world->slots)); + s->live = 0; + s->next_free = world->s.free_head; + world->s.free_head = (uint32_t)(s - world->slots) + 1u; + world->s.live--; + return 1; +} + +int f3d_body_is_valid(const F3dWorld *world, F3dBody body) { + return f3d_slot_of(world, body) != NULL; +} + +static int finite3(f3d_real x, f3d_real y, f3d_real z) { + return f3d_finite(x) && f3d_finite(y) && f3d_finite(z); +} + +static void set3(F3dVec3 *v, f3d_real x, f3d_real y, f3d_real z) { + v->x = x; + v->y = y; + v->z = z; +} + +static void get3(const F3dVec3 *v, f3d_real *out) { + out[0] = v->x; + out[1] = v->y; + out[2] = v->z; +} + +void f3d_wake(F3dWorld *world, F3dSlot *s) { + s->still = F3D_R(0.0); + if (!(s->flags & F3D_FLAG_ASLEEP)) return; + s->flags &= (uint8_t)~F3D_FLAG_ASLEEP; + f3d_push_event(world, f3d_handle_of(world, s), F3D_EVENT_WOKE); +} + + +int f3d_body_set_velocity(F3dWorld *world, F3dBody body, f3d_real x, + f3d_real y, f3d_real z) { + F3dSlot *s = f3d_slot_of(world, body); + if (s == NULL || !finite3(x, y, z)) return 0; + set3(&s->velocity, x, y, z); + f3d_wake(world, s); + return 1; +} + +int f3d_body_get_velocity(const F3dWorld *world, F3dBody body, f3d_real *out) { + const F3dSlot *s = f3d_slot_of(world, body); + if (s == NULL) return 0; + get3(&s->velocity, out); + return 1; +} + +int f3d_body_set_position(F3dWorld *world, F3dBody body, f3d_real x, + f3d_real y, f3d_real z) { + F3dSlot *s = f3d_slot_of(world, body); + if (s == NULL || !finite3(x, y, z)) return 0; + set3(&s->position, x, y, z); + s->flags |= F3D_FLAG_MOVED; + f3d_wake(world, s); + return 1; +} + +int f3d_body_get_position(const F3dWorld *world, F3dBody body, f3d_real *out) { + const F3dSlot *s = f3d_slot_of(world, body); + if (s == NULL) return 0; + get3(&s->position, out); + return 1; +} + +int f3d_body_get_world_position(const F3dWorld *world, F3dBody body, + double *out) { + const F3dSlot *s = f3d_slot_of(world, body); + if (s == NULL) return 0; + out[0] = world->s.origin[0] + (double)s->position.x; + out[1] = world->s.origin[1] + (double)s->position.y; + out[2] = world->s.origin[2] + (double)s->position.z; + return 1; +} + +int f3d_body_set_angular_velocity(F3dWorld *world, F3dBody body, f3d_real x, + f3d_real y, f3d_real z) { + F3dSlot *s = f3d_slot_of(world, body); + if (s == NULL || !finite3(x, y, z)) return 0; + if (!f3d_turns(s)) return 1; + set3(&s->spin, x, y, z); + f3d_wake(world, s); + return 1; +} + +int f3d_body_get_angular_velocity(const F3dWorld *world, F3dBody body, + f3d_real *out) { + const F3dSlot *s = f3d_slot_of(world, body); + if (s == NULL) return 0; + get3(&s->spin, out); + return 1; +} + +int f3d_body_set_orientation(F3dWorld *world, F3dBody body, f3d_real x, + f3d_real y, f3d_real z, f3d_real w) { + F3dSlot *s = f3d_slot_of(world, body); + if (s == NULL || !finite3(x, y, z) || !f3d_finite(w)) return 0; + const f3d_real length = f3d_sqrt(x * x + y * y + z * z + w * w); + if (!(length > F3D_R(0.0)) || !f3d_finite(length)) { + s->orientation.x = s->orientation.y = s->orientation.z = F3D_R(0.0); + s->orientation.w = F3D_R(1.0); + } else { + s->orientation.x = x / length; + s->orientation.y = y / length; + s->orientation.z = z / length; + s->orientation.w = w / length; + } + s->flags |= F3D_FLAG_MOVED; + f3d_wake(world, s); + return 1; +} + +int f3d_body_get_orientation(const F3dWorld *world, F3dBody body, + f3d_real *out) { + const F3dSlot *s = f3d_slot_of(world, body); + if (s == NULL) return 0; + out[0] = s->orientation.x; + out[1] = s->orientation.y; + out[2] = s->orientation.z; + out[3] = s->orientation.w; + return 1; +} + +int f3d_body_set_shape(F3dWorld *world, F3dBody body, F3dShapeKind kind, + f3d_real a, f3d_real b, f3d_real c) { + F3dSlot *s = f3d_slot_of(world, body); + if (s == NULL) return 0; +#define POSITIVE(v) (f3d_finite(v) && (v) > F3D_R(0.0)) + switch (kind) { + case F3D_SHAPE_POINT: + set3(&s->size, F3D_R(0.0), F3D_R(0.0), F3D_R(0.0)); + break; + case F3D_SHAPE_SPHERE: + if (!POSITIVE(a)) return 0; + set3(&s->size, a, F3D_R(0.0), F3D_R(0.0)); + break; + case F3D_SHAPE_BOX: + if (!POSITIVE(a) || !POSITIVE(b) || !POSITIVE(c)) return 0; + set3(&s->size, a, b, c); + break; + case F3D_SHAPE_CAPSULE: + /* A capsule with no straight part is a sphere, and allowed. */ + if (!POSITIVE(a) || !(f3d_finite(b) && b >= F3D_R(0.0))) return 0; + set3(&s->size, a, b, F3D_R(0.0)); + break; + case F3D_SHAPE_CYLINDER: + case F3D_SHAPE_CONE: + if (!POSITIVE(a) || !POSITIVE(b)) return 0; + set3(&s->size, a, b, F3D_R(0.0)); + break; + default: + return 0; + } +#undef POSITIVE + s->shape = (uint8_t)kind; + s->hull = 0; + if (kind == F3D_SHAPE_POINT) s->rounding = F3D_R(0.0); + f3d_refresh_mass(world, s); + if (!f3d_turns(s)) set3(&s->spin, F3D_R(0.0), F3D_R(0.0), F3D_R(0.0)); + s->flags |= F3D_FLAG_MOVED; + f3d_wake(world, s); + return 1; +} + +int f3d_body_get_inertia(const F3dWorld *world, F3dBody body, f3d_real *out) { + const F3dSlot *s = f3d_slot_of(world, body); + if (s == NULL) return 0; + out[0] = s->inertia.xx; + out[1] = s->inertia.yy; + out[2] = s->inertia.zz; + return 1; +} + +int f3d_body_get_inertia_tensor(const F3dWorld *world, F3dBody body, + f3d_real *out) { + const F3dSlot *s = f3d_slot_of(world, body); + if (s == NULL) return 0; + out[0] = s->inertia.xx; + out[1] = s->inertia.yy; + out[2] = s->inertia.zz; + out[3] = s->inertia.xy; + out[4] = s->inertia.xz; + out[5] = s->inertia.yz; + return 1; +} + +int f3d_body_set_rounding(F3dWorld *world, F3dBody body, f3d_real radius) { + F3dSlot *s = f3d_slot_of(world, body); + if (s == NULL || !(f3d_finite(radius) && radius >= F3D_R(0.0))) return 0; + if (s->shape == F3D_SHAPE_POINT && radius > F3D_R(0.0)) return 0; + s->rounding = radius; + f3d_refresh_mass(world, s); + s->flags |= F3D_FLAG_MOVED; + f3d_wake(world, s); + return 1; +} + +int f3d_body_set_hull(F3dWorld *world, F3dBody body, uint32_t hull) { + F3dSlot *s = f3d_slot_of(world, body); + if (s == NULL || hull == 0 || hull > world->s.hull_count) return 0; + s->shape = F3D_SHAPE_HULL; + s->hull = hull; + set3(&s->size, F3D_R(0.0), F3D_R(0.0), F3D_R(0.0)); + f3d_refresh_mass(world, s); + s->flags |= F3D_FLAG_MOVED; + f3d_wake(world, s); + return 1; +} + +int f3d_body_get_mass(const F3dWorld *world, F3dBody body, f3d_real *out) { + const F3dSlot *s = f3d_slot_of(world, body); + if (s == NULL) return 0; + *out = s->mass; + return 1; +} + +int f3d_body_lock_rotation(F3dWorld *world, F3dBody body, int locked) { + F3dSlot *s = f3d_slot_of(world, body); + if (s == NULL) return 0; + if (locked) { + s->flags |= F3D_FLAG_LOCKED; + } else { + s->flags &= (uint8_t)~F3D_FLAG_LOCKED; + } + f3d_refresh_mass(world, s); + if (!f3d_turns(s)) set3(&s->spin, F3D_R(0.0), F3D_R(0.0), F3D_R(0.0)); + return 1; +} + +int f3d_body_set_damping(F3dWorld *world, F3dBody body, f3d_real linear, + f3d_real angular) { + F3dSlot *s = f3d_slot_of(world, body); + if (s == NULL) return 0; + if (!(f3d_finite(linear) && linear >= F3D_R(0.0))) return 0; + if (!(f3d_finite(angular) && angular >= F3D_R(0.0))) return 0; + s->linear_damping = linear; + s->angular_damping = angular; + return 1; +} + +int f3d_body_set_drag(F3dWorld *world, F3dBody body, f3d_real coefficient) { + F3dSlot *s = f3d_slot_of(world, body); + if (s == NULL || !(f3d_finite(coefficient) && coefficient >= F3D_R(0.0))) { + return 0; + } + s->drag = coefficient; + f3d_refresh_mass(world, s); + return 1; +} + +/* The change of spin an angular impulse [l] makes: I⁻¹ in world axes + * times it. */ +static void spin_by(F3dSlot *s, F3dVec3 l) { + const F3dVec3 w = + f3d_sym_times(f3d_sym_turned(s->orientation, s->inverse_inertia), l); + s->spin.x += w.x; + s->spin.y += w.y; + s->spin.z += w.z; +} + +int f3d_body_apply_impulse(F3dWorld *world, F3dBody body, f3d_real x, + f3d_real y, f3d_real z) { + F3dSlot *s = f3d_slot_of(world, body); + if (s == NULL || !finite3(x, y, z)) return 0; + if (s->inverse_mass == F3D_R(0.0)) return 1; + s->velocity.x += x * s->inverse_mass; + s->velocity.y += y * s->inverse_mass; + s->velocity.z += z * s->inverse_mass; + f3d_wake(world, s); + return 1; +} + +int f3d_body_apply_impulse_at(F3dWorld *world, F3dBody body, f3d_real x, + f3d_real y, f3d_real z, f3d_real px, f3d_real py, + f3d_real pz) { + F3dSlot *s = f3d_slot_of(world, body); + if (s == NULL || !finite3(x, y, z) || !finite3(px, py, pz)) return 0; + if (s->inverse_mass == F3D_R(0.0)) return 1; + s->velocity.x += x * s->inverse_mass; + s->velocity.y += y * s->inverse_mass; + s->velocity.z += z * s->inverse_mass; + if (f3d_turns(s)) { + const f3d_real rx = px - s->position.x; + const f3d_real ry = py - s->position.y; + const f3d_real rz = pz - s->position.z; + F3dVec3 l; + l.x = ry * z - rz * y; + l.y = rz * x - rx * z; + l.z = rx * y - ry * x; + spin_by(s, l); + } + f3d_wake(world, s); + return 1; +} + +int f3d_body_add_force(F3dWorld *world, F3dBody body, f3d_real x, f3d_real y, + f3d_real z) { + F3dSlot *s = f3d_slot_of(world, body); + if (s == NULL || !finite3(x, y, z)) return 0; + s->force.x += x; + s->force.y += y; + s->force.z += z; + if (s->inverse_mass != F3D_R(0.0)) f3d_wake(world, s); + return 1; +} + +int f3d_body_add_torque(F3dWorld *world, F3dBody body, f3d_real x, f3d_real y, + f3d_real z) { + F3dSlot *s = f3d_slot_of(world, body); + if (s == NULL || !finite3(x, y, z)) return 0; + s->torque.x += x; + s->torque.y += y; + s->torque.z += z; + if (f3d_turns(s)) f3d_wake(world, s); + return 1; +} + +int f3d_body_is_asleep(const F3dWorld *world, F3dBody body) { + const F3dSlot *s = f3d_slot_of(world, body); + return s != NULL && (s->flags & F3D_FLAG_ASLEEP) != 0; +} + +int f3d_body_wake(F3dWorld *world, F3dBody body) { + F3dSlot *s = f3d_slot_of(world, body); + if (s == NULL) return 0; + f3d_wake(world, s); + return 1; +} + +uint32_t f3d_world_read_transforms(const F3dWorld *world, f3d_real *transforms, + F3dBody *handles, uint32_t capacity) { + uint32_t written = 0; + for (uint32_t i = 0; i < world->s.used && written < capacity; i++) { + const F3dSlot *s = &world->slots[i]; + if (!s->live) continue; + f3d_real *t = transforms + (size_t)written * F3D_TRANSFORM_FLOATS; + t[0] = s->position.x; + t[1] = s->position.y; + t[2] = s->position.z; + t[3] = s->orientation.x; + t[4] = s->orientation.y; + t[5] = s->orientation.z; + t[6] = s->orientation.w; + if (handles != NULL) handles[written] = handle_of(i, s->generation); + written++; + } + return written; +} + +uint32_t f3d_world_read_fires(const F3dWorld *world, f3d_real *fires, + F3dBody *handles, uint32_t capacity) { + uint32_t written = 0; + for (uint32_t i = 0; i < world->s.used && written < capacity; i++) { + const F3dSlot *s = &world->slots[i]; + if (!s->live || !(s->flags & F3D_FLAG_BURNING)) continue; + f3d_real *f = fires + (size_t)written * F3D_FIRE_FLOATS; + f[0] = s->position.x; + f[1] = s->position.y; + f[2] = s->position.z; + f[3] = s->heat_release; + if (handles != NULL) handles[written] = handle_of(i, s->generation); + written++; + } + return written; +} + +void f3d_world_step(F3dWorld *world, f3d_real dt) { + if (!(f3d_finite(dt) && dt > F3D_R(0.0))) return; + f3d_step_collide(world, dt); + f3d_step_solve(world, dt); + f3d_step_heat(world, dt); +} diff --git a/packages/flutter3d_physics_native/csrc/tests/check.h b/packages/flutter3d_physics_native/csrc/tests/check.h new file mode 100644 index 000000000..f11558522 --- /dev/null +++ b/packages/flutter3d_physics_native/csrc/tests/check.h @@ -0,0 +1,62 @@ +/* + * What every C test shares: the check counter and values a test cannot + * write as literals. Built in both precisions by test/c_unit_test.dart. + */ +#ifndef F3D_CHECK_H_ +#define F3D_CHECK_H_ + +#include + +#include "f3d_internal.h" +#include "f3d_physics.h" + +static int g_failures = 0; +static int g_checks = 0; + +#define CHECK(cond) \ + do { \ + g_checks++; \ + if (!(cond)) { \ + g_failures++; \ + fprintf(stderr, "%s:%d: CHECK(%s) failed\n", __FILE__, __LINE__, \ + #cond); \ + } \ + } while (0) + +/* |a − b| no more than [tolerance] times the larger of |b| and one. */ +#define CHECK_NEAR(a, b, tolerance) \ + do { \ + const double check_a_ = (double)(a), check_b_ = (double)(b); \ + const double check_scale_ = \ + check_b_ < 0 ? (-check_b_ > 1 ? -check_b_ : 1) \ + : (check_b_ > 1 ? check_b_ : 1); \ + const double check_d_ = check_a_ - check_b_; \ + g_checks++; \ + if (!((check_d_ < 0 ? -check_d_ : check_d_) <= \ + (tolerance) * check_scale_)) { \ + g_failures++; \ + fprintf(stderr, "%s:%d: %s = %.9g, wanted %s = %.9g\n", __FILE__, \ + __LINE__, #a, check_a_, #b, check_b_); \ + } \ + } while (0) + +static inline f3d_real nan_value(void) { + volatile f3d_real zero = 0; + return zero / zero; +} + +static inline f3d_real inf_value(void) { + volatile f3d_real zero = 0; + return 1 / zero; +} + +static inline int finish(void) { + if (g_failures != 0) { + fprintf(stderr, "%d of %d checks failed\n", g_failures, g_checks); + return 1; + } + printf("%d checks passed\n", g_checks); + return 0; +} + +#endif /* F3D_CHECK_H_ */ diff --git a/packages/flutter3d_physics_native/csrc/tests/ragdoll.h b/packages/flutter3d_physics_native/csrc/tests/ragdoll.h new file mode 100644 index 000000000..3a5498cee --- /dev/null +++ b/packages/flutter3d_physics_native/csrc/tests/ragdoll.h @@ -0,0 +1,135 @@ +/* + * A ragdoll for the C tests: eleven bodies on ten joints — the spine, the + * neck, the shoulders and the hips ball joints in cones with their twist + * limited, the elbows and the knees hinges with limits, every joint with + * friction — and the floor and the flight of stairs it is dropped on. + * Include after check.h. + */ +#ifndef F3D_TEST_RAGDOLL_H_ +#define F3D_TEST_RAGDOLL_H_ + +#include + +#include "f3d_physics.h" + +enum { BODIES = 11, JOINTS = 10 }; + +/* What every joint resists turning with, N m. */ +#define FRICTION F3D_R(2.0) + +/* Where each joint is on each of its bodies, and what it may do. */ +typedef struct Ragdoll { + F3dBody body[BODIES]; + F3dJoint joint[JOINTS]; + int a[JOINTS], b[JOINTS]; + f3d_real local_a[JOINTS][3], local_b[JOINTS][3]; + /* A ball joint's cone and twist, or a hinge's angle: limits. */ + int ball[JOINTS]; + f3d_real cone[JOINTS], lower[JOINTS], upper[JOINTS]; +} Ragdoll; + +enum { PELVIS, CHEST, HEAD, ARM_L, ARM_R, FOREARM_L, FOREARM_R, THIGH_L, THIGH_R, SHIN_L, SHIN_R }; + +/* Its rest pose, standing, its feet at the origin: position, shape, mass. */ +static const struct Part { + f3d_real p[3]; + F3dShapeKind kind; + f3d_real a, b, c, mass; +} parts[BODIES] = { + [PELVIS] = {{0, F3D_R(1.0), 0}, F3D_SHAPE_BOX, F3D_R(0.15), F3D_R(0.08), F3D_R(0.1), 10}, + [CHEST] = {{0, F3D_R(1.35), 0}, F3D_SHAPE_BOX, F3D_R(0.17), F3D_R(0.2), F3D_R(0.1), 15}, + [HEAD] = {{0, F3D_R(1.72), 0}, F3D_SHAPE_SPHERE, F3D_R(0.11), 0, 0, 4}, + [ARM_L] = {{F3D_R(-0.25), F3D_R(1.35), 0}, F3D_SHAPE_CAPSULE, F3D_R(0.05), F3D_R(0.1), 0, 2}, + [ARM_R] = {{F3D_R(0.25), F3D_R(1.35), 0}, F3D_SHAPE_CAPSULE, F3D_R(0.05), F3D_R(0.1), 0, 2}, + [FOREARM_L] = {{F3D_R(-0.25), F3D_R(1.05), 0}, F3D_SHAPE_CAPSULE, F3D_R(0.045), F3D_R(0.1), 0, F3D_R(1.5)}, + [FOREARM_R] = {{F3D_R(0.25), F3D_R(1.05), 0}, F3D_SHAPE_CAPSULE, F3D_R(0.045), F3D_R(0.1), 0, F3D_R(1.5)}, + [THIGH_L] = {{F3D_R(-0.1), F3D_R(0.725), 0}, F3D_SHAPE_CAPSULE, F3D_R(0.07), F3D_R(0.15), 0, 7}, + [THIGH_R] = {{F3D_R(0.1), F3D_R(0.725), 0}, F3D_SHAPE_CAPSULE, F3D_R(0.07), F3D_R(0.15), 0, 7}, + [SHIN_L] = {{F3D_R(-0.1), F3D_R(0.27), 0}, F3D_SHAPE_CAPSULE, F3D_R(0.055), F3D_R(0.16), 0, 4}, + [SHIN_R] = {{F3D_R(0.1), F3D_R(0.27), 0}, F3D_SHAPE_CAPSULE, F3D_R(0.055), F3D_R(0.16), 0, 4}, +}; + +/* Its joints: the bodies, the point and axis in the rest pose, and the + * cone and twist of a ball joint or the angles of a hinge. */ +static const struct Link { + int a, b, ball; + f3d_real at[3], axis[3], cone, lower, upper; +} links[JOINTS] = { + {PELVIS, CHEST, 1, {0, F3D_R(1.1), 0}, {0, 1, 0}, F3D_R(0.5), F3D_R(-0.4), F3D_R(0.4)}, + {CHEST, HEAD, 1, {0, F3D_R(1.58), 0}, {0, 1, 0}, F3D_R(0.6), F3D_R(-0.7), F3D_R(0.7)}, + {CHEST, ARM_L, 1, {F3D_R(-0.25), F3D_R(1.5), 0}, {0, -1, 0}, F3D_R(1.5), -1, 1}, + {CHEST, ARM_R, 1, {F3D_R(0.25), F3D_R(1.5), 0}, {0, -1, 0}, F3D_R(1.5), -1, 1}, + {ARM_L, FOREARM_L, 0, {F3D_R(-0.25), F3D_R(1.2), 0}, {1, 0, 0}, 0, 0, F3D_R(2.4)}, + {ARM_R, FOREARM_R, 0, {F3D_R(0.25), F3D_R(1.2), 0}, {1, 0, 0}, 0, 0, F3D_R(2.4)}, + {PELVIS, THIGH_L, 1, {F3D_R(-0.1), F3D_R(0.94), 0}, {0, -1, 0}, F3D_R(1.2), F3D_R(-0.5), F3D_R(0.5)}, + {PELVIS, THIGH_R, 1, {F3D_R(0.1), F3D_R(0.94), 0}, {0, -1, 0}, F3D_R(1.2), F3D_R(-0.5), F3D_R(0.5)}, + {THIGH_L, SHIN_L, 0, {F3D_R(-0.1), F3D_R(0.495), 0}, {1, 0, 0}, 0, F3D_R(-2.4), 0}, + {THIGH_R, SHIN_R, 0, {F3D_R(0.1), F3D_R(0.495), 0}, {1, 0, 0}, 0, F3D_R(-2.4), 0}, +}; + +static void make(F3dWorld *w, Ragdoll *r, f3d_real ox, f3d_real oy, f3d_real oz) { + memset(r, 0, sizeof *r); + for (int i = 0; i < BODIES; i++) { + const struct Part *p = &parts[i]; + r->body[i] = f3d_body_create(w, F3D_BODY_DYNAMIC, ox + p->p[0], oy + p->p[1], oz + p->p[2], p->mass); + CHECK(f3d_body_set_shape(w, r->body[i], p->kind, p->a, p->b, p->c)); + } + for (int k = 0; k < JOINTS; k++) { + const struct Link *l = &links[k]; + r->a[k] = l->a; + r->b[k] = l->b; + r->ball[k] = l->ball; + r->cone[k] = l->cone; + r->lower[k] = l->lower; + r->upper[k] = l->upper; + for (int d = 0; d < 3; d++) { + r->local_a[k][d] = l->at[d] - parts[l->a].p[d]; + r->local_b[k][d] = l->at[d] - parts[l->b].p[d]; + } + r->joint[k] = f3d_joint_create(w, l->ball ? F3D_JOINT_SPHERICAL : F3D_JOINT_REVOLUTE, r->body[l->a], + r->body[l->b], ox + l->at[0], oy + l->at[1], oz + l->at[2], l->axis[0], + l->axis[1], l->axis[2]); + CHECK(f3d_joint_is_valid(w, r->joint[k])); + CHECK(f3d_joint_set_limits(w, r->joint[k], 1, l->lower, l->upper)); + /* Friction in every joint, or it never stops: a head rolls on the + * floor and a leg about its own length with nothing to slow them. */ + if (l->ball) { + CHECK(f3d_joint_set_cone(w, r->joint[k], 1, l->cone)); + CHECK(f3d_joint_set_friction(w, r->joint[k], 1, FRICTION)); + } else { + CHECK(f3d_joint_set_motor(w, r->joint[k], 1, 0, FRICTION)); + } + } +} + +static F3dWorld *floor_world(void) { + F3dWorld *w = f3d_world_create(); + f3d_world_set_gravity(w, 0, F3D_R(-9.81), 0); + const F3dBody floor = f3d_body_create(w, F3D_BODY_FIXED, 0, F3D_R(-0.5), 0, 0); + f3d_body_set_shape(w, floor, F3D_SHAPE_BOX, 20, F3D_R(0.5), 20); + /* Eight substeps, as a ragdoll wants: at four an arm struck on a stair + * edge swings past its cone by a quarter of a radian for a step. */ + f3d_world_set_substeps(w, 8); + return w; +} + +/* A flight of eight stairs, each a quarter of a metre down and forty + * centimetres deep, going down along x. */ +static F3dWorld *stairs_world(void) { + F3dWorld *w = floor_world(); + for (int k = 0; k < 8; k++) { + const f3d_real top = F3D_R(0.25) * (f3d_real)(8 - k); + const F3dBody stair = f3d_body_create(w, F3D_BODY_FIXED, F3D_R(0.4) * (f3d_real)k, top * F3D_R(0.5), 0, 0); + f3d_body_set_shape(w, stair, F3D_SHAPE_BOX, F3D_R(0.2), top * F3D_R(0.5), 2); + } + return w; +} + +/* Pushed off the top stair, forward and a little aside, the chest + * turned: so that it tumbles down in three dimensions, not in a plane. */ +static void push_off(F3dWorld *w, const Ragdoll *r) { + for (int i = 0; i < BODIES; i++) f3d_body_set_velocity(w, r->body[i], F3D_R(1.5), 0, F3D_R(0.3)); + f3d_body_set_angular_velocity(w, r->body[CHEST], F3D_R(0.5), 1, -2); +} + +#endif diff --git a/packages/flutter3d_physics_native/csrc/tests/scene.h b/packages/flutter3d_physics_native/csrc/tests/scene.h new file mode 100644 index 000000000..25df79fc3 --- /dev/null +++ b/packages/flutter3d_physics_native/csrc/tests/scene.h @@ -0,0 +1,68 @@ +/* + * A world of every kind of shape, for the C tests that step one world + * several ways and compare: a mesh for ground, a hull, boxes, balls, + * capsules, cylinders and a chain on hinges, some filtered. + */ +#ifndef F3D_TEST_SCENE_H_ +#define F3D_TEST_SCENE_H_ + +#include "f3d_physics.h" + +static F3dWorld *scene(void) { + F3dWorld *w = f3d_world_create(); + f3d_world_set_gravity(w, 0, F3D_R(-9.81), 0); + /* Ground: a mesh of a gentle bowl, twelve metres across. */ + enum { SIDE = 13 }; + f3d_real v[SIDE * SIDE * 3]; + uint32_t t[(SIDE - 1) * (SIDE - 1) * 6]; + for (int j = 0; j < SIDE; j++) { + for (int i = 0; i < SIDE; i++) { + const f3d_real x = (f3d_real)(i - 6), z = (f3d_real)(j - 6); + v[(j * SIDE + i) * 3] = x; + v[(j * SIDE + i) * 3 + 1] = (x * x + z * z) * F3D_R(0.02); + v[(j * SIDE + i) * 3 + 2] = z; + } + } + uint32_t n = 0; + for (int j = 0; j + 1 < SIDE; j++) { + for (int i = 0; i + 1 < SIDE; i++) { + const uint32_t a = (uint32_t)(j * SIDE + i); + const uint32_t q[6] = {a, a + SIDE, a + 1, a + 1, a + SIDE, a + SIDE + 1}; + for (int k = 0; k < 6; k++) t[n++] = q[k]; + } + } + const uint32_t mesh = f3d_world_create_mesh(w, v, SIDE * SIDE, t, n / 3); + const F3dBody ground = f3d_body_create(w, F3D_BODY_FIXED, 0, 0, 0, 0); + f3d_body_set_mesh(w, ground, mesh); + const f3d_real rock[18] = {F3D_R(0.3), 0, 0, F3D_R(-0.3), 0, 0, 0, F3D_R(0.4), 0, + 0, F3D_R(-0.2), 0, 0, 0, F3D_R(0.3), 0, 0, F3D_R(-0.3)}; + const uint32_t hull = f3d_world_create_hull(w, rock, 6); + /* Three hundred bodies of five kinds in a loose block above it. */ + for (int i = 0; i < 300; i++) { + const f3d_real x = (f3d_real)(i % 10) * F3D_R(0.7) - F3D_R(3.2); + const f3d_real z = (f3d_real)((i / 10) % 10) * F3D_R(0.7) - F3D_R(3.2); + const f3d_real y = F3D_R(1.5) + (f3d_real)(i / 100) * F3D_R(0.8); + const F3dBody b = f3d_body_create(w, F3D_BODY_DYNAMIC, x + (f3d_real)(i % 3) * F3D_R(0.05), y, z, 1); + switch (i % 5) { + case 0: f3d_body_set_shape(w, b, F3D_SHAPE_BOX, F3D_R(0.25), F3D_R(0.2), F3D_R(0.3)); break; + case 1: f3d_body_set_shape(w, b, F3D_SHAPE_SPHERE, F3D_R(0.25), 0, 0); break; + case 2: f3d_body_set_shape(w, b, F3D_SHAPE_CAPSULE, F3D_R(0.15), F3D_R(0.2), 0); break; + case 3: f3d_body_set_shape(w, b, F3D_SHAPE_CYLINDER, F3D_R(0.2), F3D_R(0.2), 0); break; + default: f3d_body_set_hull(w, b, hull); break; + } + f3d_body_set_angular_velocity(w, b, (f3d_real)(i % 7) * F3D_R(0.3), 0, (f3d_real)(i % 4) * F3D_R(0.2)); + if (i % 11 == 0) f3d_body_set_collision_filter(w, b, 2u, ~1u); + } + /* A chain of eight links on hinges hanging from a fixed post. */ + F3dBody last = f3d_body_create(w, F3D_BODY_FIXED, 4, 5, 0, 0); + f3d_body_set_shape(w, last, F3D_SHAPE_BOX, F3D_R(0.1), F3D_R(0.1), F3D_R(0.1)); + for (int k = 0; k < 8; k++) { + const F3dBody link = f3d_body_create(w, F3D_BODY_DYNAMIC, 4 + (f3d_real)(k + 1) * F3D_R(0.4), 5, 0, F3D_R(0.5)); + f3d_body_set_shape(w, link, F3D_SHAPE_BOX, F3D_R(0.18), F3D_R(0.05), F3D_R(0.05)); + f3d_joint_create(w, F3D_JOINT_REVOLUTE, last, link, 4 + (f3d_real)k * F3D_R(0.4) + F3D_R(0.2), 5, 0, 0, 0, 1); + last = link; + } + return w; +} + +#endif /* F3D_TEST_SCENE_H_ */ diff --git a/packages/flutter3d_physics_native/csrc/tests/test_ccd.c b/packages/flutter3d_physics_native/csrc/tests/test_ccd.c new file mode 100644 index 000000000..90b0a7d67 --- /dev/null +++ b/packages/flutter3d_physics_native/csrc/tests/test_ccd.c @@ -0,0 +1,258 @@ +/* + * Continuous collision, tested in C — P9, phase 8: a fast ball through a + * thin wall without it and stopped with it, a bullet stopped by a box and + * by a mesh, a fast ball passing close by a box and not slowed, two fast + * balls meeting head on, a bullet free to roll on a floor, a fast ball onto + * a mesh floor, and an elastic bounce at fifty metres a second. + */ +#include +#include +#include + +#include "check.h" + +static F3dWorld *quiet(int speculative) { + F3dWorld *w = f3d_world_create(); + f3d_world_set_gravity(w, 0, 0, 0); + f3d_world_set_air(w, F3D_R(293.15), F3D_R(1e-30)); + f3d_world_set_sleep(w, 0, 0); + f3d_world_set_speculative(w, speculative); + return w; +} + +static F3dBody ball(F3dWorld *w, f3d_real x, f3d_real y, f3d_real r) { + const F3dBody b = f3d_body_create(w, F3D_BODY_DYNAMIC, x, y, 0, 1); + f3d_body_set_shape(w, b, F3D_SHAPE_SPHERE, r, 0, 0); + return b; +} + +static F3dBody wall(F3dWorld *w) { + /* A centimetre thick, at x = 0. */ + const F3dBody b = f3d_body_create(w, F3D_BODY_FIXED, 0, 0, 0, 0); + f3d_body_set_shape(w, b, F3D_SHAPE_BOX, F3D_R(0.005), 2, 2); + return b; +} + +static void run(F3dWorld *w, int steps) { + for (int i = 0; i < steps; i++) f3d_world_step(w, F3D_R(1.0 / 60.0)); +} + +static f3d_real x_of(F3dWorld *w, F3dBody b) { + f3d_real p[3]; + f3d_body_get_position(w, b, p); + return p[0]; +} + +static void test_thin_wall(void) { + /* Three hundred metres a second, five metres a step: without soft + * collision the ball is past the wall before any contact sees it. */ + for (int soft = 0; soft < 2; soft++) { + F3dWorld *w = quiet(soft); + wall(w); + const F3dBody b = ball(w, -2, 0, F3D_R(0.05)); + f3d_body_set_velocity(w, b, 300, 0, 0); + run(w, 10); + if (soft) { + CHECK(x_of(w, b) < 0); + CHECK(x_of(w, b) > -0.06); + } else { + CHECK(x_of(w, b) > 1); + } + f3d_world_destroy(w); + } +} + +static void test_bullets(void) { + /* With soft collision off, a bullet still stops: swept to its time of + * impact, against a box and against a mesh one triangle thick. */ + F3dWorld *w = quiet(0); + wall(w); + const F3dBody b = ball(w, -2, 0, F3D_R(0.05)); + CHECK(f3d_body_set_bullet(w, b, 1) == 1); + f3d_body_set_velocity(w, b, 500, 0, 0); + run(w, 10); + CHECK(x_of(w, b) < 0); + CHECK(x_of(w, b) > -0.07); + /* A mesh wall at x = 10, facing the bullet: wound counter-clockwise + * seen from −x. */ + const f3d_real v[12] = {10, -2, -2, 10, 2, -2, 10, 2, 2, 10, -2, 2}; + const uint32_t t[6] = {0, 2, 1, 0, 3, 2}; + const uint32_t mesh = f3d_world_create_mesh(w, v, 4, t, 2); + const F3dBody screen = f3d_body_create(w, F3D_BODY_FIXED, 0, 0, 0, 0); + f3d_body_set_mesh(w, screen, mesh); + const F3dBody c = ball(w, 5, 5, F3D_R(0.05)); + f3d_body_set_bullet(w, c, 1); + f3d_body_set_position(w, c, 5, 0, 0); + f3d_body_set_velocity(w, c, 500, 0, 0); + run(w, 10); + CHECK(x_of(w, c) < 10); + CHECK(x_of(w, c) > 9.93); + /* Leaving a wall it is near — five centimetres off, inside the wall's + * leaf, so the sweep looks at it — a bullet is not called back to it: + * moving away is no impact. */ + const F3dBody e = ball(w, F3D_R(-0.1), 3, F3D_R(0.05)); + f3d_body_set_bullet(w, e, 1); + f3d_body_set_position(w, e, F3D_R(-0.1), 0, F3D_R(1.5)); + f3d_body_set_velocity(w, e, -300, 0, 0); + run(w, 2); + CHECK(x_of(w, e) < -9); + /* Not a bullet, it goes through. */ + const F3dBody d = ball(w, 5, 1, F3D_R(0.05)); + f3d_body_set_velocity(w, d, 500, 0, 0); + run(w, 10); + CHECK(x_of(w, d) > 11); + f3d_world_destroy(w); +} + +/* The most either end of a bar a metre either side of its centre along its + * own x reaches past the plane x = 0.5. */ +static double past_wall(F3dWorld *w, F3dBody bar) { + f3d_real q[4], p[3]; + f3d_body_get_orientation(w, bar, q); + f3d_body_get_position(w, bar, p); + const F3dQuat qq = {q[0], q[1], q[2], q[3]}; + const F3dMat3 m = f3d_mat_of(qq); + const double a = (double)p[0] + m.c[0].x, b = (double)p[0] - m.c[0].x; + return fmax(a, b) - 0.5; +} + +static void test_spinning_bullet(void) { + /* A bar two metres long spun at three hundred radians a second about + * its middle — held to 188 by the bound on a substep's turn, three radians + * a step, more than the half turn its first and last turns would + * read the short way round, backwards — beside a wall half a metre off: + * its ends would turn through the wall in a step. Swept along its path, + * place and turn, it never reaches past the wall; it strikes it with its + * end and is thrown back, as a spun bar is. Not a bullet, it turns + * through. */ + for (int bullet = 0; bullet < 2; bullet++) { + F3dWorld *w = quiet(0); + const F3dBody screen = f3d_body_create(w, F3D_BODY_FIXED, F3D_R(0.5), 0, 0, 0); + f3d_body_set_shape(w, screen, F3D_SHAPE_BOX, F3D_R(0.005), 2, 2); + const F3dBody bar = f3d_body_create(w, F3D_BODY_DYNAMIC, 0, 0, 0, 1); + f3d_body_set_shape(w, bar, F3D_SHAPE_BOX, 1, F3D_R(0.1), F3D_R(0.1)); + /* Lying along z, clear of the wall, turning about y. */ + const f3d_real c = F3D_R(0.70710678), q = F3D_R(0.70710678); + f3d_body_set_orientation(w, bar, 0, q, 0, c); + f3d_body_set_angular_velocity(w, bar, 0, 300, 0); + f3d_body_set_bullet(w, bar, bullet); + double most = -1; + for (int i = 0; i < 20; i++) { + f3d_world_step(w, F3D_R(1.0 / 60.0)); + const double past = past_wall(w, bar); + if (past > most) most = past; + } + if (bullet) { + CHECK(most < 0.02); + } else { + CHECK(most > 0.1); + } + f3d_world_destroy(w); + } +} + +static void test_no_ghosts(void) { + /* A hundred metres a second, half a metre beside a box, parallel to its + * face: the contact soft collision makes there pushes nothing, and the + * ball goes by at full speed. */ + F3dWorld *w = quiet(1); + const F3dBody block = f3d_body_create(w, F3D_BODY_FIXED, 0, 0, 0, 0); + f3d_body_set_shape(w, block, F3D_SHAPE_BOX, 1, F3D_R(0.5), 1); + const F3dBody b = ball(w, -5, F3D_R(1.1), F3D_R(0.1)); + f3d_body_set_velocity(w, b, 100, 0, 0); + run(w, 10); + f3d_real v[3], p[3]; + f3d_body_get_velocity(w, b, v); + f3d_body_get_position(w, b, p); + CHECK_NEAR(v[0], 100, 1e-4); + CHECK_NEAR(v[1], 0, 1e-4); + CHECK_NEAR(p[1], 1.1, 1e-5); + CHECK(p[0] > 5); + f3d_world_destroy(w); +} + +static void test_head_on(void) { + /* Two balls at two hundred metres a second each, straight at each + * other: with soft collision they meet; without, they pass. */ + for (int soft = 0; soft < 2; soft++) { + F3dWorld *w = quiet(soft); + const F3dBody a = ball(w, -3, 0, F3D_R(0.1)); + const F3dBody b = ball(w, 3, 0, F3D_R(0.1)); + f3d_body_set_velocity(w, a, 200, 0, 0); + f3d_body_set_velocity(w, b, -200, 0, 0); + run(w, 6); + if (soft) { + CHECK(x_of(w, a) < x_of(w, b)); + } else { + CHECK(x_of(w, a) > x_of(w, b)); + } + f3d_world_destroy(w); + } +} + +static void test_bullet_rolls(void) { + /* A bullet resting on a floor and pushed along it: it is not held at + * where each step began. */ + F3dWorld *w = f3d_world_create(); + f3d_world_set_air(w, F3D_R(293.15), F3D_R(1e-30)); + f3d_world_set_sleep(w, 0, 0); + const F3dBody floor = f3d_body_create(w, F3D_BODY_FIXED, 0, F3D_R(-0.5), 0, 0); + f3d_body_set_shape(w, floor, F3D_SHAPE_BOX, 50, F3D_R(0.5), 50); + const F3dBody b = ball(w, 0, F3D_R(0.1), F3D_R(0.1)); + f3d_body_set_bullet(w, b, 1); + run(w, 30); + f3d_body_set_velocity(w, b, 10, 0, 0); + run(w, 60); + CHECK(x_of(w, b) > 5); + f3d_real p[3]; + f3d_body_get_position(w, b, p); + CHECK(fabs((double)p[1] - 0.1) < 0.006); + f3d_world_destroy(w); +} + +static void test_onto_a_mesh(void) { + /* Dropped onto a one-triangle-thick mesh floor at a hundred metres a + * second: it stops on it. */ + F3dWorld *w = f3d_world_create(); + f3d_world_set_air(w, F3D_R(293.15), F3D_R(1e-30)); + const f3d_real v[12] = {-5, 0, -5, 5, 0, -5, 5, 0, 5, -5, 0, 5}; + const uint32_t t[6] = {0, 2, 1, 0, 3, 2}; + const uint32_t mesh = f3d_world_create_mesh(w, v, 4, t, 2); + const F3dBody ground = f3d_body_create(w, F3D_BODY_FIXED, 0, 0, 0, 0); + f3d_body_set_mesh(w, ground, mesh); + const F3dBody b = ball(w, F3D_R(0.3), 10, F3D_R(0.1)); + f3d_body_set_velocity(w, b, 0, -100, 0); + run(w, 120); + f3d_real p[3]; + f3d_body_get_position(w, b, p); + CHECK(fabs((double)p[1] - 0.1) < 0.01); + f3d_world_destroy(w); +} + +static void test_fast_bounce(void) { + /* Elastic, at fifty metres a second at the wall: it comes back at fifty, + * not stopped and not through. */ + F3dWorld *w = quiet(1); + wall(w); + const F3dBody b = ball(w, -3, 0, F3D_R(0.1)); + f3d_body_set_restitution(w, b, 1); + f3d_body_set_velocity(w, b, 50, 0, 0); + run(w, 30); + f3d_real v[3]; + f3d_body_get_velocity(w, b, v); + CHECK_NEAR(v[0], -50, 0.02); + CHECK(x_of(w, b) < 0); + f3d_world_destroy(w); +} + +int main(void) { + test_thin_wall(); + test_bullets(); + test_spinning_bullet(); + test_no_ghosts(); + test_head_on(); + test_bullet_rolls(); + test_onto_a_mesh(); + test_fast_bounce(); + return finish(); +} diff --git a/packages/flutter3d_physics_native/csrc/tests/test_cloth.c b/packages/flutter3d_physics_native/csrc/tests/test_cloth.c new file mode 100644 index 000000000..5f8e24ba8 --- /dev/null +++ b/packages/flutter3d_physics_native/csrc/tests/test_cloth.c @@ -0,0 +1,271 @@ +/* + * Cloth on the CPU, tested in C — P9, phase 10: a weight on a constraint + * of compliance α stretches it by α m g, a rigid pendulum keeps its length + * and swings through the bottom at √(2 g L), a sheet pinned by two corners + * hangs without stretching, drapes over a ball and lies on the floor + * without passing into either, the wind carries a loose point at the speed + * drag gives it, a carried pin carries the cloth, no two + * constraints of a colour share a point, and bad input is refused. + */ +#include +#include + +#include "check.h" + +static F3dClothSettings settings(void) { + F3dClothSettings s; + memset(&s, 0, sizeof s); + s.gravity[1] = F3D_R(-9.81); + s.floor_y = F3D_R(-1e9); + s.substeps = 16; + return s; +} + +static void run(F3dCloth *c, const F3dClothSettings *s, int steps) { + for (int i = 0; i < steps; i++) f3d_cloth_step(c, s, F3D_R(1.0 / 60.0)); +} + +/* A sheet of n × n points a metre wide in the xz plane at height [y], held + * by its structural, shear and bending edges; the two corners of its first + * row pinned when [pinned]. */ +enum { SIDE = 16 }; +static F3dCloth *sheet(f3d_real y, int pinned, f3d_real compliance) { + static f3d_real points[SIDE * SIDE * 4]; + static uint32_t edges[SIDE * SIDE * 12]; + static f3d_real comp[SIDE * SIDE * 6]; + const f3d_real step = F3D_R(1.0) / (SIDE - 1); + for (int j = 0; j < SIDE; j++) { + for (int i = 0; i < SIDE; i++) { + f3d_real *p = points + (j * SIDE + i) * 4; + p[0] = (f3d_real)i * step - F3D_R(0.5); + p[1] = y; + p[2] = (f3d_real)j * step - F3D_R(0.5); + p[3] = F3D_R(SIDE * SIDE) / F3D_R(0.2); /* 200 g in all */ + } + } + if (pinned) { + points[3] = 0; + points[(SIDE - 1) * 4 + 3] = 0; + } + uint32_t n = 0; + for (int j = 0; j < SIDE; j++) { + for (int i = 0; i < SIDE; i++) { + const uint32_t at = (uint32_t)(j * SIDE + i); + const int links[6][2] = {{1, 0}, {0, 1}, {1, 1}, {1, -1}, {2, 0}, {0, 2}}; + for (int k = 0; k < 6; k++) { + const int ii = i + links[k][0], jj = j + links[k][1]; + if (ii < 0 || ii >= SIDE || jj < 0 || jj >= SIDE) continue; + edges[n * 2] = at; + edges[n * 2 + 1] = (uint32_t)(jj * SIDE + ii); + comp[n] = k < 2 ? compliance : compliance * 10; + n++; + } + } + } + return f3d_cloth_create(points, SIDE * SIDE, edges, comp, n); +} + +static void test_spring(void) { + /* A kilogram on a constraint of compliance 10⁻³ m/N below a pin: it + * settles g (α m + h²) lower — the spring's α m g, and XPBD's own sag of + * g h² a substep — times what damping keeps of a substep's fall. */ + const f3d_real points[8] = {0, 0, 0, 0, 0, -1, 0, 1}; + const uint32_t edge[2] = {0, 1}; + const f3d_real alpha = F3D_R(1e-3); + F3dCloth *c = f3d_cloth_create(points, 2, edge, &alpha, 1); + CHECK(c != NULL && f3d_cloth_point_count(c) == 2 && f3d_cloth_colour_count(c) == 1); + F3dClothSettings s = settings(); + s.damping = F3D_R(5.0); + run(c, &s, 600); + f3d_real out[8]; + CHECK(f3d_cloth_read(c, out, 2) == 2); + const double h = 1.0 / 960.0; + const double keep = 1.0 / (1.0 + 5.0 * h); + CHECK_NEAR(-1.0 - out[5], 9.81 * (1e-3 + h * h) * keep, 5e-5); + /* The pin did not move. */ + CHECK(out[0] == 0 && out[1] == 0 && out[3] == 0); + CHECK(out[7] == 1); + f3d_cloth_destroy(c); +} + +static void test_pendulum(void) { + /* A rigid rod a metre long, let go level: through the bottom at + * √(2 g L), its length kept to a millimetre all the way. */ + const f3d_real points[8] = {0, 0, 0, 0, 1, 0, 0, 1}; + const uint32_t edge[2] = {0, 1}; + const f3d_real rigid = 0; + F3dCloth *c = f3d_cloth_create(points, 2, edge, &rigid, 1); + const F3dClothSettings s = settings(); + f3d_real out[8], last[8]; + f3d_cloth_read(c, last, 2); + double fastest = 0, worst_length = 0; + for (int i = 0; i < 60; i++) { + run(c, &s, 1); + f3d_cloth_read(c, out, 2); + const double len = sqrt((double)out[4] * out[4] + (double)out[5] * out[5] + (double)out[6] * out[6]); + worst_length = fmax(worst_length, fabs(len - 1.0)); + const double dx = out[4] - last[4], dy = out[5] - last[5]; + fastest = fmax(fastest, sqrt(dx * dx + dy * dy) * 60.0); + memcpy(last, out, sizeof out); + } + CHECK(worst_length < 1e-3); + /* Five per cent: CHECK_NEAR scales its tolerance by values past one. */ + CHECK_NEAR(fastest, sqrt(2 * 9.81), 0.05); + f3d_cloth_destroy(c); +} + +static void test_hanging(void) { + F3dCloth *c = sheet(0, 1, F3D_R(1e-7)); + CHECK(c != NULL); + F3dClothSettings s = settings(); + s.damping = F3D_R(1.0); + run(c, &s, 180); + static f3d_real out[SIDE * SIDE * 4]; + f3d_cloth_read(c, out, SIDE * SIDE); + /* The pins stayed; the cloth hangs below them, all of it finite. */ + CHECK(out[0] == F3D_R(-0.5) && out[1] == 0 && out[2] == F3D_R(-0.5)); + CHECK(out[(SIDE - 1) * 4] == F3D_R(0.5)); + int finite = 1; + double lowest = 0, stretch = 0; + for (int i = 0; i < SIDE * SIDE; i++) { + finite &= isfinite(out[i * 4]) && isfinite(out[i * 4 + 1]) && isfinite(out[i * 4 + 2]); + lowest = fmin(lowest, (double)out[i * 4 + 1]); + } + /* No structural edge is more than 2% longer than it was. */ + for (int j = 0; j < SIDE; j++) { + for (int i = 0; i + 1 < SIDE; i++) { + const f3d_real *a = out + (j * SIDE + i) * 4, *b = out + (j * SIDE + i + 1) * 4; + const double d = sqrt(pow(a[0] - b[0], 2) + pow(a[1] - b[1], 2) + pow(a[2] - b[2], 2)); + stretch = fmax(stretch, d * (SIDE - 1) - 1.0); + } + } + CHECK(finite); + CHECK(lowest < -0.8 && lowest > -1.1); + CHECK(stretch < 0.02); + /* SIDE² points and up to twelve edges each want at least twelve + * colours, and greedy takes few more; as the GPU is given them, every + * colour's constraints share no point, run end to end, and each is as + * long as it started. */ + const uint32_t colours = f3d_cloth_colour_count(c), count = f3d_cloth_edge_count(c); + CHECK(colours >= 12 && colours <= 24); + static uint32_t pairs[SIDE * SIDE * 12], start[257], seen[SIDE * SIDE]; + static f3d_real rest[SIDE * SIDE * 6], comp[SIDE * SIDE * 6]; + f3d_cloth_edges(c, pairs, rest, comp, start); + CHECK(start[0] == 0 && start[colours] == count); + int shared = 0, ordered = 1; + for (uint32_t k = 0; k < colours; k++) { + ordered &= start[k] < start[k + 1]; + for (uint32_t e = start[k]; e < start[k + 1]; e++) { + for (int end = 0; end < 2; end++) { + const uint32_t p = pairs[e * 2 + (uint32_t)end]; + shared += seen[p] == k + 1; + seen[p] = k + 1; + } + } + } + CHECK(shared == 0); + CHECK(ordered); + /* The first edge given, (0, 1), a structural one a fifteenth long. */ + CHECK(pairs[0] == 0 && pairs[1] == 1); + CHECK_NEAR(rest[0], 1.0 / 15.0, 1e-6); + CHECK(comp[0] == F3D_R(1e-7)); + /* Carried by a pin, it follows. */ + f3d_cloth_move_point(c, 0, F3D_R(-0.5), F3D_R(1.0), F3D_R(-0.5)); + f3d_cloth_move_point(c, SIDE - 1, F3D_R(0.5), F3D_R(1.0), F3D_R(-0.5)); + run(c, &s, 120); + f3d_cloth_read(c, out, SIDE * SIDE); + CHECK(out[1] == 1 && out[(SIDE - 1) * 4 + 1] == 1); + CHECK(out[(SIDE * SIDE - 1) * 4 + 1] > F3D_R(0.0)); + f3d_cloth_destroy(c); +} + +static void test_drape(void) { + /* Dropped onto a ball of 30 cm, then onto the floor beside nothing: no + * point closer to the ball's centre than its radius and the thickness, + * none below the floor and the thickness. */ + F3dCloth *c = sheet(F3D_R(0.8), 0, F3D_R(1e-6)); + F3dClothSettings s = settings(); + s.thickness = F3D_R(0.01); + s.friction = F3D_R(0.3); + s.floor_y = 0; + s.damping = F3D_R(0.5); + const f3d_real ball[4] = {0, F3D_R(0.3), 0, F3D_R(0.3)}; + CHECK(f3d_cloth_set_balls(c, ball, 1)); + run(c, &s, 180); + static f3d_real out[SIDE * SIDE * 4]; + f3d_cloth_read(c, out, SIDE * SIDE); + double nearest = 1e9, lowest = 1e9, top = -1e9; + for (int i = 0; i < SIDE * SIDE; i++) { + const f3d_real *p = out + i * 4; + nearest = fmin(nearest, sqrt(pow(p[0], 2) + pow(p[1] - 0.3, 2) + pow(p[2], 2))); + lowest = fmin(lowest, (double)p[1]); + top = fmax(top, (double)p[1]); + } + CHECK(nearest > 0.31 - 1e-4); + CHECK(lowest > 0.01 - 1e-6); + /* It lies over the top of the ball — no point is right over it, the + * nearest a thirtieth of a metre off, so a little under its top and the + * thickness — hanging down its sides below its centre. */ + CHECK(top > 0.6 && top < 0.62); + CHECK(lowest < 0.3); + f3d_real many[(F3D_CLOTH_MAX_BALLS + 1) * 4]; + memset(many, 0, sizeof many); + CHECK(!f3d_cloth_set_balls(c, many, F3D_CLOTH_MAX_BALLS + 1)); + f3d_cloth_destroy(c); +} + +static void test_wind_and_floor(void) { + /* Loose points, no constraints. In a wind of 4 m/s with drag 2 and no + * gravity, a point's speed after n substeps is w (1 − (1 + 2 h)^−n). */ + const f3d_real points[8] = {0, 1, 0, 1, F3D_R(0.5), F3D_R(0.2), 0, 1}; + F3dCloth *c = f3d_cloth_create(points, 2, NULL, NULL, 0); + F3dClothSettings s = settings(); + s.gravity[1] = 0; + s.wind[0] = 4; + s.drag = 2; + f3d_real before[8], out[8]; + run(c, &s, 59); + f3d_cloth_read(c, before, 2); + run(c, &s, 1); + f3d_cloth_read(c, out, 2); + const double h = 1.0 / 960.0; + const double speed = 4.0 * (1.0 - pow(1.0 + 2.0 * h, -960.0)); + CHECK_NEAR((out[0] - before[0]) * 60.0, speed, 0.01); + CHECK(out[1] == 1); + f3d_cloth_destroy(c); + /* Dropped on the floor at a tenth of a metre: they lie the thickness + * above it. */ + c = f3d_cloth_create(points, 2, NULL, NULL, 0); + s = settings(); + s.floor_y = F3D_R(0.1); + s.thickness = F3D_R(0.02); + run(c, &s, 60); + f3d_cloth_read(c, out, 2); + CHECK_NEAR(out[1], 0.12, 1e-6); + CHECK_NEAR(out[5], 0.12, 1e-6); + f3d_cloth_destroy(c); +} + +static void test_refused(void) { + const f3d_real points[8] = {0, 0, 0, 1, 1, 0, 0, 1}; + const f3d_real comp[1] = {0}; + const uint32_t self[2] = {1, 1}, outside[2] = {0, 2}; + CHECK(f3d_cloth_create(points, 2, self, comp, 1) == NULL); + CHECK(f3d_cloth_create(points, 2, outside, comp, 1) == NULL); + CHECK(f3d_cloth_create(points, 0, NULL, NULL, 0) == NULL); + /* No edges at all is a cloth of loose points. */ + F3dCloth *c = f3d_cloth_create(points, 2, NULL, NULL, 0); + CHECK(c != NULL && f3d_cloth_colour_count(c) == 0); + f3d_cloth_destroy(c); + f3d_cloth_destroy(NULL); +} + +int main(void) { + test_spring(); + test_pendulum(); + test_hanging(); + test_drape(); + test_wind_and_floor(); + test_refused(); + return finish(); +} diff --git a/packages/flutter3d_physics_native/csrc/tests/test_collide.c b/packages/flutter3d_physics_native/csrc/tests/test_collide.c new file mode 100644 index 000000000..ba0deb8fb --- /dev/null +++ b/packages/flutter3d_physics_native/csrc/tests/test_collide.c @@ -0,0 +1,575 @@ +/* + * Contacts, tested in C — P9, phase 2: every pair of shapes against its + * closed form, turned boxes with whole faces, the sweep against every pair + * tried, contact events, filters, islands that sleep and wake as one, heat + * across a contact, and contacts carried through a snapshot. + */ +#include +#include +#include + +#include "check.h" + +#ifdef F3D_REAL_DOUBLE +#define TIGHT 1e-9 +#else +#define TIGHT 1e-5 +#endif + +static F3dQuat turn_about(F3dVec3 axis, double angle) { + const double s = sin(angle / 2); + F3dQuat q = {(f3d_real)(axis.x * s), (f3d_real)(axis.y * s), + (f3d_real)(axis.z * s), (f3d_real)cos(angle / 2)}; + return q; +} + +static const F3dQuat identity = {0, 0, 0, 1}; + +static F3dPlaced placed(uint32_t kind, F3dVec3 size, F3dVec3 at, F3dQuat q) { + F3dPlaced p; + memset(&p, 0, sizeof p); + p.kind = kind; + p.size = size; + p.at = at; + p.axes = f3d_mat_of(q); + return p; +} + +static F3dPlaced ball(f3d_real r, F3dVec3 at) { + return placed(F3D_SHAPE_SPHERE, f3d_v3(r, 0, 0), at, identity); +} + +static F3dPlaced box(F3dVec3 half, F3dVec3 at, F3dQuat q) { + return placed(F3D_SHAPE_BOX, half, at, q); +} + +static F3dPlaced capsule(f3d_real r, f3d_real half, F3dVec3 at, F3dQuat q) { + return placed(F3D_SHAPE_CAPSULE, f3d_v3(r, half, 0), at, q); +} + +static uint32_t collide(const F3dPlaced *a, const F3dPlaced *b, F3dManifold *m) { + memset(m, 0, sizeof *m); + return f3d_collide(a, b, F3D_R(0.02), m); +} + +static void test_balls(void) { + F3dManifold m; + const F3dPlaced a = ball(1, f3d_v3(0, F3D_R(1.9), 0)); + const F3dPlaced b = ball(1, f3d_v3(0, 0, 0)); + CHECK(collide(&a, &b, &m) == 1); + CHECK_NEAR(m.normal.y, 1, TIGHT); + CHECK_NEAR(m.points[0].depth, 0.1, TIGHT); + CHECK_NEAR(m.points[0].point.y, 0.95, TIGHT); + CHECK(m.touching); + /* A gap within the margin is a contact with a negative depth, and not + * touching. */ + const F3dPlaced near = ball(1, f3d_v3(0, F3D_R(2.01), 0)); + CHECK(collide(&near, &b, &m) == 1); + CHECK_NEAR(m.points[0].depth, -0.01, TIGHT); + CHECK(!m.touching); + /* Within the solver's slop is touching: as close as it holds a body. */ + const F3dPlaced held = ball(1, f3d_v3(0, F3D_R(2.004), 0)); + CHECK(collide(&held, &b, &m) == 1 && m.touching); + const F3dPlaced far = ball(1, f3d_v3(0, F3D_R(2.03), 0)); + CHECK(collide(&far, &b, &m) == 0); + /* Concentric: up, as flutter3d_physics parts them. */ + CHECK(collide(&b, &b, &m) == 1); + CHECK(m.normal.y == 1); + CHECK_NEAR(m.points[0].depth, 2, TIGHT); +} + +static void test_ball_and_box(void) { + F3dManifold m; + /* A box turned 45° about z stands on its edge; a ball above that edge + * is pushed straight up, by the edge, not by a face. */ + const F3dPlaced b = box(f3d_v3(1, 1, 1), f3d_v3(0, 0, 0), + turn_about(f3d_v3(0, 0, 1), M_PI / 4)); + const double top = sqrt(2.0); + const F3dPlaced a = ball(F3D_R(0.5), f3d_v3(0, (f3d_real)(top + 0.4), 0)); + CHECK(collide(&a, &b, &m) == 1); + CHECK_NEAR(m.normal.y, 1, TIGHT); + CHECK_NEAR(m.points[0].depth, 0.1, 1e-4); + /* Swapped, the normal turns round. */ + CHECK(collide(&b, &a, &m) == 1); + CHECK_NEAR(m.normal.y, -1, TIGHT); + /* A centre inside the box goes out by the nearest face. */ + const F3dPlaced in = ball(F3D_R(0.5), f3d_v3(F3D_R(0.9), 0, 0)); + const F3dPlaced flat = box(f3d_v3(1, 1, 1), f3d_v3(0, 0, 0), identity); + CHECK(collide(&in, &flat, &m) == 1); + CHECK(m.normal.x == 1); + CHECK_NEAR(m.points[0].depth, 0.6, TIGHT); +} + +static void test_box_on_box(void) { + F3dManifold m; + const F3dPlaced floor = box(f3d_v3(5, F3D_R(0.5), 5), f3d_v3(0, F3D_R(-0.5), 0), + identity); + /* A crate sunk a centimetre into the floor rests on its whole face: four + * points at its corners, each a centimetre deep. */ + const F3dPlaced crate = box(f3d_v3(F3D_R(0.5), F3D_R(0.5), F3D_R(0.5)), + f3d_v3(1, F3D_R(0.49), 2), identity); + CHECK(collide(&crate, &floor, &m) == 4); + CHECK_NEAR(m.normal.y, 1, TIGHT); + for (uint32_t i = 0; i < m.count; i++) { + CHECK_NEAR(m.points[i].depth, 0.01, 1e-5); + CHECK_NEAR(fabs((double)m.points[i].point.x - 1), 0.5, 1e-5); + CHECK_NEAR(fabs((double)m.points[i].point.z - 2), 0.5, 1e-5); + } + /* Turned about the vertical, it still rests on four corners. */ + const F3dPlaced turned = + box(f3d_v3(F3D_R(0.5), F3D_R(0.5), F3D_R(0.5)), f3d_v3(1, F3D_R(0.49), 2), + turn_about(f3d_v3(0, 1, 0), 0.5)); + CHECK(collide(&turned, &floor, &m) == 4); + CHECK_NEAR(m.normal.y, 1, TIGHT); + /* The floor first: the normal out of the crate, down. */ + CHECK(collide(&floor, &turned, &m) == 4); + CHECK_NEAR(m.normal.y, -1, TIGHT); + /* Stood on an edge, it touches along the edge: two points. */ + const double reach = sqrt(0.5); + const F3dPlaced edge = + box(f3d_v3(F3D_R(0.5), F3D_R(0.5), F3D_R(0.5)), + f3d_v3(0, (f3d_real)(reach - 0.01), 0), + turn_about(f3d_v3(0, 0, 1), M_PI / 4)); + CHECK(collide(&edge, &floor, &m) == 2); + CHECK_NEAR(m.normal.y, 1, TIGHT); + CHECK_NEAR(m.points[0].depth, 0.01, 1e-4); + CHECK_NEAR(fabs((double)(m.points[0].point.z - m.points[1].point.z)), 1, 1e-4); + /* Two crates alike, the top one turned 45°: they meet over an octagon, + * eight points, and the four kept must hold most of it — the deepest, + * the furthest from it, and the widest either side — not four in a row + * along one side. The four alternate corners span 0.586 of the 0.828 the + * octagon covers; four in a row, about 0.35. */ + const F3dPlaced base = box(f3d_v3(F3D_R(0.5), F3D_R(0.5), F3D_R(0.5)), + f3d_v3(0, 0, 0), identity); + const F3dPlaced twisted = + box(f3d_v3(F3D_R(0.5), F3D_R(0.5), F3D_R(0.5)), f3d_v3(0, F3D_R(0.99), 0), + turn_about(f3d_v3(0, 1, 0), M_PI / 4)); + CHECK(collide(&twisted, &base, &m) == 4); + { + const F3dVec3 up = f3d_v3(0, 1, 0); + const F3dContactPoint *p = m.points; + double best = 0; + const int orders[3][4] = {{0, 1, 2, 3}, {0, 1, 3, 2}, {0, 2, 1, 3}}; + for (int o = 0; o < 3; o++) { + const F3dVec3 a0 = p[orders[o][0]].point, a1 = p[orders[o][1]].point; + const F3dVec3 a2 = p[orders[o][2]].point, a3 = p[orders[o][3]].point; + const double area = + 0.5 * fabs((double)f3d_dot(f3d_cross(f3d_sub(a2, a0), f3d_sub(a3, a1)), up)); + if (area > best) best = area; + } + CHECK(best > 0.5); + } + /* A big box on a small one clips to the small one's face. */ + const F3dPlaced small = box(f3d_v3(F3D_R(0.2), F3D_R(0.2), F3D_R(0.2)), + f3d_v3(0, 0, 0), identity); + const F3dPlaced big = box(f3d_v3(1, F3D_R(0.2), 1), f3d_v3(0, F3D_R(0.39), 0), + identity); + CHECK(collide(&big, &small, &m) == 4); + for (uint32_t i = 0; i < m.count; i++) { + CHECK_NEAR(fabs((double)m.points[i].point.x), 0.2, 1e-5); + } +} + +static void test_edge_on_edge(void) { + F3dManifold m; + /* Two crates stood on edges crossed at right angles: they touch at one + * point, edge to edge, and the way apart is straight up. */ + const double reach = sqrt(0.5); + const F3dPlaced low = box(f3d_v3(F3D_R(0.5), F3D_R(0.5), F3D_R(0.5)), + f3d_v3(0, 0, 0), turn_about(f3d_v3(0, 0, 1), M_PI / 4)); + const F3dPlaced high = + box(f3d_v3(F3D_R(0.5), F3D_R(0.5), F3D_R(0.5)), + f3d_v3(0, (f3d_real)(2 * reach - 0.01), 0), + turn_about(f3d_v3(1, 0, 0), M_PI / 4)); + CHECK(collide(&high, &low, &m) == 1); + CHECK_NEAR(m.normal.y, 1, 1e-4); + CHECK_NEAR(m.points[0].depth, 0.01, 1e-4); + CHECK_NEAR(m.points[0].point.x, 0, 1e-4); + CHECK_NEAR(m.points[0].point.z, 0, 1e-4); + CHECK(m.points[0].id >= 0x100u); +} + +static void test_capsules(void) { + F3dManifold m; + const F3dPlaced floor = box(f3d_v3(5, F3D_R(0.5), 5), f3d_v3(0, F3D_R(-0.5), 0), + identity); + /* Lying down, it rests on both ends. */ + const F3dQuat lying = turn_about(f3d_v3(0, 0, 1), M_PI / 2); + const F3dPlaced log = capsule(F3D_R(0.1), F3D_R(0.5), f3d_v3(0, F3D_R(0.09), 0), lying); + CHECK(collide(&log, &floor, &m) == 2); + CHECK_NEAR(m.normal.y, 1, TIGHT); + CHECK_NEAR(m.points[0].depth, 0.01, 1e-5); + CHECK_NEAR(m.points[1].depth, 0.01, 1e-5); + CHECK_NEAR(fabs((double)(m.points[0].point.x - m.points[1].point.x)), 1, 1e-4); + /* Upright, on one point. */ + const F3dPlaced post = capsule(F3D_R(0.1), F3D_R(0.5), f3d_v3(0, F3D_R(0.59), 0), + identity); + CHECK(collide(&post, &floor, &m) == 1); + CHECK_NEAR(m.points[0].depth, 0.01, 1e-5); + /* Two lying side by side touch along their length; crossed, at a + * point. */ + const F3dPlaced other = + capsule(F3D_R(0.1), F3D_R(0.5), f3d_v3(F3D_R(0.25), F3D_R(0.27), 0), lying); + CHECK(collide(&other, &log, &m) == 2); + CHECK_NEAR(m.normal.y, 1, TIGHT); + const F3dPlaced crossed = + capsule(F3D_R(0.1), F3D_R(0.5), f3d_v3(0, F3D_R(0.27), 0), + turn_about(f3d_v3(1, 0, 0), M_PI / 2)); + CHECK(collide(&crossed, &log, &m) == 1); + CHECK_NEAR(m.normal.y, 1, TIGHT); + CHECK_NEAR(m.points[0].depth, 0.02, 1e-5); + /* A ball on a capsule's side. */ + const F3dPlaced pebble = ball(F3D_R(0.05), f3d_v3(F3D_R(0.3), F3D_R(0.23), 0)); + CHECK(collide(&pebble, &log, &m) == 1); + CHECK_NEAR(m.normal.y, 1, TIGHT); + CHECK_NEAR(m.points[0].depth, 0.01, 1e-5); +} + +/* Every kind against every kind, turned, both ways round: the normal turns + * round with the order and the depths stay. */ +static void test_symmetry(void) { + const F3dQuat q = turn_about(f3d_v3(F3D_R(0.6), F3D_R(0.8), 0), 0.7); + const F3dQuat r = turn_about(f3d_v3(0, F3D_R(0.6), F3D_R(0.8)), -1.1); + F3dPlaced shapes[3] = { + ball(F3D_R(0.4), f3d_v3(0, 0, 0)), + box(f3d_v3(F3D_R(0.4), F3D_R(0.3), F3D_R(0.2)), f3d_v3(0, 0, 0), q), + capsule(F3D_R(0.2), F3D_R(0.3), f3d_v3(0, 0, 0), r), + }; + for (int i = 0; i < 3; i++) { + for (int j = 0; j < 3; j++) { + F3dPlaced a = shapes[i], b = shapes[j]; + b.at = f3d_v3(F3D_R(0.3), F3D_R(0.45), F3D_R(0.1)); + F3dManifold ab, ba; + const uint32_t n1 = collide(&a, &b, &ab); + const uint32_t n2 = collide(&b, &a, &ba); + CHECK(n1 > 0 && n2 > 0); + CHECK_NEAR(ab.normal.x, -ba.normal.x, 1e-4); + CHECK_NEAR(ab.normal.y, -ba.normal.y, 1e-4); + CHECK_NEAR(ab.normal.z, -ba.normal.z, 1e-4); + double da = -1e9, db = -1e9; + for (uint32_t k = 0; k < n1; k++) da = fmax(da, ab.points[k].depth); + for (uint32_t k = 0; k < n2; k++) db = fmax(db, ba.points[k].depth); + CHECK_NEAR(da, db, 1e-4); + /* A unit normal. */ + CHECK_NEAR(f3d_dot(ab.normal, ab.normal), 1, 1e-5); + } + } +} + +static double uniform(unsigned *state) { + *state = *state * 1664525u + 1013904223u; + return (*state >> 8) / 16777216.0; +} + +static F3dBody add_shape(F3dWorld *w, F3dBodyType type, int kind, F3dVec3 at, + F3dQuat q, double s) { + const F3dBody b = f3d_body_create(w, type, at.x, at.y, at.z, 1); + f3d_body_set_orientation(w, b, q.x, q.y, q.z, q.w); + if (kind == 1) f3d_body_set_shape(w, b, F3D_SHAPE_SPHERE, (f3d_real)s, 0, 0); + if (kind == 2) { + f3d_body_set_shape(w, b, F3D_SHAPE_BOX, (f3d_real)s, (f3d_real)(s * 0.6), + (f3d_real)(s * 1.3)); + } + if (kind == 3) { + f3d_body_set_shape(w, b, F3D_SHAPE_CAPSULE, (f3d_real)(s * 0.4), + (f3d_real)s, 0); + } + return b; +} + +static void test_sweep_finds_every_pair(void) { + /* Two hundred shapes of every kind, turned every way, crowded into a few + * metres: the sweep must find exactly the pairs that trying every pair + * finds. */ + enum { N = 200 }; + F3dWorld *w = f3d_world_create(); + f3d_world_set_gravity(w, 0, 0, 0); + f3d_world_set_sleep(w, 0, 0); + unsigned seed = 12345u; + F3dBody bodies[N]; + for (int i = 0; i < N; i++) { + const F3dVec3 at = f3d_v3((f3d_real)(uniform(&seed) * 6), + (f3d_real)(uniform(&seed) * 3), + (f3d_real)(uniform(&seed) * 6)); + const F3dQuat q = turn_about( + f3d_v3(F3D_R(0.48), F3D_R(0.6), F3D_R(0.64)), uniform(&seed) * 6.28); + bodies[i] = add_shape(w, i % 7 == 0 ? F3D_BODY_FIXED : F3D_BODY_DYNAMIC, + 1 + i % 3, at, q, 0.15 + uniform(&seed) * 0.3); + } + /* One point among them, which touches nothing. */ + f3d_body_create(w, F3D_BODY_DYNAMIC, 1, 1, 1, 1); + f3d_world_step(w, F3D_R(1e-6)); + int expected = 0; + for (int i = 0; i < N; i++) { + for (int j = i + 1; j < N; j++) { + const F3dSlot *a = f3d_slot_of(w, bodies[i]); + const F3dSlot *b = f3d_slot_of(w, bodies[j]); + if (a->type == F3D_BODY_FIXED && b->type == F3D_BODY_FIXED) continue; + const F3dPlaced pa = f3d_placed_of(w, a), pb = f3d_placed_of(w, b); + F3dManifold m; + memset(&m, 0, sizeof m); + if (f3d_collide(&pa, &pb, w->s.contact_margin, &m) > 0) expected++; + } + } + CHECK((int)w->s.manifold_count == expected); + CHECK(expected > 20); + /* In order of their slots. */ + int sorted = 1; + for (uint32_t i = 1; i < w->s.manifold_count; i++) { + const F3dManifold *p = &w->manifolds[i - 1], *q = &w->manifolds[i]; + const uint64_t kp = ((p->a & 0xffffffffu) << 32) | (p->b & 0xffffffffu); + const uint64_t kq = ((q->a & 0xffffffffu) << 32) | (q->b & 0xffffffffu); + sorted &= kp < kq; + } + CHECK(sorted); + /* What the reader hands out adds up. */ + const uint32_t points = f3d_world_contact_count(w); + f3d_real *contacts = (f3d_real *)malloc(points * F3D_CONTACT_FLOATS * sizeof(f3d_real)); + F3dBody *pairs = (F3dBody *)malloc(points * 2 * sizeof(F3dBody)); + CHECK(f3d_world_read_contacts(w, contacts, pairs, points) == points); + CHECK(f3d_body_is_valid(w, pairs[0]) && f3d_body_is_valid(w, pairs[1])); + free(contacts); + free(pairs); + f3d_world_destroy(w); +} + +static uint32_t events(F3dWorld *w, F3dBody *bodies, F3dBody *others, + uint32_t *kinds) { + return f3d_world_read_events(w, bodies, others, kinds, 16); +} + +static void test_contact_events(void) { + F3dWorld *w = f3d_world_create(); + f3d_world_set_gravity(w, 0, 0, 0); + f3d_world_set_sleep(w, 0, 0); + f3d_world_set_air(w, 293, F3D_R(1e-30)); + const F3dBody wall = add_shape(w, F3D_BODY_FIXED, 2, f3d_v3(0, 0, 0), identity, 0.5); + const F3dBody bullet = add_shape(w, F3D_BODY_DYNAMIC, 1, f3d_v3(-2, 0, 0), identity, 0.1); + F3dBody bodies[16], others[16]; + uint32_t kinds[16]; + /* At the wall at two metres a second, a hundredth at a time: it bounces + * off, beginning one contact and ending it. */ + f3d_body_set_restitution(w, bullet, 1); + f3d_body_set_velocity(w, bullet, 2, 0, 0); + uint32_t began = 0, ended = 0; + for (int i = 0; i < 400; i++) { + f3d_world_step(w, F3D_R(0.01)); + const uint32_t n = events(w, bodies, others, kinds); + for (uint32_t k = 0; k < n; k++) { + CHECK(bodies[k] == wall && others[k] == bullet); + began += kinds[k] == F3D_EVENT_CONTACT_BEGAN; + ended += kinds[k] == F3D_EVENT_CONTACT_ENDED; + } + } + CHECK(began == 1 && ended == 1); + /* A filter that leaves the wall out: nothing at all. */ + f3d_body_set_position(w, bullet, -2, 0, 0); + f3d_body_set_velocity(w, bullet, 2, 0, 0); + f3d_body_set_collision_filter(w, bullet, 2, ~1u); + for (int i = 0; i < 400; i++) f3d_world_step(w, F3D_R(0.01)); + CHECK(events(w, bodies, others, kinds) == 0); + /* Taken away mid-contact, the contact ends, naming who it was. */ + f3d_body_set_collision_filter(w, bullet, 1, ~0u); + f3d_body_set_position(w, bullet, 0, 0, 0); + f3d_body_set_velocity(w, bullet, 0, 0, 0); + f3d_world_step(w, F3D_R(0.01)); + CHECK(events(w, bodies, others, kinds) == 1 && kinds[0] == F3D_EVENT_CONTACT_BEGAN); + f3d_body_destroy(w, bullet); + f3d_world_step(w, F3D_R(0.01)); + CHECK(events(w, bodies, others, kinds) == 1); + CHECK(kinds[0] == F3D_EVENT_CONTACT_ENDED && others[0] == bullet); + CHECK(f3d_world_contact_count(w) == 0); + /* Margin nought: only a real overlap counts. */ + CHECK(f3d_world_set_contact_margin(w, -1) == 0); + CHECK(f3d_world_set_contact_margin(w, 0) == 1); + f3d_world_destroy(w); +} + +static void test_islands(void) { + /* Two crates stacked on a floor, and one alone across the room. With no + * solver yet they stay where they are put, a millimetre into each + * other. */ + F3dWorld *w = f3d_world_create(); + f3d_world_set_gravity(w, 0, 0, 0); + const F3dBody floor = add_shape(w, F3D_BODY_FIXED, 2, f3d_v3(0, F3D_R(-0.5), 0), identity, 0.5); + f3d_body_set_shape(w, floor, F3D_SHAPE_BOX, 10, F3D_R(0.5), 10); + const F3dBody low = add_shape(w, F3D_BODY_DYNAMIC, 1, f3d_v3(0, F3D_R(0.499), 0), identity, 0.5); + const F3dBody high = add_shape(w, F3D_BODY_DYNAMIC, 1, f3d_v3(0, F3D_R(1.498), 0), identity, 0.5); + const F3dBody alone = add_shape(w, F3D_BODY_DYNAMIC, 1, f3d_v3(5, F3D_R(0.499), 0), identity, 0.5); + F3dBody bodies[16], others[16]; + uint32_t kinds[16]; + /* The lone crate is still from the start; the stack's top only after a + * while, so the stack sleeps later, as one. */ + f3d_body_set_velocity(w, high, F3D_R(0.1), 0, 0); + for (int i = 0; i < 33; i++) f3d_world_step(w, F3D_R(1.0 / 60.0)); + CHECK(f3d_body_is_asleep(w, alone)); + CHECK(!f3d_body_is_asleep(w, low) && !f3d_body_is_asleep(w, high)); + f3d_body_set_velocity(w, high, 0, 0, 0); + for (int i = 0; i < 29; i++) f3d_world_step(w, F3D_R(1.0 / 60.0)); + CHECK(!f3d_body_is_asleep(w, low)); + for (int i = 0; i < 3; i++) f3d_world_step(w, F3D_R(1.0 / 60.0)); + CHECK(f3d_body_is_asleep(w, low) && f3d_body_is_asleep(w, high)); + events(w, bodies, others, kinds); + /* Asleep, they keep their contacts, and nothing ends. */ + const uint32_t contacts = f3d_world_contact_count(w); + CHECK(contacts > 0); + for (int i = 0; i < 10; i++) f3d_world_step(w, F3D_R(1.0 / 60.0)); + CHECK(f3d_world_contact_count(w) == contacts); + CHECK(events(w, bodies, others, kinds) == 0); + /* Knock the top one and the bottom one wakes with it; the lone crate + * sleeps on. */ + f3d_body_apply_impulse(w, high, 1, 0, 0); + f3d_world_step(w, F3D_R(1.0 / 60.0)); + CHECK(!f3d_body_is_asleep(w, low)); + CHECK(f3d_body_is_asleep(w, alone)); + const uint32_t n = events(w, bodies, others, kinds); + int low_woke = 0; + for (uint32_t k = 0; k < n; k++) { + low_woke |= bodies[k] == low && kinds[k] == F3D_EVENT_WOKE; + } + CHECK(low_woke); + f3d_world_destroy(w); +} + +static void test_conduction(void) { + /* Two steel balls of a kilogram, r = 5 cm, pressed a millimetre into each + * other, one hot. Hertz's spot is √(Rδ) = 5 mm across with R = 2.5 cm, + * Holm's conductance 4a / (2/k) = 0.5 W/K, and in still air at their mean + * temperature the difference between them falls as + * e^(−(2G + hA) t / C), never crossing. */ + F3dWorld *w = f3d_world_create(); + f3d_world_set_gravity(w, 0, 0, 0); + f3d_world_set_air(w, 350, F3D_R(1.204)); + F3dMaterial steel; + f3d_material_preset(F3D_MATERIAL_STEEL, &steel); + steel.emissivity = 0; + const F3dBody hot = add_shape(w, F3D_BODY_DYNAMIC, 1, f3d_v3(0, 0, 0), identity, 0.05); + const F3dBody cold = add_shape(w, F3D_BODY_DYNAMIC, 1, f3d_v3(F3D_R(0.099), 0, 0), identity, 0.05); + const F3dBody apart = add_shape(w, F3D_BODY_DYNAMIC, 1, f3d_v3(5, 0, 0), identity, 0.05); + const F3dBody apart2 = add_shape(w, F3D_BODY_DYNAMIC, 1, f3d_v3(8, 0, 0), identity, 0.05); + f3d_body_set_material(w, hot, &steel); + f3d_body_set_material(w, cold, &steel); + f3d_body_set_material(w, apart, &steel); + f3d_body_set_material(w, apart2, &steel); + f3d_body_set_temperature(w, hot, 400); + f3d_body_set_temperature(w, cold, 300); + f3d_body_set_temperature(w, apart, 400); + f3d_body_set_temperature(w, apart2, 300); + f3d_real th = 0, tc = 0, ta = 0, tb = 0; + int crossed = 0; + for (int i = 0; i < 6000; i++) { + f3d_world_step(w, F3D_R(0.1)); + f3d_body_get_temperature(w, hot, &th); + f3d_body_get_temperature(w, cold, &tc); + crossed |= th < tc - F3D_R(1e-3); + } + f3d_body_get_temperature(w, apart, &ta); + f3d_body_get_temperature(w, apart2, &tb); + CHECK(!crossed); + const double ha = 10.45 * 4 * 3.14159265358979 * 0.05 * 0.05; + CHECK_NEAR((th - tc) / 100.0, exp(-600.0 * (2 * 0.5 + ha) / 490.0), 1e-2); + CHECK_NEAR((ta - tb) / 100.0, exp(-600.0 * ha / 490.0), 1e-2); + /* The air is at their mean and they are alike, so the mean holds. */ + CHECK_NEAR(0.5 * (th + tc), 350, 1e-3); + + /* A hot plate of no thermal mass is a reservoir: it warms the block on it + * and stays as hot as it was. */ + const F3dBody plate = f3d_body_create(w, F3D_BODY_FIXED, 0, F3D_R(-10.5), 0, 0); + f3d_body_set_shape(w, plate, F3D_SHAPE_BOX, 1, F3D_R(0.5), 1); + f3d_body_set_material(w, plate, &steel); + f3d_body_set_temperature(w, plate, 500); + /* Its half height is 0.12: a millimetre into the plate's top at −10. */ + const F3dBody block = add_shape(w, F3D_BODY_DYNAMIC, 2, f3d_v3(0, F3D_R(-9.881), 0), identity, 0.2); + f3d_body_set_material(w, block, &steel); + f3d_body_set_temperature(w, block, 350); + for (int i = 0; i < 600; i++) f3d_world_step(w, F3D_R(0.1)); + f3d_real tp, tbk; + f3d_body_get_temperature(w, plate, &tp); + f3d_body_get_temperature(w, block, &tbk); + CHECK(tp == 500); + CHECK(tbk > 400); + f3d_world_destroy(w); +} + +static void test_fire_warms_what_it_touches(void) { + /* A burning block pressed against a cold one warms it through the + * contact, and one standing apart not at all. Not to burning: wood is an + * insulator, a contact of 10 × 10 cm passes about a hundredth of a watt + * per kelvin, and fire crosses from log to log by its flames, which the + * smoke grid will carry. */ + F3dWorld *w = f3d_world_create(); + f3d_world_set_gravity(w, 0, 0, 0); + F3dMaterial wood; + f3d_material_preset(F3D_MATERIAL_WOOD, &wood); + const f3d_real xs[3] = {0, F3D_R(0.099), 5}; + const F3dBodyType types[3] = {F3D_BODY_FIXED, F3D_BODY_DYNAMIC, F3D_BODY_DYNAMIC}; + F3dBody blocks[3]; + for (int i = 0; i < 3; i++) { + blocks[i] = f3d_body_create(w, types[i], xs[i], 0, 0, F3D_R(0.6)); + f3d_body_set_shape(w, blocks[i], F3D_SHAPE_BOX, F3D_R(0.05), F3D_R(0.05), F3D_R(0.05)); + f3d_body_set_material(w, blocks[i], &wood); + } + f3d_body_set_temperature(w, blocks[0], 700); + for (int i = 0; i < 6000; i++) f3d_world_step(w, F3D_R(0.1)); + int burning = 0, near_burning = 0; + f3d_body_is_burning(w, blocks[0], &burning); + f3d_body_is_burning(w, blocks[1], &near_burning); + f3d_real near, far; + f3d_body_get_temperature(w, blocks[1], &near); + f3d_body_get_temperature(w, blocks[2], &far); + CHECK(burning); + CHECK(near > far + 1); + CHECK_NEAR(far, 293.15, 1e-4); + CHECK(!near_burning); + f3d_world_destroy(w); +} + +static void test_snapshot_keeps_contacts(void) { + F3dWorld *a = f3d_world_create(); + f3d_world_set_gravity(a, 0, F3D_R(-1), 0); + const F3dBody floor = add_shape(a, F3D_BODY_FIXED, 2, f3d_v3(0, F3D_R(-0.5), 0), identity, 0.5); + f3d_body_set_shape(a, floor, F3D_SHAPE_BOX, 10, F3D_R(0.5), 10); + for (int i = 0; i < 6; i++) { + add_shape(a, F3D_BODY_DYNAMIC, 1 + i % 3, f3d_v3((f3d_real)i * F3D_R(0.3), F3D_R(0.4), 0), + turn_about(f3d_v3(0, 0, 1), i * 0.3), 0.2); + } + for (int i = 0; i < 20; i++) f3d_world_step(a, F3D_R(1.0 / 60.0)); + CHECK(a->s.manifold_count > 0); + const uint32_t size = f3d_world_snapshot_size(a); + uint8_t *snap = (uint8_t *)malloc(size); + f3d_world_snapshot_write(a, snap, size); + F3dWorld *b = f3d_world_create(); + CHECK(f3d_world_restore(b, snap, size) == 1); + CHECK(b->s.manifold_count == a->s.manifold_count); + for (int i = 0; i < 60; i++) { + f3d_world_step(a, F3D_R(1.0 / 60.0)); + f3d_world_step(b, F3D_R(1.0 / 60.0)); + } + const uint32_t sa = f3d_world_snapshot_size(a); + uint8_t *xa = (uint8_t *)malloc(sa), *xb = (uint8_t *)malloc(sa); + f3d_world_snapshot_write(a, xa, sa); + CHECK(f3d_world_snapshot_size(b) == sa); + f3d_world_snapshot_write(b, xb, sa); + CHECK(memcmp(xa, xb, sa) == 0); + free(snap); + free(xa); + free(xb); + f3d_world_destroy(a); + f3d_world_destroy(b); +} + +int main(void) { + test_balls(); + test_ball_and_box(); + test_box_on_box(); + test_edge_on_edge(); + test_capsules(); + test_symmetry(); + test_sweep_finds_every_pair(); + test_contact_events(); + test_islands(); + test_conduction(); + test_fire_warms_what_it_touches(); + test_snapshot_keeps_contacts(); + return finish(); +} diff --git a/packages/flutter3d_physics_native/csrc/tests/test_convex.c b/packages/flutter3d_physics_native/csrc/tests/test_convex.c new file mode 100644 index 000000000..7947fd4fa --- /dev/null +++ b/packages/flutter3d_physics_native/csrc/tests/test_convex.c @@ -0,0 +1,439 @@ +/* + * Convex shapes, tested in C — P9, phase 5: GJK's distances and EPA's + * depths against the closed forms, manifolds from faces and edges, hulls + * built and weighed, rounded shapes, the inertia of every new shape, a + * cylinder rolling down a slope at 2/3 g sin θ, and hulls in a snapshot. + */ +#include +#include +#include + +#include "check.h" + +static const F3dQuat identity = {0, 0, 0, 1}; + +static F3dQuat turn_about(F3dVec3 axis, double angle) { + const double s = sin(angle / 2); + F3dQuat q = {(f3d_real)(axis.x * s), (f3d_real)(axis.y * s), + (f3d_real)(axis.z * s), (f3d_real)cos(angle / 2)}; + return q; +} + +static F3dPlaced shape(uint32_t kind, F3dVec3 size, F3dVec3 at, F3dQuat q) { + F3dPlaced p; + memset(&p, 0, sizeof p); + p.kind = kind; + p.size = size; + p.at = at; + p.axes = f3d_mat_of(q); + return p; +} + +static uint32_t collide(const F3dPlaced *a, const F3dPlaced *b, F3dManifold *m) { + memset(m, 0, sizeof *m); + return f3d_collide(a, b, F3D_R(0.02), m); +} + +static const F3dPlaced *floor_box(void) { + static F3dPlaced f; + f = shape(F3D_SHAPE_BOX, f3d_v3(10, F3D_R(0.5), 10), f3d_v3(0, F3D_R(-0.5), 0), + identity); + return &f; +} + +static void test_distances(void) { + F3dManifold m; + const F3dPlaced cyl = shape(F3D_SHAPE_CYLINDER, f3d_v3(F3D_R(0.5), 1, 0), + f3d_v3(0, 0, 0), identity); + /* A box a centimetre from the cylinder's side: a gap within the margin, + * straight out along x. */ + const F3dPlaced near = shape(F3D_SHAPE_BOX, f3d_v3(F3D_R(0.5), F3D_R(0.5), F3D_R(0.5)), + f3d_v3(F3D_R(1.01), 0, 0), identity); + CHECK(collide(&near, &cyl, &m) > 0); + CHECK_NEAR(m.normal.x, 1, 1e-4); + for (uint32_t i = 0; i < m.count; i++) CHECK_NEAR(m.points[i].depth, -0.01, 1e-4); + const F3dPlaced far = shape(F3D_SHAPE_BOX, f3d_v3(F3D_R(0.5), F3D_R(0.5), F3D_R(0.5)), + f3d_v3(F3D_R(1.05), 0, 0), identity); + CHECK(collide(&far, &cyl, &m) == 0); + /* Deep: a small cylinder sunk thirty-five centimetres into the top of a + * big box goes out upwards, as deep as it is in. */ + const F3dPlaced big = shape(F3D_SHAPE_BOX, f3d_v3(1, 1, 1), f3d_v3(0, 0, 0), identity); + const F3dPlaced plug = shape(F3D_SHAPE_CYLINDER, f3d_v3(F3D_R(0.25), F3D_R(0.25), 0), + f3d_v3(0, F3D_R(0.9), 0), identity); + CHECK(collide(&plug, &big, &m) > 0); + CHECK_NEAR(m.normal.y, 1, 1e-4); + double deepest = -1; + for (uint32_t i = 0; i < m.count; i++) deepest = fmax(deepest, m.points[i].depth); + CHECK_NEAR(deepest, 0.35, 1e-4); + /* A cone's apex a centimetre into a floor. */ + const F3dPlaced upside = shape(F3D_SHAPE_CONE, f3d_v3(F3D_R(0.3), 1, 0), + f3d_v3(0, F3D_R(0.74), 0), + turn_about(f3d_v3(1, 0, 0), M_PI)); + CHECK(collide(&upside, floor_box(), &m) == 1); + CHECK_NEAR(m.points[0].depth, 0.01, 1e-4); + CHECK_NEAR(m.normal.y, 1, 1e-4); +} + +static void test_manifolds(void) { + F3dManifold m; + /* A cylinder standing on its end rests on four points of its rim. */ + const F3dPlaced standing = shape(F3D_SHAPE_CYLINDER, f3d_v3(F3D_R(0.5), 1, 0), + f3d_v3(0, F3D_R(0.99), 0), identity); + CHECK(collide(&standing, floor_box(), &m) == 4); + for (uint32_t i = 0; i < m.count; i++) { + CHECK_NEAR(m.points[i].depth, 0.01, 1e-4); + const double r = sqrt((double)m.points[i].point.x * m.points[i].point.x + + (double)m.points[i].point.z * m.points[i].point.z); + CHECK_NEAR(r, 0.5, 1e-4); + } + /* Lying on its side, along a line its length. */ + const F3dPlaced lying = shape(F3D_SHAPE_CYLINDER, f3d_v3(F3D_R(0.5), 1, 0), + f3d_v3(0, F3D_R(0.49), 0), + turn_about(f3d_v3(0, 0, 1), M_PI / 2)); + CHECK(collide(&lying, floor_box(), &m) == 2); + CHECK_NEAR(fabs((double)(m.points[0].point.x - m.points[1].point.x)), 2, 1e-3); + CHECK_NEAR(m.points[0].depth, 0.01, 1e-4); + /* A cone on its base: four points; on its slant: two, apex and rim. */ + const F3dPlaced cone = shape(F3D_SHAPE_CONE, f3d_v3(F3D_R(0.4), 1, 0), + f3d_v3(0, F3D_R(0.24), 0), identity); + CHECK(collide(&cone, floor_box(), &m) == 4); + /* Tipped by a right angle and the half-angle α = atan(r / H), its + * slant lies flat: lowered until the slant is a centimetre in, it rests + * on apex and rim. */ + const double slope = atan(0.4 / 1.0); + /* The slant's outward normal (H, r) turned to point straight down. */ + const F3dQuat tip = turn_about(f3d_v3(0, 0, 1), -(M_PI / 2 + slope)); + F3dPlaced tipped = shape(F3D_SHAPE_CONE, f3d_v3(F3D_R(0.4), 1, 0), + f3d_v3(0, 0, 0), tip); + const F3dVec3 apex = f3d_madd(tipped.at, tipped.axes.c[1], F3D_R(0.75)); + const F3dVec3 rim = f3d_madd(f3d_madd(tipped.at, tipped.axes.c[1], F3D_R(-0.25)), + tipped.axes.c[0], F3D_R(0.4)); + CHECK_NEAR(apex.y, rim.y, 1e-5); + tipped.at.y = -apex.y - F3D_R(0.01); + CHECK(collide(&tipped, floor_box(), &m) == 2); + CHECK_NEAR(m.points[0].depth, 0.01, 1e-3); + CHECK_NEAR(m.points[1].depth, 0.01, 1e-3); +} + +static F3dWorld *world_with_cube_hull(uint32_t *hull, f3d_real half) { + F3dWorld *w = f3d_world_create(); + /* Eight corners, shifted by (1, 2, 3), and points inside that are not + * corners. */ + f3d_real pts[3 * 11]; + int n = 0; + for (int i = 0; i < 8; i++) { + pts[n++] = F3D_R(1.0) + ((i & 1) ? half : -half); + pts[n++] = F3D_R(2.0) + ((i & 2) ? half : -half); + pts[n++] = F3D_R(3.0) + ((i & 4) ? half : -half); + } + const f3d_real inside[9] = {1, 2, 3, F3D_R(1.1), F3D_R(2.1), F3D_R(2.9), + F3D_R(0.9), F3D_R(1.95), F3D_R(3.05)}; + for (int i = 0; i < 9; i++) pts[n++] = inside[i]; + *hull = f3d_world_create_hull(w, pts, 11); + return w; +} + +static void test_hulls(void) { + uint32_t hull; + F3dWorld *w = world_with_cube_hull(&hull, F3D_R(0.5)); + CHECK(hull == 1); + CHECK(f3d_world_hull_vertex_count(w, hull) == 8); + f3d_real offset[3]; + CHECK(f3d_world_get_hull_offset(w, hull, offset) == 1); + CHECK_NEAR(offset[0], 1, 1e-6); + CHECK_NEAR(offset[1], 2, 1e-6); + CHECK_NEAR(offset[2], 3, 1e-6); + CHECK_NEAR(w->hulls[0].volume, 1, 1e-6); + CHECK_NEAR(w->hulls[0].surface, 6, 1e-6); + /* A body shaped as it weighs as a box of the same size. */ + const F3dBody b = f3d_body_create(w, F3D_BODY_DYNAMIC, 0, 0, 0, 3); + CHECK(f3d_body_set_hull(w, b, hull) == 1); + f3d_real t[6]; + f3d_body_get_inertia_tensor(w, b, t); + CHECK_NEAR(t[0], 3.0 / 3.0 * 0.5, 1e-5); + CHECK_NEAR(t[1], 0.5, 1e-5); + CHECK(fabs((double)t[3]) < 1e-6 && fabs((double)t[4]) < 1e-6); + /* And collides as one: on a floor, the same four corners and depth. */ + F3dManifold hm, bm; + F3dPlaced h = f3d_placed_of(w, f3d_slot_of(w, b)); + h.at = f3d_v3(0, F3D_R(0.49), 0); + const F3dPlaced box = shape(F3D_SHAPE_BOX, f3d_v3(F3D_R(0.5), F3D_R(0.5), F3D_R(0.5)), + f3d_v3(0, F3D_R(0.49), 0), identity); + CHECK(collide(&h, floor_box(), &hm) == 4); + CHECK(collide(&box, floor_box(), &bm) == 4); + for (uint32_t i = 0; i < 4; i++) CHECK_NEAR(hm.points[i].depth, 0.01, 1e-4); + CHECK_NEAR(hm.normal.y, bm.normal.y, 1e-5); + /* Refused: too few points, all in a plane, or not finite. */ + const f3d_real flat[12] = {0, 0, 0, 1, 0, 0, 0, 0, 1, 1, 0, 1}; + CHECK(f3d_world_create_hull(w, flat, 4) == 0); + CHECK(f3d_world_create_hull(w, flat, 3) == 0); + f3d_real bad[12] = {0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1}; + bad[4] = nan_value(); + CHECK(f3d_world_create_hull(w, bad, 4) == 0); + CHECK(f3d_body_set_hull(w, b, 7) == 0); + /* A tetrahedron's centre of mass is the mean of its corners. */ + const f3d_real tetra[12] = {0, 0, 0, 4, 0, 0, 0, 4, 0, 0, 0, 4}; + const uint32_t t2 = f3d_world_create_hull(w, tetra, 4); + CHECK(t2 == 2); + f3d_world_get_hull_offset(w, t2, offset); + CHECK_NEAR(offset[0], 1, 1e-6); + CHECK_NEAR(w->hulls[1].volume, 64.0 / 6.0, 1e-5); + f3d_world_destroy(w); +} + +static void test_turned_hull_has_products(void) { + /* A box's corners turned 30° about z, made a hull: its tensor in the + * body's axes is the box's turned, products of inertia and all. */ + F3dWorld *w = f3d_world_create(); + const F3dQuat q = turn_about(f3d_v3(0, 0, 1), M_PI / 6); + const F3dMat3 r = f3d_mat_of(q); + f3d_real pts[24]; + const double h[3] = {1.0, 0.5, 0.25}; + for (int i = 0; i < 8; i++) { + const F3dVec3 c = f3d_v3((f3d_real)((i & 1) ? h[0] : -h[0]), + (f3d_real)((i & 2) ? h[1] : -h[1]), + (f3d_real)((i & 4) ? h[2] : -h[2])); + const F3dVec3 v = f3d_add(f3d_add(f3d_scale(r.c[0], c.x), f3d_scale(r.c[1], c.y)), + f3d_scale(r.c[2], c.z)); + pts[i * 3] = v.x; + pts[i * 3 + 1] = v.y; + pts[i * 3 + 2] = v.z; + } + const uint32_t hull = f3d_world_create_hull(w, pts, 8); + const F3dBody b = f3d_body_create(w, F3D_BODY_DYNAMIC, 0, 0, 0, 2); + f3d_body_set_hull(w, b, hull); + f3d_real t[6]; + f3d_body_get_inertia_tensor(w, b, t); + const F3dSym3 diag = f3d_sym_diag((f3d_real)(2.0 / 3.0 * (h[1] * h[1] + h[2] * h[2])), + (f3d_real)(2.0 / 3.0 * (h[0] * h[0] + h[2] * h[2])), + (f3d_real)(2.0 / 3.0 * (h[0] * h[0] + h[1] * h[1]))); + const F3dSym3 want = f3d_sym_turned(q, diag); + CHECK_NEAR(t[0], want.xx, 1e-4); + CHECK_NEAR(t[1], want.yy, 1e-4); + CHECK_NEAR(t[2], want.zz, 1e-4); + CHECK_NEAR(t[3], want.xy, 1e-4); + CHECK(fabs((double)t[3]) > 0.05); + /* Spun free about no axis of its own, it keeps its angular momentum — + * which only holds if its tensor is carried into the world's axes + * right, products and all. L is worked out here, apart from the core: + * ω into the body's axes, times the tensor, back out. */ + { + F3dWorld *v = f3d_world_create(); + f3d_world_set_gravity(v, 0, 0, 0); + f3d_world_set_sleep(v, 0, 0); + f3d_world_set_air(v, F3D_R(293.15), F3D_R(1e-30)); + const uint32_t h2 = f3d_world_create_hull(v, pts, 8); + const F3dBody spun = f3d_body_create(v, F3D_BODY_DYNAMIC, 0, 0, 0, 2); + f3d_body_set_hull(v, spun, h2); + f3d_body_set_angular_velocity(v, spun, 1, 2, F3D_R(0.5)); + double l0[3] = {0, 0, 0}, l1[3] = {0, 0, 0}; + for (int pass = 0; pass < 2; pass++) { + if (pass) { + for (int i = 0; i < 600; i++) f3d_world_step(v, F3D_R(1.0 / 240.0)); + } + f3d_real q4[4], w3[3], tt[6]; + f3d_body_get_orientation(v, spun, q4); + f3d_body_get_angular_velocity(v, spun, w3); + f3d_body_get_inertia_tensor(v, spun, tt); + const F3dQuat qq = {q4[0], q4[1], q4[2], q4[3]}; + const F3dMat3 rot = f3d_mat_of(qq); + double wl[3], ll[3]; + for (int k = 0; k < 3; k++) { + wl[k] = (double)rot.c[k].x * w3[0] + (double)rot.c[k].y * w3[1] + + (double)rot.c[k].z * w3[2]; + } + const double ti[3][3] = {{tt[0], tt[3], tt[4]}, + {tt[3], tt[1], tt[5]}, + {tt[4], tt[5], tt[2]}}; + for (int k = 0; k < 3; k++) { + ll[k] = ti[k][0] * wl[0] + ti[k][1] * wl[1] + ti[k][2] * wl[2]; + } + double *out = pass ? l1 : l0; + out[0] = rot.c[0].x * ll[0] + rot.c[1].x * ll[1] + rot.c[2].x * ll[2]; + out[1] = rot.c[0].y * ll[0] + rot.c[1].y * ll[1] + rot.c[2].y * ll[2]; + out[2] = rot.c[0].z * ll[0] + rot.c[1].z * ll[1] + rot.c[2].z * ll[2]; + } + const double size = sqrt(l0[0] * l0[0] + l0[1] * l0[1] + l0[2] * l0[2]); + for (int k = 0; k < 3; k++) CHECK_NEAR(l1[k] / size, l0[k] / size, 1e-3); + f3d_world_destroy(v); + } + /* Its inverse is the tensor's: I · I⁻¹ = 1. */ + const F3dSlot *s = f3d_slot_of(w, b); + const F3dVec3 x = f3d_sym_times(s->inverse_inertia, + f3d_sym_times(s->inertia, f3d_v3(1, 2, 3))); + CHECK_NEAR(x.x, 1, 1e-4); + CHECK_NEAR(x.y, 2, 1e-4); + CHECK_NEAR(x.z, 3, 1e-4); + f3d_world_destroy(w); +} + +static void test_inertia_and_rounding(void) { + F3dWorld *w = f3d_world_create(); + const F3dBody b = f3d_body_create(w, F3D_BODY_DYNAMIC, 0, 0, 0, 2); + f3d_real i[3]; + CHECK(f3d_body_set_shape(w, b, F3D_SHAPE_CYLINDER, F3D_R(0.5), 1, 0) == 1); + f3d_body_get_inertia(w, b, i); + CHECK_NEAR(i[1], 0.5 * 2 * 0.25, 1e-6); + CHECK_NEAR(i[0], 2 * (3 * 0.25 + 4 * 1.0) / 12.0, 1e-6); + CHECK(f3d_body_set_shape(w, b, F3D_SHAPE_CONE, F3D_R(0.5), 2, 0) == 1); + f3d_body_get_inertia(w, b, i); + CHECK_NEAR(i[1], 0.3 * 2 * 0.25, 1e-6); + CHECK_NEAR(i[0], 2 * (3.0 / 20.0 * 0.25 + 3.0 / 80.0 * 4.0), 1e-6); + CHECK(f3d_body_set_shape(w, b, F3D_SHAPE_CYLINDER, 0, 1, 0) == 0); + /* Rounded, a box weighs as the box it rounds out to, and collides as + * one with rounded edges: a box of 0.4 rounded by 0.1 rests where a box + * of 0.5 rests. */ + CHECK(f3d_body_set_shape(w, b, F3D_SHAPE_BOX, F3D_R(0.4), F3D_R(0.4), F3D_R(0.4)) == 1); + CHECK(f3d_body_set_rounding(w, b, F3D_R(0.1)) == 1); + CHECK(f3d_body_set_rounding(w, b, -1) == 0); + f3d_body_get_inertia(w, b, i); + CHECK_NEAR(i[0], 2.0 / 3.0 * 0.5, 1e-6); + F3dPlaced p = f3d_placed_of(w, f3d_slot_of(w, b)); + p.at = f3d_v3(0, F3D_R(0.49), 0); + F3dManifold m; + CHECK(collide(&p, floor_box(), &m) == 4); + for (uint32_t k = 0; k < 4; k++) { + CHECK_NEAR(m.points[k].depth, 0.01, 1e-4); + /* The flat of its face: inside the unrounded core's 0.4. */ + CHECK(fabs((double)m.points[k].point.x) <= 0.4 + 1e-4); + } + /* A point cannot be rounded. */ + const F3dBody pt = f3d_body_create(w, F3D_BODY_DYNAMIC, 0, 0, 0, 1); + CHECK(f3d_body_set_rounding(w, pt, F3D_R(0.1)) == 0); + f3d_world_destroy(w); +} + +static void test_symmetry(void) { + const F3dQuat q = turn_about(f3d_v3(F3D_R(0.6), F3D_R(0.8), 0), 0.7); + const F3dQuat r = turn_about(f3d_v3(0, F3D_R(0.6), F3D_R(0.8)), -1.1); + F3dPlaced shapes[4] = { + shape(F3D_SHAPE_CYLINDER, f3d_v3(F3D_R(0.3), F3D_R(0.4), 0), f3d_v3(0, 0, 0), q), + shape(F3D_SHAPE_CONE, f3d_v3(F3D_R(0.35), F3D_R(0.8), 0), f3d_v3(0, 0, 0), r), + shape(F3D_SHAPE_BOX, f3d_v3(F3D_R(0.3), F3D_R(0.2), F3D_R(0.4)), f3d_v3(0, 0, 0), r), + shape(F3D_SHAPE_SPHERE, f3d_v3(F3D_R(0.35), 0, 0), f3d_v3(0, 0, 0), q), + }; + shapes[2].rounding = F3D_R(0.05); + for (int i = 0; i < 4; i++) { + for (int j = 0; j < 4; j++) { + F3dPlaced a = shapes[i], b = shapes[j]; + b.at = f3d_v3(F3D_R(0.25), F3D_R(0.4), F3D_R(0.1)); + F3dManifold ab, ba; + const uint32_t n1 = collide(&a, &b, &ab), n2 = collide(&b, &a, &ba); + CHECK(n1 > 0 && n2 > 0); + CHECK_NEAR(ab.normal.x, -ba.normal.x, 2e-3); + CHECK_NEAR(ab.normal.y, -ba.normal.y, 2e-3); + CHECK_NEAR(ab.normal.z, -ba.normal.z, 2e-3); + double da = -1e9, db = -1e9; + for (uint32_t k = 0; k < n1; k++) da = fmax(da, ab.points[k].depth); + for (uint32_t k = 0; k < n2; k++) db = fmax(db, ba.points[k].depth); + CHECK_NEAR(da, db, 2e-3); + CHECK_NEAR(f3d_dot(ab.normal, ab.normal), 1, 1e-5); + } + } +} + +static void test_cylinder_rolls(void) { + /* A solid cylinder on its side, down a 30° slope with friction to spare: + * it rolls at 2/3 g sin θ, its spin its speed over its radius. */ + const double theta = M_PI / 6; + const F3dQuat slope = turn_about(f3d_v3(0, 0, 1), theta); + const F3dMat3 m = f3d_mat_of(slope); + F3dWorld *w = f3d_world_create(); + f3d_world_set_air(w, F3D_R(293.15), F3D_R(1e-30)); + f3d_world_set_sleep(w, 0, 0); + const F3dVec3 at = f3d_scale(m.c[1], F3D_R(-0.5)); + const F3dBody ground = f3d_body_create(w, F3D_BODY_FIXED, at.x, at.y, at.z, 0); + f3d_body_set_shape(w, ground, F3D_SHAPE_BOX, 30, F3D_R(0.5), 30); + f3d_body_set_orientation(w, ground, slope.x, slope.y, slope.z, slope.w); + f3d_body_set_friction(w, ground, 1); + /* Its axis along z, across the slope. */ + const F3dQuat across = turn_about(f3d_v3(1, 0, 0), M_PI / 2); + const F3dQuat q = { + slope.w * across.x + slope.x * across.w + slope.y * across.z - slope.z * across.y, + slope.w * across.y - slope.x * across.z + slope.y * across.w + slope.z * across.x, + slope.w * across.z + slope.x * across.y - slope.y * across.x + slope.z * across.w, + slope.w * across.w - slope.x * across.x - slope.y * across.y - slope.z * across.z}; + const f3d_real r = F3D_R(0.3); + const F3dVec3 c = f3d_scale(m.c[1], r - F3D_R(0.001)); + const F3dBody wheel = f3d_body_create(w, F3D_BODY_DYNAMIC, c.x, c.y, c.z, 2); + f3d_body_set_shape(w, wheel, F3D_SHAPE_CYLINDER, r, F3D_R(0.2), 0); + f3d_body_set_orientation(w, wheel, q.x, q.y, q.z, q.w); + f3d_body_set_friction(w, wheel, 1); + for (int i = 0; i < 15; i++) f3d_world_step(w, F3D_R(1.0 / 60.0)); + f3d_real v0[3], v1[3], spin[3]; + f3d_body_get_velocity(w, wheel, v0); + for (int i = 0; i < 60; i++) f3d_world_step(w, F3D_R(1.0 / 60.0)); + f3d_body_get_velocity(w, wheel, v1); + f3d_body_get_angular_velocity(w, wheel, spin); + const double s0 = sqrt((double)v0[0] * v0[0] + (double)v0[1] * v0[1] + (double)v0[2] * v0[2]); + const double s1 = sqrt((double)v1[0] * v1[0] + (double)v1[1] * v1[1] + (double)v1[2] * v1[2]); + CHECK_NEAR(s1 - s0, 2.0 / 3.0 * 9.81 * sin(theta), 0.03); + const double w1 = sqrt((double)spin[0] * spin[0] + (double)spin[1] * spin[1] + (double)spin[2] * spin[2]); + CHECK_NEAR(w1 * (double)r / s1, 1, 0.03); + f3d_world_destroy(w); +} + +static void test_resting_shapes(void) { + /* An upright cylinder, a cone on its base and a hull cube, dropped a + * centimetre onto a floor: each comes to rest standing, and sleeps. */ + uint32_t hull; + F3dWorld *w = world_with_cube_hull(&hull, F3D_R(0.5)); + f3d_world_set_air(w, F3D_R(293.15), F3D_R(1e-30)); + const F3dBody floor = f3d_body_create(w, F3D_BODY_FIXED, 0, F3D_R(-0.5), 0, 0); + f3d_body_set_shape(w, floor, F3D_SHAPE_BOX, 20, F3D_R(0.5), 20); + const F3dBody cyl = f3d_body_create(w, F3D_BODY_DYNAMIC, 0, F3D_R(1.01), 0, 1); + f3d_body_set_shape(w, cyl, F3D_SHAPE_CYLINDER, F3D_R(0.3), 1, 0); + const F3dBody cone = f3d_body_create(w, F3D_BODY_DYNAMIC, 3, F3D_R(0.26), 0, 1); + f3d_body_set_shape(w, cone, F3D_SHAPE_CONE, F3D_R(0.4), 1, 0); + const F3dBody cube = f3d_body_create(w, F3D_BODY_DYNAMIC, -3, F3D_R(0.51), 0, 1); + f3d_body_set_hull(w, cube, hull); + for (int i = 0; i < 240; i++) f3d_world_step(w, F3D_R(1.0 / 60.0)); + const F3dBody bodies[3] = {cyl, cone, cube}; + const double heights[3] = {1.0, 0.25, 0.5}; + for (int k = 0; k < 3; k++) { + f3d_real p[3], q[4]; + f3d_body_get_position(w, bodies[k], p); + f3d_body_get_orientation(w, bodies[k], q); + CHECK(fabs((double)p[1] - heights[k]) < 0.01); + CHECK_NEAR(fabs((double)q[3]), 1, 1e-3); + CHECK(f3d_body_is_asleep(w, bodies[k])); + } + /* Through a snapshot, hulls and all, to the same bytes. */ + const uint32_t size = f3d_world_snapshot_size(w); + uint8_t *snap = (uint8_t *)malloc(size); + f3d_world_snapshot_write(w, snap, size); + F3dWorld *v = f3d_world_create(); + CHECK(f3d_world_restore(v, snap, size) == 1); + CHECK(v->s.hull_count == 1 && f3d_world_hull_vertex_count(v, 1) == 8); + f3d_body_apply_impulse(w, cube, 1, 0, 0); + f3d_body_apply_impulse(v, cube, 1, 0, 0); + for (int i = 0; i < 60; i++) { + f3d_world_step(w, F3D_R(1.0 / 60.0)); + f3d_world_step(v, F3D_R(1.0 / 60.0)); + } + /* Both stepped on: compared at their sizes now, which the events since + * have grown. */ + const uint32_t now = f3d_world_snapshot_size(w); + uint8_t *a = (uint8_t *)malloc(now), *b = (uint8_t *)malloc(now); + CHECK(f3d_world_snapshot_size(v) == now); + f3d_world_snapshot_write(w, a, now); + f3d_world_snapshot_write(v, b, now); + CHECK(memcmp(a, b, now) == 0); + free(snap); + free(a); + free(b); + f3d_world_destroy(v); + f3d_world_destroy(w); +} + +int main(void) { + test_distances(); + test_manifolds(); + test_hulls(); + test_turned_hull_has_products(); + test_inertia_and_rounding(); + test_symmetry(); + test_cylinder_rolls(); + test_resting_shapes(); + return finish(); +} diff --git a/packages/flutter3d_physics_native/csrc/tests/test_debris.c b/packages/flutter3d_physics_native/csrc/tests/test_debris.c new file mode 100644 index 000000000..b16b83b99 --- /dev/null +++ b/packages/flutter3d_physics_native/csrc/tests/test_debris.c @@ -0,0 +1,334 @@ +/* + * Debris on the CPU, tested in C — P9, phase 10: a ball comes to rest on a + * floor and on a box, rolls down a slope at 5/7 g sin θ and slides at + * g sin θ without friction, two balls meeting head on keep their momentum + * and swap their speeds or stick by the restitution, a free cluster keeps + * its momentum, a ball lands in a shallow groove without a hop, a ball + * whose centre is inside a box leaves by the nearest face, a column of ten + * gives under twelve millimetres, and a heap of five hundred settles in a + * pen without blowing up or leaking. + */ +#include +#include + +#include "check.h" + +static F3dDebrisSettings settings(void) { + F3dDebrisSettings s; + memset(&s, 0, sizeof s); + s.gravity[1] = F3D_R(-9.81); + s.friction = F3D_R(0.6); + s.restitution = F3D_R(0.0); + s.max_speed = F3D_R(50.0); + s.substeps = 8; + s.iterations = 4; + return s; +} + +static void ball(f3d_real *out, f3d_real x, f3d_real y, f3d_real z, f3d_real vx, + f3d_real vy, f3d_real vz, f3d_real r, f3d_real m) { + out[0] = x; + out[1] = y; + out[2] = z; + out[3] = vx; + out[4] = vy; + out[5] = vz; + out[6] = r; + out[7] = m; +} + +static const f3d_real kFloor[8] = {0, 1, 0, 0, 0, 0, 0, 0}; + +static void run(F3dDebris *d, const F3dDebrisSettings *s, int steps) { + for (int i = 0; i < steps; i++) f3d_debris_step(d, s, F3D_R(1.0 / 60.0)); +} + +static void test_rest(void) { + F3dDebris *d = f3d_debris_create(4); + CHECK(d != NULL && f3d_debris_capacity(d) == 4); + CHECK(f3d_debris_set_statics(d, kFloor, 1)); + f3d_real in[8], out[32]; + ball(in, 0, F3D_R(0.5), 0, 0, 0, 0, F3D_R(0.1), 1); + CHECK(f3d_debris_add(d, in, 1) == 0); + const F3dDebrisSettings s = settings(); + run(d, &s, 180); + f3d_debris_read(d, out, 4); + /* On the floor, sunk no more than a millimetre, still, unturned. */ + CHECK(out[1] > F3D_R(0.099) && out[1] < F3D_R(0.1005)); + const f3d_real y = out[1]; + run(d, &s, 60); + f3d_debris_read(d, out, 4); + CHECK_NEAR(out[1], y, 1e-5); + CHECK_NEAR(out[6], 1.0, 1e-6); + CHECK(out[7] == F3D_R(0.1)); + /* The empty slots stayed empty. */ + CHECK(out[15] == 0 && out[14] == 1); + f3d_debris_destroy(d); + + /* On a box a metre high, beside it past it to the floor. */ + d = f3d_debris_create(2); + const f3d_real statics[16] = {0, 1, 0, 0, 0, 0, 0, 0, + 0, F3D_R(0.5), 0, 0, F3D_R(0.5), F3D_R(0.5), F3D_R(0.5), 1}; + CHECK(f3d_debris_set_statics(d, statics, 2)); + f3d_real two[16]; + ball(two, 0, 2, 0, 0, 0, 0, F3D_R(0.1), 1); + ball(two + 8, F3D_R(0.75), 2, 0, 0, 0, 0, F3D_R(0.1), 1); + f3d_debris_add(d, two, 2); + run(d, &s, 240); + f3d_debris_read(d, out, 2); + CHECK(out[1] > F3D_R(1.099) && out[1] < F3D_R(1.1005)); + CHECK(out[9] > F3D_R(0.099) && out[9] < F3D_R(0.1005)); + CHECK_NEAR(out[8], 0.75, 1e-3); + f3d_debris_destroy(d); +} + +static void test_slope(void) { + /* The floor tilted by tilting gravity: θ = 0.3. Rolling, a solid ball + * goes 5/7 g sin θ; without friction it slides at g sin θ, unturned. */ + const double theta = 0.3, g = 9.81; + for (int rolls = 1; rolls >= 0; rolls--) { + F3dDebris *d = f3d_debris_create(1); + f3d_debris_set_statics(d, kFloor, 1); + f3d_real in[8], out[8]; + ball(in, 0, F3D_R(0.1), 0, 0, 0, 0, F3D_R(0.1), 2); + f3d_debris_add(d, in, 1); + F3dDebrisSettings s = settings(); + s.gravity[0] = (f3d_real)(g * sin(theta)); + s.gravity[1] = (f3d_real)(-g * cos(theta)); + s.friction = rolls ? F3D_R(1.0) : F3D_R(0.0); + run(d, &s, 60); + f3d_debris_read(d, out, 1); + const double a = rolls ? 5.0 / 7.0 * g * sin(theta) : g * sin(theta); + /* Two per cent: CHECK_NEAR scales its tolerance past one. */ + CHECK_NEAR(out[0], 0.5 * a, 0.02); + CHECK_NEAR(out[1], 0.1, 1e-3); + if (rolls) { + /* Turned about z the way it rolls, by x / r radians. */ + const double turned = 2.0 * atan2((double)out[5], (double)out[6]); + const double rolled = -out[0] / 0.1; + CHECK_NEAR(cos(turned), cos(rolled), 0.01); + CHECK_NEAR(sin(turned), sin(rolled), 0.01); + } else { + CHECK_NEAR(out[6], 1.0, 1e-6); + } + f3d_debris_destroy(d); + } +} + +static void test_head_on(void) { + /* Equal balls, one at 2 m/s onto one at rest, no gravity: elastic, they + * swap speeds; inelastic, they go on together at 1 m/s. Either way the + * centre of mass goes at 1 m/s. */ + for (int elastic = 1; elastic >= 0; elastic--) { + F3dDebris *d = f3d_debris_create(2); + f3d_real in[16], out[16]; + ball(in, -1, 0, 0, 2, 0, 0, F3D_R(0.1), 1); + ball(in + 8, 0, 0, 0, 0, 0, 0, F3D_R(0.1), 1); + f3d_debris_add(d, in, 2); + F3dDebrisSettings s = settings(); + s.gravity[1] = 0; + s.restitution = elastic ? F3D_R(1.0) : F3D_R(0.0); + run(d, &s, 60); + f3d_debris_read(d, out, 2); + CHECK_NEAR((out[0] + out[8]) / 2, -0.5 + 1.0, 1e-4); + CHECK_NEAR(out[1], 0, 1e-6); + if (elastic) { + /* Met after 0.4 s; A stopped there and B went on at 2 m/s. */ + CHECK_NEAR(out[0], -0.2, 0.02); + CHECK_NEAR(out[8], 1.2, 0.02); + } else { + CHECK_NEAR(out[8] - out[0], 0.2, 0.002); + } + f3d_debris_destroy(d); + } +} + +static void test_free_cluster(void) { + /* Two hundred balls of different sizes and masses thrown together, no + * gravity and nothing still: every contact gives both its bodies the + * same impulse, so the momentum stays what it was. */ + enum { N = 200 }; + F3dDebris *d = f3d_debris_create(N); + f3d_real in[N * 8], out[N * 8]; + double p0[3] = {0, 0, 0}, mass = 0; + for (int i = 0; i < N; i++) { + const f3d_real x = (f3d_real)(i % 6) * F3D_R(0.25), y = (f3d_real)((i / 6) % 6) * F3D_R(0.25), + z = (f3d_real)(i / 36) * F3D_R(0.25); + const f3d_real m = F3D_R(0.5) + (f3d_real)(i % 7) * F3D_R(0.3); + ball(in + i * 8, x, y, z, -x * 3 + (f3d_real)(i % 3), -y * 3, -z * 3, F3D_R(0.08) + (f3d_real)(i % 4) * F3D_R(0.01), m); + p0[0] += (double)m * in[i * 8 + 3]; + p0[1] += (double)m * in[i * 8 + 4]; + p0[2] += (double)m * in[i * 8 + 5]; + mass += m; + } + f3d_debris_add(d, in, N); + F3dDebrisSettings s = settings(); + s.gravity[1] = 0; + /* The centre of mass, before and after a second. */ + double c0[3] = {0, 0, 0}, c1[3] = {0, 0, 0}; + for (int i = 0; i < N; i++) { + for (int k = 0; k < 3; k++) c0[k] += (double)in[i * 8 + 7] * in[i * 8 + k]; + } + run(d, &s, 60); + f3d_debris_read(d, out, N); + for (int i = 0; i < N; i++) { + for (int k = 0; k < 3; k++) c1[k] += (double)in[i * 8 + 7] * out[i * 8 + k]; + } + for (int k = 0; k < 3; k++) CHECK_NEAR((c1[k] - c0[k]) / mass, p0[k] / mass, 1e-3); + f3d_debris_destroy(d); +} + +static void test_heap(void) { + /* Five hundred balls of three sizes poured into a pen two metres wide, + * five layers deep: four seconds later none is through the floor or a + * wall, none sits in another by more than a tenth of its radius, and the + * heap is still. */ + enum { N = 500 }; + F3dDebris *d = f3d_debris_create(N); + const f3d_real pen[40] = {0, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0, -1, 0, 0, 0, 0, + -1, 0, 0, -1, 0, 0, 0, 0, 0, 0, 1, -1, 0, 0, 0, 0, + 0, 0, -1, -1, 0, 0, 0, 0}; + CHECK(f3d_debris_set_statics(d, pen, 5)); + static f3d_real in[N * 8], out[N * 8], later[N * 8]; + for (int i = 0; i < N; i++) { + const f3d_real x = F3D_R(-0.85) + (f3d_real)(i % 10) * F3D_R(0.19); + const f3d_real z = F3D_R(-0.85) + (f3d_real)((i / 10) % 10) * F3D_R(0.19); + const f3d_real y = F3D_R(0.3) + (f3d_real)(i / 100) * F3D_R(0.25); + ball(in + i * 8, x + (f3d_real)(i % 3) * F3D_R(0.01), y, z, 0, -1, 0, + F3D_R(0.05) + (f3d_real)(i % 3) * F3D_R(0.02), 1); + } + f3d_debris_add(d, in, N); + F3dDebrisSettings s = settings(); + s.angular_damping = F3D_R(0.5); + run(d, &s, 240); + f3d_debris_read(d, out, N); + run(d, &s, 6); + f3d_debris_read(d, later, N); + double worst_overlap = 0, worst_speed = 0; + int leaked = 0; + for (int i = 0; i < N; i++) { + const f3d_real *a = out + i * 8; + const f3d_real r = a[7]; + if (a[1] < r - F3D_R(0.01) || fabs((double)a[0]) > 1 - r + 0.01 || + fabs((double)a[2]) > 1 - r + 0.01) { + leaked++; + } + for (int j = i + 1; j < N; j++) { + const f3d_real *b = out + j * 8; + const double dx = a[0] - b[0], dy = a[1] - b[1], dz = a[2] - b[2]; + const double depth = (double)(r + b[7]) - sqrt(dx * dx + dy * dy + dz * dz); + const double rel = depth / fmin((double)r, (double)b[7]); + if (rel > worst_overlap) worst_overlap = rel; + } + for (int k = 0; k < 3; k++) { + const double v = fabs((double)later[i * 8 + k] - a[k]) * 10.0; + if (v > worst_speed) worst_speed = v; + } + } + CHECK(leaked == 0); + CHECK(worst_overlap < 0.1); + CHECK(worst_speed < 0.1); + f3d_debris_destroy(d); +} + +static void test_groove(void) { + /* Two planes tilted 0.35 rad make a shallow groove. Each contact alone + * would stop the whole fall along its normal, nearly twice over + * together, and throw the ball back up; split between them, it lands + * and stays. */ + const double a = 0.35; + F3dDebris *d = f3d_debris_create(1); + const f3d_real groove[16] = {(f3d_real)sin(a), (f3d_real)cos(a), 0, 0, 0, 0, 0, 0, + (f3d_real)-sin(a), (f3d_real)cos(a), 0, 0, 0, 0, 0, 0}; + f3d_debris_set_statics(d, groove, 2); + f3d_real in[8], out[8]; + ball(in, 0, 1, 0, 0, 0, 0, F3D_R(0.1), 1); + f3d_debris_add(d, in, 1); + const F3dDebrisSettings s = settings(); + const double rest = 0.1 / cos(a); + double rise = 0; + int landed = 0; + for (int i = 0; i < 120; i++) { + run(d, &s, 1); + f3d_debris_read(d, out, 1); + if (out[1] < rest + 0.01) landed = 1; + if (landed && out[1] - rest > rise) rise = out[1] - rest; + } + CHECK(landed); + CHECK(rise < 0.002); + CHECK_NEAR(out[1], rest, 0.001); + CHECK_NEAR(out[0], 0, 1e-4); + f3d_debris_destroy(d); +} + +static void test_inside_box(void) { + /* A slab a metre wide and twenty centimetres thick, and a ball put with + * its centre inside it five centimetres under the top: out through the + * top, the nearest face, not a side. */ + F3dDebris *d = f3d_debris_create(1); + const f3d_real slab[8] = {0, 0, 0, 0, 1, F3D_R(0.1), 1, 1}; + f3d_debris_set_statics(d, slab, 1); + f3d_real in[8], out[8]; + ball(in, F3D_R(0.5), F3D_R(0.05), F3D_R(0.2), 0, 0, 0, F3D_R(0.05), 1); + f3d_debris_add(d, in, 1); + const F3dDebrisSettings s = settings(); + run(d, &s, 60); + f3d_debris_read(d, out, 1); + CHECK(out[1] > F3D_R(0.149) && out[1] < F3D_R(0.1505)); + CHECK_NEAR(out[0], 0.5, 0.01); + CHECK_NEAR(out[2], 0.2, 0.01); + f3d_debris_destroy(d); +} + +static void test_column(void) { + /* Ten balls stacked straight up on the floor: each contact carries the + * weight of those above it, and Jacobi takes it there one ball a pass, + * so the column gives a little; pushed out at four fifths of the depth a + * substep, by under twelve millimetres in its two metres. */ + F3dDebris *d = f3d_debris_create(10); + f3d_debris_set_statics(d, kFloor, 1); + f3d_real in[80], out[80]; + for (int i = 0; i < 10; i++) ball(in + i * 8, 0, F3D_R(0.1) + (f3d_real)i * F3D_R(0.2), 0, 0, 0, 0, F3D_R(0.1), 1); + f3d_debris_add(d, in, 10); + const F3dDebrisSettings s = settings(); + run(d, &s, 120); + f3d_debris_read(d, out, 10); + CHECK(out[73] > F3D_R(1.888) && out[73] < F3D_R(1.9)); + CHECK(out[72] == 0 && out[74] == 0); + f3d_debris_destroy(d); +} + +static void test_slots(void) { + F3dDebris *d = f3d_debris_create(2); + f3d_real in[24], out[16]; + ball(in, 1, 5, 0, 0, 0, 0, F3D_R(0.1), 1); + ball(in + 8, 2, 5, 0, 0, 0, 0, F3D_R(0.1), 1); + ball(in + 16, 3, 5, 0, 0, 0, 0, F3D_R(0.1), 0); + CHECK(f3d_debris_add(d, in, 2) == 0); + /* The third takes the first slot, and with no mass empties it. */ + CHECK(f3d_debris_add(d, in + 16, 1) == 0); + const F3dDebrisSettings s = settings(); + run(d, &s, 10); + f3d_debris_read(d, out, 2); + CHECK(out[7] == 0 && out[1] == 0); + CHECK(out[9] < 5); + static f3d_real many[(F3D_DEBRIS_MAX_STATICS + 1) * 8]; + CHECK(!f3d_debris_set_statics(d, many, F3D_DEBRIS_MAX_STATICS + 1)); + CHECK(f3d_debris_set_statics(d, many, 0)); + CHECK(f3d_debris_create(0) == NULL); + f3d_debris_destroy(NULL); + f3d_debris_destroy(d); +} + +int main(void) { + test_rest(); + test_slope(); + test_head_on(); + test_free_cluster(); + test_groove(); + test_inside_box(); + test_column(); + test_heap(); + test_slots(); + return finish(); +} diff --git a/packages/flutter3d_physics_native/csrc/tests/test_digest.c b/packages/flutter3d_physics_native/csrc/tests/test_digest.c new file mode 100644 index 000000000..b76e8218d --- /dev/null +++ b/packages/flutter3d_physics_native/csrc/tests/test_digest.c @@ -0,0 +1,233 @@ +/* + * The core's digests — P9, phase 13: what it steps a world of every shape, + * the same world in the fast mode on three threads, a ragdoll down a + * flight of stairs, a heap of debris, a sheet of cloth over a ball, water + * in a tank and a spray of particles to, as one hash of their every real + * apiece, held against the hashes written below. They were taken on one + * machine; any other — another operating system, compiler or processor — + * that steps the core to other bits fails here. `tool/digest_platforms.sh` + * runs this file on Linux, Android and iOS; CI on Windows too. + */ +#include +#include +#include +#include + +#include "check.h" +#include "ragdoll.h" +#include "scene.h" + +#ifdef F3D_REAL_DOUBLE +static const uint64_t expected[7] = {0x1fb772f206ab8078ull, 0x3d15a01d108662d0ull, 0x133d63714f6d0c92ull, + 0xdea75fe91bac5613ull, 0xc98ced65f832abbeull, 0x4c8f3b90f328112cull, + 0xd1be7e081d0955e8ull}; +#else +static const uint64_t expected[7] = {0x3ff514dab6b2c971ull, 0x6b72b563c215f443ull, 0x9fb0722eaf94140aull, + 0xbd9efabeee7e9ee4ull, 0x0a0952b9452611cfull, 0x840abe40761b9b85ull, + 0x3d3a82b755727489ull}; +#endif + +static const char *const names[7] = {"world", "fast", "ragdoll", "debris", "cloth", "fluid", "particles"}; + +static uint64_t fnv(uint64_t hash, const void *data, size_t bytes) { + const uint8_t *p = (const uint8_t *)data; + for (size_t i = 0; i < bytes; i++) hash = (hash ^ p[i]) * 1099511628211ull; + return hash; +} + +enum { MOST = 512, MOST_REALS = 32768 }; + +/* Every body's transform, velocity and spin. */ +static uint64_t world_hash(const F3dWorld *w) { + static f3d_real t[MOST * F3D_TRANSFORM_FLOATS]; + static F3dBody handles[MOST]; + const uint32_t n = f3d_world_read_transforms(w, t, handles, MOST); + uint64_t hash = fnv(1469598103934665603ull, t, (size_t)n * F3D_TRANSFORM_FLOATS * sizeof(f3d_real)); + for (uint32_t i = 0; i < n; i++) { + f3d_real v[6]; + f3d_body_get_velocity(w, handles[i], v); + f3d_body_get_angular_velocity(w, handles[i], v + 3); + hash = fnv(hash, v, sizeof v); + } + return hash; +} + +static uint64_t stepped_world(int fast) { + F3dWorld *w = scene(); + if (fast) { + f3d_world_set_fast(w, 1); + CHECK(f3d_world_set_threads(w, 3)); + } + for (int step = 0; step < 240; step++) f3d_world_step(w, F3D_R(1.0 / 60.0)); + const uint64_t hash = world_hash(w); + f3d_world_destroy(w); + return hash; +} + +static uint64_t stepped_ragdoll(void) { + F3dWorld *w = stairs_world(); + Ragdoll r; + make(w, &r, 0, 2, 0); + push_off(w, &r); + for (int step = 0; step < 300; step++) f3d_world_step(w, F3D_R(1.0 / 60.0)); + const uint64_t hash = world_hash(w); + f3d_world_destroy(w); + return hash; +} + +/* Six hundred balls of three sizes dropped in a heap into a box. */ +static uint64_t stepped_debris(void) { + F3dDebris *d = f3d_debris_create(600); + static f3d_real in[600 * F3D_DEBRIS_INPUT_FLOATS]; + for (int i = 0; i < 600; i++) { + f3d_real *b = in + i * F3D_DEBRIS_INPUT_FLOATS; + b[0] = F3D_R(-0.9) + F3D_R(0.2) * (f3d_real)(i % 10); + b[1] = F3D_R(0.3) + F3D_R(0.2) * (f3d_real)(i / 100); + b[2] = F3D_R(-0.9) + F3D_R(0.2) * (f3d_real)((i / 10) % 10); + b[3] = F3D_R(0.1) * (f3d_real)(i % 3 - 1); + b[4] = 0; + b[5] = F3D_R(0.1) * (f3d_real)(i % 5 - 2); + b[6] = F3D_R(0.04) + F3D_R(0.02) * (f3d_real)(i % 3); + b[7] = F3D_R(0.1) + F3D_R(0.05) * (f3d_real)(i % 3); + } + f3d_debris_add(d, in, 600); + /* A floor and a box's four walls. */ + const f3d_real statics[5 * F3D_DEBRIS_STATIC_FLOATS] = { + 0, 1, 0, 0, 0, 0, 0, 0, // + F3D_R(1.2), 1, 0, 0, F3D_R(0.1), 1, F3D_R(1.3), 1, + F3D_R(-1.2), 1, 0, 0, F3D_R(0.1), 1, F3D_R(1.3), 1, + 0, 1, F3D_R(1.2), 0, F3D_R(1.3), 1, F3D_R(0.1), 1, + 0, 1, F3D_R(-1.2), 0, F3D_R(1.3), 1, F3D_R(0.1), 1, + }; + CHECK(f3d_debris_set_statics(d, statics, 5)); + F3dDebrisSettings s; + memset(&s, 0, sizeof s); + s.gravity[1] = F3D_R(-9.81); + s.friction = F3D_R(0.5); + s.restitution = F3D_R(0.3); + s.linear_damping = F3D_R(0.05); + s.angular_damping = F3D_R(0.1); + s.max_speed = 20; + s.substeps = 4; + s.iterations = 2; + for (int step = 0; step < 120; step++) f3d_debris_step(d, &s, F3D_R(1.0 / 60.0)); + static f3d_real out[600 * F3D_DEBRIS_FLOATS]; + const uint32_t n = f3d_debris_read(d, out, 600); + f3d_debris_destroy(d); + return fnv(1469598103934665603ull, out, (size_t)n * F3D_DEBRIS_FLOATS * sizeof(f3d_real)); +} + +/* A sheet of twenty by twenty points, its edges and shears held, dropped + * over a ball in a wind. */ +static uint64_t stepped_cloth(void) { + enum { SIDE = 20, POINTS = SIDE * SIDE }; + static f3d_real points[POINTS * F3D_CLOTH_FLOATS]; + static uint32_t edges[POINTS * 4 * 2]; + static f3d_real compliance[POINTS * 4]; + for (int j = 0; j < SIDE; j++) { + for (int i = 0; i < SIDE; i++) { + f3d_real *p = points + (j * SIDE + i) * F3D_CLOTH_FLOATS; + p[0] = F3D_R(0.05) * (f3d_real)(i - SIDE / 2); + p[1] = F3D_R(1.0); + p[2] = F3D_R(0.05) * (f3d_real)(j - SIDE / 2); + p[3] = 1; + } + } + uint32_t e = 0; + for (int j = 0; j < SIDE; j++) { + for (int i = 0; i < SIDE; i++) { + const uint32_t a = (uint32_t)(j * SIDE + i); + const int right = i + 1 < SIDE, down = j + 1 < SIDE; + const uint32_t to[4] = {a + 1, a + SIDE, a + SIDE + 1, a + SIDE - 1}; + const int ok[4] = {right, down, right && down, i > 0 && down}; + for (int k = 0; k < 4; k++) { + if (!ok[k]) continue; + edges[2 * e] = a; + edges[2 * e + 1] = to[k]; + compliance[e] = k < 2 ? 0 : F3D_R(1e-4); + e++; + } + } + } + F3dCloth *c = f3d_cloth_create(points, POINTS, edges, compliance, e); + CHECK(c != NULL); + const f3d_real ball[4] = {0, F3D_R(0.5), 0, F3D_R(0.25)}; + CHECK(f3d_cloth_set_balls(c, ball, 1)); + F3dClothSettings s; + memset(&s, 0, sizeof s); + s.gravity[1] = F3D_R(-9.81); + s.wind[0] = 2; + s.drag = F3D_R(0.2); + s.damping = F3D_R(0.1); + s.thickness = F3D_R(0.01); + s.friction = F3D_R(0.3); + s.substeps = 8; + for (int step = 0; step < 120; step++) f3d_cloth_step(c, &s, F3D_R(1.0 / 60.0)); + f3d_cloth_read(c, points, POINTS); + f3d_cloth_destroy(c); + return fnv(1469598103934665603ull, points, sizeof points); +} + +/* A block of water let go at one end of a tank. */ +static uint64_t stepped_fluid(void) { + const f3d_real spacing = F3D_R(0.05); + F3dFluid *f = f3d_fluid_create(800, spacing); + static f3d_real in[800 * F3D_FLUID_INPUT_FLOATS]; + memset(in, 0, sizeof in); + for (int i = 0; i < 800; i++) { + in[i * F3D_FLUID_INPUT_FLOATS] = F3D_R(-0.45) + spacing * (f3d_real)(i % 8); + in[i * F3D_FLUID_INPUT_FLOATS + 1] = F3D_R(0.025) + spacing * (f3d_real)(i / 80); + in[i * F3D_FLUID_INPUT_FLOATS + 2] = F3D_R(-0.225) + spacing * (f3d_real)((i / 8) % 10); + } + f3d_fluid_add(f, in, 800); + F3dFluidSettings s; + memset(&s, 0, sizeof s); + s.gravity[1] = F3D_R(-9.81); + s.tank_min[0] = F3D_R(-0.5); + s.tank_min[2] = F3D_R(-0.25); + s.tank_max[0] = F3D_R(0.5); + s.tank_max[1] = F3D_R(10.0); + s.tank_max[2] = F3D_R(0.25); + s.viscosity = F3D_R(0.01); + s.relaxation = F3D_R(10.0); + s.substeps = 2; + s.iterations = 4; + for (int step = 0; step < 120; step++) f3d_fluid_step(f, &s, F3D_R(1.0 / 60.0)); + static f3d_real out[800 * F3D_FLUID_FLOATS]; + const uint32_t n = f3d_fluid_read(f, out, 800); + f3d_fluid_destroy(f); + return fnv(1469598103934665603ull, out, (size_t)n * F3D_FLUID_FLOATS * sizeof(f3d_real)); +} + +/* Sparks thrown up in a fan, falling in a wind and bouncing on a floor. */ +static uint64_t stepped_particles(void) { + F3dParticles *p = f3d_particles_create(400); + static f3d_real in[400 * 7]; + for (int i = 0; i < 400; i++) { + f3d_real *q = in + i * 7; + q[0] = 0; + q[1] = F3D_R(0.5); + q[2] = 0; + q[3] = F3D_R(0.02) * (f3d_real)(i % 40 - 20); + q[4] = 3 + F3D_R(0.01) * (f3d_real)(i % 17); + q[5] = F3D_R(0.02) * (f3d_real)(i / 10 - 20); + q[6] = 1 + F3D_R(0.005) * (f3d_real)i; + } + f3d_particles_emit(p, in, 400); + const F3dParticleForces forces = {{0, F3D_R(-9.81), 0}, {1, 0, F3D_R(0.5)}, F3D_R(0.3), 0, F3D_R(0.4), F3D_R(0.2)}; + for (int step = 0; step < 150; step++) f3d_particles_step(p, &forces, F3D_R(1.0 / 60.0)); + static f3d_real out[400 * F3D_PARTICLE_FLOATS]; + const uint32_t n = f3d_particles_read(p, out, 400); + f3d_particles_destroy(p); + return fnv(1469598103934665603ull, out, (size_t)n * F3D_PARTICLE_FLOATS * sizeof(f3d_real)); +} + +int main(void) { + const uint64_t got[7] = {stepped_world(0), stepped_world(1), stepped_ragdoll(), stepped_debris(), + stepped_cloth(), stepped_fluid(), stepped_particles()}; + for (int k = 0; k < 7; k++) { + printf("digest %s %016llx\n", names[k], (unsigned long long)got[k]); + CHECK(got[k] == expected[k]); + } + return finish(); +} diff --git a/packages/flutter3d_physics_native/csrc/tests/test_fast.c b/packages/flutter3d_physics_native/csrc/tests/test_fast.c new file mode 100644 index 000000000..5c7004f70 --- /dev/null +++ b/packages/flutter3d_physics_native/csrc/tests/test_fast.c @@ -0,0 +1,255 @@ +/* + * The fast mode, tested in C — P9, phase 11: a world of every shape steps + * to the same snapshot on one thread, two, three and eight, every step — + * and on vectors as on the scalar fallback (c_unit_test builds it both + * ways) — + * other bits than the deterministic mode's, its contacts solved in colour + * order — and still rests a crate where it should, holds a stack of ten + * up until it sleeps, keeps the mode through a snapshot, rests a hundred + * balls on one plank with more contacts than colours, bounces, and holds + * a bob on a hinge. + */ +#include +#include +#include +#include + +#include "check.h" +#include "scene.h" + +static void test_same_bits(void) { + F3dWorld *one = scene(); + CHECK(!f3d_world_fast(one)); + CHECK(f3d_world_set_fast(one, 1) && f3d_world_fast(one)); + const uint32_t counts[3] = {2, 3, 8}; + F3dWorld *many[3]; + for (int k = 0; k < 3; k++) { + many[k] = scene(); + f3d_world_set_fast(many[k], 1); + CHECK(f3d_world_set_threads(many[k], counts[k])); + } + F3dWorld *careful = scene(); + const uint32_t size = 4u << 20; + uint8_t *a = (uint8_t *)malloc(size), *b = (uint8_t *)malloc(size); + int differing = 0, unlike = 0; + for (int step = 0; step < 240; step++) { + f3d_world_step(one, F3D_R(1.0 / 60.0)); + f3d_world_step(careful, F3D_R(1.0 / 60.0)); + const uint32_t na = f3d_world_snapshot_write(one, a, size); + CHECK(na > 0); + for (int k = 0; k < 3; k++) { + f3d_world_step(many[k], F3D_R(1.0 / 60.0)); + const uint32_t nb = f3d_world_snapshot_write(many[k], b, size); + differing += na != nb || memcmp(a, b, na) != 0; + } + static f3d_real p[400 * F3D_TRANSFORM_FLOATS], q[400 * F3D_TRANSFORM_FLOATS]; + const uint32_t n = f3d_world_read_transforms(one, p, NULL, 400); + CHECK(n == f3d_world_read_transforms(careful, q, NULL, 400)); + unlike += memcmp(p, q, (size_t)n * F3D_TRANSFORM_FLOATS * sizeof(f3d_real)) != 0; + } + CHECK(differing == 0); + /* Solved in another order, the deterministic mode lands elsewhere. */ + CHECK(unlike > 0); + /* What the vectors and the scalar fallback must agree on: c_unit_test + * builds this file both ways and compares the line. */ + const uint32_t na = f3d_world_snapshot_write(one, a, size); + uint64_t hash = 1469598103934665603ull; + for (uint32_t i = 0; i < na; i++) hash = (hash ^ a[i]) * 1099511628211ull; + printf("fast snapshot %016llx\n", (unsigned long long)hash); + free(a); + free(b); + for (int k = 0; k < 3; k++) f3d_world_destroy(many[k]); + f3d_world_destroy(one); + f3d_world_destroy(careful); +} + +static void test_still_physics(void) { + /* A crate on a floor, and a stack of ten beside it, on four threads. */ + F3dWorld *w = f3d_world_create(); + f3d_world_set_gravity(w, 0, F3D_R(-9.81), 0); + CHECK(f3d_world_set_fast(w, 1) && f3d_world_set_threads(w, 4)); + const F3dBody floor = f3d_body_create(w, F3D_BODY_FIXED, 0, F3D_R(-0.5), 0, 0); + f3d_body_set_shape(w, floor, F3D_SHAPE_BOX, 20, F3D_R(0.5), 20); + const F3dBody crate = f3d_body_create(w, F3D_BODY_DYNAMIC, -3, 1, 0, 1); + f3d_body_set_shape(w, crate, F3D_SHAPE_BOX, F3D_R(0.5), F3D_R(0.5), F3D_R(0.5)); + F3dBody stack[10]; + for (int i = 0; i < 10; i++) { + stack[i] = f3d_body_create(w, F3D_BODY_DYNAMIC, 0, F3D_R(0.25) + (f3d_real)i * F3D_R(0.5), 0, 1); + f3d_body_set_shape(w, stack[i], F3D_SHAPE_BOX, F3D_R(0.25), F3D_R(0.25), F3D_R(0.25)); + } + for (int i = 0; i < 600; i++) f3d_world_step(w, F3D_R(1.0 / 60.0)); + f3d_real p[3]; + f3d_body_get_position(w, crate, p); + CHECK_NEAR(p[1], 0.5, 0.01); + CHECK_NEAR(p[0], -3, 1e-3); + f3d_body_get_position(w, stack[9], p); + /* The top of ten half-metre boxes, upright: 4.75 m less the give of ten + * joints, and walked a few centimetres aside as it settled — 5.4 here, + * 1.4 in the deterministic mode, which solves each step bottom up. A + * stack that fell would be metres off. */ + CHECK(p[1] > F3D_R(4.65) && p[1] < F3D_R(4.76)); + CHECK(p[0] * p[0] + p[2] * p[2] < F3D_R(0.01)); + CHECK(f3d_body_is_asleep(w, stack[9])); + /* The mode goes with a snapshot, and the threads do not. */ + const uint32_t size = f3d_world_snapshot_size(w); + uint8_t *buffer = (uint8_t *)malloc(size); + CHECK(f3d_world_snapshot_write(w, buffer, size) == size); + F3dWorld *restored = f3d_world_create(); + CHECK(f3d_world_restore(restored, buffer, size)); + CHECK(f3d_world_fast(restored)); + CHECK(f3d_world_threads(restored) == 1); + CHECK(f3d_world_set_fast(restored, 0) && !f3d_world_fast(restored)); + free(buffer); + f3d_world_destroy(restored); + f3d_world_destroy(w); +} + +static F3dWorld *plank(uint32_t threads) { + /* A plank four metres square, dynamic, on the floor, under a hundred + * balls: more contacts than colours, so some go to the overflow. */ + F3dWorld *w = f3d_world_create(); + f3d_world_set_gravity(w, 0, F3D_R(-9.81), 0); + f3d_world_set_fast(w, 1); + f3d_world_set_threads(w, threads); + const F3dBody floor = f3d_body_create(w, F3D_BODY_FIXED, 0, F3D_R(-0.5), 0, 0); + f3d_body_set_shape(w, floor, F3D_SHAPE_BOX, 20, F3D_R(0.5), 20); + const F3dBody board = f3d_body_create(w, F3D_BODY_DYNAMIC, 0, F3D_R(0.1), 0, 50); + f3d_body_set_shape(w, board, F3D_SHAPE_BOX, 2, F3D_R(0.1), 2); + for (int i = 0; i < 100; i++) { + const F3dBody b = f3d_body_create(w, F3D_BODY_DYNAMIC, (f3d_real)(i % 10) * F3D_R(0.4) - F3D_R(1.8), + F3D_R(0.36), (f3d_real)(i / 10) * F3D_R(0.4) - F3D_R(1.8), F3D_R(0.2)); + f3d_body_set_shape(w, b, F3D_SHAPE_SPHERE, F3D_R(0.15), 0, 0); + } + return w; +} + +static void test_overflow(void) { + F3dWorld *one = plank(1), *four = plank(4); + const uint32_t size = 1u << 20; + uint8_t *a = (uint8_t *)malloc(size), *b = (uint8_t *)malloc(size); + int differing = 0; + for (int step = 0; step < 120; step++) { + f3d_world_step(one, F3D_R(1.0 / 60.0)); + f3d_world_step(four, F3D_R(1.0 / 60.0)); + const uint32_t na = f3d_world_snapshot_write(one, a, size); + differing += na == 0 || na != f3d_world_snapshot_write(four, b, size) || memcmp(a, b, na) != 0; + } + CHECK(differing == 0); + /* The balls rest on the plank, the plank on the floor. */ + static f3d_real t[110 * F3D_TRANSFORM_FLOATS]; + const uint32_t n = f3d_world_read_transforms(one, t, NULL, 110); + CHECK(n == 102); + CHECK_NEAR(t[1 * F3D_TRANSFORM_FLOATS + 1], 0.1, 0.005); + for (uint32_t i = 2; i < n; i++) CHECK_NEAR(t[i * F3D_TRANSFORM_FLOATS + 1], 0.35, 0.005); + /* A point under each ball: the plank in a hundred contacts. */ + CHECK(f3d_world_contact_count(one) >= 100); + free(a); + free(b); + f3d_world_destroy(one); + f3d_world_destroy(four); +} + +/* The ball's highest speed upwards, and where it is, turned how and going + * how fast at the end, in [end]. */ +static f3d_real bounce(int fast, f3d_real *end) { + F3dWorld *w = f3d_world_create(); + f3d_world_set_gravity(w, 0, F3D_R(-9.81), 0); + f3d_world_set_fast(w, fast); + const F3dBody floor = f3d_body_create(w, F3D_BODY_FIXED, 0, F3D_R(-0.5), 0, 0); + f3d_body_set_shape(w, floor, F3D_SHAPE_BOX, 20, F3D_R(0.5), 20); + const F3dBody ball = f3d_body_create(w, F3D_BODY_DYNAMIC, 0, F3D_R(2.25), 0, 1); + f3d_body_set_shape(w, ball, F3D_SHAPE_SPHERE, F3D_R(0.25), 0, 0); + f3d_body_set_restitution(w, ball, F3D_R(0.8)); + f3d_real up = 0, v[3]; + for (int i = 0; i < 90; i++) { + f3d_world_step(w, F3D_R(1.0 / 60.0)); + f3d_body_get_velocity(w, ball, v); + if (v[1] > up) up = v[1]; + } + f3d_body_get_position(w, ball, end); + f3d_body_get_orientation(w, ball, end + 3); + f3d_body_get_velocity(w, ball, end + 7); + f3d_world_destroy(w); + return up; +} + +static void test_bounce(void) { + /* Dropped from two metres at restitution 0.8, through the air: one + * contact is one colour, solved in a lane as the deterministic mode + * solves it, so it comes back up at the same speed to the bit — some + * 0.74 of what it struck at, the air having taken the rest — and comes + * to rest where it does, to the bit. */ + f3d_real a[10], b[10]; + const f3d_real fast = bounce(1, a); + CHECK(fast == bounce(0, b)); + CHECK(memcmp(a, b, sizeof a) == 0); + CHECK(fast > F3D_R(4.5) && fast < F3D_R(4.8)); +} + +/* A box at [height] — 0.45 is five centimetres into the floor — turned a + * little, and where it is after a second: position, orientation and + * velocity. */ +static void sunk(int fast, f3d_real height, f3d_real *end) { + F3dWorld *w = f3d_world_create(); + f3d_world_set_gravity(w, 0, F3D_R(-9.81), 0); + f3d_world_set_fast(w, fast); + const F3dBody floor = f3d_body_create(w, F3D_BODY_FIXED, 0, F3D_R(-0.5), 0, 0); + f3d_body_set_shape(w, floor, F3D_SHAPE_BOX, 20, F3D_R(0.5), 20); + const F3dBody box = f3d_body_create(w, F3D_BODY_DYNAMIC, 0, height, 0, 1); + f3d_body_set_shape(w, box, F3D_SHAPE_BOX, F3D_R(0.5), F3D_R(0.5), F3D_R(0.5)); + f3d_body_set_orientation(w, box, 0, F3D_R(0.1), 0, 1); + for (int i = 0; i < 60; i++) f3d_world_step(w, F3D_R(1.0 / 60.0)); + f3d_body_get_position(w, box, end); + f3d_body_get_orientation(w, box, end + 3); + f3d_body_get_velocity(w, box, end + 7); + f3d_world_destroy(w); +} + +static void test_sunk(void) { + /* Pushed out by the soft contact through four points of one manifold — + * one colour, one lane — and dropped from two metres onto the floor + * with no restitution, a hard landing that must not bounce: both to the + * deterministic mode's bits. */ + const f3d_real heights[2] = {F3D_R(0.45), F3D_R(2.5)}; + for (int k = 0; k < 2; k++) { + f3d_real a[10], b[10]; + sunk(1, heights[k], a); + sunk(0, heights[k], b); + CHECK(memcmp(a, b, sizeof a) == 0); + CHECK(a[1] > F3D_R(0.49) && a[1] < F3D_R(0.501)); + } +} + +static void test_hinge(void) { + /* A bob on a hinge a metre below its pivot, swung, on two threads: the + * joints are solved on one thread between the colours, and hold. */ + F3dWorld *w = f3d_world_create(); + f3d_world_set_gravity(w, 0, F3D_R(-9.81), 0); + f3d_world_set_fast(w, 1); + f3d_world_set_threads(w, 2); + const F3dBody pivot = f3d_body_create(w, F3D_BODY_FIXED, 0, 3, 0, 0); + const F3dBody bob = f3d_body_create(w, F3D_BODY_DYNAMIC, 1, 3, 0, 1); + f3d_body_set_shape(w, bob, F3D_SHAPE_SPHERE, F3D_R(0.05), 0, 0); + CHECK(f3d_joint_create(w, F3D_JOINT_REVOLUTE, pivot, bob, 0, 3, 0, 0, 0, 1) != 0); + f3d_real lowest = 3, worst = 0, p[3]; + for (int i = 0; i < 120; i++) { + f3d_world_step(w, F3D_R(1.0 / 60.0)); + f3d_body_get_position(w, bob, p); + const f3d_real d = (f3d_real)sqrt((double)p[0] * p[0] + (p[1] - 3) * (p[1] - 3) + p[2] * p[2]); + worst = d - 1 > worst ? d - 1 : (1 - d > worst ? 1 - d : worst); + lowest = p[1] < lowest ? p[1] : lowest; + } + CHECK(worst < F3D_R(0.01)); + CHECK_NEAR(lowest, 2.0, 0.02); + f3d_world_destroy(w); +} + +int main(void) { + test_same_bits(); + test_still_physics(); + test_overflow(); + test_bounce(); + test_hinge(); + test_sunk(); + return finish(); +} diff --git a/packages/flutter3d_physics_native/csrc/tests/test_fluid.c b/packages/flutter3d_physics_native/csrc/tests/test_fluid.c new file mode 100644 index 000000000..be517442b --- /dev/null +++ b/packages/flutter3d_physics_native/csrc/tests/test_fluid.c @@ -0,0 +1,194 @@ +/* + * Fluid on the CPU, tested in C — P9, phase 10: rest density is what the + * kernel sums to on a lattice, a lone particle falls as semi-implicit + * Euler sums, viscosity takes the slide of two that pass, a dam of a + * thousand breaks and reaches the far wall, and settles as deep as its + * volume over the tank's floor, inside the tank, squeezed by a few per + * cent at most, no flatter at the floor, its sloshing dying down; slots + * fill round and round, and bad input is refused. + */ +#include +#include + +#include "check.h" + +static F3dFluidSettings settings(void) { + F3dFluidSettings s; + memset(&s, 0, sizeof s); + s.gravity[1] = F3D_R(-9.81); + s.tank_min[0] = F3D_R(-0.5); + s.tank_min[2] = F3D_R(-0.25); + s.tank_max[0] = F3D_R(0.5); + s.tank_max[1] = F3D_R(10.0); + s.tank_max[2] = F3D_R(0.25); + s.viscosity = F3D_R(0.01); + s.relaxation = F3D_R(10.0); + s.substeps = 2; + s.iterations = 4; + return s; +} + +static void run(F3dFluid *f, const F3dFluidSettings *s, int steps) { + for (int i = 0; i < steps; i++) f3d_fluid_step(f, s, F3D_R(1.0 / 60.0)); +} + +/* The mean speed over a tenth of a second from now. */ +static double mean_speed(F3dFluid *f, const F3dFluidSettings *s, uint32_t n, f3d_real *a, + f3d_real *b) { + f3d_fluid_read(f, a, n); + run(f, s, 6); + f3d_fluid_read(f, b, n); + double sum = 0; + for (uint32_t i = 0; i < n; i++) { + double v2 = 0; + for (int k = 0; k < 3; k++) v2 += pow(((double)b[i * 4 + k] - a[i * 4 + k]) * 10.0, 2); + sum += sqrt(v2); + } + return sum / n; +} + +static void test_rest_density(void) { + /* Poly6 over a lattice of 5 cm, its kernel 10 cm wide: the 33 points + * within reach. */ + F3dFluid *f = f3d_fluid_create(4, F3D_R(0.05)); + CHECK(f != NULL && f3d_fluid_capacity(f) == 4); + const double h = 0.1, pi = 3.14159265358979323846; + double rest = 0; + for (int x = -2; x <= 2; x++) { + for (int y = -2; y <= 2; y++) { + for (int z = -2; z <= 2; z++) { + const double d = h * h - (x * x + y * y + z * z) * 0.0025; + if (d > 0) rest += 315.0 / (64.0 * pi * pow(h, 9)) * d * d * d; + } + } + } + /* CHECK_NEAR scales its tolerance by values past one: a part in 10⁵. */ + CHECK_NEAR(f3d_fluid_rest_density(f), rest, 1e-5); + f3d_fluid_destroy(f); +} + +static void test_lone_particle(void) { + /* Nothing near it: its own density is short of rest, so nothing pushes + * it, and it falls as gravity sums in substeps of a hundred-and- + * twentieth. */ + F3dFluid *f = f3d_fluid_create(1, F3D_R(0.05)); + const f3d_real in[6] = {0, 5, 0, F3D_R(0.5), 0, 0}; + CHECK(f3d_fluid_add(f, in, 1) == 0); + const F3dFluidSettings s = settings(); + run(f, &s, 30); + f3d_real out[4]; + CHECK(f3d_fluid_read(f, out, 1) == 1); + const double h = 1.0 / 120.0; + CHECK_NEAR(out[0], 0.5 * 60 * h, 1e-5); + CHECK_NEAR(out[1], 5 - 9.81 * h * h * 60 * 61 / 2, 1e-4); + CHECK(out[3] > 0 && out[3] < 0.5); + f3d_fluid_destroy(f); +} + +static void test_viscosity(void) { + /* Two particles 6 cm apart sliding past each other at 2 cm/s each, no + * gravity: too few to reach rest density, so nothing pushes them, and + * only viscosity takes their slide. Without it they part 4 cm in a + * second; at 0.5 each substep takes a twentieth of it, and they part + * under a centimetre. */ + for (int viscous = 1; viscous >= 0; viscous--) { + F3dFluid *f = f3d_fluid_create(2, F3D_R(0.05)); + const f3d_real in[12] = {0, 1, 0, 0, 0, F3D_R(0.02), F3D_R(0.06), 1, 0, 0, 0, F3D_R(-0.02)}; + f3d_fluid_add(f, in, 2); + F3dFluidSettings s = settings(); + s.gravity[1] = 0; + s.viscosity = viscous ? F3D_R(0.5) : F3D_R(0.0); + run(f, &s, 60); + f3d_real out[8]; + f3d_fluid_read(f, out, 2); + const double parted = out[2] - out[6]; + if (viscous) { + CHECK(parted < 0.01); + } else { + CHECK_NEAR(parted, 0.04, 1e-4); + } + /* Either way the pair's middle stays where it was. */ + CHECK_NEAR((out[2] + out[6]) / 2, 0, 1e-6); + f3d_fluid_destroy(f); + } +} + +static void test_dam(void) { + /* A block of 10³ particles 5 cm apart against one end of a tank a metre + * long and half a metre wide: half a second on it reaches the far wall; + * after three it lies 0.125 m³ / 0.5 m² = 25 cm deep, its centre of mass + * half that up, and sloshes at under three fifths of its speed at one + * second. */ + enum { N = 1000 }; + F3dFluid *f = f3d_fluid_create(N, F3D_R(0.05)); + static f3d_real in[N * 6], out[N * 4], later[N * 4]; + for (int i = 0; i < N; i++) { + f3d_real *o = in + i * 6; + o[0] = F3D_R(-0.475) + (f3d_real)(i % 10) * F3D_R(0.05); + o[1] = F3D_R(0.025) + (f3d_real)(i / 100) * F3D_R(0.05); + o[2] = F3D_R(-0.225) + (f3d_real)((i / 10) % 10) * F3D_R(0.05); + o[3] = o[4] = o[5] = 0; + } + f3d_fluid_add(f, in, N); + const F3dFluidSettings s = settings(); + run(f, &s, 30); + f3d_fluid_read(f, out, N); + double front = -1; + for (int i = 0; i < N; i++) front = fmax(front, (double)out[i * 4]); + CHECK(front > 0.47); + run(f, &s, 30); + const double sloshing = mean_speed(f, &s, N, out, later); + run(f, &s, 114); + const double calmer = mean_speed(f, &s, N, out, later); + double height = 0, mean = 0, densest = 0; + int outside = 0, floor_layer = 0; + for (int i = 0; i < N; i++) { + const f3d_real *p = out + i * 4; + height += p[1]; + floor_layer += p[1] < 0.05; + mean += p[3]; + densest = fmax(densest, (double)p[3]); + if (p[0] < -0.475 - 1e-6 || p[0] > 0.475 + 1e-6 || p[1] < 0.025 - 1e-6 || p[2] < -0.225 - 1e-6 || + p[2] > 0.225 + 1e-6) { + outside++; + } + } + mean /= N; + height /= N; + CHECK(outside == 0); + /* Its centre of mass half the depth up, sloshing or not. */ + CHECK_NEAR(height, 0.125, 0.015); + CHECK(mean > 0.9 && mean < 1.0); + CHECK(densest < 1.08); + /* A layer of the lattice is 20 × 10 = 200 particles. With nothing past + * the floor to lean on, 325 were pressed into it. */ + CHECK(floor_layer < 230); + CHECK(calmer < 0.6 * sloshing); + f3d_fluid_destroy(f); +} + +static void test_slots(void) { + F3dFluid *f = f3d_fluid_create(2, F3D_R(0.1)); + const f3d_real in[18] = {1, 1, 1, 0, 0, 0, 2, 2, 2, 0, 0, 0, 3, 3, 3, 0, 0, 0}; + f3d_real out[8]; + CHECK(f3d_fluid_add(f, in, 1) == 0); + CHECK(f3d_fluid_read(f, out, 2) == 1); + CHECK(f3d_fluid_add(f, in + 6, 2) == 1); + CHECK(f3d_fluid_read(f, out, 2) == 2); + /* The third went into the first slot. */ + CHECK(out[0] == 3 && out[4] == 2); + CHECK(f3d_fluid_create(0, F3D_R(0.1)) == NULL); + CHECK(f3d_fluid_create(4, 0) == NULL); + CHECK(f3d_fluid_create(4, nan_value()) == NULL); + f3d_fluid_destroy(NULL); + f3d_fluid_destroy(f); +} + +int main(void) { + test_rest_density(); + test_lone_particle(); + test_viscosity(); + test_dam(); + test_slots(); + return finish(); +} diff --git a/packages/flutter3d_physics_native/csrc/tests/test_heat.c b/packages/flutter3d_physics_native/csrc/tests/test_heat.c new file mode 100644 index 000000000..6ef7e686c --- /dev/null +++ b/packages/flutter3d_physics_native/csrc/tests/test_heat.c @@ -0,0 +1,328 @@ +/* + * Heat and fire on bodies, tested in C — P9: the heat bus, Newton's + * cooling against its closed form, radiation that cannot overshoot, wind + * that cools, ignition, a fire that burns its fuel out, and water that + * keeps a body from catching and puts a fire out. + */ +#include + +#include "check.h" + +static F3dBody wood_block(F3dWorld *w) { + /* A pine block ten centimetres on a side: 0.6 kg. Fixed, so it stays + * where it is while it burns. */ + const F3dBody b = f3d_body_create(w, F3D_BODY_FIXED, 0, 0, 0, F3D_R(0.6)); + f3d_body_set_shape(w, b, F3D_SHAPE_BOX, F3D_R(0.05), F3D_R(0.05), F3D_R(0.05)); + F3dMaterial m; + f3d_material_preset(F3D_MATERIAL_WOOD, &m); + f3d_body_set_material(w, b, &m); + return b; +} + +static int burning(F3dWorld *w, F3dBody b) { + int on = 0; + f3d_body_is_burning(w, b, &on); + return on; +} + +static void test_materials(void) { + F3dMaterial m; + for (int kind = F3D_MATERIAL_INERT; kind <= F3D_MATERIAL_STONE; kind++) { + CHECK(f3d_material_preset((F3dMaterialKind)kind, &m) == 1); + CHECK(m.specific_heat > 0 && m.emissivity > 0 && m.emissivity <= 1); + CHECK(m.fuel_fraction < 1); + } + CHECK(f3d_material_preset((F3dMaterialKind)99, &m) == 0); + F3dWorld *w = f3d_world_create(); + const F3dBody b = f3d_body_create(w, F3D_BODY_DYNAMIC, 0, 0, 0, 2); + f3d_material_preset(F3D_MATERIAL_WOOD, &m); + F3dMaterial bad = m; + bad.specific_heat = 0; + CHECK(f3d_body_set_material(w, b, &bad) == 0); + bad = m; + bad.emissivity = 2; + CHECK(f3d_body_set_material(w, b, &bad) == 0); + bad = m; + bad.fuel_fraction = 1; + CHECK(f3d_body_set_material(w, b, &bad) == 0); + bad = m; + bad.flame_feedback = -1; + CHECK(f3d_body_set_material(w, b, &bad) == 0); + bad = m; + bad.burn_rate = nan_value(); + CHECK(f3d_body_set_material(w, b, &bad) == 0); + CHECK(f3d_body_set_material(w, b, NULL) == 0); + CHECK(f3d_body_set_material(w, b, &m) == 1); + f3d_real fuel; + f3d_body_get_fuel(w, b, &fuel); + CHECK_NEAR(fuel, 2 * 0.8, 1e-6); + /* A new body is at the air's temperature. */ + f3d_real t; + f3d_body_get_temperature(w, b, &t); + CHECK(t == F3D_R(293.15)); + CHECK(f3d_body_set_temperature(w, b, 0) == 0); + CHECK(f3d_body_set_temperature(w, b, nan_value()) == 0); + CHECK(f3d_body_add_heat(w, b, inf_value()) == 0); + CHECK(f3d_body_add_water(w, b, nan_value()) == 0); + f3d_world_destroy(w); +} + +static void test_heat_bus(void) { + /* A point has no surface to lose heat through: two kilojoules into two + * kilograms of a thousand J/(kg K) is one kelvin, exactly. */ + F3dWorld *w = f3d_world_create(); + f3d_world_set_gravity(w, 0, 0, 0); + const F3dBody b = f3d_body_create(w, F3D_BODY_DYNAMIC, 0, 0, 0, 2); + f3d_body_add_heat(w, b, 1500); + f3d_body_add_heat(w, b, 500); + f3d_world_step(w, F3D_R(0.25)); + f3d_real t; + f3d_body_get_temperature(w, b, &t); + CHECK_NEAR(t, 294.15, 1e-6); + /* Spent: the next step adds nothing. */ + f3d_world_step(w, F3D_R(0.25)); + f3d_body_get_temperature(w, b, &t); + CHECK_NEAR(t, 294.15, 1e-6); + /* And taken out the same way. */ + f3d_body_add_heat(w, b, -4000); + f3d_world_step(w, 1); + f3d_body_get_temperature(w, b, &t); + CHECK_NEAR(t, 292.15, 1e-6); + f3d_world_destroy(w); +} + +static void test_newton_cooling(void) { + /* A sphere that does not radiate cools by convection alone, in still + * air at 10.45 W/(m² K): T − Tₐ = (T₀ − Tₐ) e^(−hA t / C). */ + F3dWorld *w = f3d_world_create(); + f3d_world_set_gravity(w, 0, 0, 0); + const f3d_real r = F3D_R(0.05); + const F3dBody b = f3d_body_create(w, F3D_BODY_DYNAMIC, 0, 0, 0, 1); + f3d_body_set_shape(w, b, F3D_SHAPE_SPHERE, r, 0, 0); + F3dMaterial m; + f3d_material_preset(F3D_MATERIAL_INERT, &m); + m.emissivity = 0; + f3d_body_set_material(w, b, &m); + f3d_body_set_temperature(w, b, F3D_R(393.15)); + for (int i = 0; i < 3000; i++) f3d_world_step(w, 1); + f3d_real t; + f3d_body_get_temperature(w, b, &t); + const double area = 4 * 3.14159265358979 * (double)r * (double)r; + const double tau = 1000.0 / (10.45 * area); + CHECK_NEAR(((double)t - 293.15) / 100.0, exp(-3000.0 / tau), 1e-3); + f3d_world_destroy(w); +} + +static void test_radiation_cannot_overshoot(void) { + /* A sheet of steel at a thousand kelvin, stepped a hundred seconds at a + * time: every step lands between where it was and the air, never past + * the air. */ + F3dWorld *w = f3d_world_create(); + const F3dBody b = f3d_body_create(w, F3D_BODY_FIXED, 0, 0, 0, F3D_R(0.01)); + f3d_body_set_shape(w, b, F3D_SHAPE_BOX, F3D_R(0.1), F3D_R(0.0005), F3D_R(0.1)); + F3dMaterial m; + f3d_material_preset(F3D_MATERIAL_STEEL, &m); + f3d_body_set_material(w, b, &m); + f3d_body_set_temperature(w, b, 1000); + f3d_real last = 1000, t = 0; + int ok = 1; + for (int i = 0; i < 50; i++) { + f3d_world_step(w, 100); + f3d_body_get_temperature(w, b, &t); + /* Within rounding: at the air's temperature a step can land an ulp + * either side of it. */ + ok &= t <= last * (1 + 1e-12) && t >= F3D_R(293.15) * (1 - 1e-12); + last = t; + } + CHECK(ok); + CHECK_NEAR(t, 293.15, 1e-4); + f3d_world_destroy(w); +} + +static void test_wind_cools(void) { + F3dWorld *w = f3d_world_create(); + const F3dBody still = f3d_body_create(w, F3D_BODY_FIXED, 0, 0, 0, 1); + const F3dBody windy = f3d_body_create(w, F3D_BODY_FIXED, 10, 0, 0, 1); + f3d_body_set_shape(w, still, F3D_SHAPE_SPHERE, F3D_R(0.1), 0, 0); + f3d_body_set_shape(w, windy, F3D_SHAPE_SPHERE, F3D_R(0.1), 0, 0); + f3d_body_set_temperature(w, still, 400); + f3d_body_set_temperature(w, windy, 400); + /* Wind only where the second one stands. */ + const f3d_real grid[6] = {0, 0, 0, 10, 0, 0}; + f3d_world_set_wind_grid(w, 0, 0, 0, 10, 2, 1, 1, grid); + for (int i = 0; i < 100; i++) f3d_world_step(w, 1); + f3d_real a, b; + f3d_body_get_temperature(w, still, &a); + f3d_body_get_temperature(w, windy, &b); + CHECK(b < a); + CHECK(a < 400); + f3d_world_destroy(w); +} + +static void test_fire(void) { + F3dWorld *w = f3d_world_create(); + const F3dBody block = wood_block(w); + F3dBody bodies[8]; + uint32_t kinds[8]; + f3d_real fires[F3D_FIRE_FLOATS * 2]; + CHECK(f3d_world_read_fires(w, fires, NULL, 2) == 0); + /* Below its ignition point it only cools. */ + f3d_body_set_temperature(w, block, 560); + f3d_world_step(w, F3D_R(0.1)); + CHECK(!burning(w, block)); + /* Above it, it catches, and says so. */ + f3d_body_set_temperature(w, block, 600); + f3d_world_step(w, F3D_R(0.1)); + CHECK(burning(w, block)); + CHECK(f3d_world_read_events(w, bodies, NULL, kinds, 8) == 1); + CHECK(bodies[0] == block && kinds[0] == F3D_EVENT_IGNITED); + /* Alight, it loses 11 g/s per m² of its 0.06 m², gives 15 MJ/kg, and + * keeps three tenths of it: it heats itself, and the rest leaves. */ + for (int i = 0; i < 100; i++) f3d_world_step(w, F3D_R(0.1)); + f3d_real t, mass, fuel, release; + f3d_body_get_temperature(w, block, &t); + CHECK(t > 600); + const double rate = 0.011 * 0.06; + f3d_body_get_mass(w, block, &mass); + CHECK_NEAR(mass, 0.6 - rate * 10.0, 1e-4); + f3d_body_get_fuel(w, block, &fuel); + CHECK_NEAR(fuel, 0.48 - rate * 10.0, 1e-4); + f3d_body_get_heat_release(w, block, &release); + CHECK_NEAR(release, 0.7 * rate * 1.5e7, 1e-4); + F3dBody handle = 0; + CHECK(f3d_world_read_fires(w, fires, &handle, 2) == 1); + CHECK(handle == block && fires[3] == release); + /* It burns until its fuel is gone, about twelve minutes, then goes out + * and cools, a fifth of its mass left as char. */ + int steps = 0; + while (burning(w, block) && steps < 10000) { + f3d_world_step(w, F3D_R(0.1)); + steps++; + } + CHECK_NEAR(steps * 0.1 + 10.1, 0.48 / rate, 1e-2); + CHECK(f3d_world_read_events(w, bodies, NULL, kinds, 8) == 1); + CHECK(kinds[0] == F3D_EVENT_BURNT_OUT); + f3d_body_get_fuel(w, block, &fuel); + CHECK(fuel == 0); + f3d_body_get_mass(w, block, &mass); + CHECK_NEAR(mass, 0.12, 1e-4); + f3d_body_get_heat_release(w, block, &release); + for (int i = 0; i < 100; i++) f3d_world_step(w, 1); + f3d_body_get_temperature(w, block, &t); + CHECK(t < 600 && !burning(w, block)); + /* Inert matter never catches, however hot. */ + const F3dBody stone = f3d_body_create(w, F3D_BODY_FIXED, 0, 0, 0, 1); + f3d_body_set_shape(w, stone, F3D_SHAPE_SPHERE, F3D_R(0.1), 0, 0); + f3d_body_set_temperature(w, stone, 2000); + f3d_world_step(w, F3D_R(0.1)); + CHECK(!burning(w, stone)); + f3d_world_destroy(w); +} + +static void test_burning_body_gets_lighter(void) { + /* A dynamic log that burns loses mass and, with it, inertia; its + * velocity stays, since what burns leaves at the body's speed. */ + F3dWorld *w = f3d_world_create(); + f3d_world_set_gravity(w, 0, 0, 0); + f3d_world_set_sleep(w, 0, 0); + const F3dBody log = f3d_body_create(w, F3D_BODY_DYNAMIC, 0, 0, 0, F3D_R(0.6)); + f3d_body_set_shape(w, log, F3D_SHAPE_BOX, F3D_R(0.05), F3D_R(0.05), F3D_R(0.05)); + f3d_body_set_drag(w, log, F3D_R(1e-12)); + F3dMaterial m; + f3d_material_preset(F3D_MATERIAL_WOOD, &m); + f3d_body_set_material(w, log, &m); + f3d_body_set_temperature(w, log, 700); + f3d_body_set_velocity(w, log, 1, 0, 0); + f3d_real i0[3], i1[3], v[3]; + f3d_body_get_inertia(w, log, i0); + for (int i = 0; i < 200; i++) f3d_world_step(w, F3D_R(0.1)); + CHECK(burning(w, log)); + f3d_body_get_inertia(w, log, i1); + CHECK(i1[0] < i0[0]); + f3d_body_get_velocity(w, log, v); + CHECK_NEAR(v[0], 1, 1e-6); + f3d_world_destroy(w); +} + +static void test_water(void) { + F3dWorld *w = f3d_world_create(); + F3dBody bodies[8]; + uint32_t kinds[8]; + /* Water lands at the air's temperature and mixes: 0.6 kg of pine at + * 400 K and 0.1 kg of water at 293.15 K. */ + const F3dBody warm = wood_block(w); + f3d_body_set_temperature(w, warm, 400); + f3d_body_add_water(w, warm, F3D_R(0.1)); + f3d_real t, water; + f3d_body_get_temperature(w, warm, &t); + const double dry = 0.6 * 1700, wet = 0.1 * 4186; + CHECK_NEAR(t, (dry * 400 + wet * 293.15) / (dry + wet), 1e-6); + f3d_body_get_water(w, warm, &water); + CHECK_NEAR(water, 0.1, 1e-6); + /* Taken off, it takes none of the body's temperature with it, and no + * more than there is. */ + f3d_body_add_water(w, warm, -1); + f3d_body_get_water(w, warm, &water); + CHECK(water == 0); + f3d_real after; + f3d_body_get_temperature(w, warm, &after); + CHECK(after == t); + + /* A wet block heated hard sits at boiling while its water boils off, + * and does not catch until it has. */ + const F3dBody wet_block = wood_block(w); + f3d_body_add_water(w, wet_block, F3D_R(0.05)); + int caught_wet = 0, held = 0; + f3d_real w0 = F3D_R(0.05); + for (int i = 0; i < 4000 && !burning(w, wet_block); i++) { + f3d_body_add_heat(w, wet_block, 2000); + f3d_world_step(w, F3D_R(0.1)); + f3d_body_get_water(w, wet_block, &water); + f3d_body_get_temperature(w, wet_block, &t); + if (water > 0) { + caught_wet |= burning(w, wet_block); + if (water < w0) held |= t == F3D_WATER_BOILS; + CHECK(t <= F3D_WATER_BOILS); + } + w0 = water; + } + CHECK(held); + CHECK(!caught_wet); + CHECK(burning(w, wet_block)); + f3d_world_read_events(w, bodies, NULL, kinds, 8); + + /* Half a kilogram on the burning block puts it out. */ + for (int i = 0; i < 100; i++) f3d_world_step(w, F3D_R(0.1)); + CHECK(burning(w, wet_block)); + f3d_body_add_water(w, wet_block, F3D_R(0.5)); + f3d_world_step(w, F3D_R(0.1)); + CHECK(!burning(w, wet_block)); + CHECK(f3d_world_read_events(w, bodies, NULL, kinds, 8) == 1); + CHECK(bodies[0] == wet_block && kinds[0] == F3D_EVENT_EXTINGUISHED); + f3d_body_get_temperature(w, wet_block, &t); + CHECK(t == F3D_WATER_BOILS); + /* Too little only hisses: ten grams on a block that has burnt for a + * minute and a half boil away at once, and it burns on. */ + const F3dBody dry_block = wood_block(w); + f3d_body_set_temperature(w, dry_block, 600); + for (int i = 0; i < 1000; i++) f3d_world_step(w, F3D_R(0.1)); + CHECK(burning(w, dry_block)); + f3d_body_add_water(w, dry_block, F3D_R(0.01)); + f3d_world_step(w, F3D_R(0.1)); + CHECK(burning(w, dry_block)); + f3d_body_get_water(w, dry_block, &water); + CHECK(water == 0); + f3d_world_destroy(w); +} + +int main(void) { + test_materials(); + test_heat_bus(); + test_newton_cooling(); + test_radiation_cannot_overshoot(); + test_wind_cools(); + test_fire(); + test_burning_body_gets_lighter(); + test_water(); + return finish(); +} diff --git a/packages/flutter3d_physics_native/csrc/tests/test_joint.c b/packages/flutter3d_physics_native/csrc/tests/test_joint.c new file mode 100644 index 000000000..c2adbfd39 --- /dev/null +++ b/packages/flutter3d_physics_native/csrc/tests/test_joint.c @@ -0,0 +1,527 @@ +/* + * Joints, tested in C — P9, phase 7: each against the physics it has to + * reproduce. The core's arctangent against the library's, a pendulum's + * period, a rod holding its length, a rope that is slack until it is taut, + * a spring's period, a welded cantilever, a hinge's limits and motor, a + * slider on its axis, joined bodies that do not collide, a chain asleep as + * one, the force a rod holds a weight with, joints through a snapshot, and + * a spherical joint's swing held in a cone and its twist between limits, + * and what its friction and cone refuse. + */ +#include +#include +#include + +#include "check.h" + +#ifdef F3D_REAL_DOUBLE +#define TIGHT 1e-12 +#else +#define TIGHT 3e-7 +#endif + +static F3dWorld *still_world(void) { + F3dWorld *w = f3d_world_create(); + f3d_world_set_air(w, F3D_R(293.15), F3D_R(1e-30)); + f3d_world_set_sleep(w, 0, 0); + return w; +} + +static F3dBody anchor(F3dWorld *w, f3d_real x, f3d_real y, f3d_real z) { + return f3d_body_create(w, F3D_BODY_FIXED, x, y, z, 0); +} + +static F3dBody ball(F3dWorld *w, f3d_real x, f3d_real y, f3d_real z, + f3d_real r, f3d_real m) { + const F3dBody b = f3d_body_create(w, F3D_BODY_DYNAMIC, x, y, z, m); + f3d_body_set_shape(w, b, F3D_SHAPE_SPHERE, r, 0, 0); + return b; +} + +static void run(F3dWorld *w, int steps) { + for (int i = 0; i < steps; i++) f3d_world_step(w, F3D_R(1.0 / 60.0)); +} + +static void test_arctangent(void) { + double worst = 0; + for (int i = -720; i <= 720; i++) { + const double a = i * M_PI / 360.0 * 0.999; + for (int k = 0; k < 3; k++) { + const double r = k == 0 ? 1.0 : (k == 1 ? 1e-3 : 37.5); + const double y = r * sin(a), x = r * cos(a); + const double got = f3d_atan2((f3d_real)y, (f3d_real)x); + const double want = atan2((double)(f3d_real)y, (double)(f3d_real)x); + const double e = fabs(got - want); + if (e > worst) worst = e; + } + } + CHECK(worst < TIGHT * 4); + CHECK(f3d_atan2(0, 0) == 0); + CHECK_NEAR(f3d_atan2(0, -1), M_PI, TIGHT); +} + +static void test_pendulum(void) { + /* A small ball on a hinge a metre below its pivot, let go at five + * degrees: a physical pendulum, its period 2π √((L² + 2/5 r²) / (g L)). */ + F3dWorld *w = still_world(); + const double length = 1.0, r = 0.05, start = 5.0 * M_PI / 180.0; + const F3dBody pivot = anchor(w, 0, 2, 0); + const F3dBody bob = ball(w, (f3d_real)(length * sin(start)), + (f3d_real)(2 - length * cos(start)), 0, (f3d_real)r, 1); + const F3dJoint hinge = + f3d_joint_create(w, F3D_JOINT_REVOLUTE, pivot, bob, 0, 2, 0, 0, 0, 1); + CHECK(hinge != 0); + /* Time between the bob's crossings of the bottom, going the same way. */ + double last = 0, first = -1, crossings = 0, t = 0; + f3d_real prev[3]; + f3d_body_get_position(w, bob, prev); + for (int i = 0; i < 1200; i++) { + f3d_world_step(w, F3D_R(1.0 / 240.0)); + t += 1.0 / 240.0; + f3d_real p[3]; + f3d_body_get_position(w, bob, p); + if (prev[0] > 0 && p[0] <= 0) { + const double at = t - (1.0 / 240.0) * p[0] / (p[0] - prev[0]); + if (first < 0) first = at; + last = at; + crossings++; + } + prev[0] = p[0]; + } + const double period = (last - first) / (crossings - 1); + const double want = + 2 * M_PI * sqrt((length * length + 0.4 * r * r) / (9.81 * length)); + CHECK_NEAR(period, want, 0.01); + /* Its angle is the hinge's: about the axis, from where it started. */ + f3d_real angle; + CHECK(f3d_joint_get_value(w, hinge, &angle) == 1); + f3d_real p[3]; + f3d_body_get_position(w, bob, p); + const double swung = atan2((double)p[0], 2.0 - (double)p[1]); + CHECK_NEAR(angle, swung - start, 2e-3); + f3d_world_destroy(w); +} + +static void test_hinge_keeps_its_plane(void) { + /* Pushed across its plane, a ball on a hinge does not leave it: the + * hinge locks every turn but its own. On a spherical joint the same push + * swings it out. */ + for (int ball_joint = 0; ball_joint < 2; ball_joint++) { + F3dWorld *w = still_world(); + const F3dBody pivot = anchor(w, 0, 2, 0); + const F3dBody bob = ball(w, 0, 1, 0, F3D_R(0.1), 1); + f3d_joint_create(w, ball_joint ? F3D_JOINT_SPHERICAL : F3D_JOINT_REVOLUTE, + pivot, bob, 0, 2, 0, 0, 0, 1); + f3d_body_set_velocity(w, bob, 1, 0, 1); + run(w, 30); + f3d_real p[3]; + f3d_body_get_position(w, bob, p); + if (ball_joint) { + CHECK(fabs((double)p[2]) > 0.1); + } else { + CHECK(fabs((double)p[2]) < 1e-3); + } + f3d_world_destroy(w); + } +} + +static void test_rod_and_rope(void) { + F3dWorld *w = still_world(); + /* A rod of two metres, swung hard: it holds its length to a millimetre. */ + const F3dBody pivot = anchor(w, 0, 3, 0); + const F3dBody bob = ball(w, 2, 3, 0, F3D_R(0.1), 1); + const F3dJoint rod = f3d_joint_create_distance(w, pivot, bob, 0, 3, 0, 2, 3, 0); + f3d_body_set_velocity(w, bob, 0, 0, 3); + double worst = 0; + for (int i = 0; i < 300; i++) { + f3d_world_step(w, F3D_R(1.0 / 60.0)); + f3d_real len; + f3d_joint_get_value(w, rod, &len); + if (fabs((double)len - 2) > worst) worst = fabs((double)len - 2); + } + CHECK(worst < 1e-3); + /* Hanging still from it, it holds the weight: m g, upward on the bob, + * which is the joint's B. */ + f3d_body_set_position(w, bob, 0, 1, 0); + f3d_body_set_velocity(w, bob, 0, 0, 0); + run(w, 240); + f3d_real force[3]; + CHECK(f3d_joint_get_force(w, rod, force) == 1); + CHECK_NEAR(force[1], 9.81, 0.02); + /* The rod holding the weight made a rope a metre longer: what it held + * as a rod lets go, and the weight falls the metre. */ + CHECK(f3d_joint_set_length(w, rod, 3, 0, 3) == 1); + CHECK(f3d_joint_set_spring(w, rod, 1, 0, 0) == 1); + run(w, 120); + f3d_real stretched; + f3d_joint_get_value(w, rod, &stretched); + CHECK_NEAR(stretched, 3, 2e-3); + /* A rope of two metres from a ball a metre below: slack, it falls + * freely until the rope is taut, and then no further. */ + const F3dBody hook = anchor(w, 10, 3, 0); + const F3dBody load = ball(w, 10, 2, 0, F3D_R(0.1), 1); + const F3dJoint rope = f3d_joint_create_distance(w, hook, load, 10, 3, 0, 10, 2, 0); + CHECK(f3d_joint_set_length(w, rope, 2, 0, 2) == 1); + CHECK(f3d_joint_set_spring(w, rope, 1, 0, 0) == 1); + CHECK(f3d_joint_set_length(w, rope, 2, F3D_R(2.5), 3) == 0); + run(w, 20); + f3d_real v[3]; + f3d_body_get_velocity(w, load, v); + CHECK_NEAR(v[1], -9.81 * 20.0 / 60.0, 1e-3); + run(w, 240); + f3d_real len; + f3d_joint_get_value(w, rope, &len); + CHECK(len <= 2.001 && len > 1.99); + f3d_world_destroy(w); +} + +static void test_spring(void) { + /* A kilogram on a spring of two hertz, no gravity, pulled ten centimetres + * and let go: it swings with a period of half a second. */ + F3dWorld *w = still_world(); + f3d_world_set_gravity(w, 0, 0, 0); + const F3dBody wall = anchor(w, 0, 0, 0); + const F3dBody mass = ball(w, 1, 0, 0, F3D_R(0.05), 1); + const F3dJoint spring = f3d_joint_create_distance(w, wall, mass, 0, 0, 0, 1, 0, 0); + f3d_joint_set_length(w, spring, 1, 0, 10); + f3d_joint_set_spring(w, spring, 1, 2, 0); + f3d_body_set_position(w, mass, F3D_R(1.1), 0, 0); + double first = -1, last = 0, t = 0; + int crossings = 0; + f3d_real prev = F3D_R(0.1); + for (int i = 0; i < 960; i++) { + f3d_world_step(w, F3D_R(1.0 / 240.0)); + t += 1.0 / 240.0; + f3d_real p[3]; + f3d_body_get_position(w, mass, p); + const f3d_real x = p[0] - 1; + if (prev > 0 && x <= 0) { + const double at = t - (1.0 / 240.0) * x / (x - prev); + if (first < 0) first = at; + last = at; + crossings++; + } + prev = x; + } + CHECK(crossings >= 6); + CHECK_NEAR((last - first) / (crossings - 1), 0.5, 0.01); + f3d_world_destroy(w); +} + +static void test_weld(void) { + /* A box welded to the end of another that is welded to a wall: a + * cantilever. It sags only by the joints' give, and keeps its turn. */ + F3dWorld *w = still_world(); + const F3dBody wall = anchor(w, 0, 2, 0); + const F3dBody arm = f3d_body_create(w, F3D_BODY_DYNAMIC, F3D_R(0.5), 2, 0, 1); + f3d_body_set_shape(w, arm, F3D_SHAPE_BOX, F3D_R(0.5), F3D_R(0.1), F3D_R(0.1)); + const F3dBody tip = f3d_body_create(w, F3D_BODY_DYNAMIC, F3D_R(1.5), 2, 0, 1); + f3d_body_set_shape(w, tip, F3D_SHAPE_BOX, F3D_R(0.5), F3D_R(0.1), F3D_R(0.1)); + CHECK(f3d_joint_create(w, F3D_JOINT_FIXED, wall, arm, 0, 2, 0, 0, 0, 0) != 0); + CHECK(f3d_joint_create(w, F3D_JOINT_FIXED, arm, tip, 1, 2, 0, 0, 0, 0) != 0); + run(w, 300); + f3d_real p[3], q[4]; + f3d_body_get_position(w, tip, p); + f3d_body_get_orientation(w, tip, q); + CHECK(fabs((double)p[1] - 2) < 0.02); + CHECK(fabs((double)p[0] - 1.5) < 0.005); + CHECK(fabs((double)q[2]) < 0.01); + f3d_world_destroy(w); +} + +static void test_hinge_limits_and_motor(void) { + F3dWorld *w = still_world(); + f3d_world_set_gravity(w, 0, 0, 0); + /* A door on a vertical hinge, limited to half a radian either way and + * kicked hard: it stops at the limit. */ + const F3dBody frame = anchor(w, 0, 1, 0); + const F3dBody door = f3d_body_create(w, F3D_BODY_DYNAMIC, F3D_R(0.5), 1, 0, 10); + f3d_body_set_shape(w, door, F3D_SHAPE_BOX, F3D_R(0.5), 1, F3D_R(0.02)); + const F3dJoint hinge = + f3d_joint_create(w, F3D_JOINT_REVOLUTE, frame, door, 0, 1, 0, 0, 1, 0); + CHECK(f3d_joint_set_limits(w, hinge, 1, F3D_R(-0.5), F3D_R(0.5)) == 1); + CHECK(f3d_joint_set_limits(w, hinge, 1, 1, -1) == 0); + f3d_body_set_angular_velocity(w, door, 0, 6, 0); + double most = 0; + for (int i = 0; i < 120; i++) { + f3d_world_step(w, F3D_R(1.0 / 60.0)); + f3d_real a; + f3d_joint_get_value(w, hinge, &a); + if (fabs((double)a) > most) most = fabs((double)a); + } + CHECK(most < 0.52 && most > 0.45); + /* Limits off and a motor on: it turns at the motor's speed. */ + f3d_joint_set_limits(w, hinge, 0, 0, 0); + CHECK(f3d_joint_set_motor(w, hinge, 1, 2, 1000) == 1); + run(w, 60); + f3d_real spin[3]; + f3d_body_get_angular_velocity(w, door, spin); + CHECK_NEAR(spin[1], 2, 1e-3); + /* Too weak a motor against a held door cannot turn it: a motor of a + * newton-metre against a hundred holding it back. */ + const F3dBody stuck = f3d_body_create(w, F3D_BODY_DYNAMIC, F3D_R(5.5), 1, 0, 10); + f3d_body_set_shape(w, stuck, F3D_SHAPE_BOX, F3D_R(0.5), 1, F3D_R(0.02)); + const F3dBody post = anchor(w, 5, 1, 0); + const F3dJoint weak = + f3d_joint_create(w, F3D_JOINT_REVOLUTE, post, stuck, 5, 1, 0, 0, 1, 0); + f3d_joint_set_motor(w, weak, 1, 2, 1); + for (int i = 0; i < 60; i++) { + f3d_body_add_torque(w, stuck, 0, -100, 0); + f3d_world_step(w, F3D_R(1.0 / 60.0)); + } + f3d_body_get_angular_velocity(w, stuck, spin); + CHECK(spin[1] < 0); + f3d_world_destroy(w); +} + +static void test_slider(void) { + /* A block on a slider tilted thirty degrees, no friction: it slides + * along the axis at g sin θ and nowhere else, stops at its limit, and a + * motor drives it back up. */ + F3dWorld *w = still_world(); + const double theta = M_PI / 6; + const F3dBody rail = anchor(w, 0, 0, 0); + const F3dBody block = f3d_body_create(w, F3D_BODY_DYNAMIC, 0, 0, 0, 2); + f3d_body_set_shape(w, block, F3D_SHAPE_BOX, F3D_R(0.1), F3D_R(0.1), F3D_R(0.1)); + const f3d_real ux = (f3d_real)cos(theta), uy = (f3d_real)sin(theta); + const F3dJoint slide = + f3d_joint_create(w, F3D_JOINT_PRISMATIC, rail, block, 0, 0, 0, ux, uy, 0); + CHECK(slide != 0); + CHECK(f3d_joint_create(w, F3D_JOINT_PRISMATIC, rail, block, 0, 0, 0, 0, 0, 0) == 0); + run(w, 30); + f3d_real travel; + f3d_joint_get_value(w, slide, &travel); + /* Down the axis by g sin θ h² n(n + 1)/2 after n substeps of h: + * semi-implicit Euler's ½ a t², taken a substep at a time. */ + const double h = 1.0 / 240.0, n = 120.0; + CHECK_NEAR(travel, -9.81 * sin(theta) * h * h * n * (n + 1) / 2, 1e-4); + f3d_real p[3], q[4]; + f3d_body_get_position(w, block, p); + const double off = fabs(-(double)p[0] * uy + (double)p[1] * ux); + CHECK(off < 1e-3); + f3d_body_get_orientation(w, block, q); + CHECK(fabs((double)q[3]) > 0.99999); + f3d_joint_set_limits(w, slide, 1, -1, 1); + run(w, 120); + f3d_joint_get_value(w, slide, &travel); + CHECK(travel > -1.01 && travel < -0.98); + f3d_joint_set_motor(w, slide, 1, F3D_R(0.5), 1000); + run(w, 60); + f3d_real v[3]; + f3d_body_get_velocity(w, block, v); + CHECK_NEAR((double)v[0] * ux + (double)v[1] * uy, 0.5, 1e-3); + f3d_world_destroy(w); +} + +static void test_joined_do_not_collide(void) { + /* Two crates welded half inside each other: they stay so. Told to + * collide, they are pushed apart and the weld holds against it. */ + F3dWorld *w = still_world(); + f3d_world_set_gravity(w, 0, 0, 0); + const F3dBody a = f3d_body_create(w, F3D_BODY_DYNAMIC, 0, 0, 0, 1); + const F3dBody b = f3d_body_create(w, F3D_BODY_DYNAMIC, F3D_R(0.5), 0, 0, 1); + f3d_body_set_shape(w, a, F3D_SHAPE_BOX, F3D_R(0.5), F3D_R(0.5), F3D_R(0.5)); + f3d_body_set_shape(w, b, F3D_SHAPE_BOX, F3D_R(0.5), F3D_R(0.5), F3D_R(0.5)); + const F3dJoint joint = f3d_joint_create(w, F3D_JOINT_SPHERICAL, a, b, + F3D_R(0.25), 0, 0, 0, 0, 0); + run(w, 60); + CHECK(f3d_world_contact_count(w) == 0); + f3d_real pa[3], pb[3]; + f3d_body_get_position(w, a, pa); + f3d_body_get_position(w, b, pb); + CHECK_NEAR(pb[0] - pa[0], 0.5, 1e-4); + f3d_joint_set_collide(w, joint, 1); + run(w, 2); + CHECK(f3d_world_contact_count(w) > 0); + /* Taken away with a body, the joint goes too. */ + CHECK(f3d_world_joint_count(w) == 1); + f3d_body_destroy(w, b); + CHECK(f3d_world_joint_count(w) == 0); + CHECK(!f3d_joint_is_valid(w, joint)); + f3d_world_destroy(w); +} + +static void test_chain(void) { + /* Ten links on spherical joints hanging from a hook: the chain hangs + * straight, its links a link apart, and sleeps as one island; a tug on + * the last wakes the first. */ + F3dWorld *w = f3d_world_create(); + f3d_world_set_air(w, F3D_R(293.15), F3D_R(1e-30)); + const F3dBody hook = anchor(w, 0, 10, 0); + F3dBody links[10]; + F3dBody above = hook; + for (int i = 0; i < 10; i++) { + links[i] = ball(w, 0, F3D_R(9.75) - F3D_R(0.5) * (f3d_real)i, 0, F3D_R(0.1), F3D_R(0.5)); + const f3d_real y = F3D_R(10.0) - F3D_R(0.5) * (f3d_real)i; + CHECK(f3d_joint_create(w, F3D_JOINT_SPHERICAL, above, links[i], 0, y, 0, 0, 0, 0) != 0); + above = links[i]; + } + run(w, 300); + f3d_real p[3]; + f3d_body_get_position(w, links[9], p); + CHECK(fabs((double)p[0]) < 1e-3); + CHECK(fabs((double)p[1] - 5.25) < 0.02); + int asleep = 1; + for (int i = 0; i < 10; i++) asleep &= f3d_body_is_asleep(w, links[i]); + CHECK(asleep); + f3d_body_apply_impulse(w, links[9], 1, 0, 0); + f3d_world_step(w, F3D_R(1.0 / 60.0)); + CHECK(!f3d_body_is_asleep(w, links[0])); + f3d_world_destroy(w); +} + +static void test_snapshot(void) { + F3dWorld *w = f3d_world_create(); + const F3dBody hook = anchor(w, 0, 5, 0); + F3dBody above = hook; + for (int i = 0; i < 6; i++) { + const F3dBody link = ball(w, F3D_R(0.4) * (f3d_real)(i + 1), 5, 0, F3D_R(0.1), 1); + f3d_joint_create(w, i % 2 ? F3D_JOINT_REVOLUTE : F3D_JOINT_SPHERICAL, above, link, + F3D_R(0.4) * (f3d_real)i, 5, 0, 0, 0, 1); + above = link; + } + run(w, 30); + const uint32_t size = f3d_world_snapshot_size(w); + uint8_t *snap = (uint8_t *)malloc(size); + f3d_world_snapshot_write(w, snap, size); + F3dWorld *v = f3d_world_create(); + CHECK(f3d_world_restore(v, snap, size) == 1); + CHECK(f3d_world_joint_count(v) == 6); + run(w, 120); + run(v, 120); + const uint32_t now = f3d_world_snapshot_size(w); + CHECK(f3d_world_snapshot_size(v) == now); + uint8_t *a = (uint8_t *)malloc(now), *b = (uint8_t *)malloc(now); + f3d_world_snapshot_write(w, a, now); + f3d_world_snapshot_write(v, b, now); + CHECK(memcmp(a, b, now) == 0); + free(snap); + free(a); + free(b); + f3d_world_destroy(w); + f3d_world_destroy(v); +} + +static void test_refusals(void) { + F3dWorld *w = f3d_world_create(); + const F3dBody a = ball(w, 0, 0, 0, 1, 1); + const F3dBody b = ball(w, 1, 0, 0, 1, 1); + CHECK(f3d_joint_create(w, F3D_JOINT_FIXED, a, a, 0, 0, 0, 0, 0, 0) == 0); + CHECK(f3d_joint_create(w, F3D_JOINT_FIXED, a, 0, 0, 0, 0, 0, 0, 0) == 0); + CHECK(f3d_joint_create(w, F3D_JOINT_DISTANCE, a, b, 0, 0, 0, 0, 0, 0) == 0); + CHECK(f3d_joint_create(w, (F3dJointType)9, a, b, 0, 0, 0, 0, 0, 0) == 0); + CHECK(f3d_joint_create(w, F3D_JOINT_FIXED, a, b, nan_value(), 0, 0, 0, 0, 0) == 0); + const F3dJoint fixed = f3d_joint_create(w, F3D_JOINT_FIXED, a, b, 0, 0, 0, 0, 0, 0); + CHECK(f3d_joint_set_motor(w, fixed, 1, 1, 1) == 0); + CHECK(f3d_joint_set_limits(w, fixed, 1, -1, 1) == 0); + CHECK(f3d_joint_set_spring(w, fixed, 1, 1, 1) == 0); + CHECK(f3d_joint_set_length(w, fixed, 1, 0, 1) == 0); + f3d_real out[3]; + CHECK(f3d_joint_get_value(w, fixed, out) == 1 && out[0] == 0); + CHECK(f3d_joint_destroy(w, fixed) == 1); + CHECK(f3d_joint_destroy(w, fixed) == 0); + CHECK(f3d_joint_get_force(w, fixed, out) == 0); + /* A freed slot is used again under a new generation. */ + const F3dJoint again = f3d_joint_create(w, F3D_JOINT_FIXED, a, b, 0, 0, 0, 0, 0, 0); + CHECK((uint32_t)again == (uint32_t)fixed && (again >> 32) == (fixed >> 32) + 1); + f3d_world_destroy(w); +} + +/* A limb a metre long hung by its end from a pivot on a spherical joint, + * its axis down, knocked sideways at 6 m/s — enough to swing it past level + * — and the most it swung over two seconds, with a cone of [cone] radians + * or none. */ +static double knocked(f3d_real cone) { + F3dWorld *w = still_world(); + const F3dBody pivot = anchor(w, 0, 2, 0); + const F3dBody bob = f3d_body_create(w, F3D_BODY_DYNAMIC, 0, F3D_R(1.5), 0, 1); + f3d_body_set_shape(w, bob, F3D_SHAPE_BOX, F3D_R(0.05), F3D_R(0.5), F3D_R(0.05)); + const F3dJoint j = f3d_joint_create(w, F3D_JOINT_SPHERICAL, pivot, bob, 0, 2, 0, 0, -1, 0); + if (cone > 0) CHECK(f3d_joint_set_cone(w, j, 1, cone)); + f3d_body_set_velocity(w, bob, 6, 0, F3D_R(1.5)); + double most = 0; + f3d_real swing = 0, p[3]; + for (int i = 0; i < 120; i++) { + run(w, 1); + CHECK(f3d_joint_get_swing(w, j, &swing)); + if (swing > most) most = swing; + } + /* And the point held throughout. */ + f3d_body_get_position(w, bob, p); + CHECK_NEAR(sqrt((double)p[0] * p[0] + (p[1] - 2.0) * (p[1] - 2.0) + (double)p[2] * p[2]), 0.5, 0.01); + f3d_world_destroy(w); + return most; +} + +static void test_cone(void) { + /* Free, it swings past level; in a cone of 30 degrees it stops at the + * cone, give or take what the soft limit lets through. */ + CHECK(knocked(0) > M_PI / 2); + const double held = knocked((f3d_real)(M_PI / 6)); + CHECK(held < M_PI / 6 + 0.05); + CHECK(held > M_PI / 6 - 0.05); +} + +static void test_twist(void) { + /* A plank hung on a spherical joint by its axis, spun about it at 8 + * rad/s: its twist stops at the limits, ±0.5, and does not swing it. */ + F3dWorld *w = still_world(); + const F3dBody pivot = anchor(w, 0, 2, 0); + const F3dBody plank = f3d_body_create(w, F3D_BODY_DYNAMIC, 0, F3D_R(1.5), 0, 1); + f3d_body_set_shape(w, plank, F3D_SHAPE_BOX, F3D_R(0.4), F3D_R(0.5), F3D_R(0.05)); + const F3dJoint j = f3d_joint_create(w, F3D_JOINT_SPHERICAL, pivot, plank, 0, 2, 0, 0, -1, 0); + CHECK(f3d_joint_set_limits(w, j, 1, F3D_R(-0.5), F3D_R(0.5))); + CHECK(f3d_joint_set_cone(w, j, 1, F3D_R(0.2))); + f3d_body_set_angular_velocity(w, plank, 0, 8, 0); + double most = 0; + f3d_real twist = 0, swing = 0; + for (int i = 0; i < 120; i++) { + run(w, 1); + f3d_joint_get_value(w, j, &twist); + if (fabs((double)twist) > most) most = fabs((double)twist); + } + CHECK(most < 0.55); + CHECK(most > 0.45); + f3d_joint_get_swing(w, j, &swing); + CHECK(swing < F3D_R(0.25)); + /* And off, it spins on through them. */ + CHECK(f3d_joint_set_limits(w, j, 0, 0, 0)); + f3d_body_set_angular_velocity(w, plank, 0, 8, 0); + most = 0; + for (int i = 0; i < 30; i++) { + run(w, 1); + f3d_joint_get_value(w, j, &twist); + if (fabs((double)twist) > most) most = fabs((double)twist); + } + CHECK(most > 1.0); + /* A cone is a spherical joint's alone, and between nought and π. */ + const F3dBody other = ball(w, 3, 2, 0, F3D_R(0.1), 1); + const F3dJoint hinge = f3d_joint_create(w, F3D_JOINT_REVOLUTE, pivot, other, 0, 2, 0, 0, 0, 1); + CHECK(!f3d_joint_set_cone(w, hinge, 1, F3D_R(0.5))); + CHECK(!f3d_joint_set_cone(w, j, 1, 0)); + CHECK(!f3d_joint_set_cone(w, j, 1, F3D_R(3.5))); + CHECK(f3d_joint_set_cone(w, j, 0, 0)); + /* So is friction, nought or more; a hinge has a motor for it. */ + CHECK(!f3d_joint_set_friction(w, hinge, 1, 1)); + CHECK(!f3d_joint_set_friction(w, j, 1, -1)); + CHECK(f3d_joint_set_friction(w, j, 1, 0)); + f3d_world_destroy(w); +} + +int main(void) { + test_arctangent(); + test_cone(); + test_twist(); + test_pendulum(); + test_hinge_keeps_its_plane(); + test_rod_and_rope(); + test_spring(); + test_weld(); + test_hinge_limits_and_motor(); + test_slider(); + test_joined_do_not_collide(); + test_chain(); + test_snapshot(); + test_refusals(); + return finish(); +} diff --git a/packages/flutter3d_physics_native/csrc/tests/test_mesh.c b/packages/flutter3d_physics_native/csrc/tests/test_mesh.c new file mode 100644 index 000000000..3c3068b42 --- /dev/null +++ b/packages/flutter3d_physics_native/csrc/tests/test_mesh.c @@ -0,0 +1,276 @@ +/* + * Triangle meshes, tested in C — P9, phase 6: a mesh built and its internal + * edges found, bodies of every shape resting on one, a crate sliding over a + * fine grid without tripping on its seams, a mesh seen from behind letting + * a body through, a ball rolling down a sloped mesh at 5/7 g sin θ, and a + * mesh through a snapshot. + */ +#include +#include +#include + +#include "check.h" + +/* A flat grid of n × n squares, 2·half across, in the plane y = 0, each + * square two triangles facing up, its vertices shared. Turned by [q]. */ +static uint32_t grid(F3dWorld *w, int n, double half, F3dQuat q, F3dBody *body) { + const int side = n + 1; + f3d_real *v = (f3d_real *)malloc((size_t)side * side * 3 * sizeof(f3d_real)); + uint32_t *t = (uint32_t *)malloc((size_t)n * n * 6 * sizeof(uint32_t)); + for (int j = 0; j < side; j++) { + for (int i = 0; i < side; i++) { + f3d_real *p = &v[(j * side + i) * 3]; + p[0] = (f3d_real)(-half + 2 * half * i / n); + p[1] = 0; + p[2] = (f3d_real)(-half + 2 * half * j / n); + } + } + int k = 0; + for (int j = 0; j < n; j++) { + for (int i = 0; i < n; i++) { + const uint32_t v00 = (uint32_t)(j * side + i), v10 = v00 + 1; + const uint32_t v01 = v00 + (uint32_t)side, v11 = v01 + 1; + t[k++] = v00; + t[k++] = v11; + t[k++] = v10; + t[k++] = v00; + t[k++] = v01; + t[k++] = v11; + } + } + const uint32_t mesh = + f3d_world_create_mesh(w, v, (uint32_t)(side * side), t, (uint32_t)(n * n * 2)); + free(v); + free(t); + if (body != NULL) { + *body = f3d_body_create(w, F3D_BODY_FIXED, 0, 0, 0, 0); + f3d_body_set_mesh(w, *body, mesh); + f3d_body_set_orientation(w, *body, q.x, q.y, q.z, q.w); + } + return mesh; +} + +static const F3dQuat identity = {0, 0, 0, 1}; + +static F3dQuat turn_about(F3dVec3 axis, double angle) { + const double s = sin(angle / 2); + F3dQuat q = {(f3d_real)(axis.x * s), (f3d_real)(axis.y * s), + (f3d_real)(axis.z * s), (f3d_real)cos(angle / 2)}; + return q; +} + +static void run(F3dWorld *w, int steps) { + for (int i = 0; i < steps; i++) f3d_world_step(w, F3D_R(1.0 / 60.0)); +} + +static void test_building(void) { + F3dWorld *w = f3d_world_create(); + /* A grid of four by four: 32 triangles, 56 edges, 16 round the rim, + * the other 40 shared across a flat fold. */ + const uint32_t mesh = grid(w, 4, 2, identity, NULL); + CHECK(mesh == 1); + CHECK(f3d_world_mesh_triangle_count(w, mesh) == 32); + CHECK(f3d_world_mesh_internal_edges(w, mesh) == 40); + /* Two triangles folded along z: as a tent, the fold sticks out and is a + * real edge; as a dip, it is hollow and internal. */ + const f3d_real tent_v[12] = {0, 1, -1, 0, 1, 1, -1, 0, 0, 1, 0, 0}; + const uint32_t tent_t[6] = {0, 1, 3, 0, 2, 1}; + const uint32_t tent = f3d_world_create_mesh(w, tent_v, 4, tent_t, 2); + CHECK(f3d_world_mesh_internal_edges(w, tent) == 0); + const f3d_real dip_v[12] = {0, 0, -1, 0, 0, 1, -1, 1, 0, 1, 1, 0}; + const uint32_t dip = f3d_world_create_mesh(w, dip_v, 4, tent_t, 2); + CHECK(f3d_world_mesh_internal_edges(w, dip) == 1); + /* Refused: no triangles, an index past the vertices, a vertex not + * finite; and a dynamic body cannot be a mesh. */ + const uint32_t bad_t[3] = {0, 1, 9}; + CHECK(f3d_world_create_mesh(w, tent_v, 4, bad_t, 1) == 0); + CHECK(f3d_world_create_mesh(w, tent_v, 4, tent_t, 0) == 0); + f3d_real nan_v[12]; + memcpy(nan_v, tent_v, sizeof nan_v); + nan_v[5] = nan_value(); + CHECK(f3d_world_create_mesh(w, nan_v, 4, tent_t, 2) == 0); + const F3dBody moving = f3d_body_create(w, F3D_BODY_DYNAMIC, 0, 0, 0, 1); + CHECK(f3d_body_set_mesh(w, moving, mesh) == 0); + f3d_world_destroy(w); +} + +static void test_resting(void) { + F3dWorld *w = f3d_world_create(); + f3d_world_set_air(w, F3D_R(293.15), F3D_R(1e-30)); + F3dBody ground; + grid(w, 10, 10, identity, &ground); + const f3d_real pts[24] = {-.4f, -.4f, -.4f, .4f, -.4f, -.4f, -.4f, .4f, -.4f, .4f, .4f, -.4f, + -.4f, -.4f, .4f, .4f, -.4f, .4f, -.4f, .4f, .4f, .4f, .4f, .4f}; + const uint32_t hull = f3d_world_create_hull(w, pts, 8); + /* A ball over a triangle's middle and one over a vertex; a crate across + * a seam; a capsule lying down; a cylinder on end; a hull cube. */ + const F3dBody ball = f3d_body_create(w, F3D_BODY_DYNAMIC, F3D_R(0.3), F3D_R(0.31), F3D_R(0.7), 1); + f3d_body_set_shape(w, ball, F3D_SHAPE_SPHERE, F3D_R(0.3), 0, 0); + const F3dBody on_vertex = f3d_body_create(w, F3D_BODY_DYNAMIC, 2, F3D_R(0.31), 2, 1); + f3d_body_set_shape(w, on_vertex, F3D_SHAPE_SPHERE, F3D_R(0.3), 0, 0); + const F3dBody crate = f3d_body_create(w, F3D_BODY_DYNAMIC, -3, F3D_R(0.51), F3D_R(-3.0), 1); + f3d_body_set_shape(w, crate, F3D_SHAPE_BOX, F3D_R(0.5), F3D_R(0.5), F3D_R(0.5)); + const F3dBody log = f3d_body_create(w, F3D_BODY_DYNAMIC, 4, F3D_R(0.21), -4, 1); + f3d_body_set_shape(w, log, F3D_SHAPE_CAPSULE, F3D_R(0.2), F3D_R(0.7), 0); + const F3dQuat lying = turn_about(f3d_v3(0, 0, 1), M_PI / 2); + f3d_body_set_orientation(w, log, lying.x, lying.y, lying.z, lying.w); + const F3dBody can = f3d_body_create(w, F3D_BODY_DYNAMIC, -5, F3D_R(0.61), 5, 1); + f3d_body_set_shape(w, can, F3D_SHAPE_CYLINDER, F3D_R(0.3), F3D_R(0.6), 0); + const F3dBody cube = f3d_body_create(w, F3D_BODY_DYNAMIC, 6, F3D_R(0.41), 0, 1); + f3d_body_set_hull(w, cube, hull); + run(w, 240); + const F3dBody bodies[6] = {ball, on_vertex, crate, log, can, cube}; + const double heights[6] = {0.3, 0.3, 0.5, 0.2, 0.6, 0.4}; + for (int k = 0; k < 6; k++) { + f3d_real p[3]; + f3d_body_get_position(w, bodies[k], p); + CHECK(fabs((double)p[1] - heights[k]) < 0.01); + CHECK(f3d_body_is_asleep(w, bodies[k])); + } + /* The crate rests on four points, across the triangles it spans. */ + int crate_points = 0; + for (uint32_t i = 0; i < w->s.manifold_count; i++) { + const F3dManifold *m = &w->manifolds[i]; + if (m->b == crate || m->a == crate) crate_points = (int)m->count; + } + CHECK(crate_points == 4); + /* The box query finds the ground. */ + F3dBody found[8]; + CHECK(f3d_world_query_box(w, -1, -1, -1, 1, 1, 1, found, 8) >= 2); + CHECK(found[0] == ground); + f3d_world_destroy(w); +} + +/* A crate slid across a grid of fine triangles at five metres a second on + * no friction: the most it moves up or down. Its face meets the triangles' + * faces, so this holds the merging of their contacts into one manifold; + * the internal edges are held by the ball rolling down a mesh, whose + * contacts land on seams. */ +static double slide(void) { + F3dWorld *w = f3d_world_create(); + f3d_world_set_air(w, F3D_R(293.15), F3D_R(1e-30)); + f3d_world_set_sleep(w, 0, 0); + F3dBody ground; + grid(w, 100, 10, identity, &ground); + f3d_body_set_friction(w, ground, 0); + const F3dBody crate = f3d_body_create(w, F3D_BODY_DYNAMIC, -8, F3D_R(0.25), F3D_R(0.03), 1); + f3d_body_set_shape(w, crate, F3D_SHAPE_BOX, F3D_R(0.25), F3D_R(0.25), F3D_R(0.25)); + f3d_body_set_friction(w, crate, 0); + run(w, 30); + f3d_body_set_velocity(w, crate, 5, 0, 0); + double most = 0; + for (int i = 0; i < 150; i++) { + f3d_world_step(w, F3D_R(1.0 / 60.0)); + f3d_real v[3]; + f3d_body_get_velocity(w, crate, v); + if (fabs((double)v[1]) > most) most = fabs((double)v[1]); + } + f3d_world_destroy(w); + return most; +} + +static void test_no_bumps_at_seams(void) { + const double smooth = slide(); + CHECK(smooth < 0.05); +} + +static void test_one_sided(void) { + /* A ball under the grid going up passes through it, as through the back + * of a wall. */ + F3dWorld *w = f3d_world_create(); + f3d_world_set_gravity(w, 0, 0, 0); + f3d_world_set_air(w, F3D_R(293.15), F3D_R(1e-30)); + F3dBody ground; + grid(w, 4, 4, identity, &ground); + const F3dBody ball = f3d_body_create(w, F3D_BODY_DYNAMIC, F3D_R(0.3), -1, F3D_R(0.3), 1); + f3d_body_set_shape(w, ball, F3D_SHAPE_SPHERE, F3D_R(0.2), 0, 0); + f3d_body_set_velocity(w, ball, 0, 2, 0); + run(w, 60); + f3d_real p[3], v[3]; + f3d_body_get_position(w, ball, p); + f3d_body_get_velocity(w, ball, v); + CHECK(p[1] > F3D_R(0.5)); + CHECK_NEAR(v[1], 2, 1e-4); + /* Resting half through it from below, it is left there: seen from + * behind, the floor is not there to push it anywhere. */ + const F3dBody under = f3d_body_create(w, F3D_BODY_DYNAMIC, F3D_R(-1.3), F3D_R(-0.1), F3D_R(1.3), 1); + f3d_body_set_shape(w, under, F3D_SHAPE_SPHERE, F3D_R(0.2), 0, 0); + run(w, 30); + f3d_body_get_position(w, under, p); + CHECK_NEAR(p[1], -0.1, 1e-6); + /* Coming down onto it from above, it stops. */ + f3d_body_set_velocity(w, ball, 0, -2, 0); + run(w, 120); + f3d_body_get_position(w, ball, p); + CHECK(fabs((double)p[1] - 0.2) < 0.01); + f3d_world_destroy(w); +} + +/* Mutation: take the internal-edge snap out of f3d_collide_mesh — a ball + * crossing seams is caught on their edges and rolls slow. */ +static void test_rolling_down_a_mesh(void) { + const double theta = M_PI / 6; + const F3dQuat q = turn_about(f3d_v3(0, 0, 1), theta); + const F3dMat3 m = f3d_mat_of(q); + F3dWorld *w = f3d_world_create(); + f3d_world_set_air(w, F3D_R(293.15), F3D_R(1e-30)); + f3d_world_set_sleep(w, 0, 0); + F3dBody ground; + grid(w, 40, 20, q, &ground); + f3d_body_set_friction(w, ground, 1); + const f3d_real r = F3D_R(0.2); + const F3dVec3 at = f3d_scale(m.c[1], r - F3D_R(0.001)); + const F3dBody ball = f3d_body_create(w, F3D_BODY_DYNAMIC, at.x, at.y, at.z, 1); + f3d_body_set_shape(w, ball, F3D_SHAPE_SPHERE, r, 0, 0); + f3d_body_set_friction(w, ball, 1); + run(w, 15); + f3d_real v0[3], v1[3]; + f3d_body_get_velocity(w, ball, v0); + run(w, 60); + f3d_body_get_velocity(w, ball, v1); + const double s0 = sqrt((double)v0[0] * v0[0] + (double)v0[1] * v0[1] + (double)v0[2] * v0[2]); + const double s1 = sqrt((double)v1[0] * v1[0] + (double)v1[1] * v1[1] + (double)v1[2] * v1[2]); + CHECK_NEAR(s1 - s0, 5.0 / 7.0 * 9.81 * sin(theta), 0.03); + f3d_world_destroy(w); +} + +static void test_snapshot(void) { + F3dWorld *w = f3d_world_create(); + F3dBody ground; + grid(w, 8, 8, identity, &ground); + for (int i = 0; i < 5; i++) { + const F3dBody b = f3d_body_create(w, F3D_BODY_DYNAMIC, (f3d_real)i - 2, F3D_R(0.6), 0, 1); + f3d_body_set_shape(w, b, (F3dShapeKind)(1 + i % 4), F3D_R(0.3), F3D_R(0.3), F3D_R(0.3)); + } + run(w, 20); + const uint32_t size = f3d_world_snapshot_size(w); + uint8_t *snap = (uint8_t *)malloc(size); + f3d_world_snapshot_write(w, snap, size); + F3dWorld *v = f3d_world_create(); + CHECK(f3d_world_restore(v, snap, size) == 1); + CHECK(v->s.mesh_count == 1 && v->mesh_tree_count == 0); + run(w, 60); + run(v, 60); + CHECK(v->mesh_tree_count == 1); + const uint32_t now = f3d_world_snapshot_size(w); + CHECK(f3d_world_snapshot_size(v) == now); + uint8_t *a = (uint8_t *)malloc(now), *b = (uint8_t *)malloc(now); + f3d_world_snapshot_write(w, a, now); + f3d_world_snapshot_write(v, b, now); + CHECK(memcmp(a, b, now) == 0); + free(snap); + free(a); + free(b); + f3d_world_destroy(w); + f3d_world_destroy(v); +} + +int main(void) { + test_building(); + test_resting(); + test_no_bumps_at_seams(); + test_one_sided(); + test_rolling_down_a_mesh(); + test_snapshot(); + return finish(); +} diff --git a/packages/flutter3d_physics_native/csrc/tests/test_motion.c b/packages/flutter3d_physics_native/csrc/tests/test_motion.c new file mode 100644 index 000000000..2c88c344d --- /dev/null +++ b/packages/flutter3d_physics_native/csrc/tests/test_motion.c @@ -0,0 +1,321 @@ +/* + * How bodies move, tested in C — P9: shapes as inertia, the turn that + * keeps angular momentum, impulses and the force bus, damping, the wind's + * drag, and sleep. + */ +#include + +#include "check.h" + +/* The precision a long run is held to: f32's, or much better in f64. */ +#ifdef F3D_REAL_DOUBLE +#define LOOSE 1e-9 +#else +#define LOOSE 1e-4 +#endif + +static void test_inertia(void) { + F3dWorld *w = f3d_world_create(); + const F3dBody b = f3d_body_create(w, F3D_BODY_DYNAMIC, 0, 0, 0, 2); + f3d_real i[3]; + /* A point has none, and does not turn. */ + f3d_body_get_inertia(w, b, i); + CHECK(i[0] == 0 && i[1] == 0 && i[2] == 0); + f3d_body_set_angular_velocity(w, b, 1, 2, 3); + f3d_real spin[3]; + f3d_body_get_angular_velocity(w, b, spin); + CHECK(spin[0] == 0 && spin[1] == 0 && spin[2] == 0); + + CHECK(f3d_body_set_shape(w, b, F3D_SHAPE_SPHERE, F3D_R(0.5), 0, 0) == 1); + f3d_body_get_inertia(w, b, i); + CHECK_NEAR(i[0], 0.4 * 2 * 0.25, 1e-6); + CHECK(i[0] == i[1] && i[1] == i[2]); + + /* A box 2 × 4 × 6: m/12 · (h² + d²) with the full widths. */ + CHECK(f3d_body_set_shape(w, b, F3D_SHAPE_BOX, 1, 2, 3) == 1); + f3d_body_get_inertia(w, b, i); + CHECK_NEAR(i[0], 2.0 / 12.0 * (16 + 36), 1e-6); + CHECK_NEAR(i[1], 2.0 / 12.0 * (4 + 36), 1e-6); + CHECK_NEAR(i[2], 2.0 / 12.0 * (4 + 16), 1e-6); + + /* A capsule with no straight part is a sphere. */ + CHECK(f3d_body_set_shape(w, b, F3D_SHAPE_CAPSULE, F3D_R(0.5), 0, 0) == 1); + f3d_body_get_inertia(w, b, i); + CHECK_NEAR(i[0], 0.4 * 2 * 0.25, 1e-6); + CHECK_NEAR(i[1], 0.4 * 2 * 0.25, 1e-6); + /* A long one is easier to turn about its axis than across it. */ + CHECK(f3d_body_set_shape(w, b, F3D_SHAPE_CAPSULE, F3D_R(0.1), 1, 0) == 1); + f3d_body_get_inertia(w, b, i); + CHECK(i[1] < i[0] && i[0] == i[2]); + + /* A fixed body has its inertia but never turns. */ + const F3dBody pinned = f3d_body_create(w, F3D_BODY_FIXED, 0, 0, 0, 1); + f3d_body_set_shape(w, pinned, F3D_SHAPE_SPHERE, 1, 0, 0); + f3d_body_set_angular_velocity(w, pinned, 1, 0, 0); + f3d_body_get_angular_velocity(w, pinned, spin); + CHECK(spin[0] == 0); + f3d_world_destroy(w); +} + +/* L = R I Rᵀ ω, in doubles. */ +static void momentum(F3dWorld *w, F3dBody b, double *l) { + f3d_real q[4], spin[3], inertia[3]; + f3d_body_get_orientation(w, b, q); + f3d_body_get_angular_velocity(w, b, spin); + f3d_body_get_inertia(w, b, inertia); + F3dQuat qq = {q[0], q[1], q[2], q[3]}; + const F3dSym3 m = + f3d_sym_turned(qq, f3d_sym_diag(inertia[0], inertia[1], inertia[2])); + l[0] = (double)m.xx * spin[0] + (double)m.xy * spin[1] + (double)m.xz * spin[2]; + l[1] = (double)m.xy * spin[0] + (double)m.yy * spin[1] + (double)m.yz * spin[2]; + l[2] = (double)m.xz * spin[0] + (double)m.yz * spin[1] + (double)m.zz * spin[2]; +} + +static void test_turning(void) { + F3dWorld *w = f3d_world_create(); + f3d_world_set_gravity(w, 0, 0, 0); + f3d_world_set_sleep(w, 0, 0); + /* About a principal axis, the spin is kept exactly and the orientation + * stays a unit quaternion. */ + const F3dBody steady = f3d_body_create(w, F3D_BODY_DYNAMIC, 0, 0, 0, 1); + f3d_body_set_shape(w, steady, F3D_SHAPE_BOX, 1, 2, 3); + f3d_body_set_angular_velocity(w, steady, 0, 2, 0); + /* Off its axes, a box wobbles: its spin changes and its angular momentum + * does not. */ + const F3dBody wobbly = f3d_body_create(w, F3D_BODY_DYNAMIC, 5, 0, 0, 1); + f3d_body_set_shape(w, wobbly, F3D_SHAPE_BOX, F3D_R(0.1), F3D_R(0.5), 1); + f3d_body_set_angular_velocity(w, wobbly, 3, 1, F3D_R(0.5)); + double l0[3]; + momentum(w, wobbly, l0); + f3d_real s0[3]; + f3d_body_get_angular_velocity(w, wobbly, s0); + for (int i = 0; i < 2000; i++) f3d_world_step(w, F3D_R(1.0 / 240.0)); + f3d_real spin[3], q[4]; + f3d_body_get_angular_velocity(w, steady, spin); + CHECK(spin[0] == 0 && spin[1] == 2 && spin[2] == 0); + f3d_body_get_orientation(w, steady, q); + CHECK_NEAR(q[0] * q[0] + q[1] * q[1] + q[2] * q[2] + q[3] * q[3], 1, LOOSE); + /* Two radians a second for 2000/240 s, turned about y. */ + const double angle = 2.0 * 2000.0 / 240.0; + CHECK_NEAR(q[1], sin(angle / 2), 1e-3); + CHECK_NEAR(q[3], cos(angle / 2), 1e-3); + double l1[3]; + momentum(w, wobbly, l1); + const double l0n = sqrt(l0[0] * l0[0] + l0[1] * l0[1] + l0[2] * l0[2]); + for (int k = 0; k < 3; k++) CHECK_NEAR(l1[k] / l0n, l0[k] / l0n, 10 * LOOSE); + f3d_body_get_angular_velocity(w, wobbly, spin); + CHECK(fabs((double)spin[0] - s0[0]) > 1e-2 || + fabs((double)spin[1] - s0[1]) > 1e-2); + f3d_world_destroy(w); +} + +/* Air too thin to slow anything by a bit: what a test of the motion alone + * wants. Not nought, which the world refuses. */ +static void vacuum(F3dWorld *w) { f3d_world_set_air(w, F3D_R(293.15), F3D_R(1e-30)); } + +static void test_impulses_and_forces(void) { + F3dWorld *w = f3d_world_create(); + f3d_world_set_gravity(w, 0, 0, 0); + vacuum(w); + const F3dBody b = f3d_body_create(w, F3D_BODY_DYNAMIC, 0, 0, 0, 2); + f3d_body_set_shape(w, b, F3D_SHAPE_SPHERE, F3D_R(0.5), 0, 0); + /* Through the centre: J / m, and no spin. */ + f3d_body_apply_impulse(w, b, 4, 0, 0); + f3d_real v[3], spin[3]; + f3d_body_get_velocity(w, b, v); + CHECK(v[0] == 2 && v[1] == 0); + /* At a point: the same push, and r × J / I of spin. */ + f3d_body_apply_impulse_at(w, b, 0, 2, 0, F3D_R(0.5), 0, 0); + f3d_body_get_velocity(w, b, v); + CHECK(v[0] == 2 && v[1] == 1); + f3d_body_get_angular_velocity(w, b, spin); + CHECK_NEAR(spin[2], 0.5 * 2 / (0.4 * 2 * 0.25), 1e-6); + CHECK(spin[0] == 0 && spin[1] == 0); + + /* A force over one step is F/m · dt, then spent. */ + const F3dBody c = f3d_body_create(w, F3D_BODY_DYNAMIC, 0, 0, 0, 2); + f3d_body_add_force(w, c, 1, 0, 0); + f3d_body_add_force(w, c, 3, 0, 0); + f3d_world_step(w, F3D_R(0.5)); + f3d_body_get_velocity(w, c, v); + CHECK(v[0] == 1); + f3d_world_step(w, F3D_R(0.5)); + f3d_body_get_velocity(w, c, v); + CHECK(v[0] == 1); + /* A torque likewise, through I⁻¹. */ + const F3dBody d = f3d_body_create(w, F3D_BODY_DYNAMIC, 0, 0, 0, 1); + f3d_body_set_shape(w, d, F3D_SHAPE_SPHERE, 1, 0, 0); + f3d_body_add_torque(w, d, 0, F3D_R(0.4), 0); + f3d_world_step(w, F3D_R(0.5)); + f3d_body_get_angular_velocity(w, d, spin); + CHECK_NEAR(spin[1], 0.5, 1e-6); + /* Locked, it keeps its push and loses its turn. */ + f3d_body_lock_rotation(w, d, 1); + f3d_body_get_angular_velocity(w, d, spin); + CHECK(spin[1] == 0); + f3d_body_add_torque(w, d, 0, 1, 0); + f3d_body_apply_impulse_at(w, d, 1, 0, 0, 0, 1, 0); + f3d_world_step(w, F3D_R(0.5)); + f3d_body_get_angular_velocity(w, d, spin); + CHECK(spin[0] == 0 && spin[1] == 0 && spin[2] == 0); + f3d_body_get_velocity(w, d, v); + CHECK(v[0] == 1); + /* A fixed body takes nothing. */ + const F3dBody pinned = f3d_body_create(w, F3D_BODY_FIXED, 0, 0, 0, 1); + f3d_body_apply_impulse(w, pinned, 1, 0, 0); + f3d_body_add_force(w, pinned, 1, 0, 0); + f3d_world_step(w, 1); + f3d_body_get_velocity(w, pinned, v); + CHECK(v[0] == 0); + f3d_world_destroy(w); +} + +static void test_damping(void) { + F3dWorld *w = f3d_world_create(); + f3d_world_set_substeps(w, 1); + f3d_world_set_gravity(w, 0, 0, 0); + vacuum(w); + const F3dBody b = f3d_body_create(w, F3D_BODY_DYNAMIC, 0, 0, 0, 1); + f3d_body_set_shape(w, b, F3D_SHAPE_SPHERE, 1, 0, 0); + f3d_body_set_damping(w, b, 1, 3); + f3d_body_set_velocity(w, b, 2, 0, 0); + /* Under the bound on a substep's turn, π/4 in a step of a second. */ + f3d_body_set_angular_velocity(w, b, 0, 0, F3D_R(0.4)); + f3d_world_step(w, 1); + f3d_real v[3], spin[3]; + f3d_body_get_velocity(w, b, v); + f3d_body_get_angular_velocity(w, b, spin); + CHECK(v[0] == 1); + CHECK_NEAR(spin[2], 0.1, 1e-6); + f3d_world_destroy(w); +} + +static void test_drag(void) { + /* A ball dropped in still air falls to its terminal speed, where drag + * holds its weight: ½ ρ C π r² v² = m g. */ + F3dWorld *w = f3d_world_create(); + f3d_world_set_sleep(w, 0, 0); + const f3d_real r = F3D_R(0.1), m = F3D_R(0.05); + const F3dBody ball = f3d_body_create(w, F3D_BODY_DYNAMIC, 0, 0, 0, m); + f3d_body_set_shape(w, ball, F3D_SHAPE_SPHERE, r, 0, 0); + for (int i = 0; i < 6000; i++) f3d_world_step(w, F3D_R(1.0 / 120.0)); + f3d_real v[3]; + f3d_body_get_velocity(w, ball, v); + const double terminal = + sqrt(2.0 * (double)m * 9.81 / (1.204 * 0.47 * 3.14159265358979 * (double)r * (double)r)); + CHECK_NEAR(-v[1], terminal, 1e-4); + /* At a step far too long for an explicit drag — a leaf in a gale — it + * still settles, at the same speed. */ + const F3dBody leaf = f3d_body_create(w, F3D_BODY_DYNAMIC, 0, 0, 0, F3D_R(1e-4)); + f3d_body_set_shape(w, leaf, F3D_SHAPE_BOX, F3D_R(0.05), F3D_R(0.001), F3D_R(0.05)); + f3d_world_set_wind(w, 30, 0, 0); + for (int i = 0; i < 200; i++) f3d_world_step(w, F3D_R(0.1)); + f3d_body_get_velocity(w, leaf, v); + CHECK(f3d_finite(v[0]) && f3d_finite(v[1])); + CHECK_NEAR(v[0], 30, 1e-4); + /* A drag coefficient of its own changes it. */ + f3d_body_set_drag(w, ball, F3D_R(0.94)); + f3d_world_set_wind(w, 0, 0, 0); + for (int i = 0; i < 6000; i++) f3d_world_step(w, F3D_R(1.0 / 120.0)); + f3d_body_get_velocity(w, ball, v); + CHECK_NEAR(-v[1], terminal / sqrt(2.0), 1e-4); + /* A point feels no wind. */ + const F3dBody point = f3d_body_create(w, F3D_BODY_DYNAMIC, 0, 0, 0, 1); + f3d_world_set_gravity(w, 0, 0, 0); + f3d_world_set_wind(w, 10, 0, 0); + f3d_world_step(w, 1); + f3d_body_get_velocity(w, point, v); + CHECK(v[0] == 0); + f3d_world_destroy(w); +} + +static void test_wind_carries(void) { + /* With no gravity, a ball let go in a steady wind is carried towards the + * wind's speed and never past it, along quadratic drag's closed form: + * u = u₀ / (1 + c u₀ t), with c = ½ ρ C π r² / m. */ + F3dWorld *w = f3d_world_create(); + f3d_world_set_gravity(w, 0, 0, 0); + f3d_world_set_sleep(w, 0, 0); + const F3dBody ball = f3d_body_create(w, F3D_BODY_DYNAMIC, 0, 0, 0, F3D_R(0.01)); + f3d_body_set_shape(w, ball, F3D_SHAPE_SPHERE, F3D_R(0.05), 0, 0); + /* A grid wind only, so the uniform one is nought. */ + const f3d_real grid[3] = {0, 0, 5}; + f3d_world_set_wind_grid(w, 0, 0, 0, 1, 1, 1, 1, grid); + f3d_real v[3]; + f3d_real last = 0; + int monotone = 1; + for (int i = 0; i < 2000; i++) { + f3d_world_step(w, F3D_R(1.0 / 60.0)); + f3d_body_get_velocity(w, ball, v); + monotone &= v[2] >= last && v[2] <= 5; + last = v[2]; + } + CHECK(monotone); + const double c = 0.5 * 1.204 * 0.47 * 3.14159265358979 * 0.05 * 0.05 / 0.01; + const double t = 2000.0 / 60.0; + CHECK_NEAR(v[2], 5.0 - 5.0 / (1.0 + c * 5.0 * t), 1e-3); + f3d_world_destroy(w); +} + +static void test_sleep(void) { + F3dWorld *w = f3d_world_create(); + f3d_world_set_gravity(w, 0, 0, 0); + F3dBody bodies[4]; + uint32_t kinds[4]; + const F3dBody b = f3d_body_create(w, F3D_BODY_DYNAMIC, 0, 0, 0, 1); + f3d_body_set_shape(w, b, F3D_SHAPE_SPHERE, F3D_R(0.1), 0, 0); + f3d_body_set_velocity(w, b, F3D_R(0.01), 0, 0); + /* Just short of half a second still: awake. */ + for (int i = 0; i < 29; i++) f3d_world_step(w, F3D_R(1.0 / 60.0)); + CHECK(!f3d_body_is_asleep(w, b)); + /* Asleep at the start of the step after the half second is up: sleep is + * its island's to decide, where the contacts are. */ + for (int i = 0; i < 3; i++) f3d_world_step(w, F3D_R(1.0 / 60.0)); + CHECK(f3d_body_is_asleep(w, b)); + CHECK(f3d_world_read_events(w, bodies, NULL, kinds, 4) == 1); + CHECK(bodies[0] == b && kinds[0] == F3D_EVENT_SLEPT); + f3d_real v[3], p[3], q[3]; + f3d_body_get_velocity(w, b, v); + CHECK(v[0] == 0); + /* Asleep, it does not move, whatever gravity does. */ + f3d_body_get_position(w, b, p); + f3d_world_set_gravity(w, 0, -9.81f, 0); + f3d_world_step(w, 1); + f3d_body_get_position(w, b, q); + CHECK(p[1] == q[1]); + /* Pushed, it wakes and says so. */ + f3d_body_apply_impulse(w, b, 0, 1, 0); + CHECK(!f3d_body_is_asleep(w, b)); + CHECK(f3d_world_read_events(w, bodies, NULL, kinds, 4) == 1); + CHECK(kinds[0] == F3D_EVENT_WOKE); + /* A change of wind wakes what it blows on. */ + f3d_world_set_gravity(w, 0, 0, 0); + f3d_body_set_velocity(w, b, 0, 0, 0); + for (int i = 0; i < 60; i++) f3d_world_step(w, F3D_R(1.0 / 60.0)); + CHECK(f3d_body_is_asleep(w, b)); + f3d_world_set_wind(w, 3, 0, 0); + CHECK(!f3d_body_is_asleep(w, b)); + /* Turning on the spot is not rest. */ + f3d_world_set_wind(w, 0, 0, 0); + f3d_body_set_velocity(w, b, 0, 0, 0); + f3d_body_set_angular_velocity(w, b, 0, 1, 0); + for (int i = 0; i < 120; i++) f3d_world_step(w, F3D_R(1.0 / 60.0)); + CHECK(!f3d_body_is_asleep(w, b)); + /* A sleep time of nought turns it off. */ + f3d_world_set_sleep(w, 1, 0); + f3d_body_set_angular_velocity(w, b, 0, 0, 0); + for (int i = 0; i < 120; i++) f3d_world_step(w, F3D_R(1.0 / 60.0)); + CHECK(!f3d_body_is_asleep(w, b)); + f3d_world_destroy(w); +} + +int main(void) { + test_inertia(); + test_turning(); + test_impulses_and_forces(); + test_damping(); + test_drag(); + test_wind_carries(); + test_sleep(); + return finish(); +} diff --git a/packages/flutter3d_physics_native/csrc/tests/test_particles.c b/packages/flutter3d_physics_native/csrc/tests/test_particles.c new file mode 100644 index 000000000..8abdeaf07 --- /dev/null +++ b/packages/flutter3d_physics_native/csrc/tests/test_particles.c @@ -0,0 +1,110 @@ +/* + * Particles on the CPU, tested in C — P9, phase 10: a spark's flight is + * semi-implicit Euler's to the bit, drag carries it to the wind, a floor + * turns its fall back by the restitution, a slot is used again round and + * round, and a dead particle is still. + */ +#include + +#include "check.h" + +static F3dParticleForces calm(void) { + F3dParticleForces f; + memset(&f, 0, sizeof f); + f.gravity[1] = F3D_R(-9.81); + f.floor_y = F3D_R(-1e9); + return f; +} + +static void test_flight(void) { + /* Thrown at (3, 4, 0) m/s with no drag: after n steps of h, + * x = 3 n h and y = 4 n h − g h² n(n + 1)/2, semi-implicit Euler's own + * sums. */ + F3dParticles *p = f3d_particles_create(4); + CHECK(p != NULL && f3d_particles_capacity(p) == 4); + const f3d_real spark[7] = {0, 0, 0, 3, 4, 0, 10}; + CHECK(f3d_particles_emit(p, spark, 1) == 0); + const F3dParticleForces f = calm(); + const int n = 60; + const f3d_real h = F3D_R(1.0 / 60.0); + for (int i = 0; i < n; i++) f3d_particles_step(p, &f, h); + f3d_real out[16]; + CHECK(f3d_particles_read(p, out, 4) == 4); + CHECK_NEAR(out[0], 3.0 * n * (double)h, 1e-5); + CHECK_NEAR(out[1], 4.0 * n * (double)h - 9.81 * (double)h * (double)h * n * (n + 1) / 2, 1e-4); + CHECK_NEAR(out[3], 10 - n * (double)h, 1e-5); + /* The other slots were never alive and did not move. */ + CHECK(out[4] == 0 && out[7] == 0); + f3d_particles_destroy(p); +} + +static void test_drag_and_floor(void) { + F3dParticles *p = f3d_particles_create(2); + F3dParticleForces f = calm(); + f.gravity[1] = 0; + f.wind[0] = 5; + f.drag = 2; + const f3d_real still[7] = {0, 0, 0, 0, 0, 0, 100}; + f3d_particles_emit(p, still, 1); + for (int i = 0; i < 600; i++) f3d_particles_step(p, &f, F3D_R(1.0 / 60.0)); + f3d_real out[8]; + f3d_particles_read(p, out, 2); + /* Ten seconds at two per second: the wind's speed, to a part in a + * million; it travelled 5 t less the 5/2 it took to catch up. */ + CHECK_NEAR(out[0], 50 - 2.5, 0.05); + f3d_particles_destroy(p); + /* Dropped onto a floor at nought with restitution a half: it comes back + * up at half the speed it hit at, and slides half as fast. */ + p = f3d_particles_create(1); + f = calm(); + f.floor_y = 0; + f.restitution = F3D_R(0.5); + f.friction = F3D_R(0.5); + const f3d_real drop[7] = {0, F3D_R(0.001), 0, 2, -10, 0, 100}; + f3d_particles_emit(p, drop, 1); + f3d_particles_step(p, &f, F3D_R(1.0 / 60.0)); + f3d_particles_read(p, out, 1); + CHECK(out[1] == 0); + f3d_particles_step(p, &f, F3D_R(1.0 / 60.0)); + f3d_particles_read(p, out, 1); + /* Up at (10 + g h)/2 less a step of gravity, for a sixtieth. */ + const double up = (10 + 9.81 / 60.0) / 2 - 9.81 / 60.0; + CHECK_NEAR(out[1], up / 60.0, 1e-5); + CHECK_NEAR(out[0], 2.0 / 60.0 + 1.0 / 60.0, 1e-5); + f3d_particles_destroy(p); +} + +static void test_slots_and_life(void) { + F3dParticles *p = f3d_particles_create(3); + const F3dParticleForces f = calm(); + const f3d_real a[7] = {1, 0, 0, 0, 0, 0, F3D_R(0.05)}; + const f3d_real b[14] = {2, 0, 0, 0, 0, 0, 10, 3, 0, 0, 0, 0, 0, 10}; + CHECK(f3d_particles_emit(p, a, 1) == 0); + CHECK(f3d_particles_emit(p, b, 2) == 1); + /* The fourth takes the first slot again: the oldest goes. */ + const f3d_real c[7] = {4, 0, 0, 0, 0, 0, F3D_R(0.04)}; + CHECK(f3d_particles_emit(p, c, 1) == 0); + f3d_real out[12]; + f3d_particles_read(p, out, 3); + CHECK(out[0] == 4); + /* With a twenty-fifth of a second it dies in its third step of a + * sixtieth, and then stays where it died. */ + for (int i = 0; i < 3; i++) f3d_particles_step(p, &f, F3D_R(1.0 / 60.0)); + f3d_particles_read(p, out, 3); + CHECK(out[3] <= 0); + const f3d_real fell = out[1]; + for (int i = 0; i < 30; i++) f3d_particles_step(p, &f, F3D_R(1.0 / 60.0)); + f3d_particles_read(p, out, 3); + CHECK(out[1] == fell); + CHECK(out[5] < -1); + CHECK(f3d_particles_create(0) == NULL); + f3d_particles_destroy(NULL); + f3d_particles_destroy(p); +} + +int main(void) { + test_flight(); + test_drag_and_floor(); + test_slots_and_life(); + return finish(); +} diff --git a/packages/flutter3d_physics_native/csrc/tests/test_query.c b/packages/flutter3d_physics_native/csrc/tests/test_query.c new file mode 100644 index 000000000..c9365987d --- /dev/null +++ b/packages/flutter3d_physics_native/csrc/tests/test_query.c @@ -0,0 +1,325 @@ +/* + * Queries and characters, tested in C — P9, phase 9: rays against every + * shape's closed form and against a mesh, the nearest of many and all of + * them in order, a thousand rays against trying every ball, overlaps, + * casts, and a character on floors, against walls, up slopes and steps, + * landing, keeping to a slope going down, and under a ceiling. + */ +#include +#include +#include + +#include "check.h" + +static const F3dQuat identity = {0, 0, 0, 1}; + +static F3dQuat turn_about(F3dVec3 axis, double angle) { + const double s = sin(angle / 2); + F3dQuat q = {(f3d_real)(axis.x * s), (f3d_real)(axis.y * s), + (f3d_real)(axis.z * s), (f3d_real)cos(angle / 2)}; + return q; +} + +static F3dBody fixed_shape(F3dWorld *w, F3dShapeKind kind, f3d_real a, + f3d_real b, f3d_real c, F3dVec3 at, F3dQuat q) { + const F3dBody body = f3d_body_create(w, F3D_BODY_FIXED, at.x, at.y, at.z, 0); + f3d_body_set_shape(w, body, kind, a, b, c); + f3d_body_set_orientation(w, body, q.x, q.y, q.z, q.w); + return body; +} + +static int ray(F3dWorld *w, F3dVec3 o, F3dVec3 d, f3d_real max, F3dBody *b, + f3d_real *hit) { + return f3d_world_ray_cast(w, o.x, o.y, o.z, d.x, d.y, d.z, max, ~0u, 0, b, hit); +} + +static void test_rays_on_shapes(void) { + F3dWorld *w = f3d_world_create(); + F3dBody b; + f3d_real hit[F3D_HIT_FLOATS]; + /* A ball of half a metre at (0, 0, 0): from x = −5 along x, 4.5 away, + * the normal back at the ray. */ + const F3dBody ball = fixed_shape(w, F3D_SHAPE_SPHERE, F3D_R(0.5), 0, 0, f3d_v3(0, 0, 0), identity); + CHECK(ray(w, f3d_v3(-5, 0, 0), f3d_v3(2, 0, 0), 10, &b, hit) == 1); + CHECK(b == ball); + CHECK_NEAR(hit[6], 4.5, 1e-5); + CHECK_NEAR(hit[3], -1, 1e-5); + CHECK_NEAR(hit[0], -0.5, 1e-5); + /* Too short, aimed past, or starting inside: nothing. */ + CHECK(ray(w, f3d_v3(-5, 0, 0), f3d_v3(1, 0, 0), 4, &b, hit) == 0); + CHECK(ray(w, f3d_v3(-5, 1, 0), f3d_v3(1, 0, 0), 10, &b, hit) == 0); + CHECK(ray(w, f3d_v3(0, 0, 0), f3d_v3(1, 0, 0), 10, &b, hit) == 0); + CHECK(ray(w, f3d_v3(-5, 0, 0), f3d_v3(0, 0, 0), 10, &b, hit) == 0); + /* A box turned 45° about z: its edge toward the ray, a metre and a + * quarter of root two out. */ + const F3dBody box = fixed_shape(w, F3D_SHAPE_BOX, 1, 1, 1, f3d_v3(0, 10, 0), + turn_about(f3d_v3(0, 0, 1), M_PI / 4)); + CHECK(ray(w, f3d_v3(-5, F3D_R(10.2), 0), f3d_v3(1, 0, 0), 10, &b, hit) == 1); + CHECK(b == box); + CHECK_NEAR(hit[6], 5 - (sqrt(2.0) - 0.2), 1e-5); + CHECK_NEAR(hit[3], -sqrt(0.5), 1e-5); + CHECK_NEAR(hit[4], sqrt(0.5), 1e-5); + /* An upright cylinder, a lying capsule, a cone on its base and a hull + * cube, each from the side: met at their surface, normal outward. */ + const F3dBody cyl = fixed_shape(w, F3D_SHAPE_CYLINDER, F3D_R(0.5), 1, 0, f3d_v3(0, 20, 0), identity); + CHECK(ray(w, f3d_v3(-5, F3D_R(20.3), 0), f3d_v3(1, 0, 0), 10, &b, hit) == 1); + CHECK(b == cyl); + CHECK_NEAR(hit[6], 4.5, 1e-3); + CHECK_NEAR(hit[3], -1, 1e-3); + /* From inside it, nothing: a ray that starts in a shape does not see + * it. */ + CHECK(ray(w, f3d_v3(0, 20, 0), f3d_v3(1, 0, 0), F3D_R(0.4), &b, hit) == 0); + const F3dBody cap = fixed_shape(w, F3D_SHAPE_CAPSULE, F3D_R(0.3), 1, 0, f3d_v3(0, 30, 0), + turn_about(f3d_v3(0, 0, 1), M_PI / 2)); + CHECK(ray(w, f3d_v3(F3D_R(0.5), 35, 0), f3d_v3(0, -1, 0), 10, &b, hit) == 1); + CHECK(b == cap); + CHECK_NEAR(hit[6], 4.7, 1e-3); + CHECK_NEAR(hit[4], 1, 1e-3); + /* The cone's base is a quarter of its height below its centre. */ + const F3dBody cone = fixed_shape(w, F3D_SHAPE_CONE, F3D_R(0.5), 2, 0, f3d_v3(0, 40, 0), identity); + CHECK(ray(w, f3d_v3(0, 35, 0), f3d_v3(0, 1, 0), 10, &b, hit) == 1); + CHECK(b == cone); + CHECK_NEAR(hit[6], 4.5, 1e-3); + CHECK_NEAR(hit[4], -1, 1e-3); + const f3d_real corners[24] = {-1, -1, -1, 1, -1, -1, -1, 1, -1, 1, 1, -1, + -1, -1, 1, 1, -1, 1, -1, 1, 1, 1, 1, 1}; + const uint32_t hull = f3d_world_create_hull(w, corners, 8); + const F3dBody cube = f3d_body_create(w, F3D_BODY_FIXED, 0, 50, 0, 0); + f3d_body_set_hull(w, cube, hull); + CHECK(ray(w, f3d_v3(-5, F3D_R(50.5), F3D_R(0.5)), f3d_v3(1, 0, 0), 10, &b, hit) == 1); + CHECK(b == cube); + CHECK_NEAR(hit[6], 4, 1e-3); + /* A mesh: from the front, met; from behind, not. */ + const f3d_real v[12] = {-2, 60, -2, 2, 60, -2, 2, 60, 2, -2, 60, 2}; + const uint32_t t[6] = {0, 2, 1, 0, 3, 2}; + const F3dBody ground = f3d_body_create(w, F3D_BODY_FIXED, 0, 0, 0, 0); + f3d_body_set_mesh(w, ground, f3d_world_create_mesh(w, v, 4, t, 2)); + CHECK(ray(w, f3d_v3(F3D_R(0.5), 65, F3D_R(0.5)), f3d_v3(0, -1, 0), 10, &b, hit) == 1); + CHECK(b == ground); + CHECK_NEAR(hit[6], 5, 1e-5); + CHECK_NEAR(hit[4], 1, 1e-5); + CHECK(ray(w, f3d_v3(F3D_R(0.5), 55, F3D_R(0.5)), f3d_v3(0, 1, 0), 4.9f, &b, hit) == 0); + f3d_world_destroy(w); +} + +static void test_nearest_and_all(void) { + F3dWorld *w = f3d_world_create(); + F3dBody balls[5]; + for (int i = 0; i < 5; i++) { + balls[i] = fixed_shape(w, F3D_SHAPE_SPHERE, F3D_R(0.25), 0, 0, + f3d_v3((f3d_real)(4 - i) * 2, 0, 0), identity); + } + F3dBody b; + f3d_real hit[F3D_HIT_FLOATS]; + CHECK(ray(w, f3d_v3(-1, 0, 0), f3d_v3(1, 0, 0), 100, &b, hit) == 1); + CHECK(b == balls[4]); + F3dBody all[3]; + f3d_real hits[3 * F3D_HIT_FLOATS]; + CHECK(f3d_world_ray_cast_all(w, -1, 0, 0, 1, 0, 0, 100, ~0u, 0, all, hits, 3) == 5); + CHECK(all[0] == balls[4] && all[1] == balls[3] && all[2] == balls[2]); + CHECK(hits[6] < hits[F3D_HIT_FLOATS + 6] && hits[F3D_HIT_FLOATS + 6] < hits[2 * F3D_HIT_FLOATS + 6]); + /* Ignoring the nearest, or masking every layer it has, finds the next. */ + CHECK(f3d_world_ray_cast(w, -1, 0, 0, 1, 0, 0, 100, ~0u, balls[4], &b, hit) == 1); + CHECK(b == balls[3]); + f3d_body_set_collision_filter(w, balls[4], 2, ~0u); + CHECK(f3d_world_ray_cast(w, -1, 0, 0, 1, 0, 0, 100, ~2u, 0, &b, hit) == 1); + CHECK(b == balls[3]); + f3d_world_destroy(w); +} + +static double uniform(unsigned *state) { + *state = *state * 1664525u + 1013904223u; + return (*state >> 8) / 16777216.0; +} + +static void test_rays_against_every_ball(void) { + /* Three hundred balls and a thousand rays: the nearest the tree finds is + * the nearest trying every ball finds. */ + enum { N = 300 }; + F3dWorld *w = f3d_world_create(); + unsigned seed = 99u; + F3dVec3 at[N]; + f3d_real r[N]; + F3dBody balls[N]; + for (int i = 0; i < N; i++) { + at[i] = f3d_v3((f3d_real)(uniform(&seed) * 40 - 20), (f3d_real)(uniform(&seed) * 40 - 20), + (f3d_real)(uniform(&seed) * 40 - 20)); + r[i] = (f3d_real)(0.2 + uniform(&seed)); + balls[i] = fixed_shape(w, F3D_SHAPE_SPHERE, r[i], 0, 0, at[i], identity); + } + int agree = 1; + for (int q = 0; q < 1000; q++) { + const F3dVec3 o = f3d_v3((f3d_real)(uniform(&seed) * 60 - 30), (f3d_real)(uniform(&seed) * 60 - 30), + (f3d_real)(uniform(&seed) * 60 - 30)); + F3dVec3 d = f3d_v3((f3d_real)(uniform(&seed) - 0.5), (f3d_real)(uniform(&seed) - 0.5), + (f3d_real)(uniform(&seed) - 0.5)); + d = f3d_scale(d, F3D_R(1.0) / f3d_sqrt(f3d_dot(d, d))); + double best = 1e30; + int who = -1; + for (int i = 0; i < N; i++) { + const F3dVec3 m = f3d_sub(o, at[i]); + const double b = f3d_dot(m, d), c = f3d_dot(m, m) - (double)r[i] * r[i]; + if (c <= 0 || b > 0 || b * b - c < 0) continue; + const double t = -b - sqrt(b * b - c); + if (t < best && t <= 80) { + best = t; + who = i; + } + } + F3dBody b; + f3d_real hit[F3D_HIT_FLOATS]; + const int found = ray(w, o, d, 80, &b, hit); + if (who < 0) { + agree &= !found; + } else { + agree &= found && b == balls[who] && fabs((double)hit[6] - best) < 1e-3; + } + } + CHECK(agree); + f3d_world_destroy(w); +} + +static void test_overlaps_and_casts(void) { + F3dWorld *w = f3d_world_create(); + const F3dBody a = fixed_shape(w, F3D_SHAPE_BOX, F3D_R(0.5), F3D_R(0.5), F3D_R(0.5), f3d_v3(0, 0, 0), identity); + const F3dBody b = fixed_shape(w, F3D_SHAPE_SPHERE, F3D_R(0.5), 0, 0, f3d_v3(2, 0, 0), identity); + fixed_shape(w, F3D_SHAPE_SPHERE, F3D_R(0.5), 0, 0, f3d_v3(10, 0, 0), identity); + F3dBody out[4]; + /* A ball at (1, 0, 0) of 0.6 overlaps both the box and the first ball, + * in slot order, and not the far one. */ + CHECK(f3d_world_overlap_shape(w, F3D_SHAPE_SPHERE, F3D_R(0.6), 0, 0, 0, 1, 0, 0, + 0, 0, 0, 1, ~0u, 0, out, 4) == 2); + CHECK(out[0] == a && out[1] == b); + /* Half a centimetre short of each: neither. */ + CHECK(f3d_world_overlap_shape(w, F3D_SHAPE_SPHERE, F3D_R(0.495), 0, 0, 0, 1, 0, 0, + 0, 0, 0, 1, ~0u, 0, out, 4) == 0); + CHECK(f3d_world_overlap_shape(w, F3D_SHAPE_SPHERE, F3D_R(0.6), 0, 0, 0, 1, 0, 0, + 0, 0, 0, 1, ~0u, a, out, 4) == 1); + CHECK(f3d_world_overlap_shape(w, (F3dShapeKind)9, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 1, + ~0u, 0, out, 4) == 0); + /* A ball of 0.25 cast from x = −5 along x by 10 meets the box at + * x = −0.75: four and a quarter of ten. */ + F3dBody hitb; + f3d_real hit[F3D_HIT_FLOATS]; + CHECK(f3d_world_cast_shape(w, F3D_SHAPE_SPHERE, F3D_R(0.25), 0, 0, 0, -5, 0, 0, 0, 0, 0, 1, + 10, 0, 0, ~0u, 0, &hitb, hit) == 1); + CHECK(hitb == a); + CHECK_NEAR(hit[6], 0.425, 1e-4); + CHECK_NEAR(hit[3], -1, 1e-4); + /* A box cast down onto the box: as far as their faces. */ + CHECK(f3d_world_cast_shape(w, F3D_SHAPE_BOX, F3D_R(0.2), F3D_R(0.2), F3D_R(0.2), 0, 0, 3, 0, + 0, 0, 0, 1, 0, -4, 0, ~0u, 0, &hitb, hit) == 1); + CHECK_NEAR(hit[6], (3.0 - 0.7) / 4.0, 1e-4); + CHECK_NEAR(hit[4], 1, 1e-4); + /* Overlapping where it starts: met at nought. */ + CHECK(f3d_world_cast_shape(w, F3D_SHAPE_SPHERE, F3D_R(0.25), 0, 0, 0, F3D_R(0.5), 0, 0, 0, 0, 0, 1, + 0, 5, 0, ~0u, 0, &hitb, hit) == 1); + CHECK(hit[6] == 0); + /* Missing everything. */ + CHECK(f3d_world_cast_shape(w, F3D_SHAPE_SPHERE, F3D_R(0.25), 0, 0, 0, -5, 5, 0, 0, 0, 0, 1, + 10, 0, 0, ~0u, 0, &hitb, hit) == 0); + f3d_world_destroy(w); +} + +/* A level: a floor, a wall at x = 3, a step 0.3 high at z = 3, a ledge + * 0.5 high at z = −3, a gentle slope and a steep one, and a ceiling. */ +static F3dWorld *level(void) { + F3dWorld *w = f3d_world_create(); + fixed_shape(w, F3D_SHAPE_BOX, 50, F3D_R(0.5), 50, f3d_v3(0, F3D_R(-0.5), 0), identity); + fixed_shape(w, F3D_SHAPE_BOX, F3D_R(0.5), 5, 50, f3d_v3(F3D_R(3.5), 5, 0), identity); + fixed_shape(w, F3D_SHAPE_BOX, 2, F3D_R(0.15), 2, f3d_v3(0, F3D_R(0.15), 5), identity); + fixed_shape(w, F3D_SHAPE_BOX, 2, F3D_R(0.25), 2, f3d_v3(0, F3D_R(0.25), -5), identity); + /* The slopes rise along +x, lifted clear of the floor where the tests + * stand on them. */ + fixed_shape(w, F3D_SHAPE_BOX, 5, F3D_R(0.5), 5, f3d_v3(-20, F3D_R(1.5), 0), + turn_about(f3d_v3(0, 0, 1), 20.0 * M_PI / 180.0)); + fixed_shape(w, F3D_SHAPE_BOX, 5, F3D_R(0.5), 5, f3d_v3(-20, F3D_R(1.5), 20), + turn_about(f3d_v3(0, 0, 1), 60.0 * M_PI / 180.0)); + fixed_shape(w, F3D_SHAPE_BOX, 3, F3D_R(0.5), 3, f3d_v3(20, F3D_R(2.5), 0), identity); + return w; +} + +#define R F3D_R(0.3) +#define H F3D_R(0.6) +#define STANDING (R + H + F3D_R(0.01)) +#define SLOPE_COS F3D_R(0.7071) + +static uint32_t walk(F3dWorld *w, f3d_real *p, F3dVec3 d, f3d_real step, + f3d_real *ground) { + F3dBody gb; + return f3d_world_move_character(w, R, H, p, d.x, d.y, d.z, SLOPE_COS, step, + ~0u, 0, &gb, ground); +} + +static void test_character(void) { + F3dWorld *w = level(); + f3d_real g[3]; + /* Falling onto the floor: it lands, its skin above it, on ground. */ + f3d_real p[3] = {0, 3, 0}; + uint32_t flags = walk(w, p, f3d_v3(0, -5, 0), F3D_R(0.35), g); + CHECK(flags & F3D_CHARACTER_GROUNDED); + CHECK_NEAR(p[1], STANDING, 2e-3); + CHECK_NEAR(g[1], 1, 1e-4); + /* Walking on it: along, level, on ground. */ + flags = walk(w, p, f3d_v3(1, 0, 0), F3D_R(0.35), g); + CHECK_NEAR(p[0], 1, 1e-4); + CHECK_NEAR(p[1], STANDING, 2e-3); + CHECK(flags & F3D_CHARACTER_GROUNDED); + /* Into the wall at a slant: stopped at it, sliding along it. */ + flags = walk(w, p, f3d_v3(3, 0, 1), F3D_R(0.35), g); + CHECK(flags & F3D_CHARACTER_WALL); + CHECK_NEAR(p[0], 3 - 0.3 - 0.01, 2e-3); + CHECK_NEAR(p[2], 1, 1e-3); + /* Up the step of thirty centimetres; not up the ledge of fifty. */ + f3d_real q[3] = {0, STANDING, F3D_R(2.0)}; + flags = walk(w, q, f3d_v3(0, 0, 2), F3D_R(0.35), g); + CHECK(flags & F3D_CHARACTER_STEPPED); + CHECK_NEAR(q[1], 0.3 + STANDING, 3e-3); + CHECK(q[2] > 3.5); + f3d_real e[3] = {0, STANDING, F3D_R(-2.0)}; + flags = walk(w, e, f3d_v3(0, 0, -2), F3D_R(0.35), g); + CHECK(!(flags & F3D_CHARACTER_STEPPED)); + CHECK(flags & F3D_CHARACTER_WALL); + CHECK(e[2] > -2.71); + /* Up the twenty-degree slope, it climbs; the sixty-degree one stops it + * as a wall. */ + f3d_real s[3] = {-22, 5, 0}; + walk(w, s, f3d_v3(0, -10, 0), F3D_R(0.35), g); + const f3d_real y0 = s[1]; + flags = walk(w, s, f3d_v3(1, 0, 0), F3D_R(0.35), g); + CHECK(s[1] > y0 + 0.2); + CHECK(flags & F3D_CHARACTER_GROUNDED); + /* The steep one walked at from the floor at its foot. */ + f3d_real t[3] = {-23, STANDING, 20}; + const f3d_real x0 = t[0], y1 = t[1]; + flags = walk(w, t, f3d_v3(2, 0, 0), 0, g); + CHECK(flags & F3D_CHARACTER_WALL); + CHECK(t[1] < y1 + 0.05); + CHECK(t[0] < x0 + 1.9); + /* Down the gentle slope with no fall asked: it keeps to it. */ + f3d_real d[3] = {-17, 10, 0}; + walk(w, d, f3d_v3(0, -20, 0), F3D_R(0.35), g); + const f3d_real yd = d[1]; + flags = walk(w, d, f3d_v3(-0.5f, 0, 0), F3D_R(0.35), g); + CHECK(flags & F3D_CHARACTER_GROUNDED); + CHECK_NEAR(d[1], yd - 0.5 * tan(20.0 * M_PI / 180.0), 3e-2); + /* Jumping into the ceiling: stopped under it. */ + f3d_real u[3] = {20, STANDING, 0}; + flags = walk(w, u, f3d_v3(0, 3, 0), F3D_R(0.35), g); + CHECK(flags & F3D_CHARACTER_CEILING); + CHECK_NEAR(u[1], 2.0 - R - H - 0.01, 2e-3); + /* Refused: no radius. */ + CHECK(walk(w, u, f3d_v3(0, 0, 0), 0, g) == walk(w, u, f3d_v3(0, 0, 0), 0, g)); + F3dBody gb; + CHECK(f3d_world_move_character(w, 0, H, u, 0, 0, 0, SLOPE_COS, 0, ~0u, 0, &gb, g) == 0); + f3d_world_destroy(w); +} + +int main(void) { + test_rays_on_shapes(); + test_nearest_and_all(); + test_rays_against_every_ball(); + test_overlaps_and_casts(); + test_character(); + return finish(); +} diff --git a/packages/flutter3d_physics_native/csrc/tests/test_ragdoll.c b/packages/flutter3d_physics_native/csrc/tests/test_ragdoll.c new file mode 100644 index 000000000..f1812052e --- /dev/null +++ b/packages/flutter3d_physics_native/csrc/tests/test_ragdoll.c @@ -0,0 +1,207 @@ +/* + * A ragdoll, tested in C — P9, phase 13: eleven bodies on ten joints — the + * spine, the neck, the shoulders and the hips ball joints in cones with + * their twist limited, the elbows and the knees hinges with limits, every + * joint with friction — falls to the floor and goes to sleep there, is + * knocked along by a bullet and sleeps again, and tumbles down a flight of + * stairs. Throughout, every joint holds its point within a few centimetres + * and its limits within a few degrees as it strikes, and to a fraction of + * a millimetre at rest; nothing is ever not finite or flung away; and it + * steps to the same bits on one thread and four, in the deterministic + * mode and the fast one. */ +#include +#include +#include +#include + +#include "check.h" +#include "ragdoll.h" + +/* A point on a body, its own coordinates turned by its orientation. */ +static void on_body(const F3dWorld *w, F3dBody b, const f3d_real *local, double *out) { + f3d_real p[3], q[4]; + f3d_body_get_position(w, b, p); + f3d_body_get_orientation(w, b, q); + const double x = q[0], y = q[1], z = q[2], s = q[3]; + const double v[3] = {local[0], local[1], local[2]}; + /* t = 2 q×v; v' = v + s t + q×t. */ + const double t[3] = {2 * (y * v[2] - z * v[1]), 2 * (z * v[0] - x * v[2]), 2 * (x * v[1] - y * v[0])}; + out[0] = p[0] + v[0] + s * t[0] + (y * t[2] - z * t[1]); + out[1] = p[1] + v[1] + s * t[1] + (z * t[0] - x * t[2]); + out[2] = p[2] + v[2] + s * t[2] + (x * t[1] - y * t[0]); +} + +/* The worst a ragdoll did this step: a joint's points apart, m; a limit + * overstepped, radians; the fastest body, m/s; and whether anything was + * not finite. */ +typedef struct Worst { + double gap, over, speed; + int bad; +} Worst; + +static void measure(const F3dWorld *w, const Ragdoll *r, Worst *worst) { + for (int k = 0; k < JOINTS; k++) { + double pa[3], pb[3]; + on_body(w, r->body[r->a[k]], r->local_a[k], pa); + on_body(w, r->body[r->b[k]], r->local_b[k], pb); + const double gap = sqrt((pa[0] - pb[0]) * (pa[0] - pb[0]) + (pa[1] - pb[1]) * (pa[1] - pb[1]) + + (pa[2] - pb[2]) * (pa[2] - pb[2])); + if (!isfinite(gap)) worst->bad = 1; + if (gap > worst->gap) worst->gap = gap; + f3d_real value = 0, swing = 0; + f3d_joint_get_value(w, r->joint[k], &value); + double over = fmax((double)(r->lower[k] - value), (double)(value - r->upper[k])); + if (r->ball[k]) { + f3d_joint_get_swing(w, r->joint[k], &swing); + over = fmax(over, (double)(swing - r->cone[k])); + } + if (over > worst->over) worst->over = over; + } + for (int i = 0; i < BODIES; i++) { + f3d_real v[3]; + f3d_body_get_velocity(w, r->body[i], v); + const double speed = sqrt((double)v[0] * v[0] + (double)v[1] * v[1] + (double)v[2] * v[2]); + if (!isfinite(speed)) worst->bad = 1; + if (speed > worst->speed) worst->speed = speed; + } +} + +static int asleep(const F3dWorld *w, const Ragdoll *r) { + for (int i = 0; i < BODIES; i++) { + if (!f3d_body_is_asleep(w, r->body[i])) return 0; + } + return 1; +} + +/* Held, under [gap] metres and [over] radians, nothing faster than + * [speed] and everything finite. */ +static void held(const Worst *worst, double gap, double over, double speed) { + CHECK(!worst->bad); + CHECK(worst->gap < gap); + CHECK(worst->over < over); + CHECK(worst->speed < speed); +} + +/* Dropped from half a metre, leaning forward: it folds onto the floor, + * lies there, and sleeps as one; and then a bullet knocks it along. */ +static void test_floor_and_bullet(void) { + F3dWorld *w = floor_world(); + Ragdoll r; + make(w, &r, 0, F3D_R(0.5), 0); + f3d_body_set_velocity(w, r.body[CHEST], 0, 0, F3D_R(1.5)); + f3d_body_set_angular_velocity(w, r.body[CHEST], F3D_R(2.0), 0, F3D_R(0.5)); + Worst worst = {0}; + int slept = -1; + for (int step = 0; step < 900 && slept < 0; step++) { + f3d_world_step(w, F3D_R(1.0 / 60.0)); + measure(w, &r, &worst); + if (asleep(w, &r)) slept = step; + } + /* A centimetre and a half and two degrees at the worst, as it lands; + * asleep within two seconds, lying down, its joints held to a fraction + * of a millimetre and a degree. */ + held(&worst, 0.015, 0.03, 8); + CHECK(slept > 0 && slept < 150); + Worst rest = {0}; + measure(w, &r, &rest); + held(&rest, 0.002, 0.01, 1e-6); + f3d_real p[3]; + f3d_body_get_position(w, r.body[HEAD], p); + CHECK(p[1] < F3D_R(0.2)); + /* A bullet, thirty grams at three hundred metres a second, at the chest + * from two metres off along x. */ + f3d_real chest[3]; + f3d_body_get_position(w, r.body[CHEST], chest); + const F3dBody bullet = f3d_body_create(w, F3D_BODY_DYNAMIC, chest[0] - 2, chest[1], chest[2], F3D_R(0.03)); + f3d_body_set_shape(w, bullet, F3D_SHAPE_SPHERE, F3D_R(0.01), 0, 0); + f3d_body_set_bullet(w, bullet, 1); + f3d_body_set_velocity(w, bullet, 300, 0, 0); + Worst hit = {0}; + double pushed = 0; + int woke = 0, slept_again = -1; + for (int step = 0; step < 300 && slept_again < 0; step++) { + f3d_world_step(w, F3D_R(1.0 / 60.0)); + measure(w, &r, &hit); + f3d_real v[3]; + f3d_body_get_velocity(w, r.body[CHEST], v); + if (v[0] > pushed) pushed = v[0]; + if (!f3d_body_is_asleep(w, r.body[CHEST])) woke = 1; + if (step > 30 && asleep(w, &r)) slept_again = step; + } + /* It woke, took a good part of the bullet's 9 N s — the chest alone + * would go at 0.6 m/s — kept the bullet out, held, and slept again. */ + CHECK(woke); + CHECK(pushed > 0.2 && pushed < 0.7); + f3d_real bp[3]; + f3d_body_get_position(w, bullet, bp); + CHECK(bp[0] < chest[0]); + held(&hit, 0.01, 0.02, 2); + CHECK(slept_again > 0); + f3d_world_destroy(w); +} + +/* Standing on the top stair and pushed off it: it tumbles down. */ +static void test_stairs(void) { + F3dWorld *w = stairs_world(); + Ragdoll r; + make(w, &r, 0, 2, 0); + push_off(w, &r); + Worst worst = {0}; + int slept = -1; + for (int step = 0; step < 900 && slept < 0; step++) { + f3d_world_step(w, F3D_R(1.0 / 60.0)); + measure(w, &r, &worst); + if (asleep(w, &r)) slept = step; + } + /* At the foot of the stairs, two metres down and past the last, asleep + * within six seconds; two and a half centimetres and five degrees at + * the worst on the way. */ + f3d_real p[3]; + f3d_body_get_position(w, r.body[PELVIS], p); + CHECK(p[0] > 3 && p[1] < F3D_R(0.5)); + CHECK(slept > 0 && slept < 360); + held(&worst, 0.025, 0.08, 15); + f3d_world_destroy(w); +} + +/* The stairs on one thread and on four, in each mode: the same bits every + * step. */ +static void test_same_bits(void) { + for (int fast = 0; fast < 2; fast++) { + F3dWorld *one = stairs_world(), *four = stairs_world(); + Ragdoll r1, r4; + make(one, &r1, 0, 2, 0); + make(four, &r4, 0, 2, 0); + CHECK(f3d_world_set_threads(four, 4)); + f3d_world_set_fast(one, fast); + f3d_world_set_fast(four, fast); + push_off(one, &r1); + push_off(four, &r4); + const uint32_t size = 1u << 20; + uint8_t *a = (uint8_t *)malloc(size), *b = (uint8_t *)malloc(size); + int differing = 0; + Worst worst = {0}; + for (int step = 0; step < 300; step++) { + f3d_world_step(one, F3D_R(1.0 / 60.0)); + f3d_world_step(four, F3D_R(1.0 / 60.0)); + measure(one, &r1, &worst); + const uint32_t na = f3d_world_snapshot_write(one, a, size); + const uint32_t nb = f3d_world_snapshot_write(four, b, size); + CHECK(na > 0); + differing += na != nb || memcmp(a, b, na) != 0; + } + CHECK(differing == 0); + held(&worst, 0.025, 0.08, 15); + free(a); + free(b); + f3d_world_destroy(one); + f3d_world_destroy(four); + } +} + +int main(void) { + test_floor_and_bullet(); + test_stairs(); + test_same_bits(); + return finish(); +} diff --git a/packages/flutter3d_physics_native/csrc/tests/test_snapshot.c b/packages/flutter3d_physics_native/csrc/tests/test_snapshot.c new file mode 100644 index 000000000..3f7dfa66c --- /dev/null +++ b/packages/flutter3d_physics_native/csrc/tests/test_snapshot.c @@ -0,0 +1,145 @@ +/* + * Snapshots, tested in C — P9: a world restored from one steps to the same + * bits as the world it was taken from, the same world is the same bytes, + * and a buffer that is not a snapshot of this build is refused with the + * world left as it was. + */ +#include +#include + +#include "check.h" + +/* A world with something of everything in it: bodies turning, falling + * through a wind grid, one burning, one wet, a fixed one in a freed slot, + * and events waiting. */ +static F3dWorld *busy_world(F3dBody *keep) { + F3dWorld *w = f3d_world_create(); + f3d_world_shift_origin(w, 1234.5, -6.25, 1e5); + const f3d_real grid[12] = {1, 0, 0, 0, 0, 2, -1, 0, 0, 0, 3, 0}; + f3d_world_set_wind_grid(w, -5, 0, -5, 5, 2, 1, 2, grid); + F3dMaterial wood; + f3d_material_preset(F3D_MATERIAL_WOOD, &wood); + for (int i = 0; i < 6; i++) { + const F3dBody b = f3d_body_create(w, F3D_BODY_DYNAMIC, (f3d_real)i, + (f3d_real)(i * 2), 0, F3D_R(0.5) + i); + f3d_body_set_shape(w, b, (F3dShapeKind)(1 + i % 3), F3D_R(0.2), + F3D_R(0.3), F3D_R(0.1)); + f3d_body_set_angular_velocity(w, b, F3D_R(0.3) * i, 1, F3D_R(-0.7)); + f3d_body_set_material(w, b, &wood); + keep[i] = b; + } + f3d_body_set_temperature(w, keep[1], 650); + f3d_body_add_water(w, keep[2], F3D_R(0.02)); + f3d_body_destroy(w, keep[3]); + const F3dBody sleeper = f3d_body_create(w, F3D_BODY_FIXED, 9, 9, 9, 1); + keep[3] = sleeper; + for (int i = 0; i < 30; i++) f3d_world_step(w, F3D_R(1.0 / 60.0)); + return w; +} + +static uint8_t *snapshot_of(const F3dWorld *w, uint32_t *size) { + *size = f3d_world_snapshot_size(w); + uint8_t *buffer = (uint8_t *)malloc(*size); + CHECK(f3d_world_snapshot_write(w, buffer, *size) == *size); + return buffer; +} + +static void test_round_trip(void) { + F3dBody keep[6]; + F3dWorld *a = busy_world(keep); + uint32_t size; + uint8_t *snap = snapshot_of(a, &size); + /* The same world is the same bytes. */ + uint32_t again_size; + uint8_t *again = snapshot_of(a, &again_size); + CHECK(again_size == size && memcmp(snap, again, size) == 0); + free(again); + + /* Restored into a world that held something else entirely, and both + * stepped on: the same bits, all the way down. */ + F3dWorld *b = f3d_world_create(); + for (int i = 0; i < 100; i++) f3d_body_create(b, F3D_BODY_DYNAMIC, 0, 0, 0, 1); + CHECK(f3d_world_restore(b, snap, size) == 1); + CHECK(f3d_world_body_count(b) == f3d_world_body_count(a)); + for (int i = 0; i < 6; i++) CHECK(f3d_body_is_valid(b, keep[i])); + for (int i = 0; i < 600; i++) { + f3d_world_step(a, F3D_R(1.0 / 60.0)); + f3d_world_step(b, F3D_R(1.0 / 60.0)); + } + uint32_t sa, sb; + uint8_t *after_a = snapshot_of(a, &sa); + uint8_t *after_b = snapshot_of(b, &sb); + CHECK(sa == sb && memcmp(after_a, after_b, sa) == 0); + free(after_a); + free(after_b); + /* Including what they said happened. */ + F3dBody ba[64], bb[64]; + uint32_t ka[64], kb[64]; + const uint32_t na = f3d_world_read_events(a, ba, NULL, ka, 64); + const uint32_t nb = f3d_world_read_events(b, bb, NULL, kb, 64); + CHECK(na == nb && na > 0); + CHECK(memcmp(ba, bb, na * sizeof(F3dBody)) == 0); + CHECK(memcmp(ka, kb, na * sizeof(uint32_t)) == 0); + + /* A body made after the snapshot is gone once it is restored, and its + * handle with it. */ + const F3dBody late = f3d_body_create(a, F3D_BODY_DYNAMIC, 0, 0, 0, 1); + CHECK(f3d_world_restore(a, snap, size) == 1); + CHECK(!f3d_body_is_valid(a, late)); + /* And a world can go back to its own past and grow from there. */ + for (int i = 0; i < 200; i++) f3d_body_create(a, F3D_BODY_DYNAMIC, 0, 0, 0, 1); + CHECK(f3d_world_body_count(a) == 206); + free(snap); + f3d_world_destroy(a); + f3d_world_destroy(b); +} + +static void test_refusals(void) { + F3dBody keep[6]; + F3dWorld *a = busy_world(keep); + uint32_t size; + uint8_t *snap = snapshot_of(a, &size); + /* Too small a buffer to write into. */ + CHECK(f3d_world_snapshot_write(a, snap, size - 1) == 0); + CHECK(f3d_world_snapshot_write(a, NULL, size) == 0); + + F3dWorld *b = f3d_world_create(); + const F3dBody mine = f3d_body_create(b, F3D_BODY_DYNAMIC, 7, 7, 7, 1); + CHECK(f3d_world_restore(b, NULL, size) == 0); + CHECK(f3d_world_restore(b, snap, 3) == 0); + CHECK(f3d_world_restore(b, snap, size - 1) == 0); + /* Not a snapshot, or one from another build. */ + uint8_t *bad = (uint8_t *)malloc(size); + memcpy(bad, snap, size); + bad[0] ^= 1; + CHECK(f3d_world_restore(b, bad, size) == 0); + memcpy(bad, snap, size); + bad[8] ^= 4; /* The real's size. */ + CHECK(f3d_world_restore(b, bad, size) == 0); + memcpy(bad, snap, size); + bad[4] ^= 1; /* The version. */ + CHECK(f3d_world_restore(b, bad, size) == 0); + /* Refused, the world is as it was. */ + CHECK(f3d_world_body_count(b) == 1); + f3d_real p[3]; + CHECK(f3d_body_get_position(b, mine, p) == 1 && p[0] == 7); + free(bad); + /* An empty world round-trips too. */ + F3dWorld *empty = f3d_world_create(); + uint32_t empty_size; + uint8_t *nothing = snapshot_of(empty, &empty_size); + CHECK(f3d_world_restore(b, nothing, empty_size) == 1); + CHECK(f3d_world_body_count(b) == 0); + CHECK(f3d_body_create(b, F3D_BODY_DYNAMIC, 0, 0, 0, 1) != 0); + free(nothing); + f3d_world_destroy(empty); + free(snap); + f3d_world_destroy(a); + f3d_world_destroy(b); +} + +int main(void) { + test_round_trip(); + test_refusals(); + return finish(); +} diff --git a/packages/flutter3d_physics_native/csrc/tests/test_solve.c b/packages/flutter3d_physics_native/csrc/tests/test_solve.c new file mode 100644 index 000000000..ffad41482 --- /dev/null +++ b/packages/flutter3d_physics_native/csrc/tests/test_solve.c @@ -0,0 +1,360 @@ +/* + * The solver, tested in C — P9, phase 3: each test against the physics it + * has to reproduce. A crate comes to rest and sleeps, a stack of ten + * stands, a ball bounces back with its restitution's share, a crate slides + * down a slope at g(sin θ − μ cos θ) and holds on a steeper μ, a ball rolls + * down it at 5/7 g sin θ without slipping, a collision keeps its momentum, + * a crate stood on its edge falls flat, and one spawned inside the floor is + * pushed out no faster than the push limit. + */ +#include +#include +#include + +#include "check.h" + +static const F3dQuat identity = {0, 0, 0, 1}; + +static F3dQuat turn_about(F3dVec3 axis, double angle) { + const double s = sin(angle / 2); + F3dQuat q = {(f3d_real)(axis.x * s), (f3d_real)(axis.y * s), + (f3d_real)(axis.z * s), (f3d_real)cos(angle / 2)}; + return q; +} + +/* A world with a floor whose top is at y = 0, turned by [q] about the + * origin. */ +static F3dWorld *with_floor(F3dQuat q, F3dBody *floor) { + F3dWorld *w = f3d_world_create(); + f3d_world_set_air(w, F3D_R(293.15), F3D_R(1e-30)); + const F3dMat3 m = f3d_mat_of(q); + const F3dVec3 at = f3d_scale(m.c[1], F3D_R(-0.5)); + *floor = f3d_body_create(w, F3D_BODY_FIXED, at.x, at.y, at.z, 0); + f3d_body_set_shape(w, *floor, F3D_SHAPE_BOX, 20, F3D_R(0.5), 20); + f3d_body_set_orientation(w, *floor, q.x, q.y, q.z, q.w); + return w; +} + +static F3dBody crate(F3dWorld *w, F3dVec3 at, F3dQuat q, f3d_real half) { + const F3dBody b = f3d_body_create(w, F3D_BODY_DYNAMIC, at.x, at.y, at.z, 1); + f3d_body_set_shape(w, b, F3D_SHAPE_BOX, half, half, half); + f3d_body_set_orientation(w, b, q.x, q.y, q.z, q.w); + return b; +} + +static void run(F3dWorld *w, double seconds) { + const int steps = (int)(seconds * 60.0 + 0.5); + for (int i = 0; i < steps; i++) f3d_world_step(w, F3D_R(1.0 / 60.0)); +} + +static double length(const f3d_real *v) { + return sqrt((double)v[0] * v[0] + (double)v[1] * v[1] + (double)v[2] * v[2]); +} + +static void test_crate_comes_to_rest(void) { + F3dBody floor; + F3dWorld *w = with_floor(identity, &floor); + const F3dBody b = crate(w, f3d_v3(0, F3D_R(0.51), 0), identity, F3D_R(0.5)); + run(w, 3); + f3d_real p[3], v[3], q[4]; + f3d_body_get_position(w, b, p); + f3d_body_get_velocity(w, b, v); + f3d_body_get_orientation(w, b, q); + /* On the floor, sunk no further than the slop, still, unturned. */ + CHECK(p[1] > F3D_R(0.5) - F3D_R(0.006) && p[1] < F3D_R(0.5)); + CHECK(length(v) < 1e-3); + CHECK_NEAR(fabs((double)q[3]), 1, 1e-5); + CHECK(f3d_body_is_asleep(w, b)); + /* The floor held its weight: four points pushing m g h a substep between + * them, on the last step it was awake. */ + double held = 0; + for (uint32_t i = 0; i < w->s.manifold_count; i++) { + for (uint32_t k = 0; k < w->manifolds[i].count; k++) { + held += w->manifolds[i].points[k].normal_impulse; + } + } + CHECK_NEAR(held, 9.81 / 240.0, 2e-2); + f3d_world_destroy(w); +} + +static void test_stack_stands(void) { + /* Ten crates a metre on a side, one on another. Each joint settles to + * the slop and the soft contact's give under the weight above it — under + * a centimetre — the pile does not walk sideways, and once it sleeps it + * does not move again. */ + F3dBody floor; + F3dWorld *w = with_floor(identity, &floor); + F3dBody boxes[10]; + for (int i = 0; i < 10; i++) { + boxes[i] = crate(w, f3d_v3(0, F3D_R(0.5) + (f3d_real)i, 0), identity, + F3D_R(0.5)); + } + run(w, 2); + f3d_real early[3]; + f3d_body_get_position(w, boxes[9], early); + run(w, 8); + f3d_real p[3]; + f3d_body_get_position(w, boxes[9], p); + CHECK(p[0] == early[0] && p[1] == early[1] && p[2] == early[2]); + CHECK(fabs((double)p[0]) < 0.02 && fabs((double)p[2]) < 0.02); + CHECK(p[1] > F3D_R(9.5) - 10 * F3D_R(0.01) && p[1] < F3D_R(9.5)); + for (int i = 1; i < 10; i++) { + f3d_real lo[3], hi[3]; + f3d_body_get_position(w, boxes[i - 1], lo); + f3d_body_get_position(w, boxes[i], hi); + CHECK(hi[1] - lo[1] > F3D_R(0.99) && hi[1] - lo[1] < 1); + } + int asleep = 1; + for (int i = 0; i < 10; i++) asleep &= f3d_body_is_asleep(w, boxes[i]); + CHECK(asleep); + f3d_world_destroy(w); +} + +static void test_bounce(void) { + /* Dropped from two metres onto the floor with a restitution of a half: + * it leaves at half the speed it arrived with. */ + F3dBody floor; + F3dWorld *w = with_floor(identity, &floor); + const F3dBody ball = f3d_body_create(w, F3D_BODY_DYNAMIC, 0, F3D_R(2.25), 0, 1); + f3d_body_set_shape(w, ball, F3D_SHAPE_SPHERE, F3D_R(0.25), 0, 0); + f3d_body_set_restitution(w, ball, F3D_R(0.5)); + double fastest_down = 0, fastest_up = 0; + for (int i = 0; i < 120; i++) { + f3d_world_step(w, F3D_R(1.0 / 60.0)); + f3d_real v[3]; + f3d_body_get_velocity(w, ball, v); + if (v[1] < -fastest_down) fastest_down = -v[1]; + if (v[1] > fastest_up) fastest_up = v[1]; + } + CHECK_NEAR(fastest_down, sqrt(2 * 9.81 * 2), 0.03); + CHECK_NEAR(fastest_up, 0.5 * fastest_down, 0.03); + f3d_world_destroy(w); +} + +static void test_slope(void) { + /* A slope of thirty degrees. With μ = 0.3 a crate slides down it at + * g (sin θ − μ cos θ); with μ = 0.7, more than tan θ, it holds. */ + const double theta = M_PI / 6; + const F3dQuat q = turn_about(f3d_v3(0, 0, 1), theta); + const F3dMat3 m = f3d_mat_of(q); + for (int grip = 0; grip < 2; grip++) { + F3dBody floor; + F3dWorld *w = with_floor(q, &floor); + const f3d_real mu = grip ? F3D_R(0.7) : F3D_R(0.3); + f3d_body_set_friction(w, floor, mu); + const F3dBody b = crate(w, f3d_scale(m.c[1], F3D_R(0.499)), q, F3D_R(0.25)); + f3d_body_set_shape(w, b, F3D_SHAPE_BOX, F3D_R(0.5), F3D_R(0.25), F3D_R(0.5)); + f3d_body_set_position(w, b, m.c[1].x * F3D_R(0.249), m.c[1].y * F3D_R(0.249), + m.c[1].z * F3D_R(0.249)); + f3d_body_set_friction(w, b, mu); + run(w, 0.5); + f3d_real v0[3]; + f3d_body_get_velocity(w, b, v0); + run(w, 1); + f3d_real v1[3]; + f3d_body_get_velocity(w, b, v1); + /* Down the slope is −x of the slope's own frame. */ + const double along0 = -f3d_dot(f3d_v3(v0[0], v0[1], v0[2]), m.c[0]); + const double along1 = -f3d_dot(f3d_v3(v1[0], v1[1], v1[2]), m.c[0]); + if (grip) { + CHECK(fabs(along1) < 1e-2); + } else { + const double a = 9.81 * (sin(theta) - 0.3 * cos(theta)); + CHECK_NEAR(along1 - along0, a, 0.03); + } + f3d_world_destroy(w); + } +} + +static void test_rolling(void) { + /* A ball on the same slope with friction to spare rolls without + * slipping: a solid ball's acceleration is 5/7 g sin θ, and its spin is + * its speed over its radius. */ + const double theta = M_PI / 6; + const F3dQuat q = turn_about(f3d_v3(0, 0, 1), theta); + const F3dMat3 m = f3d_mat_of(q); + F3dBody floor; + F3dWorld *w = with_floor(q, &floor); + f3d_world_set_sleep(w, 0, 0); + f3d_body_set_friction(w, floor, 1); + const f3d_real r = F3D_R(0.2); + const F3dVec3 at = f3d_scale(m.c[1], r - F3D_R(0.001)); + const F3dBody ball = f3d_body_create(w, F3D_BODY_DYNAMIC, at.x, at.y, at.z, 1); + f3d_body_set_shape(w, ball, F3D_SHAPE_SPHERE, r, 0, 0); + f3d_body_set_friction(w, ball, 1); + run(w, 0.25); + f3d_real v0[3], v1[3], spin[3]; + f3d_body_get_velocity(w, ball, v0); + run(w, 1); + f3d_body_get_velocity(w, ball, v1); + f3d_body_get_angular_velocity(w, ball, spin); + const double a = length(v1) - length(v0); + CHECK_NEAR(a, 5.0 / 7.0 * 9.81 * sin(theta), 0.03); + CHECK_NEAR(length(spin) * (double)r / length(v1), 1, 0.02); + f3d_world_destroy(w); +} + +static void test_fast_roll_stays_up(void) { + /* A ball rolling at ten metres a second turns a radian and a half a + * step. It stays on the floor within the slop: the contact follows the + * point it touches at, not a point of the ball carried round, which + * read a gap under a resting ball and let it sink two centimetres. */ + F3dBody floor; + F3dWorld *w = with_floor(identity, &floor); + f3d_world_set_sleep(w, 0, 0); + const F3dBody b = f3d_body_create(w, F3D_BODY_DYNAMIC, 0, F3D_R(0.1), 0, 1); + f3d_body_set_shape(w, b, F3D_SHAPE_SPHERE, F3D_R(0.1), 0, 0); + run(w, 0.5); + f3d_body_set_velocity(w, b, 10, 0, 0); + double lowest = 1; + for (int i = 0; i < 60; i++) { + f3d_world_step(w, F3D_R(1.0 / 60.0)); + f3d_real p[3]; + f3d_body_get_position(w, b, p); + if ((double)p[1] < lowest) lowest = p[1]; + } + CHECK(lowest > 0.1 - 0.006); + f3d_world_destroy(w); +} + +static void test_thin_rod_stays_sane(void) { + /* A rod two metres by two centimetres spun at three hundred radians a + * second into a wall with its end: friction at its end spins it about its + * length, where it is five thousand times easier to turn. It is thrown + * back and spins, and nothing blows up: its spin stays within an eighth of + * a turn a substep and its speed within reason. Before the bound it flew + * off at hundreds of millions of radians a second. */ + F3dWorld *w = f3d_world_create(); + f3d_world_set_gravity(w, 0, 0, 0); + f3d_world_set_air(w, F3D_R(293.15), F3D_R(1e-30)); + f3d_world_set_sleep(w, 0, 0); + const F3dBody wall = f3d_body_create(w, F3D_BODY_FIXED, F3D_R(0.5), 0, 0, 0); + f3d_body_set_shape(w, wall, F3D_SHAPE_BOX, F3D_R(0.05), 2, 2); + const F3dBody rod = f3d_body_create(w, F3D_BODY_DYNAMIC, 0, 0, 0, 1); + f3d_body_set_shape(w, rod, F3D_SHAPE_BOX, 1, F3D_R(0.01), F3D_R(0.01)); + f3d_body_set_orientation(w, rod, 0, F3D_R(0.70710678), 0, F3D_R(0.70710678)); + f3d_body_set_angular_velocity(w, rod, 0, 300, 0); + f3d_body_set_bullet(w, rod, 1); + double fastest = 0, spin = 0; + for (int i = 0; i < 120; i++) { + f3d_world_step(w, F3D_R(1.0 / 60.0)); + f3d_real v[3], o[3]; + f3d_body_get_velocity(w, rod, v); + f3d_body_get_angular_velocity(w, rod, o); + const double sv = sqrt((double)v[0] * v[0] + (double)v[1] * v[1] + (double)v[2] * v[2]); + const double so = sqrt((double)o[0] * o[0] + (double)o[1] * o[1] + (double)o[2] * o[2]); + if (sv > fastest) fastest = sv; + if (so > spin) spin = so; + } + CHECK(fastest < 200); + CHECK(spin <= 0.25 * M_PI * 240.0 * (1 + 1e-5)); + f3d_world_destroy(w); +} + +static void test_collision_keeps_momentum(void) { + /* Two balls of a kilogram head on, no gravity. Elastic, they trade + * velocities; inelastic, they go on together; either way the momentum is + * what it was. */ + for (int elastic = 0; elastic < 2; elastic++) { + F3dWorld *w = f3d_world_create(); + f3d_world_set_gravity(w, 0, 0, 0); + f3d_world_set_air(w, F3D_R(293.15), F3D_R(1e-30)); + const F3dBody a = f3d_body_create(w, F3D_BODY_DYNAMIC, -1, 0, 0, 1); + const F3dBody b = f3d_body_create(w, F3D_BODY_DYNAMIC, 1, 0, 0, 1); + f3d_body_set_shape(w, a, F3D_SHAPE_SPHERE, F3D_R(0.25), 0, 0); + f3d_body_set_shape(w, b, F3D_SHAPE_SPHERE, F3D_R(0.25), 0, 0); + f3d_body_set_velocity(w, a, 3, 0, 0); + f3d_body_set_velocity(w, b, -1, 0, 0); + if (elastic) { + f3d_body_set_restitution(w, a, 1); + f3d_body_set_restitution(w, b, 1); + } + run(w, 1); + f3d_real va[3], vb[3]; + f3d_body_get_velocity(w, a, va); + f3d_body_get_velocity(w, b, vb); + CHECK_NEAR(va[0] + vb[0], 2, 1e-5); + if (elastic) { + CHECK_NEAR(va[0], -1, 0.02); + CHECK_NEAR(vb[0], 3, 0.02); + } else { + CHECK_NEAR(va[0], 1, 0.02); + CHECK_NEAR(vb[0], 1, 0.02); + } + f3d_world_destroy(w); + } +} + +static void test_tips_over(void) { + /* Stood on its edge a little past balance, a crate falls onto a face and + * lies flat. */ + F3dBody floor; + F3dWorld *w = with_floor(identity, &floor); + const double angle = M_PI / 4 + 0.05; + const double reach = sqrt(0.5) * cos(angle - M_PI / 4); + const F3dBody b = crate(w, f3d_v3(0, (f3d_real)(reach * 0.5 / 0.5 - 0.001), 0), + turn_about(f3d_v3(0, 0, 1), angle), F3D_R(0.5)); + run(w, 4); + f3d_real q[4]; + f3d_body_get_orientation(w, b, q); + F3dQuat qq = {q[0], q[1], q[2], q[3]}; + const F3dMat3 m = f3d_mat_of(qq); + /* Some axis of the crate is straight up. */ + const double most = fmax(fabs((double)m.c[0].y), + fmax(fabs((double)m.c[1].y), fabs((double)m.c[2].y))); + CHECK_NEAR(most, 1, 1e-3); + f3d_real p[3]; + f3d_body_get_position(w, b, p); + CHECK(fabs((double)p[1] - 0.5) < 0.01); + f3d_world_destroy(w); +} + +static void test_pushed_out_gently(void) { + /* Spawned thirty centimetres into the floor, a crate comes out no faster + * than three metres a second and settles on top. Measured by how far it + * moves a step, not by its velocity: the relaxing pass takes the push + * back out of the velocity, which is the point of it. */ + F3dBody floor; + F3dWorld *w = with_floor(identity, &floor); + const F3dBody b = crate(w, f3d_v3(0, F3D_R(0.2), 0), identity, F3D_R(0.5)); + double fastest = 0, last = 0.2; + for (int i = 0; i < 180; i++) { + f3d_world_step(w, F3D_R(1.0 / 60.0)); + f3d_real p[3]; + f3d_body_get_position(w, b, p); + if ((p[1] - last) * 60.0 > fastest) fastest = (p[1] - last) * 60.0; + last = p[1]; + } + CHECK(fastest > 1 && fastest <= 3.0 + 1e-3); + f3d_real p[3]; + f3d_body_get_position(w, b, p); + CHECK(fabs((double)p[1] - 0.5) < 0.01); + f3d_world_destroy(w); +} + +static void test_friction_and_restitution_refused(void) { + F3dWorld *w = f3d_world_create(); + const F3dBody b = f3d_body_create(w, F3D_BODY_DYNAMIC, 0, 0, 0, 1); + CHECK(f3d_body_set_friction(w, b, -1) == 0); + CHECK(f3d_body_set_friction(w, b, nan_value()) == 0); + CHECK(f3d_body_set_restitution(w, b, F3D_R(1.5)) == 0); + CHECK(f3d_body_set_restitution(w, b, -1) == 0); + CHECK(f3d_body_set_friction(w, 0, 1) == 0); + CHECK(f3d_body_set_restitution(w, b, F3D_R(0.3)) == 1); + f3d_world_destroy(w); +} + +int main(void) { + test_crate_comes_to_rest(); + test_stack_stands(); + test_bounce(); + test_slope(); + test_rolling(); + test_fast_roll_stays_up(); + test_thin_rod_stays_sane(); + test_collision_keeps_momentum(); + test_tips_over(); + test_pushed_out_gently(); + test_friction_and_restitution_refused(); + return finish(); +} diff --git a/packages/flutter3d_physics_native/csrc/tests/test_threads.c b/packages/flutter3d_physics_native/csrc/tests/test_threads.c new file mode 100644 index 000000000..ed4ea52b3 --- /dev/null +++ b/packages/flutter3d_physics_native/csrc/tests/test_threads.c @@ -0,0 +1,72 @@ +/* + * Threads, tested in C — P9, phase 11: a world of every kind of shape — a + * mesh for ground, a hull, boxes, balls, capsules, cylinders and a chain + * on hinges, filtered and sleeping — stepped on one thread, two, three and + * eight steps to the same snapshot, byte for byte, every step; and asking + * for no threads or too many is refused. + */ +#include +#include + +#include "check.h" +#include "scene.h" + +static void test_same_bits(void) { + F3dWorld *one = scene(); + CHECK(f3d_world_threads(one) == 1); + const uint32_t counts[3] = {2, 3, 8}; + F3dWorld *many[3]; + for (int k = 0; k < 3; k++) { + many[k] = scene(); + CHECK(f3d_world_set_threads(many[k], counts[k])); + CHECK(f3d_world_threads(many[k]) == counts[k]); + } + /* Room for the snapshot once the contacts have come: it grows with them. */ + const uint32_t size = 4u << 20; + uint8_t *a = (uint8_t *)malloc(size), *b = (uint8_t *)malloc(size); + int differing = 0, contacts = 0; + for (int step = 0; step < 240; step++) { + f3d_world_step(one, F3D_R(1.0 / 60.0)); + const uint32_t na = f3d_world_snapshot_write(one, a, size); + CHECK(na > 0 && na <= size); + contacts = contacts > (int)f3d_world_contact_count(one) ? contacts : (int)f3d_world_contact_count(one); + for (int k = 0; k < 3; k++) { + f3d_world_step(many[k], F3D_R(1.0 / 60.0)); + const uint32_t nb = f3d_world_snapshot_write(many[k], b, size); + differing += na == 0 || na != nb || memcmp(a, b, na) != 0; + } + } + CHECK(differing == 0); + /* There was something to share out: hundreds of contacts at once. */ + CHECK(contacts > 300); + /* Back to one thread, still the same. */ + CHECK(f3d_world_set_threads(many[2], 1)); + CHECK(f3d_world_threads(many[2]) == 1); + f3d_world_step(one, F3D_R(1.0 / 60.0)); + f3d_world_step(many[2], F3D_R(1.0 / 60.0)); + const uint32_t na = f3d_world_snapshot_write(one, a, size); + CHECK(na == f3d_world_snapshot_write(many[2], b, size) && memcmp(a, b, na) == 0); + free(a); + free(b); + for (int k = 0; k < 3; k++) f3d_world_destroy(many[k]); + f3d_world_destroy(one); +} + +static void test_refused(void) { + F3dWorld *w = f3d_world_create(); + CHECK(!f3d_world_set_threads(w, 0)); + CHECK(!f3d_world_set_threads(w, 65)); + CHECK(f3d_world_threads(w) == 1); + CHECK(f3d_world_set_threads(w, 64)); + CHECK(f3d_world_set_threads(w, 64)); + CHECK(f3d_world_threads(w) == 64); + /* An empty world steps on all of them. */ + f3d_world_step(w, F3D_R(1.0 / 60.0)); + f3d_world_destroy(w); +} + +int main(void) { + test_same_bits(); + test_refused(); + return finish(); +} diff --git a/packages/flutter3d_physics_native/csrc/tests/test_tree.c b/packages/flutter3d_physics_native/csrc/tests/test_tree.c new file mode 100644 index 000000000..9e680f807 --- /dev/null +++ b/packages/flutter3d_physics_native/csrc/tests/test_tree.c @@ -0,0 +1,256 @@ +/* + * The broadphase tree, tested in C — P9, phase 4: its invariants after + * every kind of change, its balance where a naive tree degenerates, its + * queries against trying every box, leaves that stay put while their body + * moves inside them, and a world that rebuilds it after a restore. + */ +#include +#include +#include + +#include "check.h" + +static F3dBox box_at(double x, double y, double z, double h) { + F3dBox b; + b.lo = f3d_v3((f3d_real)(x - h), (f3d_real)(y - h), (f3d_real)(z - h)); + b.hi = f3d_v3((f3d_real)(x + h), (f3d_real)(y + h), (f3d_real)(z + h)); + return b; +} + +static int encloses(F3dBox outer, F3dBox inner) { + return outer.lo.x <= inner.lo.x && outer.lo.y <= inner.lo.y && + outer.lo.z <= inner.lo.z && inner.hi.x <= outer.hi.x && + inner.hi.y <= outer.hi.y && inner.hi.z <= outer.hi.z; +} + +/* Every link, box and height, below [id]; returns the leaves under it. */ +static uint32_t valid(const F3dTree *t, int32_t id, int32_t parent, int *ok) { + const F3dTreeNode *n = &t->nodes[id]; + if (n->parent != parent) *ok = 0; + if (n->child1 == -1) { + if (n->child2 != -1 || n->height != 0) *ok = 0; + return 1; + } + const F3dTreeNode *a = &t->nodes[n->child1], *b = &t->nodes[n->child2]; + if (!encloses(n->box, a->box) || !encloses(n->box, b->box)) *ok = 0; + const int32_t hi = a->height > b->height ? a->height : b->height; + if (n->height != 1 + hi) *ok = 0; + if (abs(a->height - b->height) > 1) *ok = 0; + return valid(t, n->child1, id, ok) + valid(t, n->child2, id, ok); +} + +static int tree_valid(const F3dTree *t) { + int ok = 1; + const uint32_t leaves = t->root == -1 ? 0 : valid(t, t->root, -1, &ok); + return ok && leaves == t->leaves; +} + +static double uniform(unsigned *state) { + *state = *state * 1664525u + 1013904223u; + return (*state >> 8) / 16777216.0; +} + +typedef struct Hits { + uint32_t slots[4096]; + uint32_t count; + const F3dTree *tree; +} Hits; + +static int hit(void *context, int32_t leaf) { + Hits *h = (Hits *)context; + h->slots[h->count++] = h->tree->nodes[leaf].slot; + return 1; +} + +static int by_value(const void *a, const void *b) { + const uint32_t x = *(const uint32_t *)a, y = *(const uint32_t *)b; + return x < y ? -1 : x > y; +} + +static void test_balanced_and_valid(void) { + /* Boxes in a row along x, each further than the last: a tree that only + * appends would be a list a thousand deep. Balanced, it stays within a + * small multiple of log₂ n. */ + enum { N = 1024 }; + F3dTree t; + memset(&t, 0, sizeof t); + t.root = -1; + t.free_list = -1; + static int32_t leaves[N]; + for (int i = 0; i < N; i++) { + leaves[i] = f3d_tree_insert(&t, box_at(i, 0, 0, 0.4), (uint32_t)i); + CHECK(leaves[i] >= 0); + } + CHECK(tree_valid(&t)); + CHECK(t.nodes[t.root].height <= 2 * 10 + 2); + /* Half taken out, every other one: still a valid, balanced tree. */ + for (int i = 0; i < N; i += 2) f3d_tree_remove(&t, leaves[i]); + CHECK(t.leaves == N / 2); + CHECK(tree_valid(&t)); + CHECK(t.nodes[t.root].height <= 2 * 9 + 2); + /* Freed nodes are used again before the tree grows. */ + const uint32_t capacity = t.capacity; + for (int i = 0; i < N; i += 2) { + leaves[i] = f3d_tree_insert(&t, box_at(i, 3, 0, 0.4), (uint32_t)i); + } + CHECK(t.capacity == capacity); + CHECK(tree_valid(&t)); + /* All out: empty. */ + for (int i = 0; i < N; i++) f3d_tree_remove(&t, leaves[i]); + CHECK(t.root == -1 && t.leaves == 0); + f3d_tree_clear(&t); +} + +static void test_queries_find_every_box(void) { + /* Two thousand boxes of every size scattered over a hundred metres, and + * two hundred queries: each finds exactly the boxes trying every one + * finds. */ + enum { N = 2000 }; + static F3dBox boxes[N]; + F3dTree t; + memset(&t, 0, sizeof t); + t.root = -1; + t.free_list = -1; + unsigned seed = 7u; + for (int i = 0; i < N; i++) { + boxes[i] = box_at(uniform(&seed) * 100, uniform(&seed) * 20, + uniform(&seed) * 100, 0.1 + uniform(&seed) * 2); + f3d_tree_insert(&t, boxes[i], (uint32_t)i); + } + CHECK(tree_valid(&t)); + static Hits hits; + int all = 1; + for (int q = 0; q < 200; q++) { + const F3dBox probe = box_at(uniform(&seed) * 100, uniform(&seed) * 20, + uniform(&seed) * 100, 1 + uniform(&seed) * 5); + hits.count = 0; + hits.tree = &t; + f3d_tree_query(&t, probe, hit, &hits); + qsort(hits.slots, hits.count, sizeof(uint32_t), by_value); + uint32_t expected = 0; + for (int i = 0; i < N; i++) { + if (!f3d_box_overlap(boxes[i], probe)) continue; + all &= expected < hits.count && hits.slots[expected] == (uint32_t)i; + expected++; + } + all &= expected == hits.count; + } + CHECK(all); + f3d_tree_clear(&t); +} + +static void test_world_tree(void) { + F3dWorld *w = f3d_world_create(); + f3d_world_set_gravity(w, 0, 0, 0); + f3d_world_set_sleep(w, 0, 0); + F3dBody bodies[50]; + for (int i = 0; i < 50; i++) { + bodies[i] = f3d_body_create(w, F3D_BODY_DYNAMIC, (f3d_real)(i % 10) * 3, + 0, (f3d_real)(i / 10) * 3, 1); + f3d_body_set_shape(w, bodies[i], F3D_SHAPE_SPHERE, F3D_R(0.5), 0, 0); + } + /* A point has no leaf. */ + const F3dBody point = f3d_body_create(w, F3D_BODY_DYNAMIC, 0, 0, 0, 1); + f3d_world_step(w, F3D_R(1.0 / 60.0)); + CHECK(w->tree.leaves == 50); + CHECK(w->proxies[(uint32_t)point] == -1); + CHECK(tree_valid(&w->tree)); + /* A box query, in slot order, the lowest slots first when the room is + * short. */ + F3dBody out[8]; + CHECK(f3d_world_query_box(w, -1, -1, -1, F3D_R(3.5), 1, F3D_R(3.5), out, 8) == 4); + CHECK(out[0] == bodies[0] && out[1] == bodies[1] && out[2] == bodies[10] && + out[3] == bodies[11]); + CHECK(f3d_world_query_box(w, -1, -1, -1, 100, 1, 100, out, 3) == 50); + CHECK(out[0] == bodies[0] && out[1] == bodies[1] && out[2] == bodies[2]); + CHECK(f3d_world_query_box(w, 200, 0, 0, 201, 1, 1, out, 8) == 0); + /* A body moving a few centimetres stays in its leaf; one leaving it + * gets a new one, and one destroyed loses its own. */ + /* By its box, not its index: a leaf taken out and put back can land in + * the node it left. */ + const F3dBox fat = w->tree.nodes[w->proxies[(uint32_t)bodies[5]]].box; + f3d_body_set_velocity(w, bodies[5], F3D_R(0.6), 0, 0); + /* Two steps: the leaves are brought up to date at a step's start, before + * the body moves. */ + f3d_world_step(w, F3D_R(1.0 / 60.0)); + f3d_world_step(w, F3D_R(1.0 / 60.0)); + const F3dBox same = w->tree.nodes[w->proxies[(uint32_t)bodies[5]]].box; + CHECK(memcmp(&fat, &same, sizeof fat) == 0); + for (int i = 0; i < 30; i++) f3d_world_step(w, F3D_R(1.0 / 60.0)); + f3d_body_destroy(w, bodies[7]); + f3d_world_step(w, F3D_R(1.0 / 60.0)); + CHECK(w->tree.leaves == 49); + CHECK(tree_valid(&w->tree)); + /* Restored, the tree is built again, and the world steps on as the one + * it came from. */ + const uint32_t size = f3d_world_snapshot_size(w); + uint8_t *snap = (uint8_t *)malloc(size); + f3d_world_snapshot_write(w, snap, size); + F3dWorld *v = f3d_world_create(); + f3d_world_restore(v, snap, size); + CHECK(v->tree.leaves == 0); + for (int i = 0; i < 60; i++) { + f3d_world_step(w, F3D_R(1.0 / 60.0)); + f3d_world_step(v, F3D_R(1.0 / 60.0)); + } + CHECK(v->tree.leaves == 49); + uint8_t *a = (uint8_t *)malloc(size), *b = (uint8_t *)malloc(size); + CHECK(f3d_world_snapshot_size(v) == size); + f3d_world_snapshot_write(w, a, size); + f3d_world_snapshot_write(v, b, size); + CHECK(memcmp(a, b, size) == 0); + free(snap); + free(a); + free(b); + f3d_world_destroy(w); + f3d_world_destroy(v); +} + +static void test_moving_the_ground_wakes_what_sleeps_on_it(void) { + /* A crate asleep on a fixed plank: the plank lifted away by hand, the + * crate wakes and falls. */ + F3dWorld *w = f3d_world_create(); + const F3dBody plank = f3d_body_create(w, F3D_BODY_FIXED, 0, F3D_R(-0.5), 0, 0); + f3d_body_set_shape(w, plank, F3D_SHAPE_BOX, 2, F3D_R(0.5), 2); + const F3dBody crate = f3d_body_create(w, F3D_BODY_DYNAMIC, 0, F3D_R(0.5), 0, 1); + f3d_body_set_shape(w, crate, F3D_SHAPE_BOX, F3D_R(0.5), F3D_R(0.5), F3D_R(0.5)); + for (int i = 0; i < 120; i++) f3d_world_step(w, F3D_R(1.0 / 60.0)); + CHECK(f3d_body_is_asleep(w, crate)); + f3d_body_set_position(w, plank, 10, F3D_R(-0.5), 0); + for (int i = 0; i < 30; i++) f3d_world_step(w, F3D_R(1.0 / 60.0)); + CHECK(!f3d_body_is_asleep(w, crate)); + f3d_real p[3]; + f3d_body_get_position(w, crate, p); + CHECK(p[1] < 0); + f3d_world_destroy(w); +} + +static void test_many_bodies_few_pairs(void) { + /* Four thousand balls a metre apart in a grid, none touching: the step + * finds no pairs and makes no contacts — the tree, not a sweep that + * meets everything in a column. */ + enum { N = 4000 }; + F3dWorld *w = f3d_world_create(); + f3d_world_set_gravity(w, 0, 0, 0); + f3d_world_set_sleep(w, 0, 0); + for (int i = 0; i < N; i++) { + const F3dBody b = f3d_body_create(w, F3D_BODY_DYNAMIC, (f3d_real)(i % 20), + (f3d_real)((i / 20) % 10), + (f3d_real)(i / 200), 1); + f3d_body_set_shape(w, b, F3D_SHAPE_SPHERE, F3D_R(0.3), 0, 0); + } + f3d_world_step(w, F3D_R(1.0 / 60.0)); + CHECK(w->s.manifold_count == 0); + CHECK(w->tree.leaves == N); + CHECK(tree_valid(&w->tree)); + f3d_world_destroy(w); +} + +int main(void) { + test_balanced_and_valid(); + test_queries_find_every_box(); + test_world_tree(); + test_moving_the_ground_wakes_what_sleeps_on_it(); + test_many_bodies_few_pairs(); + return finish(); +} diff --git a/packages/flutter3d_physics_native/csrc/tests/test_world.c b/packages/flutter3d_physics_native/csrc/tests/test_world.c new file mode 100644 index 000000000..d557aa2a9 --- /dev/null +++ b/packages/flutter3d_physics_native/csrc/tests/test_world.c @@ -0,0 +1,381 @@ +/* + * The world, its arena, its air, wind and origin, and its events, tested in + * C — P9. + * + * Built and run by test/c_unit_test.dart in both precisions, with every + * warning an error and the address and undefined-behaviour sanitisers on, + * so a use after free or an overflow in the arena fails here before it + * fails in a game. + */ +#include + +#include "check.h" + +static void test_abi_and_defaults(void) { + CHECK(f3d_abi_version() == F3D_ABI_VERSION); + CHECK(f3d_real_bytes() == sizeof(f3d_real)); + F3dWorld *w = f3d_world_create(); + CHECK(w != NULL); + f3d_real g[3]; + f3d_world_get_gravity(w, g); + CHECK(g[0] == 0 && g[1] == F3D_R(-9.81) && g[2] == 0); + CHECK(f3d_world_body_count(w) == 0); + f3d_world_set_gravity(w, 1, 2, 3); + f3d_world_get_gravity(w, g); + CHECK(g[0] == 1 && g[1] == 2 && g[2] == 3); + f3d_real air[2]; + f3d_world_get_air(w, air); + CHECK(air[0] == F3D_R(293.15) && air[1] == F3D_R(1.204)); + CHECK(f3d_world_set_air(w, 0, 1) == 0); + CHECK(f3d_world_set_air(w, 300, -1) == 0); + CHECK(f3d_world_set_air(w, nan_value(), 1) == 0); + CHECK(f3d_world_set_air(w, 300, 1.2f) == 1); + f3d_world_get_air(w, air); + CHECK(air[0] == 300 && air[1] == F3D_R(1.2f)); + CHECK(f3d_world_set_sleep(w, -1, 1) == 0); + CHECK(f3d_world_set_sleep(w, 1, inf_value()) == 0); + CHECK(f3d_world_set_sleep(w, 0.1f, 0) == 1); + f3d_world_destroy(w); + f3d_world_destroy(NULL); /* Allowed. */ +} + +static void test_buffers(void) { + float *a = (float *)f3d_buffer_alloc(64 * sizeof(float)); + float *b = (float *)f3d_buffer_alloc(8); + CHECK(a != NULL && b != NULL && (void *)a != (void *)b); + for (int i = 0; i < 64; i++) a[i] = (float)i; + CHECK(a[63] == 63.0f); + f3d_buffer_free(a); + f3d_buffer_free(b); + f3d_buffer_free(NULL); /* Allowed. */ +} + +static void test_refusals(void) { + F3dWorld *w = f3d_world_create(); + CHECK(f3d_body_create(w, F3D_BODY_DYNAMIC, 0, 0, 0, 0) == 0); + CHECK(f3d_body_create(w, F3D_BODY_DYNAMIC, 0, 0, 0, -1) == 0); + CHECK(f3d_body_create(w, F3D_BODY_DYNAMIC, 0, 0, 0, nan_value()) == 0); + CHECK(f3d_body_create(w, F3D_BODY_DYNAMIC, 0, 0, 0, inf_value()) == 0); + CHECK(f3d_body_create(w, (F3dBodyType)7, 0, 0, 0, 1) == 0); + CHECK(f3d_body_create(w, F3D_BODY_DYNAMIC, nan_value(), 0, 0, 1) == 0); + CHECK(f3d_body_create(w, F3D_BODY_DYNAMIC, 0, inf_value(), 0, 1) == 0); + /* A fixed body's mass is thermal only, so nought is fine there. */ + const F3dBody fixed = f3d_body_create(w, F3D_BODY_FIXED, 0, 0, 0, 0); + CHECK(fixed != 0); + CHECK(f3d_world_body_count(w) == 1); + /* A setter given nonsense changes nothing and says so. */ + const F3dBody a = f3d_body_create(w, F3D_BODY_DYNAMIC, 1, 2, 3, 1); + CHECK(f3d_body_set_position(w, a, nan_value(), 0, 0) == 0); + CHECK(f3d_body_set_velocity(w, a, 0, inf_value(), 0) == 0); + CHECK(f3d_body_add_force(w, a, 0, 0, nan_value()) == 0); + CHECK(f3d_body_set_shape(w, a, F3D_SHAPE_SPHERE, 0, 0, 0) == 0); + CHECK(f3d_body_set_shape(w, a, F3D_SHAPE_BOX, 1, -1, 1) == 0); + CHECK(f3d_body_set_shape(w, a, F3D_SHAPE_CAPSULE, 1, -1, 0) == 0); + CHECK(f3d_body_set_shape(w, a, (F3dShapeKind)9, 1, 1, 1) == 0); + CHECK(f3d_body_set_damping(w, a, -1, 0) == 0); + CHECK(f3d_body_set_drag(w, a, nan_value()) == 0); + f3d_real p[3]; + f3d_body_get_position(w, a, p); + CHECK(p[0] == 1 && p[1] == 2 && p[2] == 3); + f3d_world_destroy(w); +} + +static void test_handles(void) { + F3dWorld *w = f3d_world_create(); + const F3dBody a = f3d_body_create(w, F3D_BODY_DYNAMIC, 1, 2, 3, 2); + const F3dBody b = f3d_body_create(w, F3D_BODY_DYNAMIC, 4, 5, 6, 1); + CHECK(a != 0 && b != 0 && a != b); + CHECK(f3d_body_is_valid(w, a) && f3d_body_is_valid(w, b)); + CHECK(!f3d_body_is_valid(w, 0)); + /* A slot past the arena's high-water mark, with a plausible generation. */ + CHECK(!f3d_body_is_valid(w, ((uint64_t)1 << 32) | 999u)); + + CHECK(f3d_body_destroy(w, a) == 1); + CHECK(f3d_body_destroy(w, a) == 0); + CHECK(!f3d_body_is_valid(w, a)); + CHECK(f3d_world_body_count(w) == 1); + + /* The freed slot is reused with a new generation: the old handle still + * names nothing, and the new one names the new body. */ + const F3dBody c = f3d_body_create(w, F3D_BODY_DYNAMIC, 7, 8, 9, 1); + CHECK((uint32_t)c == (uint32_t)a); + CHECK((c >> 32) == (a >> 32) + 1); + CHECK(!f3d_body_is_valid(w, a)); + f3d_real p[4]; + CHECK(f3d_body_get_position(w, a, p) == 0); + CHECK(f3d_body_get_position(w, c, p) == 1); + CHECK(p[0] == 7 && p[1] == 8 && p[2] == 9); + + /* Every accessor refuses a stale handle. */ + int burning; + F3dMaterial m; + f3d_material_preset(F3D_MATERIAL_WOOD, &m); + CHECK(f3d_body_set_velocity(w, a, 1, 1, 1) == 0); + CHECK(f3d_body_get_velocity(w, a, p) == 0); + CHECK(f3d_body_set_position(w, a, 1, 1, 1) == 0); + CHECK(f3d_body_set_angular_velocity(w, a, 1, 1, 1) == 0); + CHECK(f3d_body_get_angular_velocity(w, a, p) == 0); + CHECK(f3d_body_set_orientation(w, a, 0, 0, 0, 1) == 0); + CHECK(f3d_body_get_orientation(w, a, p) == 0); + CHECK(f3d_body_set_shape(w, a, F3D_SHAPE_SPHERE, 1, 0, 0) == 0); + CHECK(f3d_body_get_inertia(w, a, p) == 0); + CHECK(f3d_body_get_mass(w, a, p) == 0); + CHECK(f3d_body_lock_rotation(w, a, 1) == 0); + CHECK(f3d_body_set_damping(w, a, 0, 0) == 0); + CHECK(f3d_body_set_drag(w, a, 0) == 0); + CHECK(f3d_body_apply_impulse(w, a, 1, 0, 0) == 0); + CHECK(f3d_body_apply_impulse_at(w, a, 1, 0, 0, 0, 0, 0) == 0); + CHECK(f3d_body_add_force(w, a, 1, 0, 0) == 0); + CHECK(f3d_body_add_torque(w, a, 1, 0, 0) == 0); + CHECK(f3d_body_is_asleep(w, a) == 0); + CHECK(f3d_body_wake(w, a) == 0); + CHECK(f3d_body_set_material(w, a, &m) == 0); + CHECK(f3d_body_set_temperature(w, a, 300) == 0); + CHECK(f3d_body_get_temperature(w, a, p) == 0); + CHECK(f3d_body_add_heat(w, a, 1) == 0); + CHECK(f3d_body_add_water(w, a, 1) == 0); + CHECK(f3d_body_get_water(w, a, p) == 0); + CHECK(f3d_body_get_fuel(w, a, p) == 0); + CHECK(f3d_body_is_burning(w, a, &burning) == 0); + CHECK(f3d_body_get_heat_release(w, a, p) == 0); + double wp[3]; + CHECK(f3d_body_get_world_position(w, a, wp) == 0); + f3d_world_destroy(w); +} + +static void test_generation_wraps_past_nought(void) { + F3dWorld *w = f3d_world_create(); + const F3dBody a = f3d_body_create(w, F3D_BODY_DYNAMIC, 0, 0, 0, 1); + /* Pretend the slot has been reused four billion times. */ + w->slots[(uint32_t)a].generation = UINT32_MAX; + const F3dBody worn = ((uint64_t)UINT32_MAX << 32) | (uint32_t)a; + CHECK(f3d_body_destroy(w, worn) == 1); + const F3dBody b = f3d_body_create(w, F3D_BODY_DYNAMIC, 0, 0, 0, 1); + CHECK(b != 0); + CHECK((b >> 32) == 1); + CHECK(f3d_body_is_valid(w, b)); + f3d_world_destroy(w); +} + +static void test_growth(void) { + enum { N = 1000 }; + static F3dBody bodies[N]; + F3dWorld *w = f3d_world_create(); + for (int i = 0; i < N; i++) { + bodies[i] = f3d_body_create(w, F3D_BODY_DYNAMIC, (f3d_real)i, 0, 0, 1); + CHECK(bodies[i] != 0); + } + CHECK(f3d_world_body_count(w) == N); + int all_valid = 1; + for (int i = 0; i < N; i++) { + f3d_real p[3]; + all_valid &= f3d_body_get_position(w, bodies[i], p) && p[0] == (f3d_real)i; + } + CHECK(all_valid); + f3d_world_destroy(w); +} + +static void test_step(void) { + F3dWorld *w = f3d_world_create(); + /* One substep: the step's own algebra, checked to the bit. */ + CHECK(f3d_world_set_substeps(w, 0) == 0); + CHECK(f3d_world_set_substeps(w, 65) == 0); + CHECK(f3d_world_set_substeps(w, 1) == 1); + f3d_world_set_gravity(w, 0, -10, 0); + const F3dBody falling = f3d_body_create(w, F3D_BODY_DYNAMIC, 0, 10, 0, 1); + const F3dBody pinned = f3d_body_create(w, F3D_BODY_FIXED, 0, 10, 0, 0); + const F3dBody thrown = f3d_body_create(w, F3D_BODY_DYNAMIC, 0, 0, 0, 3); + f3d_body_set_velocity(w, thrown, 2, 0, 0); + + f3d_world_step(w, 0.5f); + f3d_real p[3], v[3]; + /* Velocity first, then position with it: v = -5, y = 10 - 2.5. */ + f3d_body_get_velocity(w, falling, v); + f3d_body_get_position(w, falling, p); + CHECK(v[1] == -5); + CHECK(p[1] == F3D_R(7.5)); + f3d_body_get_position(w, pinned, p); + CHECK(p[1] == 10); + /* Mass does not change a free fall. */ + f3d_body_get_position(w, thrown, p); + CHECK(p[0] == 1 && p[1] == F3D_R(-2.5)); + + /* A step of nothing, or of nonsense, changes nothing. */ + f3d_world_step(w, 0); + f3d_world_step(w, -1); + f3d_world_step(w, nan_value()); + f3d_world_step(w, inf_value()); + f3d_body_get_position(w, falling, p); + CHECK(p[1] == F3D_R(7.5)); + f3d_world_destroy(w); +} + +static void test_substeps(void) { + /* A step of n substeps is n steps of dt / n, to the bit. */ + F3dWorld *a = f3d_world_create(); + F3dWorld *b = f3d_world_create(); + f3d_world_set_substeps(a, 4); + f3d_world_set_substeps(b, 1); + const F3dBody ba = f3d_body_create(a, F3D_BODY_DYNAMIC, 0, 10, 0, 1); + const F3dBody bb = f3d_body_create(b, F3D_BODY_DYNAMIC, 0, 10, 0, 1); + f3d_body_set_velocity(a, ba, 3, 1, 0); + f3d_body_set_velocity(b, bb, 3, 1, 0); + for (int i = 0; i < 30; i++) { + f3d_world_step(a, F3D_R(0.25)); + for (int k = 0; k < 4; k++) f3d_world_step(b, F3D_R(0.25) / 4); + } + f3d_real pa[3], pb[3]; + f3d_body_get_position(a, ba, pa); + f3d_body_get_position(b, bb, pb); + CHECK(pa[0] == pb[0] && pa[1] == pb[1]); + f3d_world_destroy(a); + f3d_world_destroy(b); +} + +static void test_read_transforms(void) { + F3dWorld *w = f3d_world_create(); + const F3dBody a = f3d_body_create(w, F3D_BODY_DYNAMIC, 1, 0, 0, 1); + const F3dBody b = f3d_body_create(w, F3D_BODY_DYNAMIC, 2, 0, 0, 1); + const F3dBody c = f3d_body_create(w, F3D_BODY_DYNAMIC, 3, 0, 0, 1); + f3d_body_destroy(w, b); + + f3d_real t[3 * F3D_TRANSFORM_FLOATS]; + F3dBody h[3]; + memset(t, 0, sizeof t); + CHECK(f3d_world_read_transforms(w, t, h, 3) == 2); + /* Slot order, skipping the freed slot. */ + CHECK(h[0] == a && h[1] == c); + CHECK(t[0] == 1 && t[F3D_TRANSFORM_FLOATS] == 3); + /* Unrotated: the identity quaternion. */ + CHECK(t[3] == 0 && t[4] == 0 && t[5] == 0 && t[6] == 1); + /* No more than the capacity, and the handles may be left out. */ + CHECK(f3d_world_read_transforms(w, t, NULL, 1) == 1); + CHECK(f3d_world_read_transforms(w, t, NULL, 0) == 0); + f3d_world_destroy(w); +} + +static void test_origin(void) { + /* A body ten kilometres out: f32 holds its position to a millimetre at + * best. Moved to an origin beside it, the same body is held to a + * micrometre, and nothing in the world has moved. */ + F3dWorld *w = f3d_world_create(); + f3d_world_set_gravity(w, 0, 0, 0); + const F3dBody far = f3d_body_create(w, F3D_BODY_DYNAMIC, 10000, 0, 0, 1); + double before[3], after[3], origin[3]; + f3d_body_get_world_position(w, far, before); + f3d_world_shift_origin(w, 10000.0, 0.0, 0.0); + f3d_world_get_origin(w, origin); + CHECK(origin[0] == 10000.0 && origin[1] == 0.0); + f3d_real p[3]; + f3d_body_get_position(w, far, p); + CHECK(p[0] == 0); + f3d_body_get_world_position(w, far, after); + CHECK(after[0] == before[0] && after[1] == before[1]); + /* Now a micrometre is a step it can take. */ + f3d_body_set_velocity(w, far, 1e-6f, 0, 0); + f3d_world_step(w, 1); + f3d_body_get_world_position(w, far, after); + CHECK_NEAR(after[0] - 10000.0, 1e-6, 1e-12); + /* Not a shift at all: refused, nothing moved. */ + f3d_world_shift_origin(w, (double)nan_value(), 0, 0); + f3d_world_get_origin(w, origin); + CHECK(origin[0] == 10000.0); + f3d_world_destroy(w); +} + +static void test_wind_grid(void) { + F3dWorld *w = f3d_world_create(); + f3d_real out[3]; + f3d_world_sample_wind(w, 5, 5, 5, out); + CHECK(out[0] == 0 && out[1] == 0 && out[2] == 0); + f3d_world_set_wind(w, 1, 0, 0); + /* Two by one by one samples, two metres apart: 0 then 4 m/s along z, + * plus the uniform 1 along x. */ + const f3d_real grid[6] = {0, 0, 0, 0, 0, 4}; + CHECK(f3d_world_set_wind_grid(w, 0, 0, 0, 0, 2, 1, 1, grid) == 0); + CHECK(f3d_world_set_wind_grid(w, 0, 0, 0, 2, 0, 1, 1, grid) == 0); + CHECK(f3d_world_set_wind_grid(w, 0, 0, 0, 2, 2, 1, 1, grid) == 1); + f3d_world_sample_wind(w, 1, 7, -3, out); + CHECK(out[0] == 1 && out[1] == 0 && out[2] == 2); + /* Held at its edges. */ + f3d_world_sample_wind(w, -5, 0, 0, out); + CHECK(out[2] == 0); + f3d_world_sample_wind(w, 50, 0, 0, out); + CHECK(out[2] == 4); + f3d_world_sample_wind(w, F3D_R(0.5), 0, 0, out); + CHECK(out[2] == 1); + /* Trilinear across all three axes: the middle of a 2×2×2 cube is the + * mean of its corners. */ + f3d_real cube[24]; + for (int i = 0; i < 8; i++) { + cube[i * 3] = (f3d_real)i; + cube[i * 3 + 1] = 0; + cube[i * 3 + 2] = 0; + } + CHECK(f3d_world_set_wind_grid(w, 0, 0, 0, 1, 2, 2, 2, cube) == 1); + f3d_world_sample_wind(w, F3D_R(0.5), F3D_R(0.5), F3D_R(0.5), out); + CHECK(out[0] == F3D_R(1.0) + F3D_R(3.5)); + /* At a sample, the sample. */ + f3d_world_sample_wind(w, 1, 1, 0, out); + CHECK(out[0] == F3D_R(1.0) + 3); + /* Cleared. */ + CHECK(f3d_world_set_wind_grid(w, 0, 0, 0, 1, 1, 1, 1, NULL) == 1); + f3d_world_sample_wind(w, F3D_R(0.5), F3D_R(0.5), F3D_R(0.5), out); + CHECK(out[0] == 1); + /* The grid moves with the origin, so the world's wind stays put. */ + CHECK(f3d_world_set_wind_grid(w, 0, 0, 0, 2, 2, 1, 1, grid) == 1); + f3d_world_shift_origin(w, 1.0, 0.0, 0.0); + f3d_world_sample_wind(w, 0, 0, 0, out); + CHECK(out[2] == 2); + f3d_world_destroy(w); +} + +static void test_events(void) { + F3dWorld *w = f3d_world_create(); + F3dBody bodies[4]; + uint32_t kinds[4]; + CHECK(f3d_world_read_events(w, bodies, NULL, kinds, 4) == 0); + f3d_push_event(w, 11, F3D_EVENT_IGNITED); + f3d_push_event(w, 12, F3D_EVENT_SLEPT); + f3d_push_event(w, 13, F3D_EVENT_WOKE); + /* Read a part: the oldest first, and the rest waits. */ + CHECK(f3d_world_read_events(w, bodies, NULL, kinds, 2) == 2); + CHECK(bodies[0] == 11 && kinds[0] == F3D_EVENT_IGNITED); + CHECK(bodies[1] == 12 && kinds[1] == F3D_EVENT_SLEPT); + CHECK(f3d_world_read_events(w, bodies, NULL, kinds, 4) == 1); + CHECK(bodies[0] == 13 && kinds[0] == F3D_EVENT_WOKE); + /* Past the capacity, the newest are dropped and counted, and the ring + * wraps without losing order. */ + for (uint32_t i = 0; i < F3D_EVENT_CAPACITY + 5u; i++) { + f3d_push_event(w, i + 1u, F3D_EVENT_SLEPT); + } + CHECK(f3d_world_events_dropped(w) == 5); + CHECK(f3d_world_read_events(w, bodies, NULL, kinds, 1) == 1); + CHECK(bodies[0] == 1); + f3d_push_event(w, 777, F3D_EVENT_WOKE); + uint32_t left = 0; + F3dBody last = 0; + while (f3d_world_read_events(w, bodies, NULL, kinds, 1) == 1) { + left++; + last = bodies[0]; + } + CHECK(left == F3D_EVENT_CAPACITY); + CHECK(last == 777); + f3d_world_destroy(w); +} + +int main(void) { + test_abi_and_defaults(); + test_buffers(); + test_refusals(); + test_handles(); + test_generation_wraps_past_nought(); + test_growth(); + test_step(); + test_substeps(); + test_read_transforms(); + test_origin(); + test_wind_grid(); + test_events(); + return finish(); +} diff --git a/packages/flutter3d_physics_native/csrc/wasm/f3d_wasm_shim.c b/packages/flutter3d_physics_native/csrc/wasm/f3d_wasm_shim.c new file mode 100644 index 000000000..e0a2c188b --- /dev/null +++ b/packages/flutter3d_physics_native/csrc/wasm/f3d_wasm_shim.c @@ -0,0 +1,256 @@ +/* Generated by tool/gen_core.dart from f3d_physics.h: do not edit. + * + * The WebAssembly build's exports for the calls that take or give a + * 64-bit value, which JavaScript would have as a BigInt: two 32-bit halves + * in, and out the low half, the high one kept for f3d_wasm_high. */ +#include "f3d_physics.h" + +static uint32_t g_high; + +F3D_API uint32_t f3d_wasm_high(void) { return g_high; } + +F3D_API uint32_t f3d_joint_create__w(void * world, int type, uint32_t a_low, uint32_t a_high, uint32_t b_low, uint32_t b_high, f3d_real ax, f3d_real ay, f3d_real az, f3d_real ux, f3d_real uy, f3d_real uz) { + const uint64_t v = f3d_joint_create((void *)world, type, ((uint64_t)a_high << 32) | a_low, ((uint64_t)b_high << 32) | b_low, ax, ay, az, ux, uy, uz); + g_high = (uint32_t)(v >> 32); + return (uint32_t)v; +} + +F3D_API uint32_t f3d_joint_create_distance__w(void * world, uint32_t a_low, uint32_t a_high, uint32_t b_low, uint32_t b_high, f3d_real ax, f3d_real ay, f3d_real az, f3d_real bx, f3d_real by, f3d_real bz) { + const uint64_t v = f3d_joint_create_distance((void *)world, ((uint64_t)a_high << 32) | a_low, ((uint64_t)b_high << 32) | b_low, ax, ay, az, bx, by, bz); + g_high = (uint32_t)(v >> 32); + return (uint32_t)v; +} + +F3D_API int f3d_joint_destroy__w(void * world, uint32_t joint_low, uint32_t joint_high) { + return f3d_joint_destroy((void *)world, ((uint64_t)joint_high << 32) | joint_low); +} + +F3D_API int f3d_joint_is_valid__w(void * world, uint32_t joint_low, uint32_t joint_high) { + return f3d_joint_is_valid((void *)world, ((uint64_t)joint_high << 32) | joint_low); +} + +F3D_API int f3d_joint_set_limits__w(void * world, uint32_t joint_low, uint32_t joint_high, int enabled, f3d_real lower, f3d_real upper) { + return f3d_joint_set_limits((void *)world, ((uint64_t)joint_high << 32) | joint_low, enabled, lower, upper); +} + +F3D_API int f3d_joint_set_cone__w(void * world, uint32_t joint_low, uint32_t joint_high, int enabled, f3d_real angle) { + return f3d_joint_set_cone((void *)world, ((uint64_t)joint_high << 32) | joint_low, enabled, angle); +} + +F3D_API int f3d_joint_set_friction__w(void * world, uint32_t joint_low, uint32_t joint_high, int enabled, f3d_real torque) { + return f3d_joint_set_friction((void *)world, ((uint64_t)joint_high << 32) | joint_low, enabled, torque); +} + +F3D_API int f3d_joint_set_motor__w(void * world, uint32_t joint_low, uint32_t joint_high, int enabled, f3d_real speed, f3d_real max_force) { + return f3d_joint_set_motor((void *)world, ((uint64_t)joint_high << 32) | joint_low, enabled, speed, max_force); +} + +F3D_API int f3d_joint_set_spring__w(void * world, uint32_t joint_low, uint32_t joint_high, int enabled, f3d_real hertz, f3d_real damping) { + return f3d_joint_set_spring((void *)world, ((uint64_t)joint_high << 32) | joint_low, enabled, hertz, damping); +} + +F3D_API int f3d_joint_set_length__w(void * world, uint32_t joint_low, uint32_t joint_high, f3d_real length, f3d_real least, f3d_real most) { + return f3d_joint_set_length((void *)world, ((uint64_t)joint_high << 32) | joint_low, length, least, most); +} + +F3D_API int f3d_joint_set_collide__w(void * world, uint32_t joint_low, uint32_t joint_high, int collide) { + return f3d_joint_set_collide((void *)world, ((uint64_t)joint_high << 32) | joint_low, collide); +} + +F3D_API int f3d_joint_get_value__w(void * world, uint32_t joint_low, uint32_t joint_high, void * out) { + return f3d_joint_get_value((void *)world, ((uint64_t)joint_high << 32) | joint_low, (void *)out); +} + +F3D_API int f3d_joint_get_swing__w(void * world, uint32_t joint_low, uint32_t joint_high, void * out) { + return f3d_joint_get_swing((void *)world, ((uint64_t)joint_high << 32) | joint_low, (void *)out); +} + +F3D_API int f3d_joint_get_force__w(void * world, uint32_t joint_low, uint32_t joint_high, void * out) { + return f3d_joint_get_force((void *)world, ((uint64_t)joint_high << 32) | joint_low, (void *)out); +} + +F3D_API int f3d_world_ray_cast__w(void * world, f3d_real ox, f3d_real oy, f3d_real oz, f3d_real dx, f3d_real dy, f3d_real dz, f3d_real max_distance, uint32_t mask, uint32_t ignore_low, uint32_t ignore_high, void * body, void * hit) { + return f3d_world_ray_cast((void *)world, ox, oy, oz, dx, dy, dz, max_distance, mask, ((uint64_t)ignore_high << 32) | ignore_low, (void *)body, (void *)hit); +} + +F3D_API uint32_t f3d_world_ray_cast_all__w(void * world, f3d_real ox, f3d_real oy, f3d_real oz, f3d_real dx, f3d_real dy, f3d_real dz, f3d_real max_distance, uint32_t mask, uint32_t ignore_low, uint32_t ignore_high, void * bodies, void * hits, uint32_t capacity) { + return f3d_world_ray_cast_all((void *)world, ox, oy, oz, dx, dy, dz, max_distance, mask, ((uint64_t)ignore_high << 32) | ignore_low, (void *)bodies, (void *)hits, capacity); +} + +F3D_API uint32_t f3d_world_overlap_shape__w(void * world, int kind, f3d_real a, f3d_real b, f3d_real c, f3d_real rounding, f3d_real px, f3d_real py, f3d_real pz, f3d_real qx, f3d_real qy, f3d_real qz, f3d_real qw, uint32_t mask, uint32_t ignore_low, uint32_t ignore_high, void * out, uint32_t capacity) { + return f3d_world_overlap_shape((void *)world, kind, a, b, c, rounding, px, py, pz, qx, qy, qz, qw, mask, ((uint64_t)ignore_high << 32) | ignore_low, (void *)out, capacity); +} + +F3D_API int f3d_world_cast_shape__w(void * world, int kind, f3d_real a, f3d_real b, f3d_real c, f3d_real rounding, f3d_real px, f3d_real py, f3d_real pz, f3d_real qx, f3d_real qy, f3d_real qz, f3d_real qw, f3d_real tx, f3d_real ty, f3d_real tz, uint32_t mask, uint32_t ignore_low, uint32_t ignore_high, void * body, void * hit) { + return f3d_world_cast_shape((void *)world, kind, a, b, c, rounding, px, py, pz, qx, qy, qz, qw, tx, ty, tz, mask, ((uint64_t)ignore_high << 32) | ignore_low, (void *)body, (void *)hit); +} + +F3D_API uint32_t f3d_world_move_character__w(void * world, f3d_real radius, f3d_real half_height, void * position, f3d_real dx, f3d_real dy, f3d_real dz, f3d_real max_slope_cos, f3d_real step_height, uint32_t mask, uint32_t ignore_low, uint32_t ignore_high, void * ground_body, void * ground) { + return f3d_world_move_character((void *)world, radius, half_height, (void *)position, dx, dy, dz, max_slope_cos, step_height, mask, ((uint64_t)ignore_high << 32) | ignore_low, (void *)ground_body, (void *)ground); +} + +F3D_API uint32_t f3d_body_create__w(void * world, int type, f3d_real px, f3d_real py, f3d_real pz, f3d_real mass) { + const uint64_t v = f3d_body_create((void *)world, type, px, py, pz, mass); + g_high = (uint32_t)(v >> 32); + return (uint32_t)v; +} + +F3D_API int f3d_body_destroy__w(void * world, uint32_t body_low, uint32_t body_high) { + return f3d_body_destroy((void *)world, ((uint64_t)body_high << 32) | body_low); +} + +F3D_API int f3d_body_is_valid__w(void * world, uint32_t body_low, uint32_t body_high) { + return f3d_body_is_valid((void *)world, ((uint64_t)body_high << 32) | body_low); +} + +F3D_API int f3d_body_set_velocity__w(void * world, uint32_t body_low, uint32_t body_high, f3d_real x, f3d_real y, f3d_real z) { + return f3d_body_set_velocity((void *)world, ((uint64_t)body_high << 32) | body_low, x, y, z); +} + +F3D_API int f3d_body_get_velocity__w(void * world, uint32_t body_low, uint32_t body_high, void * out) { + return f3d_body_get_velocity((void *)world, ((uint64_t)body_high << 32) | body_low, (void *)out); +} + +F3D_API int f3d_body_set_position__w(void * world, uint32_t body_low, uint32_t body_high, f3d_real x, f3d_real y, f3d_real z) { + return f3d_body_set_position((void *)world, ((uint64_t)body_high << 32) | body_low, x, y, z); +} + +F3D_API int f3d_body_get_position__w(void * world, uint32_t body_low, uint32_t body_high, void * out) { + return f3d_body_get_position((void *)world, ((uint64_t)body_high << 32) | body_low, (void *)out); +} + +F3D_API int f3d_body_get_world_position__w(void * world, uint32_t body_low, uint32_t body_high, void * out) { + return f3d_body_get_world_position((void *)world, ((uint64_t)body_high << 32) | body_low, (void *)out); +} + +F3D_API int f3d_body_set_angular_velocity__w(void * world, uint32_t body_low, uint32_t body_high, f3d_real x, f3d_real y, f3d_real z) { + return f3d_body_set_angular_velocity((void *)world, ((uint64_t)body_high << 32) | body_low, x, y, z); +} + +F3D_API int f3d_body_get_angular_velocity__w(void * world, uint32_t body_low, uint32_t body_high, void * out) { + return f3d_body_get_angular_velocity((void *)world, ((uint64_t)body_high << 32) | body_low, (void *)out); +} + +F3D_API int f3d_body_set_orientation__w(void * world, uint32_t body_low, uint32_t body_high, f3d_real x, f3d_real y, f3d_real z, f3d_real w) { + return f3d_body_set_orientation((void *)world, ((uint64_t)body_high << 32) | body_low, x, y, z, w); +} + +F3D_API int f3d_body_get_orientation__w(void * world, uint32_t body_low, uint32_t body_high, void * out) { + return f3d_body_get_orientation((void *)world, ((uint64_t)body_high << 32) | body_low, (void *)out); +} + +F3D_API int f3d_body_set_shape__w(void * world, uint32_t body_low, uint32_t body_high, int kind, f3d_real a, f3d_real b, f3d_real c) { + return f3d_body_set_shape((void *)world, ((uint64_t)body_high << 32) | body_low, kind, a, b, c); +} + +F3D_API int f3d_body_get_inertia__w(void * world, uint32_t body_low, uint32_t body_high, void * out) { + return f3d_body_get_inertia((void *)world, ((uint64_t)body_high << 32) | body_low, (void *)out); +} + +F3D_API int f3d_body_get_inertia_tensor__w(void * world, uint32_t body_low, uint32_t body_high, void * out) { + return f3d_body_get_inertia_tensor((void *)world, ((uint64_t)body_high << 32) | body_low, (void *)out); +} + +F3D_API int f3d_body_set_rounding__w(void * world, uint32_t body_low, uint32_t body_high, f3d_real radius) { + return f3d_body_set_rounding((void *)world, ((uint64_t)body_high << 32) | body_low, radius); +} + +F3D_API int f3d_body_set_hull__w(void * world, uint32_t body_low, uint32_t body_high, uint32_t hull) { + return f3d_body_set_hull((void *)world, ((uint64_t)body_high << 32) | body_low, hull); +} + +F3D_API int f3d_body_set_mesh__w(void * world, uint32_t body_low, uint32_t body_high, uint32_t mesh) { + return f3d_body_set_mesh((void *)world, ((uint64_t)body_high << 32) | body_low, mesh); +} + +F3D_API int f3d_body_get_mass__w(void * world, uint32_t body_low, uint32_t body_high, void * out) { + return f3d_body_get_mass((void *)world, ((uint64_t)body_high << 32) | body_low, (void *)out); +} + +F3D_API int f3d_body_lock_rotation__w(void * world, uint32_t body_low, uint32_t body_high, int locked) { + return f3d_body_lock_rotation((void *)world, ((uint64_t)body_high << 32) | body_low, locked); +} + +F3D_API int f3d_body_set_damping__w(void * world, uint32_t body_low, uint32_t body_high, f3d_real linear, f3d_real angular) { + return f3d_body_set_damping((void *)world, ((uint64_t)body_high << 32) | body_low, linear, angular); +} + +F3D_API int f3d_body_set_drag__w(void * world, uint32_t body_low, uint32_t body_high, f3d_real coefficient) { + return f3d_body_set_drag((void *)world, ((uint64_t)body_high << 32) | body_low, coefficient); +} + +F3D_API int f3d_body_apply_impulse__w(void * world, uint32_t body_low, uint32_t body_high, f3d_real x, f3d_real y, f3d_real z) { + return f3d_body_apply_impulse((void *)world, ((uint64_t)body_high << 32) | body_low, x, y, z); +} + +F3D_API int f3d_body_apply_impulse_at__w(void * world, uint32_t body_low, uint32_t body_high, f3d_real x, f3d_real y, f3d_real z, f3d_real px, f3d_real py, f3d_real pz) { + return f3d_body_apply_impulse_at((void *)world, ((uint64_t)body_high << 32) | body_low, x, y, z, px, py, pz); +} + +F3D_API int f3d_body_add_force__w(void * world, uint32_t body_low, uint32_t body_high, f3d_real x, f3d_real y, f3d_real z) { + return f3d_body_add_force((void *)world, ((uint64_t)body_high << 32) | body_low, x, y, z); +} + +F3D_API int f3d_body_add_torque__w(void * world, uint32_t body_low, uint32_t body_high, f3d_real x, f3d_real y, f3d_real z) { + return f3d_body_add_torque((void *)world, ((uint64_t)body_high << 32) | body_low, x, y, z); +} + +F3D_API int f3d_body_set_bullet__w(void * world, uint32_t body_low, uint32_t body_high, int bullet) { + return f3d_body_set_bullet((void *)world, ((uint64_t)body_high << 32) | body_low, bullet); +} + +F3D_API int f3d_body_set_friction__w(void * world, uint32_t body_low, uint32_t body_high, f3d_real friction) { + return f3d_body_set_friction((void *)world, ((uint64_t)body_high << 32) | body_low, friction); +} + +F3D_API int f3d_body_set_restitution__w(void * world, uint32_t body_low, uint32_t body_high, f3d_real restitution) { + return f3d_body_set_restitution((void *)world, ((uint64_t)body_high << 32) | body_low, restitution); +} + +F3D_API int f3d_body_set_collision_filter__w(void * world, uint32_t body_low, uint32_t body_high, uint32_t layer, uint32_t mask) { + return f3d_body_set_collision_filter((void *)world, ((uint64_t)body_high << 32) | body_low, layer, mask); +} + +F3D_API int f3d_body_is_asleep__w(void * world, uint32_t body_low, uint32_t body_high) { + return f3d_body_is_asleep((void *)world, ((uint64_t)body_high << 32) | body_low); +} + +F3D_API int f3d_body_wake__w(void * world, uint32_t body_low, uint32_t body_high) { + return f3d_body_wake((void *)world, ((uint64_t)body_high << 32) | body_low); +} + +F3D_API int f3d_body_set_material__w(void * world, uint32_t body_low, uint32_t body_high, void * material) { + return f3d_body_set_material((void *)world, ((uint64_t)body_high << 32) | body_low, (void *)material); +} + +F3D_API int f3d_body_set_temperature__w(void * world, uint32_t body_low, uint32_t body_high, f3d_real kelvin) { + return f3d_body_set_temperature((void *)world, ((uint64_t)body_high << 32) | body_low, kelvin); +} + +F3D_API int f3d_body_get_temperature__w(void * world, uint32_t body_low, uint32_t body_high, void * out) { + return f3d_body_get_temperature((void *)world, ((uint64_t)body_high << 32) | body_low, (void *)out); +} + +F3D_API int f3d_body_add_heat__w(void * world, uint32_t body_low, uint32_t body_high, f3d_real joules) { + return f3d_body_add_heat((void *)world, ((uint64_t)body_high << 32) | body_low, joules); +} + +F3D_API int f3d_body_add_water__w(void * world, uint32_t body_low, uint32_t body_high, f3d_real kg) { + return f3d_body_add_water((void *)world, ((uint64_t)body_high << 32) | body_low, kg); +} + +F3D_API int f3d_body_get_water__w(void * world, uint32_t body_low, uint32_t body_high, void * out) { + return f3d_body_get_water((void *)world, ((uint64_t)body_high << 32) | body_low, (void *)out); +} + +F3D_API int f3d_body_get_fuel__w(void * world, uint32_t body_low, uint32_t body_high, void * out) { + return f3d_body_get_fuel((void *)world, ((uint64_t)body_high << 32) | body_low, (void *)out); +} + +F3D_API int f3d_body_is_burning__w(void * world, uint32_t body_low, uint32_t body_high, void * out) { + return f3d_body_is_burning((void *)world, ((uint64_t)body_high << 32) | body_low, (void *)out); +} + +F3D_API int f3d_body_get_heat_release__w(void * world, uint32_t body_low, uint32_t body_high, void * out) { + return f3d_body_get_heat_release((void *)world, ((uint64_t)body_high << 32) | body_low, (void *)out); +} diff --git a/packages/flutter3d_physics_native/hook/build.dart b/packages/flutter3d_physics_native/hook/build.dart new file mode 100644 index 000000000..d9161b6a6 --- /dev/null +++ b/packages/flutter3d_physics_native/hook/build.dart @@ -0,0 +1,104 @@ +// Compiles the physics core in `csrc/` into the code asset the calls in +// `lib/src/core/calls_native.g.dart` load — P9. +// +// With the C compiler the target already uses: Xcode's clang on Apple +// platforms, the NDK's on Android, MSVC or clang on Windows, the system's on +// Linux. The flags are the deterministic mode's: no fused multiply-add +// contraction and no fast math, so the same inputs step to the same bits on +// every platform. The WebAssembly module is not built here — hooks build +// for the native targets — but by `tool/build_wasm.dart`, from the same +// sources with the same flags. +// +// Then the GPU passes, `csrc/gpu/`, as a library of their own, linked with +// wgpu-native, which `wgpu_native.dart` fetches for the target; without it, +// the core alone. +import 'package:code_assets/code_assets.dart'; +import 'package:hooks/hooks.dart'; +import 'package:logging/logging.dart'; +import 'package:native_toolchain_c/native_toolchain_c.dart'; + +import 'wgpu_native.dart'; + +/// The core's sources, as `tool/build_wasm.dart` and the C tests list them. +const List coreSources = [ + 'csrc/src/f3d_world.c', + 'csrc/src/f3d_motion.c', + 'csrc/src/f3d_heat.c', + 'csrc/src/f3d_snapshot.c', + 'csrc/src/f3d_collide.c', + 'csrc/src/f3d_narrow.c', + 'csrc/src/f3d_solve.c', + 'csrc/src/f3d_tree.c', + 'csrc/src/f3d_convex.c', + 'csrc/src/f3d_hull.c', + 'csrc/src/f3d_mesh.c', + 'csrc/src/f3d_joint.c', + 'csrc/src/f3d_ccd.c', + 'csrc/src/f3d_query.c', + 'csrc/src/f3d_particles.c', + 'csrc/src/f3d_debris.c', + 'csrc/src/f3d_cloth.c', + 'csrc/src/f3d_fluid.c', + 'csrc/src/f3d_pool.c', + 'csrc/src/f3d_memory_libc.c', +]; + +void main(List args) async { + await build(args, (input, output) async { + if (!input.config.buildCodeAssets) return; + final msvc = input.config.code.targetOS == OS.windows; + await CBuilder.library( + name: 'f3d_physics', + assetName: 'src/core/calls_native.g.dart', + sources: coreSources, + includes: const ['csrc/include', 'csrc/src'], + std: 'c11', + flags: msvc + // MSVC contracts nothing without /fp:fast; /fp:precise says so. + ? const ['/fp:precise'] + : [ + '-ffp-contract=off', + '-fno-fast-math', + // The pool's threads: in libc everywhere but older glibc. + if (input.config.code.targetOS == OS.linux) '-pthread', + // 32-bit x86 rounds as everywhere else only in SSE2, not on + // the x87 it uses by default; f3d_internal.h refuses it. + if (input.config.code.targetArchitecture == + Architecture.ia32) ...['-msse2', '-mfpmath=sse'], + ], + ).run(input: input, output: output, logger: logger); + final code = input.config.code; + final target = wgpuNativeTarget(code); + if (target == null) { + logger.info('no wgpu-native for ${code.targetOS}: no GPU passes'); + return; + } + final wgpu = await fetchWgpuNative( + target, + input.outputDirectoryShared, + logger.warning, + ); + if (wgpu == null) return; + await CBuilder.library( + name: 'f3d_gpu', + assetName: 'src/gpu_bindings.dart', + sources: const [ + 'csrc/gpu/f3d_gpu.c', + 'csrc/gpu/f3d_gpu_particles.c', + 'csrc/gpu/f3d_gpu_debris.c', + 'csrc/gpu/f3d_gpu_cloth.c', + 'csrc/gpu/f3d_gpu_fluid.c', + ], + includes: ['csrc/gpu', '${wgpu.path}/include'], + std: 'c11', + libraries: const ['wgpu_native'], + libraryDirectories: ['${wgpu.path}/lib'], + flags: wgpuNativeSystemLibraries(code.targetOS), + ).run(input: input, output: output, logger: logger); + }); +} + +/// The builders' own messages, which a hook can only print. +final Logger logger = Logger('') + // ignore: avoid_print + ..onRecord.listen((record) => print(record.message)); diff --git a/packages/flutter3d_physics_native/hook/wgpu_native.dart b/packages/flutter3d_physics_native/hook/wgpu_native.dart new file mode 100644 index 000000000..6ee5497fc --- /dev/null +++ b/packages/flutter3d_physics_native/hook/wgpu_native.dart @@ -0,0 +1,191 @@ +// Fetches wgpu-native for the target the hook builds for — P9, phase 10. +// +// A pinned release of gfx-rs/wgpu-native, its archive's sha256 checked +// against the one written here, unpacked into the hooks' shared output so a +// second build does not fetch it again. Only the static library and the +// headers are kept: the GPU library links wgpu-native in, so there is one +// binary to ship and no second one to find at run time. +// +// When there is no release for the target, no network, or a checksum that +// does not match, this says why and returns null, and the hook builds the +// core without its GPU passes: a game built offline still runs, on the CPU. +import 'dart:io'; + +import 'package:archive/archive.dart'; +import 'package:code_assets/code_assets.dart'; +import 'package:crypto/crypto.dart'; + +/// The release, and each archive's sha256 as the release lists it. +const String wgpuNativeVersion = 'v29.0.1.1'; + +const Map wgpuNativeArchives = { + 'android-aarch64': + '721741f1b05a20c1738166bedf7a5efb2ba4b382da689526d3fc33de22bdd573', + 'android-armv7': + 'f9d76c77b3fda3f7121476884eb16ec067f7dada83276298a3cc8bf6a8403d60', + 'android-i686': + '593b94875bc4fcc1506ea0b6714dd12b96b7c852921caa63f45eb61517793312', + 'android-x86_64': + 'ef16fc0644bf0e308a39ac4516742da8e22d8c201d3a542cc5baf533d272c491', + 'ios-aarch64': + 'e36c9913b9e5095a530fa9121c50b16a4e3dd020e1eebf601f2f47ce24d56941', + 'ios-aarch64-simulator': + '750e706765bef3744313745194774d095c916fc21d2a0e7d4d7b0bc4d0c92789', + 'ios-x86_64-simulator': + '94f67e1b268e8dd31b8e59b32f211ac469f09ed7950fceee52bd84f0623da3d9', + 'linux-aarch64': + '015fcdf1dbae82e614a783cc38017e5399ae0927a889fe9b69c9b664bc61b47a', + 'linux-x86_64': + '95a4d90c071005a98d03eab348beaa6b07e16eb00d1dcdb9f8348f75eb97ec5a', + 'macos-aarch64': + 'a5797a37b1adf720bcd5dcffb291edbbd5b7b14be0a3874c28e6393a655a7a3e', + 'macos-x86_64': + '8e2f7378548ddd0e2cf21e7d864dda46e953f0af724855a33778b85ead206d41', + 'windows-aarch64-msvc': + '4a876421a8c1e5fe72f849b3722214280fe485cb1c56f77f8b0c82414be5b29f', + 'windows-x86_64-msvc': + '7e67d7445c42aeb85e30f88930fd8d7d83ee769e3390aeb1ada75ebf3cf78132', +}; + +/// The release's name for [config]'s target, or null when it has none. +String? wgpuNativeTarget(CodeConfig config) { + final arch = config.targetArchitecture; + switch (config.targetOS) { + case OS.macOS: + return arch == Architecture.arm64 ? 'macos-aarch64' : 'macos-x86_64'; + case OS.linux: + if (arch == Architecture.arm64) return 'linux-aarch64'; + return arch == Architecture.x64 ? 'linux-x86_64' : null; + case OS.windows: + if (arch == Architecture.arm64) return 'windows-aarch64-msvc'; + return arch == Architecture.x64 ? 'windows-x86_64-msvc' : null; + case OS.android: + if (arch == Architecture.arm64) return 'android-aarch64'; + if (arch == Architecture.arm) return 'android-armv7'; + if (arch == Architecture.x64) return 'android-x86_64'; + return arch == Architecture.ia32 ? 'android-i686' : null; + case OS.iOS: + final simulator = config.iOS.targetSdk == IOSSdk.iPhoneSimulator; + if (!simulator) return arch == Architecture.arm64 ? 'ios-aarch64' : null; + return arch == Architecture.arm64 + ? 'ios-aarch64-simulator' + : 'ios-x86_64-simulator'; + default: + return null; + } +} + +/// When every unpacked file says it was last modified. +final DateTime _unpackedAt = DateTime.utc(2000); + +/// Where wgpu-native for [target] is unpacked under [shared]: its `include` +/// and `lib` directories. Fetches it the first time; null, after saying why +/// through [say], when it cannot. +Future fetchWgpuNative( + String target, + Uri shared, + void Function(String) say, +) async { + final expected = wgpuNativeArchives[target]; + if (expected == null) { + say( + 'no wgpu-native $wgpuNativeVersion for $target: building without GPU passes', + ); + return null; + } + final home = Directory.fromUri( + shared.resolve('wgpu-native/$wgpuNativeVersion/$target/'), + ); + final done = File('${home.path}/.unpacked'); + if (done.existsSync() && done.readAsStringSync() == expected) return home; + final url = Uri.parse( + 'https://github.com/gfx-rs/wgpu-native/releases/download/' + '$wgpuNativeVersion/wgpu-$target-release.zip', + ); + final List bytes; + try { + final client = HttpClient(); + try { + final request = await client.getUrl(url); + final response = await request.close(); + if (response.statusCode != 200) { + say( + 'fetching $url: HTTP ${response.statusCode}: building without GPU passes', + ); + return null; + } + bytes = await response.fold>( + [], + (all, part) => all..addAll(part), + ); + } finally { + client.close(); + } + } on IOException catch (e) { + say('fetching $url: $e: building without GPU passes'); + return null; + } + final actual = sha256.convert(bytes).toString(); + if (actual != expected) { + say('$url has sha256 $actual, not $expected: building without GPU passes'); + return null; + } + // Only the headers and the static library: the dynamic one would be what + // the linker prefers, and is not what is shipped. + final archive = ZipDecoder().decodeBytes(bytes); + home.createSync(recursive: true); + for (final entry in archive) { + if (!entry.isFile) continue; + final name = entry.name; + final keep = + name.startsWith('include/') || + name.endsWith('/libwgpu_native.a') || + name.endsWith('/wgpu_native.lib') || + name == 'lib/libwgpu_native.a' || + name == 'lib/wgpu_native.lib'; + if (!keep) continue; + File('${home.path}/$name') + ..createSync(recursive: true) + ..writeAsBytesSync(entry.content as List) + // Dated long before this build: the headers are what the GPU library + // is compiled against, so they are among its dependencies, and a file + // dated now told the build runner it was modified during the build — + // "Build must be rerun", and `flutter test` exiting 1 on every fresh + // checkout, CI's every run. + ..setLastModifiedSync(_unpackedAt); + } + done.writeAsStringSync(expected); + return home; +} + +/// What the static wgpu-native needs linked beside it on [os]. +List wgpuNativeSystemLibraries(OS os) => switch (os) { + OS.macOS || OS.iOS => const [ + '-framework', + 'Metal', + '-framework', + 'QuartzCore', + '-framework', + 'Foundation', + ], + OS.linux => const ['-lm', '-ldl', '-lpthread'], + OS.android => const ['-lm', '-ldl', '-llog', '-landroid'], + OS.windows => const [ + 'd3d12.lib', + 'dxgi.lib', + 'd3dcompiler.lib', + 'user32.lib', + 'ws2_32.lib', + 'userenv.lib', + 'bcrypt.lib', + 'ntdll.lib', + 'opengl32.lib', + 'gdi32.lib', + 'ole32.lib', + 'oleaut32.lib', + 'advapi32.lib', + 'propsys.lib', + 'runtimeobject.lib', + ], + _ => const [], +}; diff --git a/packages/flutter3d_physics_native/lib/flutter3d_physics_native.dart b/packages/flutter3d_physics_native/lib/flutter3d_physics_native.dart new file mode 100644 index 000000000..9a1ff17ea --- /dev/null +++ b/packages/flutter3d_physics_native/lib/flutter3d_physics_native.dart @@ -0,0 +1,20 @@ +/// The physics core of flutter3d in C11 — P9. +/// +/// Rigid bodies owned and stepped by the core: natively a library this +/// package's hook builds with the C compiler the target already uses, +/// reached through `dart:ffi`; in the browser the same core as a +/// WebAssembly module, reached through `dart:js_interop` — one API over +/// both. `flutter3d_physics` is the pure-Dart reference it is held to, and +/// the fallback where it cannot run. +library; + +export 'src/core/load.dart'; +export 'src/gpu.dart'; +export 'src/native_cloth.dart' + hide ClothPacked, createCoreCloth, packClothBalls, writeClothSettings; +export 'src/native_debris.dart' + hide packDebrisBodies, packDebrisStatics, writeDebrisSettings; +export 'src/native_dynamics.dart'; +export 'src/native_fluid.dart' hide packFluidParticles, writeFluidSettings; +export 'src/native_particles.dart' hide packParticles, writeParticleForces; +export 'src/native_world.dart'; diff --git a/packages/flutter3d_physics_native/lib/src/core/buffers.dart b/packages/flutter3d_physics_native/lib/src/core/buffers.dart new file mode 100644 index 000000000..3019f99e4 --- /dev/null +++ b/packages/flutter3d_physics_native/lib/src/core/buffers.dart @@ -0,0 +1,62 @@ +/// Blocks of the core's memory by element — P9, phase 12. +/// +/// An address with the element's width: what a `Pointer` was, read +/// and written by index, handed to a call as the address it is, and freed +/// back to the core. The same natively and in the browser. +library; + +import 'dart:typed_data'; + +import 'memory.dart'; + +/// Floats. +extension type const F32s(int address) implements int { + F32s.alloc(int count) : address = coreAlloc(count * 4); + double operator [](int i) => readF32(address + i * 4); + void operator []=(int i, double value) => writeF32(address + i * 4, value); + Float32List copy(int count) => copyF32s(address, count); + void setAll(List values) => setF32s(address, values); + void free() => coreFree(address); +} + +/// Doubles. +extension type const F64s(int address) implements int { + F64s.alloc(int count) : address = coreAlloc(count * 8); + double operator [](int i) => readF64(address + i * 8); + void operator []=(int i, double value) => writeF64(address + i * 8, value); + void free() => coreFree(address); +} + +/// Bytes. +extension type const U8s(int address) implements int { + U8s.alloc(int count) : address = coreAlloc(count); + int operator [](int i) => readU8(address + i); + void operator []=(int i, int value) => writeU8(address + i, value); + Uint8List copy(int count) => copyU8s(address, count); + void setAll(List values) => setU8s(address, values); + void free() => coreFree(address); +} + +/// Unsigned 32-bit words. +extension type const U32s(int address) implements int { + U32s.alloc(int count) : address = coreAlloc(count * 4); + int operator [](int i) => readU32(address + i * 4); + void operator []=(int i, int value) => writeU32(address + i * 4, value); + void free() => coreFree(address); +} + +/// Signed 32-bit words. +extension type const I32s(int address) implements int { + I32s.alloc(int count) : address = coreAlloc(count * 4); + int operator [](int i) => readI32(address + i * 4); + void operator []=(int i, int value) => writeI32(address + i * 4, value); + void free() => coreFree(address); +} + +/// Unsigned 64-bit words: body and joint handles. +extension type const U64s(int address) implements int { + U64s.alloc(int count) : address = coreAlloc(count * 8); + int operator [](int i) => readU64(address + i * 8); + void operator []=(int i, int value) => writeU64(address + i * 8, value); + void free() => coreFree(address); +} diff --git a/packages/flutter3d_physics_native/lib/src/core/calls_native.g.dart b/packages/flutter3d_physics_native/lib/src/core/calls_native.g.dart new file mode 100644 index 000000000..6d7778e84 --- /dev/null +++ b/packages/flutter3d_physics_native/lib/src/core/calls_native.g.dart @@ -0,0 +1,2116 @@ +// Generated by tool/gen_core.dart from csrc/include/f3d_physics.h: do not edit. +// +// The core's calls natively: through dart:ffi, a pointer an address. +// ignore_for_file: non_constant_identifier_names +@DefaultAsset('package:flutter3d_physics_native/src/core/calls_native.g.dart') +library; + +import 'dart:ffi'; + +@Native(symbol: 'f3d_abi_version', isLeaf: true) +external int _f3d_abi_version(); +int f3d_abi_version() => _f3d_abi_version(); + +@Native(symbol: 'f3d_real_bytes', isLeaf: true) +external int _f3d_real_bytes(); +int f3d_real_bytes() => _f3d_real_bytes(); + +@Native Function(Uint32)>(symbol: 'f3d_buffer_alloc') +external Pointer _f3d_buffer_alloc(int bytes); +int f3d_buffer_alloc(int bytes) => _f3d_buffer_alloc(bytes).address; + +@Native)>(symbol: 'f3d_buffer_free') +external void _f3d_buffer_free(Pointer buffer); +void f3d_buffer_free(int buffer) => + _f3d_buffer_free(Pointer.fromAddress(buffer)); + +@Native Function()>(symbol: 'f3d_world_create') +external Pointer _f3d_world_create(); +int f3d_world_create() => _f3d_world_create().address; + +@Native)>(symbol: 'f3d_world_destroy') +external void _f3d_world_destroy(Pointer world); +void f3d_world_destroy(int world) => + _f3d_world_destroy(Pointer.fromAddress(world)); + +@Native, Float, Float, Float)>( + symbol: 'f3d_world_set_gravity', + isLeaf: true, +) +external void _f3d_world_set_gravity( + Pointer world, + double x, + double y, + double z, +); +void f3d_world_set_gravity(int world, double x, double y, double z) => + _f3d_world_set_gravity(Pointer.fromAddress(world), x, y, z); + +@Native, Pointer)>( + symbol: 'f3d_world_get_gravity', + isLeaf: true, +) +external void _f3d_world_get_gravity(Pointer world, Pointer out); +void f3d_world_get_gravity(int world, int out) => _f3d_world_get_gravity( + Pointer.fromAddress(world), + Pointer.fromAddress(out), +); + +@Native, Float, Float)>( + symbol: 'f3d_world_set_air', + isLeaf: true, +) +external int _f3d_world_set_air( + Pointer world, + double temperature, + double density, +); +int f3d_world_set_air(int world, double temperature, double density) => + _f3d_world_set_air(Pointer.fromAddress(world), temperature, density); + +@Native, Pointer)>( + symbol: 'f3d_world_get_air', + isLeaf: true, +) +external void _f3d_world_get_air(Pointer world, Pointer out); +void f3d_world_get_air(int world, int out) => + _f3d_world_get_air(Pointer.fromAddress(world), Pointer.fromAddress(out)); + +@Native, Float, Float, Float)>( + symbol: 'f3d_world_set_wind', + isLeaf: true, +) +external void _f3d_world_set_wind( + Pointer world, + double x, + double y, + double z, +); +void f3d_world_set_wind(int world, double x, double y, double z) => + _f3d_world_set_wind(Pointer.fromAddress(world), x, y, z); + +@Native< + Int32 Function( + Pointer, + Float, + Float, + Float, + Float, + Uint32, + Uint32, + Uint32, + Pointer, + ) +>(symbol: 'f3d_world_set_wind_grid', isLeaf: true) +external int _f3d_world_set_wind_grid( + Pointer world, + double ox, + double oy, + double oz, + double cell, + int nx, + int ny, + int nz, + Pointer velocities, +); +int f3d_world_set_wind_grid( + int world, + double ox, + double oy, + double oz, + double cell, + int nx, + int ny, + int nz, + int velocities, +) => _f3d_world_set_wind_grid( + Pointer.fromAddress(world), + ox, + oy, + oz, + cell, + nx, + ny, + nz, + Pointer.fromAddress(velocities), +); + +@Native, Float, Float, Float, Pointer)>( + symbol: 'f3d_world_sample_wind', + isLeaf: true, +) +external void _f3d_world_sample_wind( + Pointer world, + double x, + double y, + double z, + Pointer out, +); +void f3d_world_sample_wind(int world, double x, double y, double z, int out) => + _f3d_world_sample_wind( + Pointer.fromAddress(world), + x, + y, + z, + Pointer.fromAddress(out), + ); + +@Native, Float, Float)>( + symbol: 'f3d_world_set_sleep', + isLeaf: true, +) +external int _f3d_world_set_sleep( + Pointer world, + double speed, + double time, +); +int f3d_world_set_sleep(int world, double speed, double time) => + _f3d_world_set_sleep(Pointer.fromAddress(world), speed, time); + +@Native, Pointer)>( + symbol: 'f3d_world_get_origin', + isLeaf: true, +) +external void _f3d_world_get_origin(Pointer world, Pointer out); +void f3d_world_get_origin(int world, int out) => + _f3d_world_get_origin(Pointer.fromAddress(world), Pointer.fromAddress(out)); + +@Native, Double, Double, Double)>( + symbol: 'f3d_world_shift_origin', + isLeaf: true, +) +external void _f3d_world_shift_origin( + Pointer world, + double dx, + double dy, + double dz, +); +void f3d_world_shift_origin(int world, double dx, double dy, double dz) => + _f3d_world_shift_origin(Pointer.fromAddress(world), dx, dy, dz); + +@Native, Pointer, Uint32)>( + symbol: 'f3d_world_create_hull', +) +external int _f3d_world_create_hull( + Pointer world, + Pointer points, + int count, +); +int f3d_world_create_hull(int world, int points, int count) => + _f3d_world_create_hull( + Pointer.fromAddress(world), + Pointer.fromAddress(points), + count, + ); + +@Native< + Uint32 Function(Pointer, Pointer, Uint32, Pointer, Uint32) +>(symbol: 'f3d_world_create_mesh') +external int _f3d_world_create_mesh( + Pointer world, + Pointer vertices, + int vertexCount, + Pointer indices, + int triangleCount, +); +int f3d_world_create_mesh( + int world, + int vertices, + int vertexCount, + int indices, + int triangleCount, +) => _f3d_world_create_mesh( + Pointer.fromAddress(world), + Pointer.fromAddress(vertices), + vertexCount, + Pointer.fromAddress(indices), + triangleCount, +); + +@Native, Uint32)>( + symbol: 'f3d_world_mesh_triangle_count', + isLeaf: true, +) +external int _f3d_world_mesh_triangle_count(Pointer world, int mesh); +int f3d_world_mesh_triangle_count(int world, int mesh) => + _f3d_world_mesh_triangle_count(Pointer.fromAddress(world), mesh); + +@Native, Uint32)>( + symbol: 'f3d_world_mesh_internal_edges', + isLeaf: true, +) +external int _f3d_world_mesh_internal_edges(Pointer world, int mesh); +int f3d_world_mesh_internal_edges(int world, int mesh) => + _f3d_world_mesh_internal_edges(Pointer.fromAddress(world), mesh); + +@Native, Uint32, Pointer)>( + symbol: 'f3d_world_get_hull_offset', + isLeaf: true, +) +external int _f3d_world_get_hull_offset( + Pointer world, + int hull, + Pointer out, +); +int f3d_world_get_hull_offset(int world, int hull, int out) => + _f3d_world_get_hull_offset( + Pointer.fromAddress(world), + hull, + Pointer.fromAddress(out), + ); + +@Native, Uint32)>( + symbol: 'f3d_world_hull_vertex_count', + isLeaf: true, +) +external int _f3d_world_hull_vertex_count(Pointer world, int hull); +int f3d_world_hull_vertex_count(int world, int hull) => + _f3d_world_hull_vertex_count(Pointer.fromAddress(world), hull); + +@Native)>( + symbol: 'f3d_world_body_count', + isLeaf: true, +) +external int _f3d_world_body_count(Pointer world); +int f3d_world_body_count(int world) => + _f3d_world_body_count(Pointer.fromAddress(world)); + +@Native, Float)>(symbol: 'f3d_world_step') +external void _f3d_world_step(Pointer world, double dt); +void f3d_world_step(int world, double dt) => + _f3d_world_step(Pointer.fromAddress(world), dt); + +@Native, Uint32)>(symbol: 'f3d_world_set_threads') +external int _f3d_world_set_threads(Pointer world, int threads); +int f3d_world_set_threads(int world, int threads) => + _f3d_world_set_threads(Pointer.fromAddress(world), threads); + +@Native)>( + symbol: 'f3d_world_threads', + isLeaf: true, +) +external int _f3d_world_threads(Pointer world); +int f3d_world_threads(int world) => + _f3d_world_threads(Pointer.fromAddress(world)); + +@Native, Int32)>( + symbol: 'f3d_world_set_fast', + isLeaf: true, +) +external int _f3d_world_set_fast(Pointer world, int fast); +int f3d_world_set_fast(int world, int fast) => + _f3d_world_set_fast(Pointer.fromAddress(world), fast); + +@Native)>(symbol: 'f3d_world_fast', isLeaf: true) +external int _f3d_world_fast(Pointer world); +int f3d_world_fast(int world) => _f3d_world_fast(Pointer.fromAddress(world)); + +@Native, Pointer, Pointer, Uint32)>( + symbol: 'f3d_world_read_transforms', + isLeaf: true, +) +external int _f3d_world_read_transforms( + Pointer world, + Pointer transforms, + Pointer handles, + int capacity, +); +int f3d_world_read_transforms( + int world, + int transforms, + int handles, + int capacity, +) => _f3d_world_read_transforms( + Pointer.fromAddress(world), + Pointer.fromAddress(transforms), + Pointer.fromAddress(handles), + capacity, +); + +@Native, Pointer, Pointer, Uint32)>( + symbol: 'f3d_world_read_fires', + isLeaf: true, +) +external int _f3d_world_read_fires( + Pointer world, + Pointer fires, + Pointer handles, + int capacity, +); +int f3d_world_read_fires(int world, int fires, int handles, int capacity) => + _f3d_world_read_fires( + Pointer.fromAddress(world), + Pointer.fromAddress(fires), + Pointer.fromAddress(handles), + capacity, + ); + +@Native< + Uint32 Function( + Pointer, + Pointer, + Pointer, + Pointer, + Uint32, + ) +>(symbol: 'f3d_world_read_events', isLeaf: true) +external int _f3d_world_read_events( + Pointer world, + Pointer bodies, + Pointer others, + Pointer kinds, + int capacity, +); +int f3d_world_read_events( + int world, + int bodies, + int others, + int kinds, + int capacity, +) => _f3d_world_read_events( + Pointer.fromAddress(world), + Pointer.fromAddress(bodies), + Pointer.fromAddress(others), + Pointer.fromAddress(kinds), + capacity, +); + +@Native, Uint32)>( + symbol: 'f3d_world_set_substeps', + isLeaf: true, +) +external int _f3d_world_set_substeps(Pointer world, int substeps); +int f3d_world_set_substeps(int world, int substeps) => + _f3d_world_set_substeps(Pointer.fromAddress(world), substeps); + +@Native, Int32)>( + symbol: 'f3d_world_set_speculative', + isLeaf: true, +) +external int _f3d_world_set_speculative(Pointer world, int enabled); +int f3d_world_set_speculative(int world, int enabled) => + _f3d_world_set_speculative(Pointer.fromAddress(world), enabled); + +@Native, Float)>( + symbol: 'f3d_world_set_contact_margin', + isLeaf: true, +) +external int _f3d_world_set_contact_margin(Pointer world, double margin); +int f3d_world_set_contact_margin(int world, double margin) => + _f3d_world_set_contact_margin(Pointer.fromAddress(world), margin); + +@Native< + Uint32 Function( + Pointer, + Float, + Float, + Float, + Float, + Float, + Float, + Pointer, + Uint32, + ) +>(symbol: 'f3d_world_query_box', isLeaf: true) +external int _f3d_world_query_box( + Pointer world, + double lx, + double ly, + double lz, + double hx, + double hy, + double hz, + Pointer out, + int capacity, +); +int f3d_world_query_box( + int world, + double lx, + double ly, + double lz, + double hx, + double hy, + double hz, + int out, + int capacity, +) => _f3d_world_query_box( + Pointer.fromAddress(world), + lx, + ly, + lz, + hx, + hy, + hz, + Pointer.fromAddress(out), + capacity, +); + +@Native)>( + symbol: 'f3d_world_contact_count', + isLeaf: true, +) +external int _f3d_world_contact_count(Pointer world); +int f3d_world_contact_count(int world) => + _f3d_world_contact_count(Pointer.fromAddress(world)); + +@Native, Pointer, Pointer, Uint32)>( + symbol: 'f3d_world_read_contacts', + isLeaf: true, +) +external int _f3d_world_read_contacts( + Pointer world, + Pointer contacts, + Pointer pairs, + int capacity, +); +int f3d_world_read_contacts(int world, int contacts, int pairs, int capacity) => + _f3d_world_read_contacts( + Pointer.fromAddress(world), + Pointer.fromAddress(contacts), + Pointer.fromAddress(pairs), + capacity, + ); + +@Native)>( + symbol: 'f3d_world_events_dropped', + isLeaf: true, +) +external int _f3d_world_events_dropped(Pointer world); +int f3d_world_events_dropped(int world) => + _f3d_world_events_dropped(Pointer.fromAddress(world)); + +@Native< + Uint64 Function( + Pointer, + Int32, + Uint64, + Uint64, + Float, + Float, + Float, + Float, + Float, + Float, + ) +>(symbol: 'f3d_joint_create') +external int _f3d_joint_create( + Pointer world, + int type, + int a, + int b, + double ax, + double ay, + double az, + double ux, + double uy, + double uz, +); +int f3d_joint_create( + int world, + int type, + int a, + int b, + double ax, + double ay, + double az, + double ux, + double uy, + double uz, +) => _f3d_joint_create( + Pointer.fromAddress(world), + type, + a, + b, + ax, + ay, + az, + ux, + uy, + uz, +); + +@Native< + Uint64 Function( + Pointer, + Uint64, + Uint64, + Float, + Float, + Float, + Float, + Float, + Float, + ) +>(symbol: 'f3d_joint_create_distance') +external int _f3d_joint_create_distance( + Pointer world, + int a, + int b, + double ax, + double ay, + double az, + double bx, + double by, + double bz, +); +int f3d_joint_create_distance( + int world, + int a, + int b, + double ax, + double ay, + double az, + double bx, + double by, + double bz, +) => _f3d_joint_create_distance( + Pointer.fromAddress(world), + a, + b, + ax, + ay, + az, + bx, + by, + bz, +); + +@Native, Uint64)>(symbol: 'f3d_joint_destroy') +external int _f3d_joint_destroy(Pointer world, int joint); +int f3d_joint_destroy(int world, int joint) => + _f3d_joint_destroy(Pointer.fromAddress(world), joint); + +@Native, Uint64)>( + symbol: 'f3d_joint_is_valid', + isLeaf: true, +) +external int _f3d_joint_is_valid(Pointer world, int joint); +int f3d_joint_is_valid(int world, int joint) => + _f3d_joint_is_valid(Pointer.fromAddress(world), joint); + +@Native)>( + symbol: 'f3d_world_joint_count', + isLeaf: true, +) +external int _f3d_world_joint_count(Pointer world); +int f3d_world_joint_count(int world) => + _f3d_world_joint_count(Pointer.fromAddress(world)); + +@Native, Uint64, Int32, Float, Float)>( + symbol: 'f3d_joint_set_limits', + isLeaf: true, +) +external int _f3d_joint_set_limits( + Pointer world, + int joint, + int enabled, + double lower, + double upper, +); +int f3d_joint_set_limits( + int world, + int joint, + int enabled, + double lower, + double upper, +) => _f3d_joint_set_limits( + Pointer.fromAddress(world), + joint, + enabled, + lower, + upper, +); + +@Native, Uint64, Int32, Float)>( + symbol: 'f3d_joint_set_cone', + isLeaf: true, +) +external int _f3d_joint_set_cone( + Pointer world, + int joint, + int enabled, + double angle, +); +int f3d_joint_set_cone(int world, int joint, int enabled, double angle) => + _f3d_joint_set_cone(Pointer.fromAddress(world), joint, enabled, angle); + +@Native, Uint64, Int32, Float)>( + symbol: 'f3d_joint_set_friction', + isLeaf: true, +) +external int _f3d_joint_set_friction( + Pointer world, + int joint, + int enabled, + double torque, +); +int f3d_joint_set_friction(int world, int joint, int enabled, double torque) => + _f3d_joint_set_friction(Pointer.fromAddress(world), joint, enabled, torque); + +@Native, Uint64, Int32, Float, Float)>( + symbol: 'f3d_joint_set_motor', + isLeaf: true, +) +external int _f3d_joint_set_motor( + Pointer world, + int joint, + int enabled, + double speed, + double maxForce, +); +int f3d_joint_set_motor( + int world, + int joint, + int enabled, + double speed, + double maxForce, +) => _f3d_joint_set_motor( + Pointer.fromAddress(world), + joint, + enabled, + speed, + maxForce, +); + +@Native, Uint64, Int32, Float, Float)>( + symbol: 'f3d_joint_set_spring', + isLeaf: true, +) +external int _f3d_joint_set_spring( + Pointer world, + int joint, + int enabled, + double hertz, + double damping, +); +int f3d_joint_set_spring( + int world, + int joint, + int enabled, + double hertz, + double damping, +) => _f3d_joint_set_spring( + Pointer.fromAddress(world), + joint, + enabled, + hertz, + damping, +); + +@Native, Uint64, Float, Float, Float)>( + symbol: 'f3d_joint_set_length', + isLeaf: true, +) +external int _f3d_joint_set_length( + Pointer world, + int joint, + double length, + double least, + double most, +); +int f3d_joint_set_length( + int world, + int joint, + double length, + double least, + double most, +) => _f3d_joint_set_length( + Pointer.fromAddress(world), + joint, + length, + least, + most, +); + +@Native, Uint64, Int32)>( + symbol: 'f3d_joint_set_collide', + isLeaf: true, +) +external int _f3d_joint_set_collide( + Pointer world, + int joint, + int collide, +); +int f3d_joint_set_collide(int world, int joint, int collide) => + _f3d_joint_set_collide(Pointer.fromAddress(world), joint, collide); + +@Native, Uint64, Pointer)>( + symbol: 'f3d_joint_get_value', + isLeaf: true, +) +external int _f3d_joint_get_value( + Pointer world, + int joint, + Pointer out, +); +int f3d_joint_get_value(int world, int joint, int out) => _f3d_joint_get_value( + Pointer.fromAddress(world), + joint, + Pointer.fromAddress(out), +); + +@Native, Uint64, Pointer)>( + symbol: 'f3d_joint_get_swing', + isLeaf: true, +) +external int _f3d_joint_get_swing( + Pointer world, + int joint, + Pointer out, +); +int f3d_joint_get_swing(int world, int joint, int out) => _f3d_joint_get_swing( + Pointer.fromAddress(world), + joint, + Pointer.fromAddress(out), +); + +@Native, Uint64, Pointer)>( + symbol: 'f3d_joint_get_force', + isLeaf: true, +) +external int _f3d_joint_get_force( + Pointer world, + int joint, + Pointer out, +); +int f3d_joint_get_force(int world, int joint, int out) => _f3d_joint_get_force( + Pointer.fromAddress(world), + joint, + Pointer.fromAddress(out), +); + +@Native< + Int32 Function( + Pointer, + Float, + Float, + Float, + Float, + Float, + Float, + Float, + Uint32, + Uint64, + Pointer, + Pointer, + ) +>(symbol: 'f3d_world_ray_cast') +external int _f3d_world_ray_cast( + Pointer world, + double ox, + double oy, + double oz, + double dx, + double dy, + double dz, + double maxDistance, + int mask, + int ignore, + Pointer body, + Pointer hit, +); +int f3d_world_ray_cast( + int world, + double ox, + double oy, + double oz, + double dx, + double dy, + double dz, + double maxDistance, + int mask, + int ignore, + int body, + int hit, +) => _f3d_world_ray_cast( + Pointer.fromAddress(world), + ox, + oy, + oz, + dx, + dy, + dz, + maxDistance, + mask, + ignore, + Pointer.fromAddress(body), + Pointer.fromAddress(hit), +); + +@Native< + Uint32 Function( + Pointer, + Float, + Float, + Float, + Float, + Float, + Float, + Float, + Uint32, + Uint64, + Pointer, + Pointer, + Uint32, + ) +>(symbol: 'f3d_world_ray_cast_all') +external int _f3d_world_ray_cast_all( + Pointer world, + double ox, + double oy, + double oz, + double dx, + double dy, + double dz, + double maxDistance, + int mask, + int ignore, + Pointer bodies, + Pointer hits, + int capacity, +); +int f3d_world_ray_cast_all( + int world, + double ox, + double oy, + double oz, + double dx, + double dy, + double dz, + double maxDistance, + int mask, + int ignore, + int bodies, + int hits, + int capacity, +) => _f3d_world_ray_cast_all( + Pointer.fromAddress(world), + ox, + oy, + oz, + dx, + dy, + dz, + maxDistance, + mask, + ignore, + Pointer.fromAddress(bodies), + Pointer.fromAddress(hits), + capacity, +); + +@Native< + Uint32 Function( + Pointer, + Int32, + Float, + Float, + Float, + Float, + Float, + Float, + Float, + Float, + Float, + Float, + Float, + Uint32, + Uint64, + Pointer, + Uint32, + ) +>(symbol: 'f3d_world_overlap_shape') +external int _f3d_world_overlap_shape( + Pointer world, + int kind, + double a, + double b, + double c, + double rounding, + double px, + double py, + double pz, + double qx, + double qy, + double qz, + double qw, + int mask, + int ignore, + Pointer out, + int capacity, +); +int f3d_world_overlap_shape( + int world, + int kind, + double a, + double b, + double c, + double rounding, + double px, + double py, + double pz, + double qx, + double qy, + double qz, + double qw, + int mask, + int ignore, + int out, + int capacity, +) => _f3d_world_overlap_shape( + Pointer.fromAddress(world), + kind, + a, + b, + c, + rounding, + px, + py, + pz, + qx, + qy, + qz, + qw, + mask, + ignore, + Pointer.fromAddress(out), + capacity, +); + +@Native< + Int32 Function( + Pointer, + Int32, + Float, + Float, + Float, + Float, + Float, + Float, + Float, + Float, + Float, + Float, + Float, + Float, + Float, + Float, + Uint32, + Uint64, + Pointer, + Pointer, + ) +>(symbol: 'f3d_world_cast_shape') +external int _f3d_world_cast_shape( + Pointer world, + int kind, + double a, + double b, + double c, + double rounding, + double px, + double py, + double pz, + double qx, + double qy, + double qz, + double qw, + double tx, + double ty, + double tz, + int mask, + int ignore, + Pointer body, + Pointer hit, +); +int f3d_world_cast_shape( + int world, + int kind, + double a, + double b, + double c, + double rounding, + double px, + double py, + double pz, + double qx, + double qy, + double qz, + double qw, + double tx, + double ty, + double tz, + int mask, + int ignore, + int body, + int hit, +) => _f3d_world_cast_shape( + Pointer.fromAddress(world), + kind, + a, + b, + c, + rounding, + px, + py, + pz, + qx, + qy, + qz, + qw, + tx, + ty, + tz, + mask, + ignore, + Pointer.fromAddress(body), + Pointer.fromAddress(hit), +); + +@Native< + Uint32 Function( + Pointer, + Float, + Float, + Pointer, + Float, + Float, + Float, + Float, + Float, + Uint32, + Uint64, + Pointer, + Pointer, + ) +>(symbol: 'f3d_world_move_character') +external int _f3d_world_move_character( + Pointer world, + double radius, + double halfHeight, + Pointer position, + double dx, + double dy, + double dz, + double maxSlopeCos, + double stepHeight, + int mask, + int ignore, + Pointer groundBody, + Pointer ground, +); +int f3d_world_move_character( + int world, + double radius, + double halfHeight, + int position, + double dx, + double dy, + double dz, + double maxSlopeCos, + double stepHeight, + int mask, + int ignore, + int groundBody, + int ground, +) => _f3d_world_move_character( + Pointer.fromAddress(world), + radius, + halfHeight, + Pointer.fromAddress(position), + dx, + dy, + dz, + maxSlopeCos, + stepHeight, + mask, + ignore, + Pointer.fromAddress(groundBody), + Pointer.fromAddress(ground), +); + +@Native Function(Uint32)>(symbol: 'f3d_particles_create') +external Pointer _f3d_particles_create(int capacity); +int f3d_particles_create(int capacity) => + _f3d_particles_create(capacity).address; + +@Native)>(symbol: 'f3d_particles_destroy') +external void _f3d_particles_destroy(Pointer particles); +void f3d_particles_destroy(int particles) => + _f3d_particles_destroy(Pointer.fromAddress(particles)); + +@Native)>( + symbol: 'f3d_particles_capacity', + isLeaf: true, +) +external int _f3d_particles_capacity(Pointer particles); +int f3d_particles_capacity(int particles) => + _f3d_particles_capacity(Pointer.fromAddress(particles)); + +@Native, Pointer, Uint32)>( + symbol: 'f3d_particles_emit', + isLeaf: true, +) +external int _f3d_particles_emit( + Pointer particles, + Pointer particlesData, + int count, +); +int f3d_particles_emit(int particles, int particlesData, int count) => + _f3d_particles_emit( + Pointer.fromAddress(particles), + Pointer.fromAddress(particlesData), + count, + ); + +@Native, Pointer, Float)>( + symbol: 'f3d_particles_step', + isLeaf: true, +) +external void _f3d_particles_step( + Pointer particles, + Pointer forces, + double dt, +); +void f3d_particles_step(int particles, int forces, double dt) => + _f3d_particles_step( + Pointer.fromAddress(particles), + Pointer.fromAddress(forces), + dt, + ); + +@Native, Pointer, Uint32)>( + symbol: 'f3d_particles_read', + isLeaf: true, +) +external int _f3d_particles_read( + Pointer particles, + Pointer out, + int capacity, +); +int f3d_particles_read(int particles, int out, int capacity) => + _f3d_particles_read( + Pointer.fromAddress(particles), + Pointer.fromAddress(out), + capacity, + ); + +@Native Function(Uint32)>(symbol: 'f3d_debris_create') +external Pointer _f3d_debris_create(int capacity); +int f3d_debris_create(int capacity) => _f3d_debris_create(capacity).address; + +@Native)>(symbol: 'f3d_debris_destroy') +external void _f3d_debris_destroy(Pointer debris); +void f3d_debris_destroy(int debris) => + _f3d_debris_destroy(Pointer.fromAddress(debris)); + +@Native)>( + symbol: 'f3d_debris_capacity', + isLeaf: true, +) +external int _f3d_debris_capacity(Pointer debris); +int f3d_debris_capacity(int debris) => + _f3d_debris_capacity(Pointer.fromAddress(debris)); + +@Native, Pointer, Uint32)>( + symbol: 'f3d_debris_add', + isLeaf: true, +) +external int _f3d_debris_add( + Pointer debris, + Pointer data, + int count, +); +int f3d_debris_add(int debris, int data, int count) => _f3d_debris_add( + Pointer.fromAddress(debris), + Pointer.fromAddress(data), + count, +); + +@Native, Pointer, Uint32)>( + symbol: 'f3d_debris_set_statics', + isLeaf: true, +) +external int _f3d_debris_set_statics( + Pointer debris, + Pointer data, + int count, +); +int f3d_debris_set_statics(int debris, int data, int count) => + _f3d_debris_set_statics( + Pointer.fromAddress(debris), + Pointer.fromAddress(data), + count, + ); + +@Native, Pointer, Float)>( + symbol: 'f3d_debris_step', +) +external void _f3d_debris_step( + Pointer debris, + Pointer settings, + double dt, +); +void f3d_debris_step(int debris, int settings, double dt) => _f3d_debris_step( + Pointer.fromAddress(debris), + Pointer.fromAddress(settings), + dt, +); + +@Native, Pointer, Uint32)>( + symbol: 'f3d_debris_read', + isLeaf: true, +) +external int _f3d_debris_read( + Pointer debris, + Pointer out, + int capacity, +); +int f3d_debris_read(int debris, int out, int capacity) => _f3d_debris_read( + Pointer.fromAddress(debris), + Pointer.fromAddress(out), + capacity, +); + +@Native< + Pointer Function( + Pointer, + Uint32, + Pointer, + Pointer, + Uint32, + ) +>(symbol: 'f3d_cloth_create') +external Pointer _f3d_cloth_create( + Pointer points, + int pointCount, + Pointer edges, + Pointer compliance, + int edgeCount, +); +int f3d_cloth_create( + int points, + int pointCount, + int edges, + int compliance, + int edgeCount, +) => _f3d_cloth_create( + Pointer.fromAddress(points), + pointCount, + Pointer.fromAddress(edges), + Pointer.fromAddress(compliance), + edgeCount, +).address; + +@Native)>(symbol: 'f3d_cloth_destroy') +external void _f3d_cloth_destroy(Pointer cloth); +void f3d_cloth_destroy(int cloth) => + _f3d_cloth_destroy(Pointer.fromAddress(cloth)); + +@Native)>( + symbol: 'f3d_cloth_point_count', + isLeaf: true, +) +external int _f3d_cloth_point_count(Pointer cloth); +int f3d_cloth_point_count(int cloth) => + _f3d_cloth_point_count(Pointer.fromAddress(cloth)); + +@Native)>( + symbol: 'f3d_cloth_colour_count', + isLeaf: true, +) +external int _f3d_cloth_colour_count(Pointer cloth); +int f3d_cloth_colour_count(int cloth) => + _f3d_cloth_colour_count(Pointer.fromAddress(cloth)); + +@Native)>( + symbol: 'f3d_cloth_edge_count', + isLeaf: true, +) +external int _f3d_cloth_edge_count(Pointer cloth); +int f3d_cloth_edge_count(int cloth) => + _f3d_cloth_edge_count(Pointer.fromAddress(cloth)); + +@Native< + Void Function( + Pointer, + Pointer, + Pointer, + Pointer, + Pointer, + ) +>(symbol: 'f3d_cloth_edges', isLeaf: true) +external void _f3d_cloth_edges( + Pointer cloth, + Pointer pairs, + Pointer rest, + Pointer compliance, + Pointer colourStart, +); +void f3d_cloth_edges( + int cloth, + int pairs, + int rest, + int compliance, + int colourStart, +) => _f3d_cloth_edges( + Pointer.fromAddress(cloth), + Pointer.fromAddress(pairs), + Pointer.fromAddress(rest), + Pointer.fromAddress(compliance), + Pointer.fromAddress(colourStart), +); + +@Native, Pointer, Uint32)>( + symbol: 'f3d_cloth_set_balls', + isLeaf: true, +) +external int _f3d_cloth_set_balls( + Pointer cloth, + Pointer balls, + int count, +); +int f3d_cloth_set_balls(int cloth, int balls, int count) => + _f3d_cloth_set_balls( + Pointer.fromAddress(cloth), + Pointer.fromAddress(balls), + count, + ); + +@Native, Uint32, Float, Float, Float)>( + symbol: 'f3d_cloth_move_point', + isLeaf: true, +) +external void _f3d_cloth_move_point( + Pointer cloth, + int index, + double x, + double y, + double z, +); +void f3d_cloth_move_point(int cloth, int index, double x, double y, double z) => + _f3d_cloth_move_point(Pointer.fromAddress(cloth), index, x, y, z); + +@Native, Pointer, Float)>( + symbol: 'f3d_cloth_step', +) +external void _f3d_cloth_step( + Pointer cloth, + Pointer settings, + double dt, +); +void f3d_cloth_step(int cloth, int settings, double dt) => _f3d_cloth_step( + Pointer.fromAddress(cloth), + Pointer.fromAddress(settings), + dt, +); + +@Native, Pointer, Uint32)>( + symbol: 'f3d_cloth_read', + isLeaf: true, +) +external int _f3d_cloth_read( + Pointer cloth, + Pointer out, + int capacity, +); +int f3d_cloth_read(int cloth, int out, int capacity) => _f3d_cloth_read( + Pointer.fromAddress(cloth), + Pointer.fromAddress(out), + capacity, +); + +@Native Function(Uint32, Float)>(symbol: 'f3d_fluid_create') +external Pointer _f3d_fluid_create(int capacity, double spacing); +int f3d_fluid_create(int capacity, double spacing) => + _f3d_fluid_create(capacity, spacing).address; + +@Native)>(symbol: 'f3d_fluid_destroy') +external void _f3d_fluid_destroy(Pointer fluid); +void f3d_fluid_destroy(int fluid) => + _f3d_fluid_destroy(Pointer.fromAddress(fluid)); + +@Native)>( + symbol: 'f3d_fluid_capacity', + isLeaf: true, +) +external int _f3d_fluid_capacity(Pointer fluid); +int f3d_fluid_capacity(int fluid) => + _f3d_fluid_capacity(Pointer.fromAddress(fluid)); + +@Native)>( + symbol: 'f3d_fluid_rest_density', + isLeaf: true, +) +external double _f3d_fluid_rest_density(Pointer fluid); +double f3d_fluid_rest_density(int fluid) => + _f3d_fluid_rest_density(Pointer.fromAddress(fluid)); + +@Native, Pointer, Uint32)>( + symbol: 'f3d_fluid_add', + isLeaf: true, +) +external int _f3d_fluid_add(Pointer fluid, Pointer data, int count); +int f3d_fluid_add(int fluid, int data, int count) => _f3d_fluid_add( + Pointer.fromAddress(fluid), + Pointer.fromAddress(data), + count, +); + +@Native, Pointer, Float)>( + symbol: 'f3d_fluid_step', +) +external void _f3d_fluid_step( + Pointer fluid, + Pointer settings, + double dt, +); +void f3d_fluid_step(int fluid, int settings, double dt) => _f3d_fluid_step( + Pointer.fromAddress(fluid), + Pointer.fromAddress(settings), + dt, +); + +@Native, Pointer, Uint32)>( + symbol: 'f3d_fluid_read', + isLeaf: true, +) +external int _f3d_fluid_read( + Pointer fluid, + Pointer out, + int capacity, +); +int f3d_fluid_read(int fluid, int out, int capacity) => _f3d_fluid_read( + Pointer.fromAddress(fluid), + Pointer.fromAddress(out), + capacity, +); + +@Native)>( + symbol: 'f3d_world_snapshot_size', + isLeaf: true, +) +external int _f3d_world_snapshot_size(Pointer world); +int f3d_world_snapshot_size(int world) => + _f3d_world_snapshot_size(Pointer.fromAddress(world)); + +@Native, Pointer, Uint32)>( + symbol: 'f3d_world_snapshot_write', + isLeaf: true, +) +external int _f3d_world_snapshot_write( + Pointer world, + Pointer buffer, + int size, +); +int f3d_world_snapshot_write(int world, int buffer, int size) => + _f3d_world_snapshot_write( + Pointer.fromAddress(world), + Pointer.fromAddress(buffer), + size, + ); + +@Native, Pointer, Uint32)>( + symbol: 'f3d_world_restore', +) +external int _f3d_world_restore( + Pointer world, + Pointer buffer, + int size, +); +int f3d_world_restore(int world, int buffer, int size) => _f3d_world_restore( + Pointer.fromAddress(world), + Pointer.fromAddress(buffer), + size, +); + +@Native, Int32, Float, Float, Float, Float)>( + symbol: 'f3d_body_create', +) +external int _f3d_body_create( + Pointer world, + int type, + double px, + double py, + double pz, + double mass, +); +int f3d_body_create( + int world, + int type, + double px, + double py, + double pz, + double mass, +) => _f3d_body_create(Pointer.fromAddress(world), type, px, py, pz, mass); + +@Native, Uint64)>( + symbol: 'f3d_body_destroy', + isLeaf: true, +) +external int _f3d_body_destroy(Pointer world, int body); +int f3d_body_destroy(int world, int body) => + _f3d_body_destroy(Pointer.fromAddress(world), body); + +@Native, Uint64)>( + symbol: 'f3d_body_is_valid', + isLeaf: true, +) +external int _f3d_body_is_valid(Pointer world, int body); +int f3d_body_is_valid(int world, int body) => + _f3d_body_is_valid(Pointer.fromAddress(world), body); + +@Native, Uint64, Float, Float, Float)>( + symbol: 'f3d_body_set_velocity', + isLeaf: true, +) +external int _f3d_body_set_velocity( + Pointer world, + int body, + double x, + double y, + double z, +); +int f3d_body_set_velocity(int world, int body, double x, double y, double z) => + _f3d_body_set_velocity(Pointer.fromAddress(world), body, x, y, z); + +@Native, Uint64, Pointer)>( + symbol: 'f3d_body_get_velocity', + isLeaf: true, +) +external int _f3d_body_get_velocity( + Pointer world, + int body, + Pointer out, +); +int f3d_body_get_velocity(int world, int body, int out) => + _f3d_body_get_velocity( + Pointer.fromAddress(world), + body, + Pointer.fromAddress(out), + ); + +@Native, Uint64, Float, Float, Float)>( + symbol: 'f3d_body_set_position', + isLeaf: true, +) +external int _f3d_body_set_position( + Pointer world, + int body, + double x, + double y, + double z, +); +int f3d_body_set_position(int world, int body, double x, double y, double z) => + _f3d_body_set_position(Pointer.fromAddress(world), body, x, y, z); + +@Native, Uint64, Pointer)>( + symbol: 'f3d_body_get_position', + isLeaf: true, +) +external int _f3d_body_get_position( + Pointer world, + int body, + Pointer out, +); +int f3d_body_get_position(int world, int body, int out) => + _f3d_body_get_position( + Pointer.fromAddress(world), + body, + Pointer.fromAddress(out), + ); + +@Native, Uint64, Pointer)>( + symbol: 'f3d_body_get_world_position', + isLeaf: true, +) +external int _f3d_body_get_world_position( + Pointer world, + int body, + Pointer out, +); +int f3d_body_get_world_position(int world, int body, int out) => + _f3d_body_get_world_position( + Pointer.fromAddress(world), + body, + Pointer.fromAddress(out), + ); + +@Native, Uint64, Float, Float, Float)>( + symbol: 'f3d_body_set_angular_velocity', + isLeaf: true, +) +external int _f3d_body_set_angular_velocity( + Pointer world, + int body, + double x, + double y, + double z, +); +int f3d_body_set_angular_velocity( + int world, + int body, + double x, + double y, + double z, +) => _f3d_body_set_angular_velocity(Pointer.fromAddress(world), body, x, y, z); + +@Native, Uint64, Pointer)>( + symbol: 'f3d_body_get_angular_velocity', + isLeaf: true, +) +external int _f3d_body_get_angular_velocity( + Pointer world, + int body, + Pointer out, +); +int f3d_body_get_angular_velocity(int world, int body, int out) => + _f3d_body_get_angular_velocity( + Pointer.fromAddress(world), + body, + Pointer.fromAddress(out), + ); + +@Native, Uint64, Float, Float, Float, Float)>( + symbol: 'f3d_body_set_orientation', + isLeaf: true, +) +external int _f3d_body_set_orientation( + Pointer world, + int body, + double x, + double y, + double z, + double w, +); +int f3d_body_set_orientation( + int world, + int body, + double x, + double y, + double z, + double w, +) => _f3d_body_set_orientation(Pointer.fromAddress(world), body, x, y, z, w); + +@Native, Uint64, Pointer)>( + symbol: 'f3d_body_get_orientation', + isLeaf: true, +) +external int _f3d_body_get_orientation( + Pointer world, + int body, + Pointer out, +); +int f3d_body_get_orientation(int world, int body, int out) => + _f3d_body_get_orientation( + Pointer.fromAddress(world), + body, + Pointer.fromAddress(out), + ); + +@Native, Uint64, Int32, Float, Float, Float)>( + symbol: 'f3d_body_set_shape', + isLeaf: true, +) +external int _f3d_body_set_shape( + Pointer world, + int body, + int kind, + double a, + double b, + double c, +); +int f3d_body_set_shape( + int world, + int body, + int kind, + double a, + double b, + double c, +) => _f3d_body_set_shape(Pointer.fromAddress(world), body, kind, a, b, c); + +@Native, Uint64, Pointer)>( + symbol: 'f3d_body_get_inertia', + isLeaf: true, +) +external int _f3d_body_get_inertia( + Pointer world, + int body, + Pointer out, +); +int f3d_body_get_inertia(int world, int body, int out) => _f3d_body_get_inertia( + Pointer.fromAddress(world), + body, + Pointer.fromAddress(out), +); + +@Native, Uint64, Pointer)>( + symbol: 'f3d_body_get_inertia_tensor', + isLeaf: true, +) +external int _f3d_body_get_inertia_tensor( + Pointer world, + int body, + Pointer out, +); +int f3d_body_get_inertia_tensor(int world, int body, int out) => + _f3d_body_get_inertia_tensor( + Pointer.fromAddress(world), + body, + Pointer.fromAddress(out), + ); + +@Native, Uint64, Float)>( + symbol: 'f3d_body_set_rounding', + isLeaf: true, +) +external int _f3d_body_set_rounding( + Pointer world, + int body, + double radius, +); +int f3d_body_set_rounding(int world, int body, double radius) => + _f3d_body_set_rounding(Pointer.fromAddress(world), body, radius); + +@Native, Uint64, Uint32)>( + symbol: 'f3d_body_set_hull', + isLeaf: true, +) +external int _f3d_body_set_hull(Pointer world, int body, int hull); +int f3d_body_set_hull(int world, int body, int hull) => + _f3d_body_set_hull(Pointer.fromAddress(world), body, hull); + +@Native, Uint64, Uint32)>( + symbol: 'f3d_body_set_mesh', + isLeaf: true, +) +external int _f3d_body_set_mesh(Pointer world, int body, int mesh); +int f3d_body_set_mesh(int world, int body, int mesh) => + _f3d_body_set_mesh(Pointer.fromAddress(world), body, mesh); + +@Native, Uint64, Pointer)>( + symbol: 'f3d_body_get_mass', + isLeaf: true, +) +external int _f3d_body_get_mass( + Pointer world, + int body, + Pointer out, +); +int f3d_body_get_mass(int world, int body, int out) => _f3d_body_get_mass( + Pointer.fromAddress(world), + body, + Pointer.fromAddress(out), +); + +@Native, Uint64, Int32)>( + symbol: 'f3d_body_lock_rotation', + isLeaf: true, +) +external int _f3d_body_lock_rotation(Pointer world, int body, int locked); +int f3d_body_lock_rotation(int world, int body, int locked) => + _f3d_body_lock_rotation(Pointer.fromAddress(world), body, locked); + +@Native, Uint64, Float, Float)>( + symbol: 'f3d_body_set_damping', + isLeaf: true, +) +external int _f3d_body_set_damping( + Pointer world, + int body, + double linear, + double angular, +); +int f3d_body_set_damping(int world, int body, double linear, double angular) => + _f3d_body_set_damping(Pointer.fromAddress(world), body, linear, angular); + +@Native, Uint64, Float)>( + symbol: 'f3d_body_set_drag', + isLeaf: true, +) +external int _f3d_body_set_drag( + Pointer world, + int body, + double coefficient, +); +int f3d_body_set_drag(int world, int body, double coefficient) => + _f3d_body_set_drag(Pointer.fromAddress(world), body, coefficient); + +@Native, Uint64, Float, Float, Float)>( + symbol: 'f3d_body_apply_impulse', + isLeaf: true, +) +external int _f3d_body_apply_impulse( + Pointer world, + int body, + double x, + double y, + double z, +); +int f3d_body_apply_impulse(int world, int body, double x, double y, double z) => + _f3d_body_apply_impulse(Pointer.fromAddress(world), body, x, y, z); + +@Native< + Int32 Function( + Pointer, + Uint64, + Float, + Float, + Float, + Float, + Float, + Float, + ) +>(symbol: 'f3d_body_apply_impulse_at', isLeaf: true) +external int _f3d_body_apply_impulse_at( + Pointer world, + int body, + double x, + double y, + double z, + double px, + double py, + double pz, +); +int f3d_body_apply_impulse_at( + int world, + int body, + double x, + double y, + double z, + double px, + double py, + double pz, +) => _f3d_body_apply_impulse_at( + Pointer.fromAddress(world), + body, + x, + y, + z, + px, + py, + pz, +); + +@Native, Uint64, Float, Float, Float)>( + symbol: 'f3d_body_add_force', + isLeaf: true, +) +external int _f3d_body_add_force( + Pointer world, + int body, + double x, + double y, + double z, +); +int f3d_body_add_force(int world, int body, double x, double y, double z) => + _f3d_body_add_force(Pointer.fromAddress(world), body, x, y, z); + +@Native, Uint64, Float, Float, Float)>( + symbol: 'f3d_body_add_torque', + isLeaf: true, +) +external int _f3d_body_add_torque( + Pointer world, + int body, + double x, + double y, + double z, +); +int f3d_body_add_torque(int world, int body, double x, double y, double z) => + _f3d_body_add_torque(Pointer.fromAddress(world), body, x, y, z); + +@Native, Uint64, Int32)>( + symbol: 'f3d_body_set_bullet', + isLeaf: true, +) +external int _f3d_body_set_bullet(Pointer world, int body, int bullet); +int f3d_body_set_bullet(int world, int body, int bullet) => + _f3d_body_set_bullet(Pointer.fromAddress(world), body, bullet); + +@Native, Uint64, Float)>( + symbol: 'f3d_body_set_friction', + isLeaf: true, +) +external int _f3d_body_set_friction( + Pointer world, + int body, + double friction, +); +int f3d_body_set_friction(int world, int body, double friction) => + _f3d_body_set_friction(Pointer.fromAddress(world), body, friction); + +@Native, Uint64, Float)>( + symbol: 'f3d_body_set_restitution', + isLeaf: true, +) +external int _f3d_body_set_restitution( + Pointer world, + int body, + double restitution, +); +int f3d_body_set_restitution(int world, int body, double restitution) => + _f3d_body_set_restitution(Pointer.fromAddress(world), body, restitution); + +@Native, Uint64, Uint32, Uint32)>( + symbol: 'f3d_body_set_collision_filter', + isLeaf: true, +) +external int _f3d_body_set_collision_filter( + Pointer world, + int body, + int layer, + int mask, +); +int f3d_body_set_collision_filter(int world, int body, int layer, int mask) => + _f3d_body_set_collision_filter( + Pointer.fromAddress(world), + body, + layer, + mask, + ); + +@Native, Uint64)>( + symbol: 'f3d_body_is_asleep', + isLeaf: true, +) +external int _f3d_body_is_asleep(Pointer world, int body); +int f3d_body_is_asleep(int world, int body) => + _f3d_body_is_asleep(Pointer.fromAddress(world), body); + +@Native, Uint64)>( + symbol: 'f3d_body_wake', + isLeaf: true, +) +external int _f3d_body_wake(Pointer world, int body); +int f3d_body_wake(int world, int body) => + _f3d_body_wake(Pointer.fromAddress(world), body); + +@Native)>( + symbol: 'f3d_material_preset', + isLeaf: true, +) +external int _f3d_material_preset(int kind, Pointer out); +int f3d_material_preset(int kind, int out) => + _f3d_material_preset(kind, Pointer.fromAddress(out)); + +@Native, Uint64, Pointer)>( + symbol: 'f3d_body_set_material', + isLeaf: true, +) +external int _f3d_body_set_material( + Pointer world, + int body, + Pointer material, +); +int f3d_body_set_material(int world, int body, int material) => + _f3d_body_set_material( + Pointer.fromAddress(world), + body, + Pointer.fromAddress(material), + ); + +@Native, Uint64, Float)>( + symbol: 'f3d_body_set_temperature', + isLeaf: true, +) +external int _f3d_body_set_temperature( + Pointer world, + int body, + double kelvin, +); +int f3d_body_set_temperature(int world, int body, double kelvin) => + _f3d_body_set_temperature(Pointer.fromAddress(world), body, kelvin); + +@Native, Uint64, Pointer)>( + symbol: 'f3d_body_get_temperature', + isLeaf: true, +) +external int _f3d_body_get_temperature( + Pointer world, + int body, + Pointer out, +); +int f3d_body_get_temperature(int world, int body, int out) => + _f3d_body_get_temperature( + Pointer.fromAddress(world), + body, + Pointer.fromAddress(out), + ); + +@Native, Uint64, Float)>( + symbol: 'f3d_body_add_heat', + isLeaf: true, +) +external int _f3d_body_add_heat(Pointer world, int body, double joules); +int f3d_body_add_heat(int world, int body, double joules) => + _f3d_body_add_heat(Pointer.fromAddress(world), body, joules); + +@Native, Uint64, Float)>( + symbol: 'f3d_body_add_water', + isLeaf: true, +) +external int _f3d_body_add_water(Pointer world, int body, double kg); +int f3d_body_add_water(int world, int body, double kg) => + _f3d_body_add_water(Pointer.fromAddress(world), body, kg); + +@Native, Uint64, Pointer)>( + symbol: 'f3d_body_get_water', + isLeaf: true, +) +external int _f3d_body_get_water( + Pointer world, + int body, + Pointer out, +); +int f3d_body_get_water(int world, int body, int out) => _f3d_body_get_water( + Pointer.fromAddress(world), + body, + Pointer.fromAddress(out), +); + +@Native, Uint64, Pointer)>( + symbol: 'f3d_body_get_fuel', + isLeaf: true, +) +external int _f3d_body_get_fuel( + Pointer world, + int body, + Pointer out, +); +int f3d_body_get_fuel(int world, int body, int out) => _f3d_body_get_fuel( + Pointer.fromAddress(world), + body, + Pointer.fromAddress(out), +); + +@Native, Uint64, Pointer)>( + symbol: 'f3d_body_is_burning', + isLeaf: true, +) +external int _f3d_body_is_burning( + Pointer world, + int body, + Pointer out, +); +int f3d_body_is_burning(int world, int body, int out) => _f3d_body_is_burning( + Pointer.fromAddress(world), + body, + Pointer.fromAddress(out), +); + +@Native, Uint64, Pointer)>( + symbol: 'f3d_body_get_heat_release', + isLeaf: true, +) +external int _f3d_body_get_heat_release( + Pointer world, + int body, + Pointer out, +); +int f3d_body_get_heat_release(int world, int body, int out) => + _f3d_body_get_heat_release( + Pointer.fromAddress(world), + body, + Pointer.fromAddress(out), + ); diff --git a/packages/flutter3d_physics_native/lib/src/core/calls_web.g.dart b/packages/flutter3d_physics_native/lib/src/core/calls_web.g.dart new file mode 100644 index 000000000..fe30c54d3 --- /dev/null +++ b/packages/flutter3d_physics_native/lib/src/core/calls_web.g.dart @@ -0,0 +1,1870 @@ +// Generated by tool/gen_core.dart from csrc/include/f3d_physics.h: do not edit. +// +// The core's calls in the browser: the WebAssembly module's exports, a +// pointer an offset into its memory, a 64-bit handle two 32-bit halves. +// ignore_for_file: non_constant_identifier_names +library; + +import 'dart:js_interop'; + +import 'module_web.dart'; + +extension type _Exports(JSObject _) implements JSObject { + @JS('f3d_abi_version') + external JSNumber f3d_abi_version(); + @JS('f3d_real_bytes') + external JSNumber f3d_real_bytes(); + @JS('f3d_buffer_alloc') + external JSNumber f3d_buffer_alloc(JSNumber bytes); + @JS('f3d_buffer_free') + external void f3d_buffer_free(JSNumber buffer); + @JS('f3d_world_create') + external JSNumber f3d_world_create(); + @JS('f3d_world_destroy') + external void f3d_world_destroy(JSNumber world); + @JS('f3d_world_set_gravity') + external void f3d_world_set_gravity( + JSNumber world, + JSNumber x, + JSNumber y, + JSNumber z, + ); + @JS('f3d_world_get_gravity') + external void f3d_world_get_gravity(JSNumber world, JSNumber out); + @JS('f3d_world_set_air') + external JSNumber f3d_world_set_air( + JSNumber world, + JSNumber temperature, + JSNumber density, + ); + @JS('f3d_world_get_air') + external void f3d_world_get_air(JSNumber world, JSNumber out); + @JS('f3d_world_set_wind') + external void f3d_world_set_wind( + JSNumber world, + JSNumber x, + JSNumber y, + JSNumber z, + ); + @JS('f3d_world_set_wind_grid') + external JSNumber f3d_world_set_wind_grid( + JSNumber world, + JSNumber ox, + JSNumber oy, + JSNumber oz, + JSNumber cell, + JSNumber nx, + JSNumber ny, + JSNumber nz, + JSNumber velocities, + ); + @JS('f3d_world_sample_wind') + external void f3d_world_sample_wind( + JSNumber world, + JSNumber x, + JSNumber y, + JSNumber z, + JSNumber out, + ); + @JS('f3d_world_set_sleep') + external JSNumber f3d_world_set_sleep( + JSNumber world, + JSNumber speed, + JSNumber time, + ); + @JS('f3d_world_get_origin') + external void f3d_world_get_origin(JSNumber world, JSNumber out); + @JS('f3d_world_shift_origin') + external void f3d_world_shift_origin( + JSNumber world, + JSNumber dx, + JSNumber dy, + JSNumber dz, + ); + @JS('f3d_world_create_hull') + external JSNumber f3d_world_create_hull( + JSNumber world, + JSNumber points, + JSNumber count, + ); + @JS('f3d_world_create_mesh') + external JSNumber f3d_world_create_mesh( + JSNumber world, + JSNumber vertices, + JSNumber vertexCount, + JSNumber indices, + JSNumber triangleCount, + ); + @JS('f3d_world_mesh_triangle_count') + external JSNumber f3d_world_mesh_triangle_count( + JSNumber world, + JSNumber mesh, + ); + @JS('f3d_world_mesh_internal_edges') + external JSNumber f3d_world_mesh_internal_edges( + JSNumber world, + JSNumber mesh, + ); + @JS('f3d_world_get_hull_offset') + external JSNumber f3d_world_get_hull_offset( + JSNumber world, + JSNumber hull, + JSNumber out, + ); + @JS('f3d_world_hull_vertex_count') + external JSNumber f3d_world_hull_vertex_count(JSNumber world, JSNumber hull); + @JS('f3d_world_body_count') + external JSNumber f3d_world_body_count(JSNumber world); + @JS('f3d_world_step') + external void f3d_world_step(JSNumber world, JSNumber dt); + @JS('f3d_world_set_threads') + external JSNumber f3d_world_set_threads(JSNumber world, JSNumber threads); + @JS('f3d_world_threads') + external JSNumber f3d_world_threads(JSNumber world); + @JS('f3d_world_set_fast') + external JSNumber f3d_world_set_fast(JSNumber world, JSNumber fast); + @JS('f3d_world_fast') + external JSNumber f3d_world_fast(JSNumber world); + @JS('f3d_world_read_transforms') + external JSNumber f3d_world_read_transforms( + JSNumber world, + JSNumber transforms, + JSNumber handles, + JSNumber capacity, + ); + @JS('f3d_world_read_fires') + external JSNumber f3d_world_read_fires( + JSNumber world, + JSNumber fires, + JSNumber handles, + JSNumber capacity, + ); + @JS('f3d_world_read_events') + external JSNumber f3d_world_read_events( + JSNumber world, + JSNumber bodies, + JSNumber others, + JSNumber kinds, + JSNumber capacity, + ); + @JS('f3d_world_set_substeps') + external JSNumber f3d_world_set_substeps(JSNumber world, JSNumber substeps); + @JS('f3d_world_set_speculative') + external JSNumber f3d_world_set_speculative(JSNumber world, JSNumber enabled); + @JS('f3d_world_set_contact_margin') + external JSNumber f3d_world_set_contact_margin( + JSNumber world, + JSNumber margin, + ); + @JS('f3d_world_query_box') + external JSNumber f3d_world_query_box( + JSNumber world, + JSNumber lx, + JSNumber ly, + JSNumber lz, + JSNumber hx, + JSNumber hy, + JSNumber hz, + JSNumber out, + JSNumber capacity, + ); + @JS('f3d_world_contact_count') + external JSNumber f3d_world_contact_count(JSNumber world); + @JS('f3d_world_read_contacts') + external JSNumber f3d_world_read_contacts( + JSNumber world, + JSNumber contacts, + JSNumber pairs, + JSNumber capacity, + ); + @JS('f3d_world_events_dropped') + external JSNumber f3d_world_events_dropped(JSNumber world); + @JS('f3d_joint_create__w') + external JSNumber f3d_joint_create( + JSNumber world, + JSNumber type, + JSNumber aLow, + JSNumber aHigh, + JSNumber bLow, + JSNumber bHigh, + JSNumber ax, + JSNumber ay, + JSNumber az, + JSNumber ux, + JSNumber uy, + JSNumber uz, + ); + @JS('f3d_joint_create_distance__w') + external JSNumber f3d_joint_create_distance( + JSNumber world, + JSNumber aLow, + JSNumber aHigh, + JSNumber bLow, + JSNumber bHigh, + JSNumber ax, + JSNumber ay, + JSNumber az, + JSNumber bx, + JSNumber by, + JSNumber bz, + ); + @JS('f3d_joint_destroy__w') + external JSNumber f3d_joint_destroy( + JSNumber world, + JSNumber jointLow, + JSNumber jointHigh, + ); + @JS('f3d_joint_is_valid__w') + external JSNumber f3d_joint_is_valid( + JSNumber world, + JSNumber jointLow, + JSNumber jointHigh, + ); + @JS('f3d_world_joint_count') + external JSNumber f3d_world_joint_count(JSNumber world); + @JS('f3d_joint_set_limits__w') + external JSNumber f3d_joint_set_limits( + JSNumber world, + JSNumber jointLow, + JSNumber jointHigh, + JSNumber enabled, + JSNumber lower, + JSNumber upper, + ); + @JS('f3d_joint_set_cone__w') + external JSNumber f3d_joint_set_cone( + JSNumber world, + JSNumber jointLow, + JSNumber jointHigh, + JSNumber enabled, + JSNumber angle, + ); + @JS('f3d_joint_set_friction__w') + external JSNumber f3d_joint_set_friction( + JSNumber world, + JSNumber jointLow, + JSNumber jointHigh, + JSNumber enabled, + JSNumber torque, + ); + @JS('f3d_joint_set_motor__w') + external JSNumber f3d_joint_set_motor( + JSNumber world, + JSNumber jointLow, + JSNumber jointHigh, + JSNumber enabled, + JSNumber speed, + JSNumber maxForce, + ); + @JS('f3d_joint_set_spring__w') + external JSNumber f3d_joint_set_spring( + JSNumber world, + JSNumber jointLow, + JSNumber jointHigh, + JSNumber enabled, + JSNumber hertz, + JSNumber damping, + ); + @JS('f3d_joint_set_length__w') + external JSNumber f3d_joint_set_length( + JSNumber world, + JSNumber jointLow, + JSNumber jointHigh, + JSNumber length, + JSNumber least, + JSNumber most, + ); + @JS('f3d_joint_set_collide__w') + external JSNumber f3d_joint_set_collide( + JSNumber world, + JSNumber jointLow, + JSNumber jointHigh, + JSNumber collide, + ); + @JS('f3d_joint_get_value__w') + external JSNumber f3d_joint_get_value( + JSNumber world, + JSNumber jointLow, + JSNumber jointHigh, + JSNumber out, + ); + @JS('f3d_joint_get_swing__w') + external JSNumber f3d_joint_get_swing( + JSNumber world, + JSNumber jointLow, + JSNumber jointHigh, + JSNumber out, + ); + @JS('f3d_joint_get_force__w') + external JSNumber f3d_joint_get_force( + JSNumber world, + JSNumber jointLow, + JSNumber jointHigh, + JSNumber out, + ); + @JS('f3d_world_ray_cast__w') + external JSNumber f3d_world_ray_cast( + JSNumber world, + JSNumber ox, + JSNumber oy, + JSNumber oz, + JSNumber dx, + JSNumber dy, + JSNumber dz, + JSNumber maxDistance, + JSNumber mask, + JSNumber ignoreLow, + JSNumber ignoreHigh, + JSNumber body, + JSNumber hit, + ); + @JS('f3d_world_ray_cast_all__w') + external JSNumber f3d_world_ray_cast_all( + JSNumber world, + JSNumber ox, + JSNumber oy, + JSNumber oz, + JSNumber dx, + JSNumber dy, + JSNumber dz, + JSNumber maxDistance, + JSNumber mask, + JSNumber ignoreLow, + JSNumber ignoreHigh, + JSNumber bodies, + JSNumber hits, + JSNumber capacity, + ); + @JS('f3d_world_overlap_shape__w') + external JSNumber f3d_world_overlap_shape( + JSNumber world, + JSNumber kind, + JSNumber a, + JSNumber b, + JSNumber c, + JSNumber rounding, + JSNumber px, + JSNumber py, + JSNumber pz, + JSNumber qx, + JSNumber qy, + JSNumber qz, + JSNumber qw, + JSNumber mask, + JSNumber ignoreLow, + JSNumber ignoreHigh, + JSNumber out, + JSNumber capacity, + ); + @JS('f3d_world_cast_shape__w') + external JSNumber f3d_world_cast_shape( + JSNumber world, + JSNumber kind, + JSNumber a, + JSNumber b, + JSNumber c, + JSNumber rounding, + JSNumber px, + JSNumber py, + JSNumber pz, + JSNumber qx, + JSNumber qy, + JSNumber qz, + JSNumber qw, + JSNumber tx, + JSNumber ty, + JSNumber tz, + JSNumber mask, + JSNumber ignoreLow, + JSNumber ignoreHigh, + JSNumber body, + JSNumber hit, + ); + @JS('f3d_world_move_character__w') + external JSNumber f3d_world_move_character( + JSNumber world, + JSNumber radius, + JSNumber halfHeight, + JSNumber position, + JSNumber dx, + JSNumber dy, + JSNumber dz, + JSNumber maxSlopeCos, + JSNumber stepHeight, + JSNumber mask, + JSNumber ignoreLow, + JSNumber ignoreHigh, + JSNumber groundBody, + JSNumber ground, + ); + @JS('f3d_particles_create') + external JSNumber f3d_particles_create(JSNumber capacity); + @JS('f3d_particles_destroy') + external void f3d_particles_destroy(JSNumber particles); + @JS('f3d_particles_capacity') + external JSNumber f3d_particles_capacity(JSNumber particles); + @JS('f3d_particles_emit') + external JSNumber f3d_particles_emit( + JSNumber particles, + JSNumber particlesData, + JSNumber count, + ); + @JS('f3d_particles_step') + external void f3d_particles_step( + JSNumber particles, + JSNumber forces, + JSNumber dt, + ); + @JS('f3d_particles_read') + external JSNumber f3d_particles_read( + JSNumber particles, + JSNumber out, + JSNumber capacity, + ); + @JS('f3d_debris_create') + external JSNumber f3d_debris_create(JSNumber capacity); + @JS('f3d_debris_destroy') + external void f3d_debris_destroy(JSNumber debris); + @JS('f3d_debris_capacity') + external JSNumber f3d_debris_capacity(JSNumber debris); + @JS('f3d_debris_add') + external JSNumber f3d_debris_add( + JSNumber debris, + JSNumber data, + JSNumber count, + ); + @JS('f3d_debris_set_statics') + external JSNumber f3d_debris_set_statics( + JSNumber debris, + JSNumber data, + JSNumber count, + ); + @JS('f3d_debris_step') + external void f3d_debris_step( + JSNumber debris, + JSNumber settings, + JSNumber dt, + ); + @JS('f3d_debris_read') + external JSNumber f3d_debris_read( + JSNumber debris, + JSNumber out, + JSNumber capacity, + ); + @JS('f3d_cloth_create') + external JSNumber f3d_cloth_create( + JSNumber points, + JSNumber pointCount, + JSNumber edges, + JSNumber compliance, + JSNumber edgeCount, + ); + @JS('f3d_cloth_destroy') + external void f3d_cloth_destroy(JSNumber cloth); + @JS('f3d_cloth_point_count') + external JSNumber f3d_cloth_point_count(JSNumber cloth); + @JS('f3d_cloth_colour_count') + external JSNumber f3d_cloth_colour_count(JSNumber cloth); + @JS('f3d_cloth_edge_count') + external JSNumber f3d_cloth_edge_count(JSNumber cloth); + @JS('f3d_cloth_edges') + external void f3d_cloth_edges( + JSNumber cloth, + JSNumber pairs, + JSNumber rest, + JSNumber compliance, + JSNumber colourStart, + ); + @JS('f3d_cloth_set_balls') + external JSNumber f3d_cloth_set_balls( + JSNumber cloth, + JSNumber balls, + JSNumber count, + ); + @JS('f3d_cloth_move_point') + external void f3d_cloth_move_point( + JSNumber cloth, + JSNumber index, + JSNumber x, + JSNumber y, + JSNumber z, + ); + @JS('f3d_cloth_step') + external void f3d_cloth_step(JSNumber cloth, JSNumber settings, JSNumber dt); + @JS('f3d_cloth_read') + external JSNumber f3d_cloth_read( + JSNumber cloth, + JSNumber out, + JSNumber capacity, + ); + @JS('f3d_fluid_create') + external JSNumber f3d_fluid_create(JSNumber capacity, JSNumber spacing); + @JS('f3d_fluid_destroy') + external void f3d_fluid_destroy(JSNumber fluid); + @JS('f3d_fluid_capacity') + external JSNumber f3d_fluid_capacity(JSNumber fluid); + @JS('f3d_fluid_rest_density') + external JSNumber f3d_fluid_rest_density(JSNumber fluid); + @JS('f3d_fluid_add') + external JSNumber f3d_fluid_add( + JSNumber fluid, + JSNumber data, + JSNumber count, + ); + @JS('f3d_fluid_step') + external void f3d_fluid_step(JSNumber fluid, JSNumber settings, JSNumber dt); + @JS('f3d_fluid_read') + external JSNumber f3d_fluid_read( + JSNumber fluid, + JSNumber out, + JSNumber capacity, + ); + @JS('f3d_world_snapshot_size') + external JSNumber f3d_world_snapshot_size(JSNumber world); + @JS('f3d_world_snapshot_write') + external JSNumber f3d_world_snapshot_write( + JSNumber world, + JSNumber buffer, + JSNumber size, + ); + @JS('f3d_world_restore') + external JSNumber f3d_world_restore( + JSNumber world, + JSNumber buffer, + JSNumber size, + ); + @JS('f3d_body_create__w') + external JSNumber f3d_body_create( + JSNumber world, + JSNumber type, + JSNumber px, + JSNumber py, + JSNumber pz, + JSNumber mass, + ); + @JS('f3d_body_destroy__w') + external JSNumber f3d_body_destroy( + JSNumber world, + JSNumber bodyLow, + JSNumber bodyHigh, + ); + @JS('f3d_body_is_valid__w') + external JSNumber f3d_body_is_valid( + JSNumber world, + JSNumber bodyLow, + JSNumber bodyHigh, + ); + @JS('f3d_body_set_velocity__w') + external JSNumber f3d_body_set_velocity( + JSNumber world, + JSNumber bodyLow, + JSNumber bodyHigh, + JSNumber x, + JSNumber y, + JSNumber z, + ); + @JS('f3d_body_get_velocity__w') + external JSNumber f3d_body_get_velocity( + JSNumber world, + JSNumber bodyLow, + JSNumber bodyHigh, + JSNumber out, + ); + @JS('f3d_body_set_position__w') + external JSNumber f3d_body_set_position( + JSNumber world, + JSNumber bodyLow, + JSNumber bodyHigh, + JSNumber x, + JSNumber y, + JSNumber z, + ); + @JS('f3d_body_get_position__w') + external JSNumber f3d_body_get_position( + JSNumber world, + JSNumber bodyLow, + JSNumber bodyHigh, + JSNumber out, + ); + @JS('f3d_body_get_world_position__w') + external JSNumber f3d_body_get_world_position( + JSNumber world, + JSNumber bodyLow, + JSNumber bodyHigh, + JSNumber out, + ); + @JS('f3d_body_set_angular_velocity__w') + external JSNumber f3d_body_set_angular_velocity( + JSNumber world, + JSNumber bodyLow, + JSNumber bodyHigh, + JSNumber x, + JSNumber y, + JSNumber z, + ); + @JS('f3d_body_get_angular_velocity__w') + external JSNumber f3d_body_get_angular_velocity( + JSNumber world, + JSNumber bodyLow, + JSNumber bodyHigh, + JSNumber out, + ); + @JS('f3d_body_set_orientation__w') + external JSNumber f3d_body_set_orientation( + JSNumber world, + JSNumber bodyLow, + JSNumber bodyHigh, + JSNumber x, + JSNumber y, + JSNumber z, + JSNumber w, + ); + @JS('f3d_body_get_orientation__w') + external JSNumber f3d_body_get_orientation( + JSNumber world, + JSNumber bodyLow, + JSNumber bodyHigh, + JSNumber out, + ); + @JS('f3d_body_set_shape__w') + external JSNumber f3d_body_set_shape( + JSNumber world, + JSNumber bodyLow, + JSNumber bodyHigh, + JSNumber kind, + JSNumber a, + JSNumber b, + JSNumber c, + ); + @JS('f3d_body_get_inertia__w') + external JSNumber f3d_body_get_inertia( + JSNumber world, + JSNumber bodyLow, + JSNumber bodyHigh, + JSNumber out, + ); + @JS('f3d_body_get_inertia_tensor__w') + external JSNumber f3d_body_get_inertia_tensor( + JSNumber world, + JSNumber bodyLow, + JSNumber bodyHigh, + JSNumber out, + ); + @JS('f3d_body_set_rounding__w') + external JSNumber f3d_body_set_rounding( + JSNumber world, + JSNumber bodyLow, + JSNumber bodyHigh, + JSNumber radius, + ); + @JS('f3d_body_set_hull__w') + external JSNumber f3d_body_set_hull( + JSNumber world, + JSNumber bodyLow, + JSNumber bodyHigh, + JSNumber hull, + ); + @JS('f3d_body_set_mesh__w') + external JSNumber f3d_body_set_mesh( + JSNumber world, + JSNumber bodyLow, + JSNumber bodyHigh, + JSNumber mesh, + ); + @JS('f3d_body_get_mass__w') + external JSNumber f3d_body_get_mass( + JSNumber world, + JSNumber bodyLow, + JSNumber bodyHigh, + JSNumber out, + ); + @JS('f3d_body_lock_rotation__w') + external JSNumber f3d_body_lock_rotation( + JSNumber world, + JSNumber bodyLow, + JSNumber bodyHigh, + JSNumber locked, + ); + @JS('f3d_body_set_damping__w') + external JSNumber f3d_body_set_damping( + JSNumber world, + JSNumber bodyLow, + JSNumber bodyHigh, + JSNumber linear, + JSNumber angular, + ); + @JS('f3d_body_set_drag__w') + external JSNumber f3d_body_set_drag( + JSNumber world, + JSNumber bodyLow, + JSNumber bodyHigh, + JSNumber coefficient, + ); + @JS('f3d_body_apply_impulse__w') + external JSNumber f3d_body_apply_impulse( + JSNumber world, + JSNumber bodyLow, + JSNumber bodyHigh, + JSNumber x, + JSNumber y, + JSNumber z, + ); + @JS('f3d_body_apply_impulse_at__w') + external JSNumber f3d_body_apply_impulse_at( + JSNumber world, + JSNumber bodyLow, + JSNumber bodyHigh, + JSNumber x, + JSNumber y, + JSNumber z, + JSNumber px, + JSNumber py, + JSNumber pz, + ); + @JS('f3d_body_add_force__w') + external JSNumber f3d_body_add_force( + JSNumber world, + JSNumber bodyLow, + JSNumber bodyHigh, + JSNumber x, + JSNumber y, + JSNumber z, + ); + @JS('f3d_body_add_torque__w') + external JSNumber f3d_body_add_torque( + JSNumber world, + JSNumber bodyLow, + JSNumber bodyHigh, + JSNumber x, + JSNumber y, + JSNumber z, + ); + @JS('f3d_body_set_bullet__w') + external JSNumber f3d_body_set_bullet( + JSNumber world, + JSNumber bodyLow, + JSNumber bodyHigh, + JSNumber bullet, + ); + @JS('f3d_body_set_friction__w') + external JSNumber f3d_body_set_friction( + JSNumber world, + JSNumber bodyLow, + JSNumber bodyHigh, + JSNumber friction, + ); + @JS('f3d_body_set_restitution__w') + external JSNumber f3d_body_set_restitution( + JSNumber world, + JSNumber bodyLow, + JSNumber bodyHigh, + JSNumber restitution, + ); + @JS('f3d_body_set_collision_filter__w') + external JSNumber f3d_body_set_collision_filter( + JSNumber world, + JSNumber bodyLow, + JSNumber bodyHigh, + JSNumber layer, + JSNumber mask, + ); + @JS('f3d_body_is_asleep__w') + external JSNumber f3d_body_is_asleep( + JSNumber world, + JSNumber bodyLow, + JSNumber bodyHigh, + ); + @JS('f3d_body_wake__w') + external JSNumber f3d_body_wake( + JSNumber world, + JSNumber bodyLow, + JSNumber bodyHigh, + ); + @JS('f3d_material_preset') + external JSNumber f3d_material_preset(JSNumber kind, JSNumber out); + @JS('f3d_body_set_material__w') + external JSNumber f3d_body_set_material( + JSNumber world, + JSNumber bodyLow, + JSNumber bodyHigh, + JSNumber material, + ); + @JS('f3d_body_set_temperature__w') + external JSNumber f3d_body_set_temperature( + JSNumber world, + JSNumber bodyLow, + JSNumber bodyHigh, + JSNumber kelvin, + ); + @JS('f3d_body_get_temperature__w') + external JSNumber f3d_body_get_temperature( + JSNumber world, + JSNumber bodyLow, + JSNumber bodyHigh, + JSNumber out, + ); + @JS('f3d_body_add_heat__w') + external JSNumber f3d_body_add_heat( + JSNumber world, + JSNumber bodyLow, + JSNumber bodyHigh, + JSNumber joules, + ); + @JS('f3d_body_add_water__w') + external JSNumber f3d_body_add_water( + JSNumber world, + JSNumber bodyLow, + JSNumber bodyHigh, + JSNumber kg, + ); + @JS('f3d_body_get_water__w') + external JSNumber f3d_body_get_water( + JSNumber world, + JSNumber bodyLow, + JSNumber bodyHigh, + JSNumber out, + ); + @JS('f3d_body_get_fuel__w') + external JSNumber f3d_body_get_fuel( + JSNumber world, + JSNumber bodyLow, + JSNumber bodyHigh, + JSNumber out, + ); + @JS('f3d_body_is_burning__w') + external JSNumber f3d_body_is_burning( + JSNumber world, + JSNumber bodyLow, + JSNumber bodyHigh, + JSNumber out, + ); + @JS('f3d_body_get_heat_release__w') + external JSNumber f3d_body_get_heat_release( + JSNumber world, + JSNumber bodyLow, + JSNumber bodyHigh, + JSNumber out, + ); +} + +_Exports get _x => coreExports as _Exports; +int f3d_abi_version() => _x.f3d_abi_version().toDartInt.toUnsigned(32); +int f3d_real_bytes() => _x.f3d_real_bytes().toDartInt.toUnsigned(32); +int f3d_buffer_alloc(int bytes) => + _x.f3d_buffer_alloc(bytes.toJS).toDartInt.toUnsigned(32); +void f3d_buffer_free(int buffer) => _x.f3d_buffer_free(buffer.toJS); +int f3d_world_create() => _x.f3d_world_create().toDartInt.toUnsigned(32); +void f3d_world_destroy(int world) => _x.f3d_world_destroy(world.toJS); +void f3d_world_set_gravity(int world, double x, double y, double z) => + _x.f3d_world_set_gravity(world.toJS, x.toJS, y.toJS, z.toJS); +void f3d_world_get_gravity(int world, int out) => + _x.f3d_world_get_gravity(world.toJS, out.toJS); +int f3d_world_set_air(int world, double temperature, double density) => + _x.f3d_world_set_air(world.toJS, temperature.toJS, density.toJS).toDartInt; +void f3d_world_get_air(int world, int out) => + _x.f3d_world_get_air(world.toJS, out.toJS); +void f3d_world_set_wind(int world, double x, double y, double z) => + _x.f3d_world_set_wind(world.toJS, x.toJS, y.toJS, z.toJS); +int f3d_world_set_wind_grid( + int world, + double ox, + double oy, + double oz, + double cell, + int nx, + int ny, + int nz, + int velocities, +) => _x + .f3d_world_set_wind_grid( + world.toJS, + ox.toJS, + oy.toJS, + oz.toJS, + cell.toJS, + nx.toJS, + ny.toJS, + nz.toJS, + velocities.toJS, + ) + .toDartInt; +void f3d_world_sample_wind(int world, double x, double y, double z, int out) => + _x.f3d_world_sample_wind(world.toJS, x.toJS, y.toJS, z.toJS, out.toJS); +int f3d_world_set_sleep(int world, double speed, double time) => + _x.f3d_world_set_sleep(world.toJS, speed.toJS, time.toJS).toDartInt; +void f3d_world_get_origin(int world, int out) => + _x.f3d_world_get_origin(world.toJS, out.toJS); +void f3d_world_shift_origin(int world, double dx, double dy, double dz) => + _x.f3d_world_shift_origin(world.toJS, dx.toJS, dy.toJS, dz.toJS); +int f3d_world_create_hull(int world, int points, int count) => _x + .f3d_world_create_hull(world.toJS, points.toJS, count.toJS) + .toDartInt + .toUnsigned(32); +int f3d_world_create_mesh( + int world, + int vertices, + int vertexCount, + int indices, + int triangleCount, +) => _x + .f3d_world_create_mesh( + world.toJS, + vertices.toJS, + vertexCount.toJS, + indices.toJS, + triangleCount.toJS, + ) + .toDartInt + .toUnsigned(32); +int f3d_world_mesh_triangle_count(int world, int mesh) => _x + .f3d_world_mesh_triangle_count(world.toJS, mesh.toJS) + .toDartInt + .toUnsigned(32); +int f3d_world_mesh_internal_edges(int world, int mesh) => _x + .f3d_world_mesh_internal_edges(world.toJS, mesh.toJS) + .toDartInt + .toUnsigned(32); +int f3d_world_get_hull_offset(int world, int hull, int out) => + _x.f3d_world_get_hull_offset(world.toJS, hull.toJS, out.toJS).toDartInt; +int f3d_world_hull_vertex_count(int world, int hull) => _x + .f3d_world_hull_vertex_count(world.toJS, hull.toJS) + .toDartInt + .toUnsigned(32); +int f3d_world_body_count(int world) => + _x.f3d_world_body_count(world.toJS).toDartInt.toUnsigned(32); +void f3d_world_step(int world, double dt) => + _x.f3d_world_step(world.toJS, dt.toJS); +int f3d_world_set_threads(int world, int threads) => + _x.f3d_world_set_threads(world.toJS, threads.toJS).toDartInt; +int f3d_world_threads(int world) => + _x.f3d_world_threads(world.toJS).toDartInt.toUnsigned(32); +int f3d_world_set_fast(int world, int fast) => + _x.f3d_world_set_fast(world.toJS, fast.toJS).toDartInt; +int f3d_world_fast(int world) => _x.f3d_world_fast(world.toJS).toDartInt; +int f3d_world_read_transforms( + int world, + int transforms, + int handles, + int capacity, +) => _x + .f3d_world_read_transforms( + world.toJS, + transforms.toJS, + handles.toJS, + capacity.toJS, + ) + .toDartInt + .toUnsigned(32); +int f3d_world_read_fires(int world, int fires, int handles, int capacity) => _x + .f3d_world_read_fires(world.toJS, fires.toJS, handles.toJS, capacity.toJS) + .toDartInt + .toUnsigned(32); +int f3d_world_read_events( + int world, + int bodies, + int others, + int kinds, + int capacity, +) => _x + .f3d_world_read_events( + world.toJS, + bodies.toJS, + others.toJS, + kinds.toJS, + capacity.toJS, + ) + .toDartInt + .toUnsigned(32); +int f3d_world_set_substeps(int world, int substeps) => + _x.f3d_world_set_substeps(world.toJS, substeps.toJS).toDartInt; +int f3d_world_set_speculative(int world, int enabled) => + _x.f3d_world_set_speculative(world.toJS, enabled.toJS).toDartInt; +int f3d_world_set_contact_margin(int world, double margin) => + _x.f3d_world_set_contact_margin(world.toJS, margin.toJS).toDartInt; +int f3d_world_query_box( + int world, + double lx, + double ly, + double lz, + double hx, + double hy, + double hz, + int out, + int capacity, +) => _x + .f3d_world_query_box( + world.toJS, + lx.toJS, + ly.toJS, + lz.toJS, + hx.toJS, + hy.toJS, + hz.toJS, + out.toJS, + capacity.toJS, + ) + .toDartInt + .toUnsigned(32); +int f3d_world_contact_count(int world) => + _x.f3d_world_contact_count(world.toJS).toDartInt.toUnsigned(32); +int f3d_world_read_contacts(int world, int contacts, int pairs, int capacity) => + _x + .f3d_world_read_contacts( + world.toJS, + contacts.toJS, + pairs.toJS, + capacity.toJS, + ) + .toDartInt + .toUnsigned(32); +int f3d_world_events_dropped(int world) => + _x.f3d_world_events_dropped(world.toJS).toDartInt.toUnsigned(32); +int f3d_joint_create( + int world, + int type, + int a, + int b, + double ax, + double ay, + double az, + double ux, + double uy, + double uz, +) => joinHalves( + _x + .f3d_joint_create( + world.toJS, + type.toJS, + lowHalf(a).toJS, + highHalf(a).toJS, + lowHalf(b).toJS, + highHalf(b).toJS, + ax.toJS, + ay.toJS, + az.toJS, + ux.toJS, + uy.toJS, + uz.toJS, + ) + .toDartInt, + highResult(), +); +int f3d_joint_create_distance( + int world, + int a, + int b, + double ax, + double ay, + double az, + double bx, + double by, + double bz, +) => joinHalves( + _x + .f3d_joint_create_distance( + world.toJS, + lowHalf(a).toJS, + highHalf(a).toJS, + lowHalf(b).toJS, + highHalf(b).toJS, + ax.toJS, + ay.toJS, + az.toJS, + bx.toJS, + by.toJS, + bz.toJS, + ) + .toDartInt, + highResult(), +); +int f3d_joint_destroy(int world, int joint) => _x + .f3d_joint_destroy(world.toJS, lowHalf(joint).toJS, highHalf(joint).toJS) + .toDartInt; +int f3d_joint_is_valid(int world, int joint) => _x + .f3d_joint_is_valid(world.toJS, lowHalf(joint).toJS, highHalf(joint).toJS) + .toDartInt; +int f3d_world_joint_count(int world) => + _x.f3d_world_joint_count(world.toJS).toDartInt.toUnsigned(32); +int f3d_joint_set_limits( + int world, + int joint, + int enabled, + double lower, + double upper, +) => _x + .f3d_joint_set_limits( + world.toJS, + lowHalf(joint).toJS, + highHalf(joint).toJS, + enabled.toJS, + lower.toJS, + upper.toJS, + ) + .toDartInt; +int f3d_joint_set_cone(int world, int joint, int enabled, double angle) => _x + .f3d_joint_set_cone( + world.toJS, + lowHalf(joint).toJS, + highHalf(joint).toJS, + enabled.toJS, + angle.toJS, + ) + .toDartInt; +int f3d_joint_set_friction(int world, int joint, int enabled, double torque) => + _x + .f3d_joint_set_friction( + world.toJS, + lowHalf(joint).toJS, + highHalf(joint).toJS, + enabled.toJS, + torque.toJS, + ) + .toDartInt; +int f3d_joint_set_motor( + int world, + int joint, + int enabled, + double speed, + double maxForce, +) => _x + .f3d_joint_set_motor( + world.toJS, + lowHalf(joint).toJS, + highHalf(joint).toJS, + enabled.toJS, + speed.toJS, + maxForce.toJS, + ) + .toDartInt; +int f3d_joint_set_spring( + int world, + int joint, + int enabled, + double hertz, + double damping, +) => _x + .f3d_joint_set_spring( + world.toJS, + lowHalf(joint).toJS, + highHalf(joint).toJS, + enabled.toJS, + hertz.toJS, + damping.toJS, + ) + .toDartInt; +int f3d_joint_set_length( + int world, + int joint, + double length, + double least, + double most, +) => _x + .f3d_joint_set_length( + world.toJS, + lowHalf(joint).toJS, + highHalf(joint).toJS, + length.toJS, + least.toJS, + most.toJS, + ) + .toDartInt; +int f3d_joint_set_collide(int world, int joint, int collide) => _x + .f3d_joint_set_collide( + world.toJS, + lowHalf(joint).toJS, + highHalf(joint).toJS, + collide.toJS, + ) + .toDartInt; +int f3d_joint_get_value(int world, int joint, int out) => _x + .f3d_joint_get_value( + world.toJS, + lowHalf(joint).toJS, + highHalf(joint).toJS, + out.toJS, + ) + .toDartInt; +int f3d_joint_get_swing(int world, int joint, int out) => _x + .f3d_joint_get_swing( + world.toJS, + lowHalf(joint).toJS, + highHalf(joint).toJS, + out.toJS, + ) + .toDartInt; +int f3d_joint_get_force(int world, int joint, int out) => _x + .f3d_joint_get_force( + world.toJS, + lowHalf(joint).toJS, + highHalf(joint).toJS, + out.toJS, + ) + .toDartInt; +int f3d_world_ray_cast( + int world, + double ox, + double oy, + double oz, + double dx, + double dy, + double dz, + double maxDistance, + int mask, + int ignore, + int body, + int hit, +) => _x + .f3d_world_ray_cast( + world.toJS, + ox.toJS, + oy.toJS, + oz.toJS, + dx.toJS, + dy.toJS, + dz.toJS, + maxDistance.toJS, + mask.toJS, + lowHalf(ignore).toJS, + highHalf(ignore).toJS, + body.toJS, + hit.toJS, + ) + .toDartInt; +int f3d_world_ray_cast_all( + int world, + double ox, + double oy, + double oz, + double dx, + double dy, + double dz, + double maxDistance, + int mask, + int ignore, + int bodies, + int hits, + int capacity, +) => _x + .f3d_world_ray_cast_all( + world.toJS, + ox.toJS, + oy.toJS, + oz.toJS, + dx.toJS, + dy.toJS, + dz.toJS, + maxDistance.toJS, + mask.toJS, + lowHalf(ignore).toJS, + highHalf(ignore).toJS, + bodies.toJS, + hits.toJS, + capacity.toJS, + ) + .toDartInt + .toUnsigned(32); +int f3d_world_overlap_shape( + int world, + int kind, + double a, + double b, + double c, + double rounding, + double px, + double py, + double pz, + double qx, + double qy, + double qz, + double qw, + int mask, + int ignore, + int out, + int capacity, +) => _x + .f3d_world_overlap_shape( + world.toJS, + kind.toJS, + a.toJS, + b.toJS, + c.toJS, + rounding.toJS, + px.toJS, + py.toJS, + pz.toJS, + qx.toJS, + qy.toJS, + qz.toJS, + qw.toJS, + mask.toJS, + lowHalf(ignore).toJS, + highHalf(ignore).toJS, + out.toJS, + capacity.toJS, + ) + .toDartInt + .toUnsigned(32); +int f3d_world_cast_shape( + int world, + int kind, + double a, + double b, + double c, + double rounding, + double px, + double py, + double pz, + double qx, + double qy, + double qz, + double qw, + double tx, + double ty, + double tz, + int mask, + int ignore, + int body, + int hit, +) => _x + .f3d_world_cast_shape( + world.toJS, + kind.toJS, + a.toJS, + b.toJS, + c.toJS, + rounding.toJS, + px.toJS, + py.toJS, + pz.toJS, + qx.toJS, + qy.toJS, + qz.toJS, + qw.toJS, + tx.toJS, + ty.toJS, + tz.toJS, + mask.toJS, + lowHalf(ignore).toJS, + highHalf(ignore).toJS, + body.toJS, + hit.toJS, + ) + .toDartInt; +int f3d_world_move_character( + int world, + double radius, + double halfHeight, + int position, + double dx, + double dy, + double dz, + double maxSlopeCos, + double stepHeight, + int mask, + int ignore, + int groundBody, + int ground, +) => _x + .f3d_world_move_character( + world.toJS, + radius.toJS, + halfHeight.toJS, + position.toJS, + dx.toJS, + dy.toJS, + dz.toJS, + maxSlopeCos.toJS, + stepHeight.toJS, + mask.toJS, + lowHalf(ignore).toJS, + highHalf(ignore).toJS, + groundBody.toJS, + ground.toJS, + ) + .toDartInt + .toUnsigned(32); +int f3d_particles_create(int capacity) => + _x.f3d_particles_create(capacity.toJS).toDartInt.toUnsigned(32); +void f3d_particles_destroy(int particles) => + _x.f3d_particles_destroy(particles.toJS); +int f3d_particles_capacity(int particles) => + _x.f3d_particles_capacity(particles.toJS).toDartInt.toUnsigned(32); +int f3d_particles_emit(int particles, int particlesData, int count) => _x + .f3d_particles_emit(particles.toJS, particlesData.toJS, count.toJS) + .toDartInt + .toUnsigned(32); +void f3d_particles_step(int particles, int forces, double dt) => + _x.f3d_particles_step(particles.toJS, forces.toJS, dt.toJS); +int f3d_particles_read(int particles, int out, int capacity) => _x + .f3d_particles_read(particles.toJS, out.toJS, capacity.toJS) + .toDartInt + .toUnsigned(32); +int f3d_debris_create(int capacity) => + _x.f3d_debris_create(capacity.toJS).toDartInt.toUnsigned(32); +void f3d_debris_destroy(int debris) => _x.f3d_debris_destroy(debris.toJS); +int f3d_debris_capacity(int debris) => + _x.f3d_debris_capacity(debris.toJS).toDartInt.toUnsigned(32); +int f3d_debris_add(int debris, int data, int count) => _x + .f3d_debris_add(debris.toJS, data.toJS, count.toJS) + .toDartInt + .toUnsigned(32); +int f3d_debris_set_statics(int debris, int data, int count) => + _x.f3d_debris_set_statics(debris.toJS, data.toJS, count.toJS).toDartInt; +void f3d_debris_step(int debris, int settings, double dt) => + _x.f3d_debris_step(debris.toJS, settings.toJS, dt.toJS); +int f3d_debris_read(int debris, int out, int capacity) => _x + .f3d_debris_read(debris.toJS, out.toJS, capacity.toJS) + .toDartInt + .toUnsigned(32); +int f3d_cloth_create( + int points, + int pointCount, + int edges, + int compliance, + int edgeCount, +) => _x + .f3d_cloth_create( + points.toJS, + pointCount.toJS, + edges.toJS, + compliance.toJS, + edgeCount.toJS, + ) + .toDartInt + .toUnsigned(32); +void f3d_cloth_destroy(int cloth) => _x.f3d_cloth_destroy(cloth.toJS); +int f3d_cloth_point_count(int cloth) => + _x.f3d_cloth_point_count(cloth.toJS).toDartInt.toUnsigned(32); +int f3d_cloth_colour_count(int cloth) => + _x.f3d_cloth_colour_count(cloth.toJS).toDartInt.toUnsigned(32); +int f3d_cloth_edge_count(int cloth) => + _x.f3d_cloth_edge_count(cloth.toJS).toDartInt.toUnsigned(32); +void f3d_cloth_edges( + int cloth, + int pairs, + int rest, + int compliance, + int colourStart, +) => _x.f3d_cloth_edges( + cloth.toJS, + pairs.toJS, + rest.toJS, + compliance.toJS, + colourStart.toJS, +); +int f3d_cloth_set_balls(int cloth, int balls, int count) => + _x.f3d_cloth_set_balls(cloth.toJS, balls.toJS, count.toJS).toDartInt; +void f3d_cloth_move_point(int cloth, int index, double x, double y, double z) => + _x.f3d_cloth_move_point(cloth.toJS, index.toJS, x.toJS, y.toJS, z.toJS); +void f3d_cloth_step(int cloth, int settings, double dt) => + _x.f3d_cloth_step(cloth.toJS, settings.toJS, dt.toJS); +int f3d_cloth_read(int cloth, int out, int capacity) => _x + .f3d_cloth_read(cloth.toJS, out.toJS, capacity.toJS) + .toDartInt + .toUnsigned(32); +int f3d_fluid_create(int capacity, double spacing) => + _x.f3d_fluid_create(capacity.toJS, spacing.toJS).toDartInt.toUnsigned(32); +void f3d_fluid_destroy(int fluid) => _x.f3d_fluid_destroy(fluid.toJS); +int f3d_fluid_capacity(int fluid) => + _x.f3d_fluid_capacity(fluid.toJS).toDartInt.toUnsigned(32); +double f3d_fluid_rest_density(int fluid) => + _x.f3d_fluid_rest_density(fluid.toJS).toDartDouble; +int f3d_fluid_add(int fluid, int data, int count) => _x + .f3d_fluid_add(fluid.toJS, data.toJS, count.toJS) + .toDartInt + .toUnsigned(32); +void f3d_fluid_step(int fluid, int settings, double dt) => + _x.f3d_fluid_step(fluid.toJS, settings.toJS, dt.toJS); +int f3d_fluid_read(int fluid, int out, int capacity) => _x + .f3d_fluid_read(fluid.toJS, out.toJS, capacity.toJS) + .toDartInt + .toUnsigned(32); +int f3d_world_snapshot_size(int world) => + _x.f3d_world_snapshot_size(world.toJS).toDartInt.toUnsigned(32); +int f3d_world_snapshot_write(int world, int buffer, int size) => _x + .f3d_world_snapshot_write(world.toJS, buffer.toJS, size.toJS) + .toDartInt + .toUnsigned(32); +int f3d_world_restore(int world, int buffer, int size) => + _x.f3d_world_restore(world.toJS, buffer.toJS, size.toJS).toDartInt; +int f3d_body_create( + int world, + int type, + double px, + double py, + double pz, + double mass, +) => joinHalves( + _x + .f3d_body_create( + world.toJS, + type.toJS, + px.toJS, + py.toJS, + pz.toJS, + mass.toJS, + ) + .toDartInt, + highResult(), +); +int f3d_body_destroy(int world, int body) => _x + .f3d_body_destroy(world.toJS, lowHalf(body).toJS, highHalf(body).toJS) + .toDartInt; +int f3d_body_is_valid(int world, int body) => _x + .f3d_body_is_valid(world.toJS, lowHalf(body).toJS, highHalf(body).toJS) + .toDartInt; +int f3d_body_set_velocity(int world, int body, double x, double y, double z) => + _x + .f3d_body_set_velocity( + world.toJS, + lowHalf(body).toJS, + highHalf(body).toJS, + x.toJS, + y.toJS, + z.toJS, + ) + .toDartInt; +int f3d_body_get_velocity(int world, int body, int out) => _x + .f3d_body_get_velocity( + world.toJS, + lowHalf(body).toJS, + highHalf(body).toJS, + out.toJS, + ) + .toDartInt; +int f3d_body_set_position(int world, int body, double x, double y, double z) => + _x + .f3d_body_set_position( + world.toJS, + lowHalf(body).toJS, + highHalf(body).toJS, + x.toJS, + y.toJS, + z.toJS, + ) + .toDartInt; +int f3d_body_get_position(int world, int body, int out) => _x + .f3d_body_get_position( + world.toJS, + lowHalf(body).toJS, + highHalf(body).toJS, + out.toJS, + ) + .toDartInt; +int f3d_body_get_world_position(int world, int body, int out) => _x + .f3d_body_get_world_position( + world.toJS, + lowHalf(body).toJS, + highHalf(body).toJS, + out.toJS, + ) + .toDartInt; +int f3d_body_set_angular_velocity( + int world, + int body, + double x, + double y, + double z, +) => _x + .f3d_body_set_angular_velocity( + world.toJS, + lowHalf(body).toJS, + highHalf(body).toJS, + x.toJS, + y.toJS, + z.toJS, + ) + .toDartInt; +int f3d_body_get_angular_velocity(int world, int body, int out) => _x + .f3d_body_get_angular_velocity( + world.toJS, + lowHalf(body).toJS, + highHalf(body).toJS, + out.toJS, + ) + .toDartInt; +int f3d_body_set_orientation( + int world, + int body, + double x, + double y, + double z, + double w, +) => _x + .f3d_body_set_orientation( + world.toJS, + lowHalf(body).toJS, + highHalf(body).toJS, + x.toJS, + y.toJS, + z.toJS, + w.toJS, + ) + .toDartInt; +int f3d_body_get_orientation(int world, int body, int out) => _x + .f3d_body_get_orientation( + world.toJS, + lowHalf(body).toJS, + highHalf(body).toJS, + out.toJS, + ) + .toDartInt; +int f3d_body_set_shape( + int world, + int body, + int kind, + double a, + double b, + double c, +) => _x + .f3d_body_set_shape( + world.toJS, + lowHalf(body).toJS, + highHalf(body).toJS, + kind.toJS, + a.toJS, + b.toJS, + c.toJS, + ) + .toDartInt; +int f3d_body_get_inertia(int world, int body, int out) => _x + .f3d_body_get_inertia( + world.toJS, + lowHalf(body).toJS, + highHalf(body).toJS, + out.toJS, + ) + .toDartInt; +int f3d_body_get_inertia_tensor(int world, int body, int out) => _x + .f3d_body_get_inertia_tensor( + world.toJS, + lowHalf(body).toJS, + highHalf(body).toJS, + out.toJS, + ) + .toDartInt; +int f3d_body_set_rounding(int world, int body, double radius) => _x + .f3d_body_set_rounding( + world.toJS, + lowHalf(body).toJS, + highHalf(body).toJS, + radius.toJS, + ) + .toDartInt; +int f3d_body_set_hull(int world, int body, int hull) => _x + .f3d_body_set_hull( + world.toJS, + lowHalf(body).toJS, + highHalf(body).toJS, + hull.toJS, + ) + .toDartInt; +int f3d_body_set_mesh(int world, int body, int mesh) => _x + .f3d_body_set_mesh( + world.toJS, + lowHalf(body).toJS, + highHalf(body).toJS, + mesh.toJS, + ) + .toDartInt; +int f3d_body_get_mass(int world, int body, int out) => _x + .f3d_body_get_mass( + world.toJS, + lowHalf(body).toJS, + highHalf(body).toJS, + out.toJS, + ) + .toDartInt; +int f3d_body_lock_rotation(int world, int body, int locked) => _x + .f3d_body_lock_rotation( + world.toJS, + lowHalf(body).toJS, + highHalf(body).toJS, + locked.toJS, + ) + .toDartInt; +int f3d_body_set_damping(int world, int body, double linear, double angular) => + _x + .f3d_body_set_damping( + world.toJS, + lowHalf(body).toJS, + highHalf(body).toJS, + linear.toJS, + angular.toJS, + ) + .toDartInt; +int f3d_body_set_drag(int world, int body, double coefficient) => _x + .f3d_body_set_drag( + world.toJS, + lowHalf(body).toJS, + highHalf(body).toJS, + coefficient.toJS, + ) + .toDartInt; +int f3d_body_apply_impulse(int world, int body, double x, double y, double z) => + _x + .f3d_body_apply_impulse( + world.toJS, + lowHalf(body).toJS, + highHalf(body).toJS, + x.toJS, + y.toJS, + z.toJS, + ) + .toDartInt; +int f3d_body_apply_impulse_at( + int world, + int body, + double x, + double y, + double z, + double px, + double py, + double pz, +) => _x + .f3d_body_apply_impulse_at( + world.toJS, + lowHalf(body).toJS, + highHalf(body).toJS, + x.toJS, + y.toJS, + z.toJS, + px.toJS, + py.toJS, + pz.toJS, + ) + .toDartInt; +int f3d_body_add_force(int world, int body, double x, double y, double z) => _x + .f3d_body_add_force( + world.toJS, + lowHalf(body).toJS, + highHalf(body).toJS, + x.toJS, + y.toJS, + z.toJS, + ) + .toDartInt; +int f3d_body_add_torque(int world, int body, double x, double y, double z) => _x + .f3d_body_add_torque( + world.toJS, + lowHalf(body).toJS, + highHalf(body).toJS, + x.toJS, + y.toJS, + z.toJS, + ) + .toDartInt; +int f3d_body_set_bullet(int world, int body, int bullet) => _x + .f3d_body_set_bullet( + world.toJS, + lowHalf(body).toJS, + highHalf(body).toJS, + bullet.toJS, + ) + .toDartInt; +int f3d_body_set_friction(int world, int body, double friction) => _x + .f3d_body_set_friction( + world.toJS, + lowHalf(body).toJS, + highHalf(body).toJS, + friction.toJS, + ) + .toDartInt; +int f3d_body_set_restitution(int world, int body, double restitution) => _x + .f3d_body_set_restitution( + world.toJS, + lowHalf(body).toJS, + highHalf(body).toJS, + restitution.toJS, + ) + .toDartInt; +int f3d_body_set_collision_filter(int world, int body, int layer, int mask) => + _x + .f3d_body_set_collision_filter( + world.toJS, + lowHalf(body).toJS, + highHalf(body).toJS, + layer.toJS, + mask.toJS, + ) + .toDartInt; +int f3d_body_is_asleep(int world, int body) => _x + .f3d_body_is_asleep(world.toJS, lowHalf(body).toJS, highHalf(body).toJS) + .toDartInt; +int f3d_body_wake(int world, int body) => _x + .f3d_body_wake(world.toJS, lowHalf(body).toJS, highHalf(body).toJS) + .toDartInt; +int f3d_material_preset(int kind, int out) => + _x.f3d_material_preset(kind.toJS, out.toJS).toDartInt; +int f3d_body_set_material(int world, int body, int material) => _x + .f3d_body_set_material( + world.toJS, + lowHalf(body).toJS, + highHalf(body).toJS, + material.toJS, + ) + .toDartInt; +int f3d_body_set_temperature(int world, int body, double kelvin) => _x + .f3d_body_set_temperature( + world.toJS, + lowHalf(body).toJS, + highHalf(body).toJS, + kelvin.toJS, + ) + .toDartInt; +int f3d_body_get_temperature(int world, int body, int out) => _x + .f3d_body_get_temperature( + world.toJS, + lowHalf(body).toJS, + highHalf(body).toJS, + out.toJS, + ) + .toDartInt; +int f3d_body_add_heat(int world, int body, double joules) => _x + .f3d_body_add_heat( + world.toJS, + lowHalf(body).toJS, + highHalf(body).toJS, + joules.toJS, + ) + .toDartInt; +int f3d_body_add_water(int world, int body, double kg) => _x + .f3d_body_add_water( + world.toJS, + lowHalf(body).toJS, + highHalf(body).toJS, + kg.toJS, + ) + .toDartInt; +int f3d_body_get_water(int world, int body, int out) => _x + .f3d_body_get_water( + world.toJS, + lowHalf(body).toJS, + highHalf(body).toJS, + out.toJS, + ) + .toDartInt; +int f3d_body_get_fuel(int world, int body, int out) => _x + .f3d_body_get_fuel( + world.toJS, + lowHalf(body).toJS, + highHalf(body).toJS, + out.toJS, + ) + .toDartInt; +int f3d_body_is_burning(int world, int body, int out) => _x + .f3d_body_is_burning( + world.toJS, + lowHalf(body).toJS, + highHalf(body).toJS, + out.toJS, + ) + .toDartInt; +int f3d_body_get_heat_release(int world, int body, int out) => _x + .f3d_body_get_heat_release( + world.toJS, + lowHalf(body).toJS, + highHalf(body).toJS, + out.toJS, + ) + .toDartInt; diff --git a/packages/flutter3d_physics_native/lib/src/core/constants.dart b/packages/flutter3d_physics_native/lib/src/core/constants.dart new file mode 100644 index 000000000..fe3d6ff1e --- /dev/null +++ b/packages/flutter3d_physics_native/lib/src/core/constants.dart @@ -0,0 +1,102 @@ +/// The core's numbers, as `csrc/include/f3d_physics.h` defines them — P9. +/// +/// Plain Dart, the same natively and in the browser; `bindings_test.dart` +/// holds every one against the header. +library; + +/// `F3D_ABI_VERSION` this file was written against. +const int abiVersion = 18; + +/// `F3D_TRANSFORM_FLOATS`. +const int transformFloats = 7; + +/// `F3D_FIRE_FLOATS`. +const int fireFloats = 4; + +/// `F3D_CONTACT_FLOATS`. +const int contactFloats = 7; + +/// `F3D_HIT_FLOATS`. +const int hitFloats = 7; + +/// `F3D_CHARACTER_*`. +abstract final class CharacterFlags { + static const int grounded = 1; + static const int wall = 2; + static const int ceiling = 4; + static const int stepped = 8; +} + +/// `F3D_EVENT_CAPACITY`. +const int eventCapacity = 65536; + +/// `F3dBodyType`. +abstract final class BodyType { + static const int dynamic = 0; + static const int fixed = 1; +} + +/// `F3dShapeKind`. +abstract final class ShapeKind { + static const int point = 0; + static const int sphere = 1; + static const int box = 2; + static const int capsule = 3; + static const int cylinder = 4; + static const int cone = 5; + static const int hull = 6; + static const int mesh = 7; +} + +/// `F3dJointType`. +abstract final class JointType { + static const int fixed = 0; + static const int spherical = 1; + static const int revolute = 2; + static const int prismatic = 3; + static const int distance = 4; +} + +/// `F3dMaterialKind`. +abstract final class MaterialKind { + static const int inert = 0; + static const int wood = 1; + static const int paper = 2; + static const int rubber = 3; + static const int steel = 4; + static const int stone = 5; +} + +/// `F3dEventKind`. +abstract final class EventKind { + static const int slept = 0; + static const int woke = 1; + static const int ignited = 2; + static const int extinguished = 3; + static const int burntOut = 4; + static const int contactBegan = 5; + static const int contactEnded = 6; +} + +/// `F3D_PARTICLE_FLOATS`. +const int particleFloats = 4; + +/// `F3D_DEBRIS_INPUT_FLOATS`, `F3D_DEBRIS_FLOATS`, `F3D_DEBRIS_STATIC_FLOATS` +/// and `F3D_DEBRIS_MAX_STATICS`. +const int debrisInputFloats = 8; + +const int debrisFloats = 8; + +const int debrisStaticFloats = 8; + +const int debrisMaxStatics = 64; + +/// `F3D_CLOTH_FLOATS` and `F3D_CLOTH_MAX_BALLS`. +const int clothFloats = 4; + +const int clothMaxBalls = 16; + +/// `F3D_FLUID_INPUT_FLOATS` and `F3D_FLUID_FLOATS`. +const int fluidInputFloats = 6; + +const int fluidFloats = 4; diff --git a/packages/flutter3d_physics_native/lib/src/core/core.dart b/packages/flutter3d_physics_native/lib/src/core/core.dart new file mode 100644 index 000000000..971a6fb32 --- /dev/null +++ b/packages/flutter3d_physics_native/lib/src/core/core.dart @@ -0,0 +1,10 @@ +/// The physics core as the Dart side reaches it, natively and in the +/// browser: its calls, its constants, its structs' layouts and its memory — +/// P9, phase 12. +library; + +export 'buffers.dart'; +export 'calls_native.g.dart' if (dart.library.js_interop) 'calls_web.g.dart'; +export 'constants.dart'; +export 'layout.g.dart'; +export 'memory.dart'; diff --git a/packages/flutter3d_physics_native/lib/src/core/layout.g.dart b/packages/flutter3d_physics_native/lib/src/core/layout.g.dart new file mode 100644 index 000000000..6b24f5c59 --- /dev/null +++ b/packages/flutter3d_physics_native/lib/src/core/layout.g.dart @@ -0,0 +1,67 @@ +// Generated by tool/gen_core.dart from csrc/include/f3d_physics.h: do not edit. +// +// The structs the Dart side fills, as byte offsets into a block of the +// core's memory, laid out as C lays them out for the float build. +library; + +/// `F3dMaterial`. +abstract final class F3dMaterialLayout { + static const int specificHeat = 0; + static const int emissivity = 4; + static const int ignitionTemperature = 8; + static const int heatOfCombustion = 12; + static const int burnRate = 16; + static const int fuelFraction = 20; + static const int flameFeedback = 24; + static const int conductivity = 28; + static const int size = 32; +} + +/// `F3dParticleForces`. +abstract final class F3dParticleForcesLayout { + static const int gravity = 0; + static const int wind = 12; + static const int drag = 24; + static const int floorY = 28; + static const int restitution = 32; + static const int friction = 36; + static const int size = 40; +} + +/// `F3dDebrisSettings`. +abstract final class F3dDebrisSettingsLayout { + static const int gravity = 0; + static const int friction = 12; + static const int restitution = 16; + static const int linearDamping = 20; + static const int angularDamping = 24; + static const int maxSpeed = 28; + static const int substeps = 32; + static const int iterations = 36; + static const int size = 40; +} + +/// `F3dClothSettings`. +abstract final class F3dClothSettingsLayout { + static const int gravity = 0; + static const int wind = 12; + static const int drag = 24; + static const int damping = 28; + static const int floorY = 32; + static const int thickness = 36; + static const int friction = 40; + static const int substeps = 44; + static const int size = 48; +} + +/// `F3dFluidSettings`. +abstract final class F3dFluidSettingsLayout { + static const int gravity = 0; + static const int tankMin = 12; + static const int tankMax = 24; + static const int viscosity = 36; + static const int relaxation = 40; + static const int substeps = 44; + static const int iterations = 48; + static const int size = 52; +} diff --git a/packages/flutter3d_physics_native/lib/src/core/load.dart b/packages/flutter3d_physics_native/lib/src/core/load.dart new file mode 100644 index 000000000..39db3e76f --- /dev/null +++ b/packages/flutter3d_physics_native/lib/src/core/load.dart @@ -0,0 +1,12 @@ +/// Loading the core: natively nothing, in the browser its module — P9. +library; + +export 'load_native.dart' + if (dart.library.js_interop) 'module_web.dart' + show + defaultPhysicsCoreThreadsUrl, + defaultPhysicsCoreUrl, + defaultPhysicsWorkerUrl, + loadPhysicsCore, + physicsCoreLoaded, + physicsCoreThreads; diff --git a/packages/flutter3d_physics_native/lib/src/core/load_native.dart b/packages/flutter3d_physics_native/lib/src/core/load_native.dart new file mode 100644 index 000000000..617918c60 --- /dev/null +++ b/packages/flutter3d_physics_native/lib/src/core/load_native.dart @@ -0,0 +1,33 @@ +/// Loading the core natively: nothing to do — P9, phase 12. +/// +/// The hook built the core into the app, and the first call finds it; this +/// is here so that the code that starts a world reads the same natively and +/// in the browser. +library; + +import 'dart:typed_data'; + +/// Where the browser fetches the core and its worker from; unused natively. +const String defaultPhysicsCoreUrl = + 'assets/packages/flutter3d_physics_native/web/f3d_physics.wasm'; +const String defaultPhysicsCoreThreadsUrl = + 'assets/packages/flutter3d_physics_native/web/f3d_physics_threads.wasm'; +const String defaultPhysicsWorkerUrl = + 'assets/packages/flutter3d_physics_native/web/f3d_worker.js'; + +/// Nothing natively: the core is in the app already, and starts its own +/// threads when a world asks for them. +Future loadPhysicsCore({ + Uint8List? bytes, + String url = defaultPhysicsCoreUrl, + int threads = 1, + String threadsUrl = defaultPhysicsCoreThreadsUrl, + String workerUrl = defaultPhysicsWorkerUrl, +}) async {} + +/// Always, natively. +bool get physicsCoreLoaded => true; + +/// The most threads a world can step on: natively as many as it asks for, +/// up to sixty-four. +int get physicsCoreThreads => 64; diff --git a/packages/flutter3d_physics_native/lib/src/core/memory.dart b/packages/flutter3d_physics_native/lib/src/core/memory.dart new file mode 100644 index 000000000..f521693b7 --- /dev/null +++ b/packages/flutter3d_physics_native/lib/src/core/memory.dart @@ -0,0 +1,5 @@ +/// The core's memory: natively through dart:ffi, in the browser the +/// WebAssembly module's — P9, phase 12. +library; + +export 'memory_native.dart' if (dart.library.js_interop) 'memory_web.dart'; diff --git a/packages/flutter3d_physics_native/lib/src/core/memory_native.dart b/packages/flutter3d_physics_native/lib/src/core/memory_native.dart new file mode 100644 index 000000000..ef0408cb1 --- /dev/null +++ b/packages/flutter3d_physics_native/lib/src/core/memory_native.dart @@ -0,0 +1,51 @@ +/// The core's memory natively: an address is a pointer — P9, phase 12. +library; + +import 'dart:ffi'; +import 'dart:typed_data'; + +import 'calls_native.g.dart' as c; + +/// [bytes] of the core's memory, zeroed; throws when there is none to give. +int coreAlloc(int bytes) { + final size = bytes < 16 ? 16 : bytes; + final address = c.f3d_buffer_alloc(size); + if (address == 0) { + throw StateError('the physics core has no memory for $size bytes'); + } + Pointer.fromAddress(address).asTypedList(size).fillRange(0, size, 0); + return address; +} + +void coreFree(int address) => c.f3d_buffer_free(address); + +double readF32(int address) => Pointer.fromAddress(address).value; +void writeF32(int address, double value) => + Pointer.fromAddress(address).value = value; +double readF64(int address) => Pointer.fromAddress(address).value; +void writeF64(int address, double value) => + Pointer.fromAddress(address).value = value; +int readU8(int address) => Pointer.fromAddress(address).value; +void writeU8(int address, int value) => + Pointer.fromAddress(address).value = value; +int readU32(int address) => Pointer.fromAddress(address).value; +void writeU32(int address, int value) => + Pointer.fromAddress(address).value = value; +int readI32(int address) => Pointer.fromAddress(address).value; +void writeI32(int address, int value) => + Pointer.fromAddress(address).value = value; +int readU64(int address) => Pointer.fromAddress(address).value; +void writeU64(int address, int value) => + Pointer.fromAddress(address).value = value; + +Float32List copyF32s(int address, int count) => Float32List.fromList( + Pointer.fromAddress(address).asTypedList(count), +); +void setF32s(int address, List values) => Pointer.fromAddress( + address, +).asTypedList(values.length).setAll(0, values); +Uint8List copyU8s(int address, int count) => + Uint8List.fromList(Pointer.fromAddress(address).asTypedList(count)); +void setU8s(int address, List values) => Pointer.fromAddress( + address, +).asTypedList(values.length).setAll(0, values); diff --git a/packages/flutter3d_physics_native/lib/src/core/memory_web.dart b/packages/flutter3d_physics_native/lib/src/core/memory_web.dart new file mode 100644 index 000000000..93ca2b41b --- /dev/null +++ b/packages/flutter3d_physics_native/lib/src/core/memory_web.dart @@ -0,0 +1,108 @@ +/// The core's memory in the browser: an address is an offset into the +/// WebAssembly module's memory — P9, phase 12. +/// +/// Read and written through a JavaScript DataView, which takes the threads +/// build's SharedArrayBuffer as readily as an ArrayBuffer. +library; + +import 'dart:js_interop'; +import 'dart:typed_data'; + +import 'calls_web.g.dart' as c; +import 'module_web.dart'; + +@JS('DataView') +extension type _DataView._(JSObject _) implements JSObject { + external factory _DataView(JSObject buffer); + external double getFloat32(int at, bool little); + external void setFloat32(int at, double value, bool little); + external double getFloat64(int at, bool little); + external void setFloat64(int at, double value, bool little); + external int getUint8(int at); + external void setUint8(int at, int value); + external int getUint32(int at, bool little); + external void setUint32(int at, int value, bool little); + external int getInt32(int at, bool little); + external void setInt32(int at, int value, bool little); +} + +_DataView? _view; +int _viewLength = -1; + +/// The memory as it is now. Growing, the memory hands over a new, longer +/// buffer — the old one detached, or for a shared memory left short — so +/// the view is kept while the memory's buffer is as long as it was. +_DataView get _bytes { + final buffer = coreBufferObject(); + final length = coreBufferLength(buffer); + final view = _view; + if (view != null && length == _viewLength) return view; + _viewLength = length; + return _view = _DataView(buffer); +} + +/// [bytes] of the core's memory, zeroed; throws when there is none to give. +int coreAlloc(int bytes) { + final size = bytes < 16 ? 16 : bytes; + final address = c.f3d_buffer_alloc(size); + if (address == 0) { + throw StateError('the physics core has no memory for $size bytes'); + } + final b = _bytes; + for (var i = 0; i < size; i++) { + b.setUint8(address + i, 0); + } + return address; +} + +void coreFree(int address) => c.f3d_buffer_free(address); + +double readF32(int address) => _bytes.getFloat32(address, true); +void writeF32(int address, double value) => + _bytes.setFloat32(address, value, true); +double readF64(int address) => _bytes.getFloat64(address, true); +void writeF64(int address, double value) => + _bytes.setFloat64(address, value, true); +int readU8(int address) => _bytes.getUint8(address); +void writeU8(int address, int value) => _bytes.setUint8(address, value); +int readU32(int address) => _bytes.getUint32(address, true); +void writeU32(int address, int value) => _bytes.setUint32(address, value, true); +int readI32(int address) => _bytes.getInt32(address, true); +void writeI32(int address, int value) => _bytes.setInt32(address, value, true); +int readU64(int address) => joinHalves(readU32(address), readU32(address + 4)); +void writeU64(int address, int value) { + writeU32(address, lowHalf(value)); + writeU32(address + 4, highHalf(value)); +} + +Float32List copyF32s(int address, int count) { + final b = _bytes; + final out = Float32List(count); + for (var i = 0; i < count; i++) { + out[i] = b.getFloat32(address + i * 4, true); + } + return out; +} + +void setF32s(int address, List values) { + final b = _bytes; + for (var i = 0; i < values.length; i++) { + b.setFloat32(address + i * 4, values[i], true); + } +} + +Uint8List copyU8s(int address, int count) { + final b = _bytes; + final out = Uint8List(count); + for (var i = 0; i < count; i++) { + out[i] = b.getUint8(address + i); + } + return out; +} + +void setU8s(int address, List values) { + final b = _bytes; + for (var i = 0; i < values.length; i++) { + b.setUint8(address + i, values[i]); + } +} diff --git a/packages/flutter3d_physics_native/lib/src/core/module_web.dart b/packages/flutter3d_physics_native/lib/src/core/module_web.dart new file mode 100644 index 000000000..ee63899a4 --- /dev/null +++ b/packages/flutter3d_physics_native/lib/src/core/module_web.dart @@ -0,0 +1,187 @@ +/// The physics core's WebAssembly module in the browser — P9, phase 12. +/// +/// Loaded once, before the first world: [loadPhysicsCore]. Its exports are +/// the core's calls; its memory is where every address points. +/// +/// Asked for threads where the page has SharedArrayBuffer — served +/// cross-origin isolated, with COOP and COEP — it loads the threads build +/// instead, on a shared memory, and starts the workers it steps on: Web +/// Workers from `f3d_worker.js`, each an instance of the module on that +/// memory with a stack of its own. Elsewhere it loads the single-threaded +/// build, and a world asked for more than one thread says no. +library; + +import 'dart:js_interop'; +import 'dart:js_interop_unsafe'; +import 'dart:typed_data'; + +@JS('WebAssembly.compile') +external JSPromise _compile(JSArrayBuffer bytes); + +@JS('WebAssembly.instantiate') +external JSPromise _instantiate(JSObject module, JSObject imports); + +@JS('WebAssembly.Memory') +extension type _Memory._(JSObject _) implements JSObject { + external factory _Memory(JSObject descriptor); + external JSObject get buffer; +} + +@JS('Worker') +extension type _Worker._(JSObject _) implements JSObject { + external factory _Worker(JSString url); + external void postMessage(JSAny message); +} + +@JS('fetch') +external JSPromise<_Response> _fetch(JSString url); + +extension type _Response(JSObject _) implements JSObject { + external bool get ok; + external int get status; + external JSPromise arrayBuffer(); +} + +extension type _Core(JSObject _) implements JSObject { + @JS('f3d_wasm_high') + external JSNumber high(); + @JS('f3d_wasm_workers_ready') + external JSNumber workersReady(); + @JS('f3d_buffer_alloc') + external JSNumber alloc(JSNumber bytes); +} + +JSObject? _exports; +JSObject? _memory; +int _threads = 1; + +/// Where the modules and the worker are fetched from when +/// [loadPhysicsCore] is given nothing: as a Flutter web app serves this +/// package's assets. +const String defaultPhysicsCoreUrl = + 'assets/packages/flutter3d_physics_native/web/f3d_physics.wasm'; +const String defaultPhysicsCoreThreadsUrl = + 'assets/packages/flutter3d_physics_native/web/f3d_physics_threads.wasm'; +const String defaultPhysicsWorkerUrl = + 'assets/packages/flutter3d_physics_native/web/f3d_worker.js'; + +/// Each worker's stack, bytes. +const int _stackBytes = 1 << 20; + +Future _load(String url) async { + final response = await _fetch(url.toJS).toDart; + if (!response.ok) { + throw StateError('fetching $url: HTTP ${response.status}'); + } + return response.arrayBuffer().toDart; +} + +JSObject _object(Map fields) { + final o = JSObject(); + for (final entry in fields.entries) { + o.setProperty(entry.key.toJS, entry.value); + } + return o; +} + +/// Whether the page can share memory with workers. +bool get _canShare => globalContext.has('SharedArrayBuffer'); + +/// Loads the core. With [threads] above one and a page that can share +/// memory, the threads build from [threadsUrl] and that many workers from +/// [workerUrl], the caller being one of them; else the single-threaded +/// build, from [bytes] when given or [url]. Done once; later calls return +/// at once. +Future loadPhysicsCore({ + Uint8List? bytes, + String url = defaultPhysicsCoreUrl, + int threads = 1, + String threadsUrl = defaultPhysicsCoreThreadsUrl, + String workerUrl = defaultPhysicsWorkerUrl, +}) async { + if (_exports != null) return; + if (threads > 1 && _canShare) { + final module = await _compile(await _load(threadsUrl)).toDart; + final memory = _Memory( + _object({ + 'initial': 256.toJS, + 'maximum': 32768.toJS, + 'shared': true.toJS, + }), + ); + final instance = await _instantiate( + module, + _object({ + 'env': _object({'memory': memory}), + }), + ).toDart; + final exports = instance.getProperty('exports'.toJS); + final core = exports as _Core; + for (var i = 0; i < threads - 1; i++) { + final stack = core.alloc(_stackBytes.toJS).toDartInt; + _Worker(workerUrl.toJS).postMessage( + _object({ + 'module': module, + 'memory': memory, + 'index': i.toJS, + 'top': (stack + _stackBytes).toJS, + }), + ); + } + while (core.workersReady().toDartInt < threads - 1) { + await Future.delayed(const Duration(milliseconds: 2)); + } + _memory = memory; + _exports = exports; + _threads = threads; + return; + } + final JSArrayBuffer source; + if (bytes != null) { + source = Uint8List.fromList(bytes).buffer.toJS; + } else { + source = await _load(url); + } + final module = await _compile(source).toDart; + final instance = await _instantiate(module, JSObject()).toDart; + final exports = instance.getProperty('exports'.toJS); + _memory = exports.getProperty('memory'.toJS); + _exports = exports; + _threads = 1; +} + +/// Whether [loadPhysicsCore] has run. +bool get physicsCoreLoaded => _exports != null; + +/// The most threads a world can step on: the workers [loadPhysicsCore] +/// started and the caller. +int get physicsCoreThreads => _threads; + +/// The module's exports. +JSObject get coreExports => + _exports ?? + (throw StateError( + 'the physics core is not loaded: await loadPhysicsCore() first', + )); + +/// The module's memory's buffer as it is now — replaced whenever it grows — +/// and its length. +JSObject coreBufferObject() { + coreExports; + return (_memory! as _Memory).buffer; +} + +int coreBufferLength(JSObject buffer) => + buffer.getProperty('byteLength'.toJS).toDartInt; + +const int _two32 = 4294967296; + +/// The low and high 32-bit halves of a 64-bit handle, and one put back +/// together — by arithmetic, which JavaScript's numbers keep exact to 2⁵³. +int lowHalf(int value) => value % _two32; +int highHalf(int value) => (value - lowHalf(value)) ~/ _two32; +int joinHalves(int low, int high) => + high.toUnsigned(32) * _two32 + low.toUnsigned(32); + +/// The high half of the 64-bit value the last call gave. +int highResult() => (coreExports as _Core).high().toDartInt; diff --git a/packages/flutter3d_physics_native/lib/src/gpu.dart b/packages/flutter3d_physics_native/lib/src/gpu.dart new file mode 100644 index 000000000..96e64efc1 --- /dev/null +++ b/packages/flutter3d_physics_native/lib/src/gpu.dart @@ -0,0 +1,4 @@ +/// The GPU passes: natively through wgpu-native, none in the browser — P9. +library; + +export 'gpu_native.dart' if (dart.library.js_interop) 'gpu_web.dart'; diff --git a/packages/flutter3d_physics_native/lib/src/gpu_bindings.dart b/packages/flutter3d_physics_native/lib/src/gpu_bindings.dart new file mode 100644 index 000000000..373246bc6 --- /dev/null +++ b/packages/flutter3d_physics_native/lib/src/gpu_bindings.dart @@ -0,0 +1,291 @@ +/// The GPU library's functions, as `csrc/gpu/f3d_gpu.h` declares them — +/// P9, phase 10. +/// +/// A library of its own beside the core's: built by the hook with +/// wgpu-native linked in, and missing where wgpu-native could not be had — +/// then every call here throws, which `NativeGpu.open` turns into null. +// The C names, kept. +// ignore_for_file: non_constant_identifier_names +@DefaultAsset('package:flutter3d_physics_native/src/gpu_bindings.dart') +library; + +import 'dart:ffi'; + +/// `F3D_GPU_ABI_VERSION` this file was written against. +const int gpuAbiVersion = 4; + +final class F3dGpu extends Opaque {} + +final class F3dGpuParticles extends Opaque {} + +/// `F3dGpuParticleForces`, field for field. +final class F3dGpuParticleForces extends Struct { + @Array(3) + external Array gravity; + @Array(3) + external Array wind; + @Float() + external double drag; + @Float() + external double floor_y; + @Float() + external double restitution; + @Float() + external double friction; +} + +@Native(isLeaf: true) +external int f3d_gpu_abi_version(); + +@Native Function()>() +external Pointer f3d_gpu_create(); + +@Native)>() +external void f3d_gpu_destroy(Pointer gpu); + +@Native, Pointer, Uint32)>(isLeaf: true) +external int f3d_gpu_adapter_name( + Pointer gpu, + Pointer out, + int capacity, +); + +@Native Function(Pointer, Uint32)>() +external Pointer f3d_gpu_particles_create( + Pointer gpu, + int capacity, +); + +@Native)>() +external void f3d_gpu_particles_destroy(Pointer particles); + +@Native, Pointer, Uint32)>() +external int f3d_gpu_particles_emit( + Pointer particles, + Pointer data, + int count, +); + +@Native< + Void Function( + Pointer, + Pointer, + Float, + Uint32, + ) +>() +external void f3d_gpu_particles_step( + Pointer particles, + Pointer forces, + double dt, + int steps, +); + +@Native, Pointer, Uint32)>() +external int f3d_gpu_particles_read( + Pointer particles, + Pointer out, + int capacity, +); + +final class F3dGpuDebris extends Opaque {} + +/// `F3dGpuDebrisSettings`, field for field. +final class F3dGpuDebrisSettings extends Struct { + @Array(3) + external Array gravity; + @Float() + external double friction; + @Float() + external double restitution; + @Float() + external double linear_damping; + @Float() + external double angular_damping; + @Float() + external double max_speed; + @Uint32() + external int substeps; + @Uint32() + external int iterations; +} + +@Native Function(Pointer, Uint32)>() +external Pointer f3d_gpu_debris_create( + Pointer gpu, + int capacity, +); + +@Native)>() +external void f3d_gpu_debris_destroy(Pointer debris); + +@Native, Pointer, Uint32)>() +external int f3d_gpu_debris_add( + Pointer debris, + Pointer data, + int count, +); + +@Native, Pointer, Uint32)>() +external int f3d_gpu_debris_set_statics( + Pointer debris, + Pointer data, + int count, +); + +@Native< + Void Function(Pointer, Pointer, Float) +>() +external void f3d_gpu_debris_step( + Pointer debris, + Pointer settings, + double dt, +); + +@Native, Pointer, Uint32, Int32)>() +external int f3d_gpu_debris_read( + Pointer debris, + Pointer out, + int capacity, + int wait, +); + +final class F3dGpuCloth extends Opaque {} + +/// `F3dGpuClothSettings`, field for field. +final class F3dGpuClothSettings extends Struct { + @Array(3) + external Array gravity; + @Array(3) + external Array wind; + @Float() + external double drag; + @Float() + external double damping; + @Float() + external double floor_y; + @Float() + external double thickness; + @Float() + external double friction; + @Uint32() + external int substeps; +} + +@Native< + Pointer Function( + Pointer, + Pointer, + Uint32, + Pointer, + Pointer, + Pointer, + Uint32, + Pointer, + Uint32, + ) +>() +external Pointer f3d_gpu_cloth_create( + Pointer gpu, + Pointer points, + int pointCount, + Pointer pairs, + Pointer rest, + Pointer compliance, + int edgeCount, + Pointer colourStart, + int colourCount, +); + +@Native)>() +external void f3d_gpu_cloth_destroy(Pointer cloth); + +@Native, Pointer, Uint32)>() +external int f3d_gpu_cloth_set_balls( + Pointer cloth, + Pointer balls, + int count, +); + +@Native, Uint32, Float, Float, Float)>() +external void f3d_gpu_cloth_move_point( + Pointer cloth, + int index, + double x, + double y, + double z, +); + +@Native< + Void Function(Pointer, Pointer, Float) +>() +external void f3d_gpu_cloth_step( + Pointer cloth, + Pointer settings, + double dt, +); + +@Native, Pointer, Uint32, Int32)>() +external int f3d_gpu_cloth_read( + Pointer cloth, + Pointer out, + int capacity, + int wait, +); + +final class F3dGpuFluid extends Opaque {} + +/// `F3dGpuFluidSettings`, field for field. +final class F3dGpuFluidSettings extends Struct { + @Array(3) + external Array gravity; + @Array(3) + external Array tank_min; + @Array(3) + external Array tank_max; + @Float() + external double viscosity; + @Float() + external double relaxation; + @Uint32() + external int substeps; + @Uint32() + external int iterations; +} + +@Native Function(Pointer, Uint32, Float)>() +external Pointer f3d_gpu_fluid_create( + Pointer gpu, + int capacity, + double spacing, +); + +@Native)>() +external void f3d_gpu_fluid_destroy(Pointer fluid); + +@Native)>() +external double f3d_gpu_fluid_rest_density(Pointer fluid); + +@Native, Pointer, Uint32)>() +external int f3d_gpu_fluid_add( + Pointer fluid, + Pointer data, + int count, +); + +@Native< + Void Function(Pointer, Pointer, Float) +>() +external void f3d_gpu_fluid_step( + Pointer fluid, + Pointer settings, + double dt, +); + +@Native, Pointer, Uint32, Int32)>() +external int f3d_gpu_fluid_read( + Pointer fluid, + Pointer out, + int capacity, + int wait, +); diff --git a/packages/flutter3d_physics_native/lib/src/gpu_native.dart b/packages/flutter3d_physics_native/lib/src/gpu_native.dart new file mode 100644 index 000000000..589a6a2f9 --- /dev/null +++ b/packages/flutter3d_physics_native/lib/src/gpu_native.dart @@ -0,0 +1,427 @@ +/// The GPU passes natively, through wgpu-native — P9, phases 10 and 12. +/// +/// The GPU library is its own, reached through `dart:ffi` alone: the +/// browser has none of it (`gpu_web.dart`), and there [NativeGpu.open] is +/// null. Its inputs are written into the core's memory as the CPU systems +/// write theirs — natively an address is a pointer — and its settings +/// structs are laid out as the core's. +library; + +import 'dart:ffi'; +import 'dart:typed_data'; + +import 'package:vector_math/vector_math.dart'; + +import 'core/core.dart' as c; +import 'gpu_bindings.dart' as g; +import 'native_cloth.dart'; +import 'native_debris.dart'; +import 'native_fluid.dart'; +import 'native_particles.dart'; + +Pointer _at(int address) => + Pointer.fromAddress(address); + +/// A GPU, through wgpu-native: what the visual passes run on. +final class NativeGpu { + NativeGpu._(this._gpu); + + /// The best adapter there is, or null: no GPU, a driver wgpu cannot use, + /// or no GPU library in this build — wgpu-native could not be fetched + /// for the target, the platform has none, or this is the browser. + static NativeGpu? open() { + try { + if (g.f3d_gpu_abi_version() != g.gpuAbiVersion) return null; + final gpu = g.f3d_gpu_create(); + return gpu == nullptr ? null : NativeGpu._(gpu); + } on ArgumentError { + // The asset is not there: no GPU library in this build. + return null; + } + } + + Pointer _gpu; + + Pointer get _live { + if (_gpu == nullptr) throw StateError('this GPU was disposed'); + return _gpu; + } + + /// The adapter's name. + String get adapterName { + final out = c.U8s.alloc(256); + try { + final n = g.f3d_gpu_adapter_name(_live, _at(out), 256); + return String.fromCharCodes(out.copy(n < 255 ? n : 255)); + } finally { + out.free(); + } + } + + /// [capacity] particle slots on this GPU. + GpuParticles particles(int capacity) => GpuParticles._(this, capacity); + + /// [capacity] debris slots on this GPU. + GpuDebris debris(int capacity) => GpuDebris._(this, capacity); + + /// [mesh] as a cloth on this GPU. + GpuCloth cloth(ClothMesh mesh) => GpuCloth._(this, mesh); + + /// Room for [capacity] particles of fluid on this GPU, [spacing] apart + /// at rest. + GpuFluid fluid(int capacity, double spacing) => + GpuFluid._(this, capacity, spacing); + + /// Frees the device. Its systems go first. + void dispose() { + if (_gpu == nullptr) return; + g.f3d_gpu_destroy(_gpu); + _gpu = nullptr; + } +} + +String _gpuRefused(String what) => + '$what, no memory, or a pass the GPU would not build'; + +/// Particles stepped by a compute shader that does what [NativeParticles] +/// does, step for step: the same to a GPU's own rounding. +final class GpuParticles implements ParticleSystem { + GpuParticles._(this._gpu, int capacity) + : _capacity = capacity, + _p = g.f3d_gpu_particles_create(_gpu._live, capacity) { + if (_p == nullptr) { + throw ArgumentError.value( + capacity, + 'capacity', + _gpuRefused('none, too many'), + ); + } + } + + final NativeGpu _gpu; + final int _capacity; + Pointer _p; + + Pointer get _live { + if (_p == nullptr) throw StateError('these particles were disposed'); + _gpu._live; + return _p; + } + + @override + int get capacity => _capacity; + + @override + void emit(List particles) { + final data = packParticles(particles); + try { + g.f3d_gpu_particles_emit(_live, _at(data), particles.length); + } finally { + data.free(); + } + } + + @override + void step(ParticleForces forces, double dt, {int steps = 1}) { + final f = writeParticleForces(forces); + try { + g.f3d_gpu_particles_step( + _live, + _at(f), + dt, + steps, + ); + } finally { + c.coreFree(f); + } + } + + @override + Float32List read() { + final out = c.F32s.alloc(_capacity * 4); + try { + final n = g.f3d_gpu_particles_read(_live, _at(out), _capacity); + if (n == 0) throw StateError('the GPU could not be read'); + return out.copy(_capacity * 4); + } finally { + out.free(); + } + } + + @override + void dispose() { + if (_p == nullptr) return; + g.f3d_gpu_particles_destroy(_p); + _p = nullptr; + } +} + +/// Debris stepped on the GPU, read a frame late. The same passes as +/// [NativeDebris]: the same to the GPU's rounding until bodies meet, and +/// after that as a heap is — the same in what it does, not body for body. +final class GpuDebris implements DebrisSystem { + GpuDebris._(this._gpu, int capacity) + : _capacity = capacity, + _d = g.f3d_gpu_debris_create(_gpu._live, capacity) { + if (_d == nullptr) { + throw ArgumentError.value( + capacity, + 'capacity', + _gpuRefused('none, too many'), + ); + } + } + + final NativeGpu _gpu; + final int _capacity; + Pointer _d; + + Pointer get _live { + if (_d == nullptr) throw StateError('this debris was disposed'); + _gpu._live; + return _d; + } + + @override + int get capacity => _capacity; + + @override + void add(List bodies) { + final data = packDebrisBodies(bodies); + try { + g.f3d_gpu_debris_add(_live, _at(data), bodies.length); + } finally { + data.free(); + } + } + + @override + void setStatics(List statics) { + final data = packDebrisStatics(statics); + try { + g.f3d_gpu_debris_set_statics(_live, _at(data), statics.length); + } finally { + data.free(); + } + } + + @override + void step(DebrisSettings settings, double dt) { + final s = writeDebrisSettings(settings); + try { + g.f3d_gpu_debris_step(_live, _at(s), dt); + } finally { + c.coreFree(s); + } + } + + @override + DebrisFrame? read({bool wait = true}) { + final out = c.F32s.alloc(_capacity * c.debrisFloats); + try { + final step = g.f3d_gpu_debris_read( + _live, + _at(out), + _capacity, + wait ? 1 : 0, + ); + if (step == 0) return null; + return (step: step, bodies: out.copy(_capacity * c.debrisFloats)); + } finally { + out.free(); + } + } + + @override + void dispose() { + if (_d == nullptr) return; + g.f3d_gpu_debris_destroy(_d); + _d = nullptr; + } +} + +/// Cloth stepped on the GPU, a dispatch a colour, read a frame late. The +/// constraints are coloured by the core, so it solves what [NativeCloth] +/// solves in the same order of colours: the same to the GPU's rounding, +/// until folds and wrinkles take the two their own ways. +final class GpuCloth implements ClothSystem { + GpuCloth._(this._gpu, ClothMesh mesh) + : _points = mesh.points.length, + _c = _upload(_gpu, mesh); + + static Pointer _upload(NativeGpu gpu, ClothMesh mesh) { + final core = createCoreCloth(mesh); + final packed = ClothPacked(mesh); + final edges = c.f3d_cloth_edge_count(core); + final colours = c.f3d_cloth_colour_count(core); + final pairs = c.U32s.alloc(edges == 0 ? 2 : edges * 2); + final rest = c.F32s.alloc(edges == 0 ? 1 : edges); + final compliance = c.F32s.alloc(edges == 0 ? 1 : edges); + final start = c.U32s.alloc(colours + 1); + try { + c.f3d_cloth_edges(core, pairs, rest, compliance, start); + final cloth = g.f3d_gpu_cloth_create( + gpu._live, + _at(packed.points), + mesh.points.length, + _at(pairs), + _at(rest), + _at(compliance), + edges, + _at(start), + colours, + ); + if (cloth == nullptr) { + throw ArgumentError.value(mesh, 'mesh', _gpuRefused('too large')); + } + return cloth; + } finally { + c.f3d_cloth_destroy(core); + packed.free(); + pairs.free(); + rest.free(); + compliance.free(); + start.free(); + } + } + + final NativeGpu _gpu; + final int _points; + Pointer _c; + + Pointer get _live { + if (_c == nullptr) throw StateError('this cloth was disposed'); + _gpu._live; + return _c; + } + + @override + int get pointCount => _points; + + @override + void setBalls(List<({Vector3 centre, double radius})> balls) { + final data = packClothBalls(balls); + try { + g.f3d_gpu_cloth_set_balls(_live, _at(data), balls.length); + } finally { + data.free(); + } + } + + @override + void movePoint(int index, Vector3 at) { + g.f3d_gpu_cloth_move_point(_live, index, at.x, at.y, at.z); + } + + @override + void step(ClothSettings settings, double dt) { + final s = writeClothSettings(settings); + try { + g.f3d_gpu_cloth_step(_live, _at(s), dt); + } finally { + c.coreFree(s); + } + } + + @override + ClothFrame? read({bool wait = true}) { + final out = c.F32s.alloc(_points * 4); + try { + final step = g.f3d_gpu_cloth_read( + _live, + _at(out), + _points, + wait ? 1 : 0, + ); + if (step == 0) return null; + return (step: step, points: out.copy(_points * 4)); + } finally { + out.free(); + } + } + + @override + void dispose() { + if (_c == nullptr) return; + g.f3d_gpu_cloth_destroy(_c); + _c = nullptr; + } +} + +/// Fluid stepped on the GPU, read a frame late: the same passes as +/// [NativeFluid] to the GPU's rounding, until its splashes go their own +/// way. +final class GpuFluid implements FluidSystem { + GpuFluid._(this._gpu, int capacity, double spacing) + : _capacity = capacity, + _f = g.f3d_gpu_fluid_create(_gpu._live, capacity, spacing) { + if (_f == nullptr) { + throw ArgumentError(_gpuRefused('no room, a spacing not above nought')); + } + } + + final NativeGpu _gpu; + final int _capacity; + Pointer _f; + int _count = 0; + + Pointer get _live { + if (_f == nullptr) throw StateError('this fluid was disposed'); + _gpu._live; + return _f; + } + + @override + int get capacity => _capacity; + + @override + int get count => _count; + + @override + double get restDensity => g.f3d_gpu_fluid_rest_density(_live); + + @override + void add(List particles) { + final data = packFluidParticles(particles); + try { + g.f3d_gpu_fluid_add(_live, _at(data), particles.length); + _count = (_count + particles.length).clamp(0, _capacity); + } finally { + data.free(); + } + } + + @override + void step(FluidSettings settings, double dt) { + final s = writeFluidSettings(settings); + try { + g.f3d_gpu_fluid_step(_live, _at(s), dt); + } finally { + c.coreFree(s); + } + } + + @override + FluidFrame? read({bool wait = true}) { + final out = c.F32s.alloc(_count == 0 ? 4 : _count * 4); + try { + final step = g.f3d_gpu_fluid_read( + _live, + _at(out), + _count, + wait ? 1 : 0, + ); + if (step == 0) return null; + return (step: step, particles: out.copy(_count * 4)); + } finally { + out.free(); + } + } + + @override + void dispose() { + if (_f == nullptr) return; + g.f3d_gpu_fluid_destroy(_f); + _f = nullptr; + } +} diff --git a/packages/flutter3d_physics_native/lib/src/gpu_web.dart b/packages/flutter3d_physics_native/lib/src/gpu_web.dart new file mode 100644 index 000000000..746c5d23c --- /dev/null +++ b/packages/flutter3d_physics_native/lib/src/gpu_web.dart @@ -0,0 +1,40 @@ +/// The GPU passes in the browser: none yet — P9, phase 12. +/// +/// The browser's WebGPU is not reached from here; [NativeGpu.open] is null, +/// and the CPU systems — [NativeParticles], [NativeDebris], [NativeCloth], +/// [NativeFluid] — stand in, as on a machine with no GPU. +library; + +import 'native_cloth.dart'; +import 'native_debris.dart'; +import 'native_fluid.dart'; +import 'native_particles.dart'; + +/// A GPU: never one in the browser. +final class NativeGpu { + NativeGpu._(); + + /// Null: there is no GPU library in the browser. + static NativeGpu? open() => null; + + String get adapterName => throw _none; + GpuParticles particles(int capacity) => throw _none; + GpuDebris debris(int capacity) => throw _none; + GpuCloth cloth(ClothMesh mesh) => throw _none; + GpuFluid fluid(int capacity, double spacing) => throw _none; + void dispose() {} +} + +final UnsupportedError _none = UnsupportedError('no GPU passes in the browser'); + +/// Never made in the browser: [NativeGpu.open] is null. +abstract final class GpuParticles implements ParticleSystem {} + +/// Never made in the browser: [NativeGpu.open] is null. +abstract final class GpuDebris implements DebrisSystem {} + +/// Never made in the browser: [NativeGpu.open] is null. +abstract final class GpuCloth implements ClothSystem {} + +/// Never made in the browser: [NativeGpu.open] is null. +abstract final class GpuFluid implements FluidSystem {} diff --git a/packages/flutter3d_physics_native/lib/src/native_cloth.dart b/packages/flutter3d_physics_native/lib/src/native_cloth.dart new file mode 100644 index 000000000..464778d39 --- /dev/null +++ b/packages/flutter3d_physics_native/lib/src/native_cloth.dart @@ -0,0 +1,308 @@ +/// Cloth on the CPU and on the GPU — P9, phase 10. +library; + +import 'dart:typed_data'; + +import 'package:vector_math/vector_math.dart'; + +import 'core/core.dart' as c; + +/// The points of a cloth and the constraints between them, each held at +/// the distance it starts at. +final class ClothMesh { + ClothMesh({ + required this.points, + required this.inverseMasses, + required this.edges, + required this.compliances, + }) { + if (inverseMasses.length != points.length) { + throw ArgumentError.value(inverseMasses.length, 'inverseMasses'); + } + if (compliances.length != edges.length) { + throw ArgumentError.value(compliances.length, 'compliances'); + } + } + + /// A sheet of [columns] × [rows] points, [width] along x and [depth] + /// along z from [origin], of [mass] kilograms in all; held by its + /// structural edges at [stretch] compliance, and its diagonals and + /// edges skipping a point at [bend]. The points in [pinned], by row and + /// column as `row * columns + column`, stay where they are. + factory ClothMesh.grid({ + required int columns, + required int rows, + double width = 1.0, + double depth = 1.0, + Vector3? origin, + double mass = 0.2, + double stretch = 1e-7, + double bend = 1e-6, + Set pinned = const {}, + }) { + final from = origin ?? Vector3.zero(); + final count = columns * rows; + final points = [ + for (var j = 0; j < rows; j++) + for (var i = 0; i < columns; i++) + from + + Vector3(width * i / (columns - 1), 0.0, depth * j / (rows - 1)), + ]; + final inverse = [ + for (var k = 0; k < count; k++) pinned.contains(k) ? 0.0 : count / mass, + ]; + final edges = <(int, int)>[]; + final compliances = []; + const links = <(int, int, bool)>[ + (1, 0, true), + (0, 1, true), + (1, 1, false), + (1, -1, false), + (2, 0, false), + (0, 2, false), + ]; + for (var j = 0; j < rows; j++) { + for (var i = 0; i < columns; i++) { + for (final (di, dj, structural) in links) { + final ii = i + di; + final jj = j + dj; + if (ii < 0 || ii >= columns || jj < 0 || jj >= rows) continue; + edges.add((j * columns + i, jj * columns + ii)); + compliances.add(structural ? stretch : bend); + } + } + } + return ClothMesh( + points: points, + inverseMasses: inverse, + edges: edges, + compliances: compliances, + ); + } + + final List points; + + /// Nought for a point pinned where it is. + final List inverseMasses; + final List<(int, int)> edges; + + /// Per edge, m/N; nought holds it rigid. + final List compliances; +} + +/// How cloth moves. Sixteen substeps hold a sheet to under 2% stretch. +final class ClothSettings { + ClothSettings({ + Vector3? gravity, + Vector3? wind, + this.drag = 0.0, + this.damping = 0.5, + this.floorY = -1e9, + this.thickness = 0.01, + this.friction = 0.3, + this.substeps = 16, + }) : gravity = gravity ?? Vector3(0.0, -9.81, 0.0), + wind = wind ?? Vector3.zero(); + + final Vector3 gravity; + + /// The air's velocity, and how fast a point drifts to it, per second. + final Vector3 wind; + final double drag; + + /// Per second, as v / (1 + c dt). + final double damping; + final double floorY; + + /// How far from a ball or the floor a point is held. + final double thickness; + + /// The part of a touching point's slide taken away each substep. + final double friction; + final int substeps; +} + +/// What a read gives: the step the points are from, counted from one, and +/// four floats a point — position xyz, inverse mass. +typedef ClothFrame = ({int step, Float32List points}); + +/// A cloth: on the CPU ([NativeCloth]) or the GPU ([GpuCloth]), the same +/// passes. Visual, not the game's state. +abstract interface class ClothSystem { + int get pointCount; + + /// The balls the cloth falls on, replacing the last: at most 16. + void setBalls(List<({Vector3 centre, double radius})> balls); + + /// Puts point [index] at [at], still: how a pinned point is carried. + void movePoint(int index, Vector3 at); + + void step(ClothSettings settings, double dt); + + /// The latest step's points not read yet, or null when there is none. On + /// the GPU, without [wait], a frame late. + ClothFrame? read({bool wait = true}); + + void dispose(); +} + +/// The mesh's arrays in the core's layout, freed by [free]. +final class ClothPacked { + ClothPacked(ClothMesh mesh) + : points = c.F32s.alloc(mesh.points.length * 4), + edges = c.U32s.alloc(mesh.edges.isEmpty ? 2 : mesh.edges.length * 2), + compliance = c.F32s.alloc(mesh.edges.isEmpty ? 1 : mesh.edges.length) { + for (var i = 0; i < mesh.points.length; i++) { + points[i * 4] = mesh.points[i].x; + points[i * 4 + 1] = mesh.points[i].y; + points[i * 4 + 2] = mesh.points[i].z; + points[i * 4 + 3] = mesh.inverseMasses[i]; + } + for (var e = 0; e < mesh.edges.length; e++) { + edges[e * 2] = mesh.edges[e].$1; + edges[e * 2 + 1] = mesh.edges[e].$2; + compliance[e] = mesh.compliances[e]; + } + } + + final c.F32s points; + final c.U32s edges; + final c.F32s compliance; + + void free() { + points.free(); + edges.free(); + compliance.free(); + } +} + +int createCoreCloth(ClothMesh mesh) { + final packed = ClothPacked(mesh); + try { + final cloth = c.f3d_cloth_create( + packed.points, + mesh.points.length, + packed.edges, + packed.compliance, + mesh.edges.length, + ); + if (cloth == 0) { + throw ArgumentError.value( + mesh, + 'mesh', + 'no points, an edge out of range or from a point to itself, or no ' + 'memory', + ); + } + return cloth; + } finally { + packed.free(); + } +} + +c.F32s packClothBalls(List<({Vector3 centre, double radius})> balls) { + if (balls.length > c.clothMaxBalls) { + throw ArgumentError.value(balls.length, 'balls', 'more than 16'); + } + final data = c.F32s.alloc(balls.isEmpty ? 4 : balls.length * 4); + for (var i = 0; i < balls.length; i++) { + data[i * 4] = balls[i].centre.x; + data[i * 4 + 1] = balls[i].centre.y; + data[i * 4 + 2] = balls[i].centre.z; + data[i * 4 + 3] = balls[i].radius; + } + return data; +} + +/// [settings] as the core's `F3dClothSettings` — and the GPU's, laid out +/// the same — in a block of the core's memory the caller frees. +int writeClothSettings(ClothSettings settings) { + final s = c.coreAlloc(c.F3dClothSettingsLayout.size); + for (var k = 0; k < 3; k++) { + c.writeF32( + s + c.F3dClothSettingsLayout.gravity + k * 4, + settings.gravity[k], + ); + c.writeF32(s + c.F3dClothSettingsLayout.wind + k * 4, settings.wind[k]); + } + c.writeF32(s + c.F3dClothSettingsLayout.drag, settings.drag); + c.writeF32(s + c.F3dClothSettingsLayout.damping, settings.damping); + c.writeF32(s + c.F3dClothSettingsLayout.floorY, settings.floorY); + c.writeF32(s + c.F3dClothSettingsLayout.thickness, settings.thickness); + c.writeF32(s + c.F3dClothSettingsLayout.friction, settings.friction); + c.writeU32(s + c.F3dClothSettingsLayout.substeps, settings.substeps); + return s; +} + +/// Cloth stepped by the core on the CPU: the reference for the GPU's, and +/// the fallback where there is none. +final class NativeCloth implements ClothSystem { + NativeCloth(ClothMesh mesh) : _c = createCoreCloth(mesh) { + _finalizer.attach(this, _c, detach: this); + } + + static final Finalizer _finalizer = Finalizer(c.f3d_cloth_destroy); + + int _c; + int _steps = 0; + int _read = 0; + + int get _live { + if (_c == 0) throw StateError('this cloth was disposed'); + return _c; + } + + @override + int get pointCount => c.f3d_cloth_point_count(_live); + + /// How many colours its constraints took. + int get colourCount => c.f3d_cloth_colour_count(_live); + + @override + void setBalls(List<({Vector3 centre, double radius})> balls) { + final data = packClothBalls(balls); + try { + c.f3d_cloth_set_balls(_live, data, balls.length); + } finally { + data.free(); + } + } + + @override + void movePoint(int index, Vector3 at) { + c.f3d_cloth_move_point(_live, index, at.x, at.y, at.z); + } + + @override + void step(ClothSettings settings, double dt) { + final s = writeClothSettings(settings); + try { + c.f3d_cloth_step(_live, s, dt); + _steps++; + } finally { + c.coreFree(s); + } + } + + @override + ClothFrame? read({bool wait = true}) { + final n = pointCount; + if (_steps == _read) return null; + final out = c.F32s.alloc(n * c.clothFloats); + try { + c.f3d_cloth_read(_live, out, n); + _read = _steps; + return (step: _steps, points: out.copy(n * c.clothFloats)); + } finally { + out.free(); + } + } + + @override + void dispose() { + if (_c == 0) return; + _finalizer.detach(this); + c.f3d_cloth_destroy(_c); + _c = 0; + } +} diff --git a/packages/flutter3d_physics_native/lib/src/native_debris.dart b/packages/flutter3d_physics_native/lib/src/native_debris.dart new file mode 100644 index 000000000..d1284e4d2 --- /dev/null +++ b/packages/flutter3d_physics_native/lib/src/native_debris.dart @@ -0,0 +1,236 @@ +/// Debris — the visual bodies — on the CPU and on the GPU: P9, phase 10. +library; + +import 'dart:typed_data'; + +import 'package:vector_math/vector_math.dart'; + +import 'core/core.dart' as c; + +/// How debris moves. Eight substeps of four impulse passes keep a heap of a +/// thousand from sinking into itself by more than a tenth of a radius. +final class DebrisSettings { + DebrisSettings({ + Vector3? gravity, + this.friction = 0.6, + this.restitution = 0.2, + this.linearDamping = 0.0, + this.angularDamping = 0.5, + this.maxSpeed = 50.0, + this.substeps = 8, + this.iterations = 4, + }) : gravity = gravity ?? Vector3(0.0, -9.81, 0.0); + + final Vector3 gravity; + final double friction; + + /// Applied when two close faster than 1 m/s. + final double restitution; + + /// Per second, as v / (1 + c dt). + final double linearDamping; + final double angularDamping; + final double maxSpeed; + final int substeps; + final int iterations; +} + +/// One ball of debris to add: a radius or mass of nought or less empties +/// its slot instead. +typedef DebrisBody = ({ + Vector3 position, + Vector3 velocity, + double radius, + double mass, +}); + +/// A still shape debris lands on. +sealed class DebrisStatic { + const DebrisStatic(); +} + +/// The plane of points p with p · [normal] = [offset]; debris stays on the +/// side [normal] points to. +final class DebrisPlane extends DebrisStatic { + const DebrisPlane(this.normal, this.offset); + final Vector3 normal; + final double offset; +} + +/// An unturned box. +final class DebrisBox extends DebrisStatic { + const DebrisBox(this.centre, this.halfExtents); + final Vector3 centre; + final Vector3 halfExtents; +} + +/// What a read gives: the step the bodies are from, counted from one, and +/// eight floats a slot — position xyz, orientation xyzw, radius. +typedef DebrisFrame = ({int step, Float32List bodies}); + +/// Debris slots, filled round and round: on the CPU ([NativeDebris]) or the +/// GPU ([GpuDebris]), the same passes. Visual, not the game's state. +abstract interface class DebrisSystem { + int get capacity; + + /// Puts [bodies] into the next slots, the oldest going first, unturned. + void add(List bodies); + + /// The still shapes, replacing the last: at most 64. + void setStatics(List statics); + + void step(DebrisSettings settings, double dt); + + /// The latest step's bodies not read yet, or null when there is none. On + /// the GPU, without [wait], a frame late: the step queued last is still + /// on its way. + DebrisFrame? read({bool wait = true}); + + void dispose(); +} + +c.F32s packDebrisBodies(List bodies) { + final data = c.F32s.alloc(bodies.isEmpty ? 8 : bodies.length * 8); + for (var i = 0; i < bodies.length; i++) { + final b = bodies[i]; + data[i * 8] = b.position.x; + data[i * 8 + 1] = b.position.y; + data[i * 8 + 2] = b.position.z; + data[i * 8 + 3] = b.velocity.x; + data[i * 8 + 4] = b.velocity.y; + data[i * 8 + 5] = b.velocity.z; + data[i * 8 + 6] = b.radius; + data[i * 8 + 7] = b.mass; + } + return data; +} + +c.F32s packDebrisStatics(List statics) { + if (statics.length > c.debrisMaxStatics) { + throw ArgumentError.value(statics.length, 'statics', 'more than 64'); + } + final data = c.F32s.alloc(statics.isEmpty ? 8 : statics.length * 8); + for (var i = 0; i < statics.length; i++) { + final o = i * 8; + switch (statics[i]) { + case DebrisPlane(:final normal, :final offset): + data[o] = normal.x; + data[o + 1] = normal.y; + data[o + 2] = normal.z; + data[o + 3] = offset; + case DebrisBox(:final centre, :final halfExtents): + data[o] = centre.x; + data[o + 1] = centre.y; + data[o + 2] = centre.z; + data[o + 4] = halfExtents.x; + data[o + 5] = halfExtents.y; + data[o + 6] = halfExtents.z; + data[o + 7] = 1.0; + } + } + return data; +} + +/// [settings] as the core's `F3dDebrisSettings` — and the GPU's, laid out +/// the same — in a block of the core's memory the caller frees. +int writeDebrisSettings(DebrisSettings settings) { + final s = c.coreAlloc(c.F3dDebrisSettingsLayout.size); + for (var k = 0; k < 3; k++) { + c.writeF32( + s + c.F3dDebrisSettingsLayout.gravity + k * 4, + settings.gravity[k], + ); + } + c.writeF32(s + c.F3dDebrisSettingsLayout.friction, settings.friction); + c.writeF32(s + c.F3dDebrisSettingsLayout.restitution, settings.restitution); + c.writeF32( + s + c.F3dDebrisSettingsLayout.linearDamping, + settings.linearDamping, + ); + c.writeF32( + s + c.F3dDebrisSettingsLayout.angularDamping, + settings.angularDamping, + ); + c.writeF32(s + c.F3dDebrisSettingsLayout.maxSpeed, settings.maxSpeed); + c.writeU32(s + c.F3dDebrisSettingsLayout.substeps, settings.substeps); + c.writeU32(s + c.F3dDebrisSettingsLayout.iterations, settings.iterations); + return s; +} + +/// Debris stepped by the core on the CPU: the reference for the GPU's, and +/// the fallback where there is none. +final class NativeDebris implements DebrisSystem { + NativeDebris(int capacity) : _d = c.f3d_debris_create(capacity) { + if (_d == 0) { + throw ArgumentError.value(capacity, 'capacity', 'none, or no memory'); + } + _finalizer.attach(this, _d, detach: this); + } + + static final Finalizer _finalizer = Finalizer(c.f3d_debris_destroy); + + int _d; + int _steps = 0; + int _read = 0; + + int get _live { + if (_d == 0) throw StateError('this debris was disposed'); + return _d; + } + + @override + int get capacity => c.f3d_debris_capacity(_live); + + @override + void add(List bodies) { + final data = packDebrisBodies(bodies); + try { + c.f3d_debris_add(_live, data, bodies.length); + } finally { + data.free(); + } + } + + @override + void setStatics(List statics) { + final data = packDebrisStatics(statics); + try { + c.f3d_debris_set_statics(_live, data, statics.length); + } finally { + data.free(); + } + } + + @override + void step(DebrisSettings settings, double dt) { + final s = writeDebrisSettings(settings); + try { + c.f3d_debris_step(_live, s, dt); + _steps++; + } finally { + c.coreFree(s); + } + } + + @override + DebrisFrame? read({bool wait = true}) { + final n = capacity; + if (_steps == _read) return null; + final out = c.F32s.alloc(n * c.debrisFloats); + try { + c.f3d_debris_read(_live, out, n); + _read = _steps; + return (step: _steps, bodies: out.copy(n * c.debrisFloats)); + } finally { + out.free(); + } + } + + @override + void dispose() { + if (_d == 0) return; + _finalizer.detach(this); + c.f3d_debris_destroy(_d); + _d = 0; + } +} diff --git a/packages/flutter3d_physics_native/lib/src/native_dynamics.dart b/packages/flutter3d_physics_native/lib/src/native_dynamics.dart new file mode 100644 index 000000000..f056d85c6 --- /dev/null +++ b/packages/flutter3d_physics_native/lib/src/native_dynamics.dart @@ -0,0 +1,423 @@ +/// The physics core underneath `flutter3d_physics`' bodies — P9. +/// +/// A [RigidDynamics], so a game builds one where it built a `Dynamics` and +/// nothing past that line changes: the bodies are still [RigidBody] objects +/// on a [CollisionWorld], their colliders still what the broadphase, the +/// sweeps and the character controller see. +/// +/// ## Who holds what +/// +/// The core holds the bodies' truth; a [RigidBody] is its mirror. Before a +/// step, whatever a game did to a body since the last one — an impulse, a +/// push, a teleport, a restore — is found by comparing it with what the +/// mirror was left at, and written in; after the step, every body is written +/// back: its collider moved, its velocity, spin and orientation, asleep or +/// not. The rest of the world — level geometry, characters, doors, lifts — +/// stands in the core as fixed bodies, made as colliders appear, moved as +/// they move and taken out as they go. Triggers stand nowhere: they block +/// nothing. +/// +/// ## What is different from Dynamics +/// +/// Contacts turn a body that can turn, a box falls flat off an edge, joints +/// and bullets are the core's own ([native]); the step is the core's, in +/// f32, to bits of its own — the same bits on every platform, but not the +/// reference's, so a run's digests are not the reference's either. A +/// rollback restores [snapshot]s, which carry the warm starts and the sleep +/// a body's own `save()` does not; a body restored on its own is put where +/// it was and moving as it was, and the core takes it from there. +library; + +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flutter3d_physics/flutter3d_physics.dart'; +import 'package:vector_math/vector_math.dart'; + +import 'native_world.dart'; + +final class NativeDynamics implements RigidDynamics { + /// [gravity] as `Dynamics`' default, and its sleep: under 0.08 m/s for + /// half a second. The air is all but taken away, as the reference has + /// none; [native] can bring it back, with wind. + NativeDynamics({ + required this.world, + Vector3? gravity, + int substeps = 4, + int threads = 1, + }) : gravity = gravity ?? Vector3(0.0, -22.0, 0.0) { + native + ..setAir(temperature: 293.15, density: 1e-30) + ..setSleep(speed: 0.08, time: 0.5) + ..substeps = substeps; + if (threads != 1) native.threads = threads; + } + + @override + final CollisionWorld world; + + @override + final Vector3 gravity; + + /// The core's world: joints, bullets, the air, the fast mode. + final NativeWorld native = NativeWorld(); + + @override + final List bodies = []; + + final Map _byCollider = {}; + final Map _mirrors = {}; + final Map _standing = {}; + final Map _hulls = + {}; + final Map _meshes = + {}; + late final Pusher _pusher = Pusher(this); + int _sweep = 0; + + /// The core's handle for [body]; null for a body not added. For a game + /// that reaches past the bodies into [native]: joining two crates with a + /// hinge, making a thrown body a bullet, giving one heat. + NativeBody? handleOf(RigidBody body) => _mirrors[body]?.handle; + + @override + RigidBody add(RigidBody body) { + bodies.add(body); + _byCollider[body.collider] = body; + _mirrors[body] = _mirrorFor(body); + return body; + } + + /// [body] made in the core as it is now, and its mirror. + _Mirror _mirrorFor(RigidBody body) { + final (shape, offset) = _shapeOf(body.collider.shape); + final handle = native.addBody( + position: body.position + _turned(body.orientation, offset), + mass: body.mass, + ); + shape(handle); + native + ..setOrientation(handle, body.orientation) + ..setVelocity(handle, body.velocity) + ..setAngularVelocity(handle, body.angularVelocity) + ..lockRotation(handle, locked: !body.canRotate) + ..setDamping(handle, angular: body.angularDamping) + ..setFriction(handle, body.friction) + ..setRestitution(handle, body.restitution) + ..setCollisionFilter( + handle, + layer: body.collider.layer & Layers.all, + mask: body.collider.mask & Layers.all, + ); + return _Mirror(handle, offset)..take(body); + } + + @override + void remove(RigidBody body) { + bodies.remove(body); + _byCollider.remove(body.collider); + final mirror = _mirrors.remove(body); + if (mirror != null) native.removeBody(mirror.handle); + world.remove(body.collider); + } + + @override + RigidBody? bodyOf(Collider collider) => _byCollider[collider]; + + @override + void push(Collider by, Vector3 velocity, {double strength = 1.0}) => + _pusher.push(by, velocity, strength: strength); + + @override + void step(double dt) { + if (dt <= 0.0) return; + _stand(dt); + for (final body in bodies) { + _mirrors[body]!.give(native, body); + } + native + ..gravity = gravity + ..step(dt); + for (final body in bodies) { + _mirrors[body]!.fetch(native, body); + } + world.reindex(); + } + + /// The whole world as the core holds it, warm starts and sleep and all, + /// for a rollback. + Uint8List snapshot() => native.snapshot(); + + /// The [snapshot], as text a simulation's save can hold. + @override + Object? saveState() => base64Encode(snapshot()); + + @override + void restoreState(Object? saved) { + if (saved is String) restore(base64Decode(saved)); + } + + /// Back to [bytes], every body's mirror with it. + /// + /// What came and went since the snapshot is put right, as a simulation + /// restoring a save has already put its own world right: a body added + /// since is made again as it is now, a collider standing since stands + /// again next step, and whatever the snapshot holds that nothing here + /// names any more — a crate since broken, a wall since taken out — goes. + /// Handles carry a generation, so a slot used again since is told apart. + /// Hulls and meshes made since are gone with the snapshot, so they are + /// made again as they are needed. + void restore(Uint8List bytes) { + native.restore(bytes); + _hulls.clear(); + _meshes.clear(); + _standing.removeWhere((_, standing) => !native.contains(standing.handle)); + // Where each stands, and whether it moves, as the snapshot has it: a + // lift restored to where it was is not a lift that jumped there. + _standing.forEach( + (collider, standing) => standing.resume(native, collider), + ); + for (final body in bodies) { + if (!native.contains(_mirrors[body]!.handle)) { + _mirrors[body] = _mirrorFor(body); + } + } + final named = { + for (final mirror in _mirrors.values) mirror.handle, + for (final standing in _standing.values) standing.handle, + }; + for (final body in native.readTransforms().bodies) { + if (!named.contains(body)) native.removeBody(body); + } + for (final body in bodies) { + _mirrors[body]!.fetch(native, body); + } + world.reindex(); + } + + /// Lets the core go. Nothing can be stepped after. + void dispose() => native.dispose(); + + /// Every collider that is not a body's and not a trigger, standing in the + /// core as a fixed body where it is now. + void _stand(double dt) { + final sweep = ++_sweep; + void consider(Collider collider) { + if (collider.kind == ColliderKind.trigger) return; + if (_byCollider.containsKey(collider)) return; + final standing = _standing[collider] ??= _standingFor(collider); + standing.sweep = sweep; + standing.follow(native, collider, dt); + } + + world.statics.forEach(consider); + world.movers.forEach(consider); + _standing.removeWhere((collider, standing) { + if (standing.sweep == sweep) return false; + native.removeBody(standing.handle); + return true; + }); + } + + _Standing _standingFor(Collider collider) { + final (shape, offset) = _shapeOf(collider.shape); + final handle = native.addBody( + position: collider.position + offset, + type: NativeBodyType.fixed, + mass: 0.0, + ); + shape(handle); + return _Standing(handle, offset)..placed(native, collider); + } + + /// What shapes a core body as [shape], and where the core puts its origin + /// from the collider's: nowhere else but for a hull, which the core + /// centres on its centre of mass. + (void Function(NativeBody), Vector3) _shapeOf(CollisionShape shape) { + switch (shape) { + case CollisionBox(:final halfExtents): + return ( + (b) => native.setShape(b, NativeShape.box(halfExtents)), + Vector3.zero(), + ); + case CollisionSphere(:final radius): + return ( + (b) => native.setShape(b, NativeShape.sphere(radius)), + Vector3.zero(), + ); + case CollisionCapsule(:final radius, :final halfHeight): + return ( + (b) => native.setShape(b, NativeShape.capsule(radius, halfHeight)), + Vector3.zero(), + ); + case CollisionWedge(): + final (hull, offset) = _hulls[shape] ??= _hullOf(shape); + return ((b) => native.setHull(b, hull), offset); + case CollisionHeightfield(): + final mesh = _meshes[shape] ??= _meshOf(shape); + return ((b) => native.setMesh(b, mesh), Vector3.zero()); + } + } + + /// A wedge as six corners: the floor's four, and the top edge's two over + /// its uphill side. + (NativeHull, Vector3) _hullOf(CollisionWedge wedge) { + final h = wedge.halfExtents; + final axis = wedge.uphill.axis; + final high = wedge.uphill.sign * h[axis]; + final points = [ + for (final x in [-h.x, h.x]) + for (final z in [-h.z, h.z]) Vector3(x, -h.y, z), + for (final across in [-1.0, 1.0]) + axis == 0 + ? Vector3(high, h.y, across * h.z) + : Vector3(across * h.x, h.y, high), + ]; + final hull = native.createHull(points); + return (hull, native.hullOffset(hull)); + } + + /// A height field as the triangles its own heights describe, split the + /// way it splits them — corner (0, 0) to (1, 1) — and wound up. + NativeMesh _meshOf(CollisionHeightfield field) { + final x0 = -field.width * 0.5; + final z0 = -field.depth * 0.5; + final y0 = -(field.highest + field.lowest) * 0.5; + final vertices = [ + for (var row = 0; row < field.rows; row++) + for (var column = 0; column < field.columns; column++) + Vector3( + x0 + column * field.cellSize, + y0 + field.sample(column, row), + z0 + row * field.cellSize, + ), + ]; + final indices = [ + for (var row = 0; row + 1 < field.rows; row++) + for (var column = 0; column + 1 < field.columns; column++) + ...() { + final a = row * field.columns + column; + final right = a + 1, below = a + field.columns; + return [a, below + 1, right, a, below, below + 1]; + }(), + ]; + return native.createMesh(vertices, indices); + } +} + +/// [offset] turned by [q]. +Vector3 _turned(Quaternion q, Vector3 offset) => + offset.length2 == 0.0 ? Vector3.zero() : q.rotated(offset); + +/// A body's mirror: its handle, the core's origin from the collider's, and +/// what the body was left at after the last step, to tell what a game did +/// to it since. +final class _Mirror { + _Mirror(this.handle, this.offset); + + final NativeBody handle; + final Vector3 offset; + final Vector3 _at = Vector3.zero(); + final Vector3 _velocity = Vector3.zero(); + final Vector3 _spin = Vector3.zero(); + final Quaternion _turn = Quaternion.identity(); + bool _asleep = false; + + /// Remembers [body] as it is now. + void take(RigidBody body) { + _at.setFrom(body.position); + _velocity.setFrom(body.velocity); + _spin.setFrom(body.angularVelocity); + _turn.setFrom(body.orientation); + _asleep = body.isAsleep; + } + + /// Whatever changed in [body] since [take], into the core. + void give(NativeWorld native, RigidBody body) { + final turned = !_same(_turn, body.orientation); + if (turned) native.setOrientation(handle, body.orientation); + if (turned || _at != body.position) { + native.setPosition( + handle, + body.position + _turned(body.orientation, offset), + ); + } + if (_velocity != body.velocity) native.setVelocity(handle, body.velocity); + if (_spin != body.angularVelocity) { + native.setAngularVelocity(handle, body.angularVelocity); + } + if (_asleep && !body.isAsleep && native.isAsleep(handle)) { + native.wake(handle); + } + } + + /// The core's body into [body], and remembered. + void fetch(NativeWorld native, RigidBody body) { + final turn = native.orientationOf(handle); + if (body.canRotate) body.orientation = turn; + body.collider.moveTo( + native.positionOf(handle) - _turned(body.orientation, offset), + ); + body.velocity.setFrom(native.velocityOf(handle)); + body.angularVelocity.setFrom(native.angularVelocityOf(handle)); + final asleep = native.isAsleep(handle); + if (asleep && !body.isAsleep) body.sleep(); + if (!asleep && body.isAsleep) body.wake(); + take(body); + } +} + +bool _same(Quaternion a, Quaternion b) => + a.x == b.x && a.y == b.y && a.z == b.z && a.w == b.w; + +/// A collider standing in the core as a fixed body, and where it was put. +final class _Standing { + _Standing(this.handle, this.offset); + + final NativeBody handle; + final Vector3 offset; + final Vector3 _at = Vector3.zero(); + int _layer = 0, _mask = 0; + bool _moving = false; + int sweep = 0; + + void placed(NativeWorld native, Collider collider) { + _at.setFrom(collider.position); + _layer = collider.layer; + _mask = collider.mask; + native.setCollisionFilter( + handle, + layer: _layer & Layers.all, + mask: _mask & Layers.all, + ); + } + + /// Where [collider] is now — put back by its owner's restore before the + /// core's — and whether it was moving when the core's snapshot was taken. + /// The collider's own position rather than the core's, which is rounded + /// to f32 and would read as a move. + void resume(NativeWorld native, Collider collider) { + _at.setFrom(collider.position); + _moving = native.velocityOf(handle).length2 != 0.0; + } + + /// Moved, or refiltered, as [collider] was. Moved, it is given the + /// velocity it moved at over the [dt] coming: a lift that only jumped would + /// push a crate on it out of its floor a little each step and leave it + /// behind, where one that moves carries it. + void follow(NativeWorld native, Collider collider, double dt) { + final moved = _at != collider.position; + if (moved) { + native + ..setPosition(handle, collider.position + offset) + ..setVelocity(handle, (collider.position - _at) / dt); + _at.setFrom(collider.position); + } else if (_moving) { + native.setVelocity(handle, Vector3.zero()); + } + _moving = moved; + if (_layer != collider.layer || _mask != collider.mask) { + placed(native, collider); + } + } +} diff --git a/packages/flutter3d_physics_native/lib/src/native_fluid.dart b/packages/flutter3d_physics_native/lib/src/native_fluid.dart new file mode 100644 index 000000000..0fc9bafd4 --- /dev/null +++ b/packages/flutter3d_physics_native/lib/src/native_fluid.dart @@ -0,0 +1,184 @@ +/// Fluid — water as particles — on the CPU and on the GPU: P9, phase 10. +library; + +import 'dart:typed_data'; + +import 'package:vector_math/vector_math.dart'; + +import 'core/core.dart' as c; + +/// How fluid moves, inside the tank from [tankMin] to [tankMax]. +final class FluidSettings { + FluidSettings({ + required this.tankMin, + required this.tankMax, + Vector3? gravity, + this.viscosity = 0.01, + this.relaxation = 10.0, + this.substeps = 2, + this.iterations = 4, + }) : gravity = gravity ?? Vector3(0.0, -9.81, 0.0); + + final Vector3 gravity; + + /// The tank's corners: no particle centre comes nearer its walls than + /// half the spacing. + final Vector3 tankMin; + final Vector3 tankMax; + + /// XSPH: how far a particle's velocity goes to its neighbours' a + /// substep. + final double viscosity; + + /// Softens the density constraint; larger is softer and steadier. + final double relaxation; + final int substeps; + final int iterations; +} + +/// One particle to add. +typedef FluidParticle = ({Vector3 position, Vector3 velocity}); + +/// What a read gives: the step the particles are from, counted from one, +/// and four floats a particle — position xyz, density over rest density. +typedef FluidFrame = ({int step, Float32List particles}); + +/// Water as particles: on the CPU ([NativeFluid]) or the GPU +/// ([GpuFluid]), the same passes. Visual, not the game's state. +abstract interface class FluidSystem { + int get capacity; + + /// Particles in the tank so far, up to [capacity]. + int get count; + + /// The density at rest, particles of mass one per cubic metre. + double get restDensity; + + /// Puts [particles] into the next slots, round and round once full. + void add(List particles); + + void step(FluidSettings settings, double dt); + + /// The latest step's particles not read yet, or null when there is + /// none. On the GPU, without [wait], a frame late. + FluidFrame? read({bool wait = true}); + + void dispose(); +} + +c.F32s packFluidParticles(List particles) { + final data = c.F32s.alloc(particles.isEmpty ? 6 : particles.length * 6); + for (var i = 0; i < particles.length; i++) { + final p = particles[i]; + data[i * 6] = p.position.x; + data[i * 6 + 1] = p.position.y; + data[i * 6 + 2] = p.position.z; + data[i * 6 + 3] = p.velocity.x; + data[i * 6 + 4] = p.velocity.y; + data[i * 6 + 5] = p.velocity.z; + } + return data; +} + +/// [settings] as the core's `F3dFluidSettings` — and the GPU's, laid out +/// the same — in a block of the core's memory the caller frees. +int writeFluidSettings(FluidSettings settings) { + final s = c.coreAlloc(c.F3dFluidSettingsLayout.size); + for (var k = 0; k < 3; k++) { + c.writeF32( + s + c.F3dFluidSettingsLayout.gravity + k * 4, + settings.gravity[k], + ); + c.writeF32( + s + c.F3dFluidSettingsLayout.tankMin + k * 4, + settings.tankMin[k], + ); + c.writeF32( + s + c.F3dFluidSettingsLayout.tankMax + k * 4, + settings.tankMax[k], + ); + } + c.writeF32(s + c.F3dFluidSettingsLayout.viscosity, settings.viscosity); + c.writeF32(s + c.F3dFluidSettingsLayout.relaxation, settings.relaxation); + c.writeU32(s + c.F3dFluidSettingsLayout.substeps, settings.substeps); + c.writeU32(s + c.F3dFluidSettingsLayout.iterations, settings.iterations); + return s; +} + +/// Fluid stepped by the core on the CPU: the reference for the GPU's, and +/// the fallback where there is none. +final class NativeFluid implements FluidSystem { + /// Room for [capacity] particles [spacing] apart at rest. + NativeFluid(int capacity, double spacing) + : _f = c.f3d_fluid_create(capacity, spacing) { + if (_f == 0) { + throw ArgumentError('no room, a spacing not above nought, or no memory'); + } + _finalizer.attach(this, _f, detach: this); + } + + static final Finalizer _finalizer = Finalizer(c.f3d_fluid_destroy); + + int _f; + int _count = 0; + int _steps = 0; + int _read = 0; + + int get _live { + if (_f == 0) throw StateError('this fluid was disposed'); + return _f; + } + + @override + int get capacity => c.f3d_fluid_capacity(_live); + + @override + int get count => _count; + + @override + double get restDensity => c.f3d_fluid_rest_density(_live); + + @override + void add(List particles) { + final data = packFluidParticles(particles); + try { + c.f3d_fluid_add(_live, data, particles.length); + _count = (_count + particles.length).clamp(0, capacity); + } finally { + data.free(); + } + } + + @override + void step(FluidSettings settings, double dt) { + final s = writeFluidSettings(settings); + try { + c.f3d_fluid_step(_live, s, dt); + _steps++; + } finally { + c.coreFree(s); + } + } + + @override + FluidFrame? read({bool wait = true}) { + final live = _live; + if (_steps == _read) return null; + final out = c.F32s.alloc(_count == 0 ? 4 : _count * 4); + try { + c.f3d_fluid_read(live, out, _count); + _read = _steps; + return (step: _steps, particles: out.copy(_count * 4)); + } finally { + out.free(); + } + } + + @override + void dispose() { + if (_f == 0) return; + _finalizer.detach(this); + c.f3d_fluid_destroy(_f); + _f = 0; + } +} diff --git a/packages/flutter3d_physics_native/lib/src/native_particles.dart b/packages/flutter3d_physics_native/lib/src/native_particles.dart new file mode 100644 index 000000000..f80e8a9df --- /dev/null +++ b/packages/flutter3d_physics_native/lib/src/native_particles.dart @@ -0,0 +1,157 @@ +/// Particles, on the CPU and on the GPU — P9, phase 10. +library; + +import 'dart:typed_data'; + +import 'package:vector_math/vector_math.dart'; + +import 'core/core.dart' as c; + +/// How particles move: gravity, the wind they drift towards at [drag] per +/// second, and a floor at [floorY] they bounce off with [restitution] and +/// lose [friction] of their sliding speed on. +final class ParticleForces { + ParticleForces({ + Vector3? gravity, + Vector3? wind, + this.drag = 0.0, + this.floorY = double.negativeInfinity, + this.restitution = 0.0, + this.friction = 0.0, + }) : gravity = gravity ?? Vector3(0.0, -9.81, 0.0), + wind = wind ?? Vector3.zero(); + + final Vector3 gravity; + final Vector3 wind; + final double drag; + final double floorY; + final double restitution; + final double friction; +} + +/// One particle to emit. +typedef Particle = ({Vector3 position, Vector3 velocity, double life}); + +/// A set of particle slots, filled round and round, that fall, drift, +/// bounce and die: on the CPU ([NativeParticles]) or the GPU +/// ([GpuParticles]), the same steps. Visual, not the game's state. +abstract interface class ParticleSystem { + /// How many slots there are. + int get capacity; + + /// Puts [particles] into the next slots, the oldest going first. + void emit(List particles); + + /// Steps every living particle [steps] times by [dt]. + void step(ParticleForces forces, double dt, {int steps = 1}); + + /// Every slot: position xyz and the life it has left, four floats apiece; + /// nought or less is dead. + Float32List read(); + + /// Frees the slots. + void dispose(); +} + +c.F32s packParticles(List particles) { + final data = c.F32s.alloc(particles.isEmpty ? 7 : particles.length * 7); + for (var i = 0; i < particles.length; i++) { + final p = particles[i]; + data[i * 7] = p.position.x; + data[i * 7 + 1] = p.position.y; + data[i * 7 + 2] = p.position.z; + data[i * 7 + 3] = p.velocity.x; + data[i * 7 + 4] = p.velocity.y; + data[i * 7 + 5] = p.velocity.z; + data[i * 7 + 6] = p.life; + } + return data; +} + +/// [forces] as the core's `F3dParticleForces` — and the GPU's, laid out +/// the same — in a block of the core's memory the caller frees. +int writeParticleForces(ParticleForces forces) { + final f = c.coreAlloc(c.F3dParticleForcesLayout.size); + for (var k = 0; k < 3; k++) { + c.writeF32( + f + c.F3dParticleForcesLayout.gravity + k * 4, + forces.gravity[k], + ); + c.writeF32(f + c.F3dParticleForcesLayout.wind + k * 4, forces.wind[k]); + } + c.writeF32(f + c.F3dParticleForcesLayout.drag, forces.drag); + c.writeF32( + f + c.F3dParticleForcesLayout.floorY, + forces.floorY == double.negativeInfinity ? -3.4e38 : forces.floorY, + ); + c.writeF32(f + c.F3dParticleForcesLayout.restitution, forces.restitution); + c.writeF32(f + c.F3dParticleForcesLayout.friction, forces.friction); + return f; +} + +/// Particles stepped by the core on the CPU: the reference for the GPU's, +/// and the fallback where there is none. +final class NativeParticles implements ParticleSystem { + NativeParticles(int capacity) : _p = c.f3d_particles_create(capacity) { + if (_p == 0) { + throw ArgumentError.value(capacity, 'capacity', 'none, or no memory'); + } + _finalizer.attach(this, _p, detach: this); + } + + static final Finalizer _finalizer = Finalizer( + c.f3d_particles_destroy, + ); + + int _p; + + int get _live { + if (_p == 0) throw StateError('these particles were disposed'); + return _p; + } + + @override + int get capacity => c.f3d_particles_capacity(_live); + + @override + void emit(List particles) { + final data = packParticles(particles); + try { + c.f3d_particles_emit(_live, data, particles.length); + } finally { + data.free(); + } + } + + @override + void step(ParticleForces forces, double dt, {int steps = 1}) { + final f = writeParticleForces(forces); + try { + for (var s = 0; s < steps; s++) { + c.f3d_particles_step(_live, f, dt); + } + } finally { + c.coreFree(f); + } + } + + @override + Float32List read() { + final n = capacity; + final out = c.F32s.alloc(n * c.particleFloats); + try { + c.f3d_particles_read(_live, out, n); + return out.copy(n * c.particleFloats); + } finally { + out.free(); + } + } + + @override + void dispose() { + if (_p == 0) return; + _finalizer.detach(this); + c.f3d_particles_destroy(_p); + _p = 0; + } +} diff --git a/packages/flutter3d_physics_native/lib/src/native_world.dart b/packages/flutter3d_physics_native/lib/src/native_world.dart new file mode 100644 index 000000000..ccc32c434 --- /dev/null +++ b/packages/flutter3d_physics_native/lib/src/native_world.dart @@ -0,0 +1,1600 @@ +/// A physics world stepped by the C core — P9. +library; + +import 'dart:typed_data'; + +import 'package:vector_math/vector_math.dart'; + +import 'core/core.dart' as c; + +/// A body in a [NativeWorld]: its slot and the slot's generation, packed as +/// the C core packs them. Nought is never one. +extension type const NativeBody(int raw) { + /// The arena slot. + int get slot => raw % 0x100000000; + + /// How many times the slot had been used when this body took it. By + /// division, not a shift: compiled to JavaScript, Dart's shifts and masks + /// see 32 bits. + int get generation => raw ~/ 0x100000000; +} + +/// What kind of body — `F3dBodyType`. +/// +/// A class of constants rather than an enum: kinematic bodies arrive in a +/// later phase, and a value added to an enum breaks every switch written +/// against it. +final class NativeBodyType { + const NativeBodyType._(this.code, this.name); + + /// Moved by gravity, forces, wind and contacts. + static const NativeBodyType dynamic = NativeBodyType._( + c.BodyType.dynamic, + 'dynamic', + ); + + /// Never moves. It still heats, cools and burns. + static const NativeBodyType fixed = NativeBodyType._( + c.BodyType.fixed, + 'fixed', + ); + + /// The core's number for it. + final int code; + final String name; + + @override + String toString() => 'NativeBodyType.$name'; +} + +/// What a body is shaped like: what it collides as, and its inertia, its +/// surface and its drag — `F3dShapeKind` and its three numbers. +final class NativeShape { + const NativeShape._(this.kind, this.first, this.second, this.third); + + /// No extent: it touches nothing, does not turn, has no surface and feels + /// no wind. What a new body is. + static const NativeShape point = NativeShape._(c.ShapeKind.point, 0, 0, 0); + + const NativeShape.sphere(double radius) + : this._(c.ShapeKind.sphere, radius, 0, 0); + + /// Half extents along the body's own axes. + NativeShape.box(Vector3 halfExtents) + : this._(c.ShapeKind.box, halfExtents.x, halfExtents.y, halfExtents.z); + + /// Upright along the body's y: a cylinder [halfLength] each way from the + /// centre, capped by hemispheres of [radius]. + const NativeShape.capsule(double radius, double halfLength) + : this._(c.ShapeKind.capsule, radius, halfLength, 0); + + /// Upright along the body's y, [halfHeight] each way from the centre. + const NativeShape.cylinder(double radius, double halfHeight) + : this._(c.ShapeKind.cylinder, radius, halfHeight, 0); + + /// Its apex up along the body's y, [height] tall over a base of + /// [radius]; its origin at its centre of mass, a quarter of the height + /// above the base. + const NativeShape.cone(double radius, double height) + : this._(c.ShapeKind.cone, radius, height, 0); + + /// `F3dShapeKind`. + final int kind; + + /// The three numbers the kind reads, as the constructors name them. + final double first, second, third; +} + +/// A convex hull the world holds, for bodies to be shaped as +/// ([NativeWorld.setHull]). Numbered from one. +extension type const NativeHull(int id) {} + +/// A triangle mesh the world holds, for fixed bodies to be shaped as +/// ([NativeWorld.setMesh]). Numbered from one. +extension type const NativeMesh(int id) {} + +/// A joint in a [NativeWorld], named as a body is. +extension type const NativeJoint(int raw) {} + +/// What kind of joint — `F3dJointType`. +final class NativeJointType { + const NativeJointType._(this.code, this.name); + + /// Holds B where it was against A, place and turn. + static const NativeJointType fixed = NativeJointType._( + c.JointType.fixed, + 'fixed', + ); + + /// Holds a point of each together; they turn freely about it. + static const NativeJointType spherical = NativeJointType._( + c.JointType.spherical, + 'spherical', + ); + + /// A hinge about the axis. + static const NativeJointType revolute = NativeJointType._( + c.JointType.revolute, + 'revolute', + ); + + /// A slider along the axis. + static const NativeJointType prismatic = NativeJointType._( + c.JointType.prismatic, + 'prismatic', + ); + + final int code; + final String name; + + @override + String toString() => 'NativeJointType.$name'; +} + +/// What a body is made of, as heat and fire see it — `F3dMaterial`. +final class NativeMaterial { + const NativeMaterial({ + required this.specificHeat, + this.emissivity = 0.9, + this.ignitionTemperature = 0.0, + this.heatOfCombustion = 0.0, + this.burnRate = 0.0, + this.fuelFraction = 0.0, + this.flameFeedback = 0.0, + this.conductivity = 1.0, + }); + + /// The core's typical values for a material: one place they live, so the + /// browser's module and the native library agree on them too. + static NativeMaterial inert() => _preset(c.MaterialKind.inert); + static NativeMaterial wood() => _preset(c.MaterialKind.wood); + static NativeMaterial paper() => _preset(c.MaterialKind.paper); + static NativeMaterial rubber() => _preset(c.MaterialKind.rubber); + static NativeMaterial steel() => _preset(c.MaterialKind.steel); + static NativeMaterial stone() => _preset(c.MaterialKind.stone); + + static NativeMaterial _preset(int kind) { + final out = c.coreAlloc(c.F3dMaterialLayout.size); + try { + c.f3d_material_preset(kind, out); + double at(int offset) => c.readF32(out + offset); + return NativeMaterial( + specificHeat: at(c.F3dMaterialLayout.specificHeat), + emissivity: at(c.F3dMaterialLayout.emissivity), + ignitionTemperature: at(c.F3dMaterialLayout.ignitionTemperature), + heatOfCombustion: at(c.F3dMaterialLayout.heatOfCombustion), + burnRate: at(c.F3dMaterialLayout.burnRate), + fuelFraction: at(c.F3dMaterialLayout.fuelFraction), + flameFeedback: at(c.F3dMaterialLayout.flameFeedback), + conductivity: at(c.F3dMaterialLayout.conductivity), + ); + } finally { + c.coreFree(out); + } + } + + /// J / (kg K). + final double specificHeat; + + /// Of the surface, nought to one. + final double emissivity; + + /// K at which it catches and below which it goes out; nought for a + /// material that never burns. + final double ignitionTemperature; + + /// J released per kilogram burnt. + final double heatOfCombustion; + + /// kg burnt per second per square metre of surface while alight. + final double burnRate; + + /// The share of the mass that can burn, nought up to but not one. + final double fuelFraction; + + /// The share of the fire's heat that goes back into the body; the rest + /// leaves as the hot gas a smoke grid takes. + final double flameFeedback; + + /// W / (m K): how readily heat crosses into what it touches. + final double conductivity; +} + +/// What a step said happened to a body — `F3dEventKind`. Constants rather +/// than an enum, for the reason [NativeBodyType] gives: the solver and the +/// joints will bring kinds of their own. +final class NativeEventKind { + const NativeEventKind._(this.code, this.name); + + static const NativeEventKind slept = NativeEventKind._( + c.EventKind.slept, + 'slept', + ); + static const NativeEventKind woke = NativeEventKind._( + c.EventKind.woke, + 'woke', + ); + static const NativeEventKind ignited = NativeEventKind._( + c.EventKind.ignited, + 'ignited', + ); + static const NativeEventKind extinguished = NativeEventKind._( + c.EventKind.extinguished, + 'extinguished', + ); + static const NativeEventKind burntOut = NativeEventKind._( + c.EventKind.burntOut, + 'burntOut', + ); + + /// Two bodies came to touch; the event's `other` is the second. + static const NativeEventKind contactBegan = NativeEventKind._( + c.EventKind.contactBegan, + 'contactBegan', + ); + + /// Two bodies stopped touching. Ended by a body's removal, it names a body + /// no longer there. + static const NativeEventKind contactEnded = NativeEventKind._( + c.EventKind.contactEnded, + 'contactEnded', + ); + + static const List _all = [ + slept, + woke, + ignited, + extinguished, + burntOut, + contactBegan, + contactEnded, + ]; + + /// The kind the core's [code] names; one this binding does not know yet + /// keeps its number. + static NativeEventKind of(int code) => _all.firstWhere( + (k) => k.code == code, + orElse: () => NativeEventKind._(code, 'unknown($code)'), + ); + + final int code; + final String name; + + @override + bool operator ==(Object other) => + other is NativeEventKind && other.code == code; + + @override + int get hashCode => code; + + @override + String toString() => 'NativeEventKind.$name'; +} + +/// One event, in the order the step raised it. [other] is the second body +/// of an event between two, and null for the rest. +typedef NativeEvent = ({ + NativeBody body, + NativeBody? other, + NativeEventKind kind, +}); + +/// Where two bodies touch: one point of their manifold. +typedef NativeContact = ({ + NativeBody a, + NativeBody b, + + /// Out of [b], into [a]: the way [a] moves to come apart. + Vector3 normal, + + /// Halfway between the two surfaces, relative to the origin. + Vector3 point, + + /// Positive inside, negative for a gap within the margin. + double depth, +}); + +/// Where a query met a body: the point and the normal there, out of the +/// body, relative to the origin, and the distance along a ray or the +/// fraction of a cast. +typedef NativeHit = ({ + NativeBody body, + Vector3 point, + Vector3 normal, + double at, +}); + +/// Where a character ended up, and what it stood on. +typedef NativeCharacterMove = ({ + Vector3 position, + bool grounded, + bool hitWall, + bool hitCeiling, + bool stepped, + + /// The ground's normal, or zero in the air. + Vector3 groundNormal, + + /// What it stands on, or null in the air. + NativeBody? ground, +}); + +/// A point in doubles: the world's origin, or a body's place in the world's +/// own coordinates. Not a [Vector3], which holds single precision and +/// would round away what the origin is for. +typedef WorldPoint = ({double x, double y, double z}); + +/// A world of bodies owned and stepped by the C core. +/// +/// **Numbers cross as f32.** The core is single precision throughout, which +/// is what makes its deterministic mode the same bits on every platform, so a +/// value set here is rounded to the nearest float on the way in. Positions +/// are relative to the world's [origin], held in doubles; a world far +/// larger than f32 can hold to a millimetre moves its origin to the play +/// with [shiftOrigin]. +/// +/// Freed by [dispose], or by the garbage collector when a world is dropped +/// without it. Every call after [dispose] throws a [StateError], so a world +/// used after it was freed is a Dart error rather than a native crash. +final class NativeWorld { + NativeWorld() : _world = c.f3d_world_create() { + if (_world == 0) { + throw StateError('the physics core could not allocate a world'); + } + if (c.f3d_abi_version() != c.abiVersion || c.f3d_real_bytes() != 4) { + c.f3d_world_destroy(_world); + throw StateError( + 'the physics core is ABI ${c.f3d_abi_version()} with ' + '${c.f3d_real_bytes()}-byte reals, and these bindings were written ' + 'for ABI ${c.abiVersion} with 4', + ); + } + _finalizer.attach(this, _world, detach: this); + } + + /// Frees a world dropped without [dispose]. Its scratch goes with the + /// process: the core's few bytes a world. + static final Finalizer _finalizer = Finalizer(c.f3d_world_destroy); + + int _world; + + /// Scratch the getters write into: the world's own, freed with it. + final c.F32s _out = c.F32s.alloc(4); + final c.F64s _outDouble = c.F64s.alloc(3); + final c.I32s _outInt = c.I32s.alloc(1); + + int get _live { + if (_world == 0) throw StateError('this world was disposed'); + return _world; + } + + /// Frees the world and everything in it. Calling it again does nothing. + void dispose() { + if (_world == 0) return; + _finalizer.detach(this); + c.f3d_world_destroy(_world); + _out.free(); + _outDouble.free(); + _outInt.free(); + _world = 0; + } + + /// Whether [dispose] has run. + bool get isDisposed => _world == 0; + + Vector3 _read3() => Vector3(_out[0], _out[1], _out[2]); + + // ---------------------------------------------------------------- world + + /// Metres per second squared; (0, −9.81, 0) for a new world. + Vector3 get gravity { + c.f3d_world_get_gravity(_live, _out); + return _read3(); + } + + set gravity(Vector3 value) => + c.f3d_world_set_gravity(_live, value.x, value.y, value.z); + + /// The air's temperature, K; 293.15 for a new world. + double get airTemperature { + c.f3d_world_get_air(_live, _out); + return _out[0]; + } + + /// The air's density, kg/m³; 1.204 for a new world. + double get airDensity { + c.f3d_world_get_air(_live, _out); + return _out[1]; + } + + /// Sets both. Throws an [ArgumentError] for a value that is not finite + /// and positive. + void setAir({required double temperature, required double density}) { + if (c.f3d_world_set_air(_live, temperature, density) == 0) { + throw ArgumentError('air of $temperature K and $density kg/m³'); + } + } + + /// The wind everywhere, m/s, added to the grid's where there is one. + /// Changing it wakes every body that feels it. + set wind(Vector3 value) => + c.f3d_world_set_wind(_live, value.x, value.y, value.z); + + /// A wind field of [nx] × [ny] × [nz] samples, three floats each, x + /// fastest, the first at [origin] (relative to the world's origin) and + /// [cell] metres apart; read trilinearly and held at its edge. Throws an + /// [ArgumentError] for a size or spacing that is not positive or a list + /// of the wrong length. + void setWindGrid({ + required Vector3 origin, + required double cell, + required int nx, + required int ny, + required int nz, + required Float32List velocities, + }) { + if (velocities.length != nx * ny * nz * 3) { + throw ArgumentError.value( + velocities.length, + 'velocities', + 'not 3 × $nx × $ny × $nz', + ); + } + final buffer = c.F32s.alloc(velocities.isEmpty ? 1 : velocities.length); + try { + buffer.setAll(velocities); + final done = c.f3d_world_set_wind_grid( + _live, + origin.x, + origin.y, + origin.z, + cell, + nx, + ny, + nz, + buffer, + ); + if (done == 0) { + throw ArgumentError('a wind grid of $nx × $ny × $nz, $cell m apart'); + } + } finally { + buffer.free(); + } + } + + /// Takes the wind grid away, leaving the uniform wind. + void clearWindGrid() => + c.f3d_world_set_wind_grid(_live, 0, 0, 0, 1, 0, 0, 0, 0); + + /// The wind at [at], relative to the origin. + Vector3 windAt(Vector3 at) { + c.f3d_world_sample_wind(_live, at.x, at.y, at.z, _out); + return _read3(); + } + + /// How long, s, a body must stay slower than [speed] (m/s and rad/s) to + /// fall asleep; a [time] of nought turns sleep off. Defaults 0.05 and 0.5. + void setSleep({required double speed, required double time}) { + if (c.f3d_world_set_sleep(_live, speed, time) == 0) { + throw ArgumentError('sleep after $time s under $speed'); + } + } + + /// Where the world's origin is, in doubles. + WorldPoint get origin { + c.f3d_world_get_origin(_live, _outDouble); + return (x: _outDouble[0], y: _outDouble[1], z: _outDouble[2]); + } + + /// Moves the origin by (dx, dy, dz), and every body and the wind grid + /// the other way, so that nothing moves in the world and what is near + /// the new origin gets f32's full precision back. + void shiftOrigin(double dx, double dy, double dz) => + c.f3d_world_shift_origin(_live, dx, dy, dz); + + /// How many bodies the world holds. + int get bodyCount => c.f3d_world_body_count(_live); + + /// Advances the world by [dt] seconds; nothing for a [dt] that is not + /// finite and positive. + void step(double dt) => c.f3d_world_step(_live, dt); + + /// Every body's transform, seven floats apiece — position xyz relative to + /// the origin, then the orientation quaternion xyzw — and its handle, in + /// the world's slot order: what the renderer uploads as instance + /// transforms. + ({Float32List transforms, List bodies}) readTransforms() => + _readPer(bodyCount, c.transformFloats, c.f3d_world_read_transforms); + + /// Every burning body: its position and the watts it gives off as hot gas, + /// four floats apiece — what a smoke grid takes its sources from. + ({Float32List fires, List bodies}) readFires() { + final read = _readPer(bodyCount, c.fireFloats, c.f3d_world_read_fires); + return (fires: read.transforms, bodies: read.bodies); + } + + ({Float32List transforms, List bodies}) _readPer( + int count, + int floats, + int Function(int, c.F32s, c.U64s, int) read, + ) { + if (count == 0) { + return (transforms: Float32List(0), bodies: const []); + } + final values = c.F32s.alloc(count * floats); + final handles = c.U64s.alloc(count); + try { + final written = read(_live, values, handles, count); + return ( + transforms: values.copy(written * floats), + bodies: [ + for (var i = 0; i < written; i++) NativeBody(handles[i]), + ], + ); + } finally { + values.free(); + handles.free(); + } + } + + /// The events the steps raised since the last call, oldest first. + List readEvents() { + const batch = 256; + final bodies = c.U64s.alloc(batch); + final others = c.U64s.alloc(batch); + final kinds = c.U32s.alloc(batch); + try { + final events = []; + while (true) { + final read = c.f3d_world_read_events( + _live, + bodies, + others, + kinds, + batch, + ); + for (var i = 0; i < read; i++) { + events.add(( + body: NativeBody(bodies[i]), + other: others[i] == 0 ? null : NativeBody(others[i]), + kind: NativeEventKind.of(kinds[i]), + )); + } + if (read < batch) return events; + } + } finally { + bodies.free(); + others.free(); + kinds.free(); + } + } + + /// A convex hull of [points], built and kept by the world: moved so its + /// centre of mass, taken as solid, is at the origin of the bodies shaped + /// as it — [hullOffset] says by how much. Throws an [ArgumentError] for + /// fewer than four points not all in a plane, more than 4096, or a point + /// not finite. + NativeHull createHull(List points) { + final buffer = c.F32s.alloc(points.isEmpty ? 3 : points.length * 3); + try { + for (var i = 0; i < points.length; i++) { + buffer[i * 3] = points[i].x; + buffer[i * 3 + 1] = points[i].y; + buffer[i * 3 + 2] = points[i].z; + } + final id = c.f3d_world_create_hull(_live, buffer, points.length); + if (id == 0) { + throw ArgumentError.value( + points.length, + 'points', + 'not four or more points, finite, not all in one plane', + ); + } + return NativeHull(id); + } finally { + buffer.free(); + } + } + + /// What was subtracted from every point [hull] was made from. + Vector3 hullOffset(NativeHull hull) { + if (c.f3d_world_get_hull_offset(_live, hull.id, _out) == 0) { + throw ArgumentError.value(hull.id, 'hull', 'not in this world'); + } + return _read3(); + } + + /// How many of the points [hull] was made from are its corners. + int hullVertexCount(NativeHull hull) => + c.f3d_world_hull_vertex_count(_live, hull.id); + + /// A triangle mesh of [vertices] and [indices], three a triangle, wound + /// counter-clockwise seen from the side bodies touch: level geometry, + /// terrain, walls. One sided. Triangles that share an edge by index + /// slide a body across it without a bump. Throws an [ArgumentError] for + /// no triangles, an index past the vertices or a vertex not finite. + NativeMesh createMesh(List vertices, List indices) { + if (indices.length % 3 != 0) { + throw ArgumentError.value( + indices.length, + 'indices', + 'not three a triangle', + ); + } + final v = c.F32s.alloc(vertices.isEmpty ? 3 : vertices.length * 3); + final t = c.U32s.alloc(indices.isEmpty ? 3 : indices.length); + try { + for (var i = 0; i < vertices.length; i++) { + v[i * 3] = vertices[i].x; + v[i * 3 + 1] = vertices[i].y; + v[i * 3 + 2] = vertices[i].z; + } + for (var i = 0; i < indices.length; i++) { + if (indices[i] < 0) { + throw ArgumentError.value(indices[i], 'indices', 'negative'); + } + t[i] = indices[i]; + } + final id = c.f3d_world_create_mesh( + _live, + v, + vertices.length, + t, + indices.length ~/ 3, + ); + if (id == 0) { + throw ArgumentError.value( + indices.length ~/ 3, + 'indices', + 'no triangles, an index past the vertices, or a vertex not finite', + ); + } + return NativeMesh(id); + } finally { + v.free(); + t.free(); + } + } + + /// How many triangles [mesh] has. + int meshTriangleCount(NativeMesh mesh) => + c.f3d_world_mesh_triangle_count(_live, mesh.id); + + /// How many of [mesh]'s edges are internal: shared with a neighbour + /// across a flat or hollow fold. + int meshInternalEdges(NativeMesh mesh) => + c.f3d_world_mesh_internal_edges(_live, mesh.id); + + /// A joint of [type] between [a] and [b] at [anchor], along [axis] for a + /// hinge or a slider, taken where the bodies stand now. Joined bodies do + /// not collide with each other unless [setJointCollide] says so. Throws an + /// [ArgumentError] for a body not in the world, a body joined to itself, + /// or a hinge or slider with no axis. + NativeJoint createJoint( + NativeJointType type, + NativeBody a, + NativeBody b, { + required Vector3 anchor, + Vector3? axis, + }) { + final u = axis ?? Vector3.zero(); + final raw = c.f3d_joint_create( + _live, + type.code, + a.raw, + b.raw, + anchor.x, + anchor.y, + anchor.z, + u.x, + u.y, + u.z, + ); + if (raw == 0) { + throw ArgumentError('a ${type.name} joint the core would not make'); + } + return NativeJoint(raw); + } + + /// A distance joint from [anchorA] on [a] to [anchorB] on [b], held at + /// the length between them now: a rod, until [setJointSpring] makes it a + /// spring, or with nought hertz and a least of nought, a rope. + NativeJoint createDistanceJoint( + NativeBody a, + NativeBody b, { + required Vector3 anchorA, + required Vector3 anchorB, + }) { + final raw = c.f3d_joint_create_distance( + _live, + a.raw, + b.raw, + anchorA.x, + anchorA.y, + anchorA.z, + anchorB.x, + anchorB.y, + anchorB.z, + ); + if (raw == 0) { + throw ArgumentError('a distance joint the core would not make'); + } + return NativeJoint(raw); + } + + /// Takes [joint] out, and says whether it was there. A body's joints go + /// with it. + bool removeJoint(NativeJoint joint) => + c.f3d_joint_destroy(_live, joint.raw) == 1; + + bool containsJoint(NativeJoint joint) => + c.f3d_joint_is_valid(_live, joint.raw) == 1; + + int get jointCount => c.f3d_world_joint_count(_live); + + void _checkJoint(int answer, NativeJoint joint) { + if (answer != 0) return; + if (c.f3d_joint_is_valid(_live, joint.raw) == 0) { + throw ArgumentError.value(joint.raw, 'joint', 'not in this world'); + } + throw ArgumentError('not a setting this joint takes, or out of range'); + } + + /// Limits on a hinge's angle or a ball joint's twist about its axis, + /// radians, or a slider's travel, m; null takes them off. + void setJointLimits( + NativeJoint joint, + ({double lower, double upper})? limits, + ) => _checkJoint( + c.f3d_joint_set_limits( + _live, + joint.raw, + limits == null ? 0 : 1, + limits?.lower ?? 0.0, + limits?.upper ?? 0.0, + ), + joint, + ); + + /// A ball joint's swing held within [angle] radians of its axis, above + /// nought and at most π — a shoulder, a hip, a neck; null takes it off. + void setJointCone(NativeJoint joint, double? angle) => _checkJoint( + c.f3d_joint_set_cone(_live, joint.raw, angle == null ? 0 : 1, angle ?? 0.0), + joint, + ); + + /// A ball joint's friction: its bodies' turn against each other + /// resisted with at most [torque], N m — what lets a ragdoll come to + /// rest; null takes it off. A hinge or slider has it as a motor of speed + /// nought. + void setJointFriction(NativeJoint joint, double? torque) => _checkJoint( + c.f3d_joint_set_friction( + _live, + joint.raw, + torque == null ? 0 : 1, + torque ?? 0.0, + ), + joint, + ); + + /// A motor driving a hinge or slider at [speed] with at most [maxForce]; + /// null takes it off. + void setJointMotor( + NativeJoint joint, + ({double speed, double maxForce})? motor, + ) => _checkJoint( + c.f3d_joint_set_motor( + _live, + joint.raw, + motor == null ? 0 : 1, + motor?.speed ?? 0.0, + motor?.maxForce ?? 0.0, + ), + joint, + ); + + /// A spring pulling a hinge or slider back to where it started, or a + /// distance joint to its length; null takes it off. + void setJointSpring( + NativeJoint joint, + ({double hertz, double damping})? spring, + ) => _checkJoint( + c.f3d_joint_set_spring( + _live, + joint.raw, + spring == null ? 0 : 1, + spring?.hertz ?? 0.0, + spring?.damping ?? 0.0, + ), + joint, + ); + + /// A distance joint's rest [length] and the [least] and [most] its + /// spring may stretch it to. + void setJointLength( + NativeJoint joint, { + required double length, + required double least, + required double most, + }) => _checkJoint( + c.f3d_joint_set_length(_live, joint.raw, length, least, most), + joint, + ); + + /// Whether [joint]'s bodies collide with each other. + void setJointCollide(NativeJoint joint, {required bool collide}) => + _checkJoint( + c.f3d_joint_set_collide(_live, joint.raw, collide ? 1 : 0), + joint, + ); + + /// A hinge's angle from where it started, a ball joint's twist, a + /// slider's travel, a distance joint's length; nought for the others. + double jointValue(NativeJoint joint) { + _checkJoint(c.f3d_joint_get_value(_live, joint.raw, _out), joint); + return _out[0]; + } + + /// A ball joint's swing: the angle between its bodies' axes, radians; + /// nought for the others. + double jointSwing(NativeJoint joint) { + _checkJoint(c.f3d_joint_get_swing(_live, joint.raw, _out), joint); + return _out[0]; + } + + /// The force [joint] held its second body with over the last substep, N. + Vector3 jointForce(NativeJoint joint) { + _checkJoint(c.f3d_joint_get_force(_live, joint.raw, _out), joint); + return _read3(); + } + + /// How many substeps a step is solved in, one to sixty-four; four for a + /// new world. More holds tall stacks and fast bodies better and costs that + /// many times the solver. A step of n substeps is n steps of dt / n. + set substeps(int count) { + if (c.f3d_world_set_substeps(_live, count) == 0) { + throw ArgumentError.value(count, 'substeps', 'not between 1 and 64'); + } + } + + /// How many threads a step runs on, the caller one of them; one for a + /// new world. The tree's queries and the narrow phase are shared out + /// among them, and the world steps to the same bits on any number. + int get threads => c.f3d_world_threads(_live); + + /// Asks for [count] threads, one to sixty-four, from the next step on. + /// Throws for a count out of range, threads that would not start, or — + /// on the web — more than one. + set threads(int count) { + if (c.f3d_world_set_threads(_live, count) == 0) { + throw ArgumentError.value( + count, + 'threads', + 'not between 1 and 64, or the threads would not start', + ); + } + } + + /// The fast mode, off for a new world: contacts coloured each step so + /// that no two of a colour share a moving body, and a colour's contacts + /// solved at once on every thread. Other bits than the deterministic + /// mode's, since the contacts are solved in another order — but the same + /// bits on any number of threads. Kept in a snapshot. + bool get fast => c.f3d_world_fast(_live) != 0; + + set fast(bool enabled) => c.f3d_world_set_fast(_live, enabled ? 1 : 0); + + /// Soft continuous collision, on for a new world: a contact reaches as + /// far as its two bodies can close in a step, so a body stops at a wall + /// it would cross in one. + set speculative(bool enabled) => + c.f3d_world_set_speculative(_live, enabled ? 1 : 0); + + /// How near two shapes must come to make a contact, m; 0.02 for a new + /// world. A contact inside it but not touching has a negative depth. + set contactMargin(double margin) { + if (c.f3d_world_set_contact_margin(_live, margin) == 0) { + throw ArgumentError.value(margin, 'margin', 'negative or not finite'); + } + } + + /// Every body whose shape's box overlaps the box from [lo] to [hi], + /// relative to the origin, in slot order: what the broadphase tree finds, + /// checked against each body's own box. Bodies with no shape are never + /// found. + List queryBox(Vector3 lo, Vector3 hi) { + var capacity = 64; + while (true) { + final out = c.U64s.alloc(capacity); + try { + final count = c.f3d_world_query_box( + _live, + lo.x, + lo.y, + lo.z, + hi.x, + hi.y, + hi.z, + out, + capacity, + ); + if (count <= capacity) { + return [ + for (var i = 0; i < count; i++) NativeBody(out[i]), + ]; + } + capacity = count; + } finally { + out.free(); + } + } + } + + NativeHit _hitAt(int body, c.F32s h) => ( + body: NativeBody(body), + point: Vector3(h[0], h[1], h[2]), + normal: Vector3(h[3], h[4], h[5]), + at: h[6], + ); + + /// The nearest body a ray from [origin] along [direction] meets within + /// [maxDistance], or null. Bodies whose layer has no bit in [mask], and + /// [ignore], are not seen; nor is a shape the ray starts inside; meshes + /// only from their front. + NativeHit? rayCast( + Vector3 origin, + Vector3 direction, + double maxDistance, { + int mask = 0xffffffff, + NativeBody? ignore, + }) { + final body = c.U64s.alloc(1); + final hit = c.F32s.alloc(c.hitFloats); + try { + final found = c.f3d_world_ray_cast( + _live, + origin.x, + origin.y, + origin.z, + direction.x, + direction.y, + direction.z, + maxDistance, + mask, + ignore?.raw ?? 0, + body, + hit, + ); + return found == 0 ? null : _hitAt(body[0], hit); + } finally { + body.free(); + hit.free(); + } + } + + /// Every body the ray meets, nearest first. + List rayCastAll( + Vector3 origin, + Vector3 direction, + double maxDistance, { + int mask = 0xffffffff, + NativeBody? ignore, + }) { + var capacity = 16; + while (true) { + final bodies = c.U64s.alloc(capacity); + final hits = c.F32s.alloc(capacity * c.hitFloats); + try { + final count = c.f3d_world_ray_cast_all( + _live, + origin.x, + origin.y, + origin.z, + direction.x, + direction.y, + direction.z, + maxDistance, + mask, + ignore?.raw ?? 0, + bodies, + hits, + capacity, + ); + if (count <= capacity) { + return [ + for (var i = 0; i < count; i++) + _hitAt(bodies[i], c.F32s(hits + i * c.hitFloats * 4)), + ]; + } + capacity = count; + } finally { + bodies.free(); + hits.free(); + } + } + } + + /// Every body overlapping [shape] (not a point, a hull or a mesh), + /// rounded by [rounding], at [position] turned by [orientation], in slot + /// order. + List overlapShape( + NativeShape shape, + Vector3 position, { + Quaternion? orientation, + double rounding = 0.0, + int mask = 0xffffffff, + NativeBody? ignore, + }) { + final q = orientation ?? Quaternion.identity(); + var capacity = 32; + while (true) { + final out = c.U64s.alloc(capacity); + try { + final count = c.f3d_world_overlap_shape( + _live, + shape.kind, + shape.first, + shape.second, + shape.third, + rounding, + position.x, + position.y, + position.z, + q.x, + q.y, + q.z, + q.w, + mask, + ignore?.raw ?? 0, + out, + capacity, + ); + if (count <= capacity) { + return [ + for (var i = 0; i < count; i++) NativeBody(out[i]), + ]; + } + capacity = count; + } finally { + out.free(); + } + } + } + + /// The first body [shape] meets moved from [position] by [translation] + /// without turning, and the fraction of the move it got; nought when it + /// starts overlapping. + NativeHit? castShape( + NativeShape shape, + Vector3 position, + Vector3 translation, { + Quaternion? orientation, + double rounding = 0.0, + int mask = 0xffffffff, + NativeBody? ignore, + }) { + final q = orientation ?? Quaternion.identity(); + final body = c.U64s.alloc(1); + final hit = c.F32s.alloc(c.hitFloats); + try { + final found = c.f3d_world_cast_shape( + _live, + shape.kind, + shape.first, + shape.second, + shape.third, + rounding, + position.x, + position.y, + position.z, + q.x, + q.y, + q.z, + q.w, + translation.x, + translation.y, + translation.z, + mask, + ignore?.raw ?? 0, + body, + hit, + ); + return found == 0 ? null : _hitAt(body[0], hit); + } finally { + body.free(); + hit.free(); + } + } + + /// Moves an upright capsule — a character — of [radius] and straight + /// [halfHeight] from [position] by [move], sliding along what it meets: + /// ground whose normal's height is at least [maxSlopeCos] — the cosine of + /// the steepest slope it stands on, forty-five degrees by default — it + /// stands on, steeper it slides along as a wall, a step up to + /// [stepHeight] it climbs, and walking down it keeps to the ground. + /// Kinematic: nothing pushes it, and it moves nothing. + /// + /// **A cosine, not an angle.** Turning an angle into one here would ask + /// the platform's library, whose last bit differs from machine to + /// machine, and a character's step must not. + NativeCharacterMove moveCharacter({ + required double radius, + required double halfHeight, + required Vector3 position, + required Vector3 move, + double maxSlopeCos = 0.7071067811865476, + double stepHeight = 0.35, + int mask = 0xffffffff, + NativeBody? ignore, + }) { + final p = c.F32s.alloc(3); + final ground = c.F32s.alloc(3); + final body = c.U64s.alloc(1); + try { + p[0] = position.x; + p[1] = position.y; + p[2] = position.z; + final flags = c.f3d_world_move_character( + _live, + radius, + halfHeight, + p, + move.x, + move.y, + move.z, + maxSlopeCos, + stepHeight, + mask, + ignore?.raw ?? 0, + body, + ground, + ); + return ( + position: Vector3(p[0], p[1], p[2]), + grounded: flags & c.CharacterFlags.grounded != 0, + hitWall: flags & c.CharacterFlags.wall != 0, + hitCeiling: flags & c.CharacterFlags.ceiling != 0, + stepped: flags & c.CharacterFlags.stepped != 0, + groundNormal: Vector3(ground[0], ground[1], ground[2]), + ground: body[0] == 0 ? null : NativeBody(body[0]), + ); + } finally { + p.free(); + ground.free(); + body.free(); + } + } + + /// Every contact point the last step found, in order of the pair's slots. + List readContacts() { + final count = c.f3d_world_contact_count(_live); + if (count == 0) return const []; + final values = c.F32s.alloc(count * c.contactFloats); + final pairs = c.U64s.alloc(count * 2); + try { + final read = c.f3d_world_read_contacts(_live, values, pairs, count); + return [ + for (var i = 0; i < read; i++) + ( + a: NativeBody(pairs[i * 2]), + b: NativeBody(pairs[i * 2 + 1]), + normal: Vector3( + values[i * c.contactFloats], + values[i * c.contactFloats + 1], + values[i * c.contactFloats + 2], + ), + point: Vector3( + values[i * c.contactFloats + 3], + values[i * c.contactFloats + 4], + values[i * c.contactFloats + 5], + ), + depth: values[i * c.contactFloats + 6], + ), + ]; + } finally { + values.free(); + pairs.free(); + } + } + + /// Events dropped because 65 536 were waiting unread. + int get eventsDropped => c.f3d_world_events_dropped(_live); + + // ------------------------------------------------------------ snapshots + + /// The world's whole state. A world [restore]d from it steps to the same + /// bits this one does. + Uint8List snapshot() { + final size = c.f3d_world_snapshot_size(_live); + final buffer = c.U8s.alloc(size); + try { + final written = c.f3d_world_snapshot_write(_live, buffer, size); + if (written != size) { + throw StateError('the physics core wrote $written of $size bytes'); + } + return buffer.copy(size); + } finally { + buffer.free(); + } + } + + /// Puts the world back as [snapshot] says. Throws an [ArgumentError] for + /// bytes that are not a snapshot from this build of the core, and leaves + /// the world as it was. + void restore(Uint8List snapshot) { + final buffer = c.U8s.alloc(snapshot.isEmpty ? 1 : snapshot.length); + try { + buffer.setAll(snapshot); + if (c.f3d_world_restore(_live, buffer, snapshot.length) == 0) { + throw ArgumentError.value( + snapshot.length, + 'snapshot', + 'not a snapshot from this build of the physics core', + ); + } + } finally { + buffer.free(); + } + } + + // --------------------------------------------------------------- bodies + + /// Adds a body at [position] and returns it: at rest, unrotated, a point + /// of inert material at the air's temperature. A fixed body's [mass] is + /// its thermal mass only. + /// + /// Throws an [ArgumentError] for a dynamic body whose [mass] is not + /// finite and positive, or a [position] that is not finite — the core + /// refuses both, and a refusal here says which. + NativeBody addBody({ + required Vector3 position, + NativeBodyType type = NativeBodyType.dynamic, + double mass = 1.0, + }) { + final raw = c.f3d_body_create( + _live, + type.code, + position.x, + position.y, + position.z, + mass, + ); + if (raw == 0) { + if (type == NativeBodyType.dynamic && !(mass.isFinite && mass > 0.0)) { + throw ArgumentError.value(mass, 'mass', 'not finite and positive'); + } + if (!(position.x.isFinite && + position.y.isFinite && + position.z.isFinite)) { + throw ArgumentError.value(position, 'position', 'not finite'); + } + throw StateError('the physics core could not grow its arena'); + } + return NativeBody(raw); + } + + /// Takes [body] out of the world, and says whether it was there. + bool removeBody(NativeBody body) => c.f3d_body_destroy(_live, body.raw) == 1; + + /// Whether [body] names a body in this world. + bool contains(NativeBody body) => c.f3d_body_is_valid(_live, body.raw) == 1; + + /// [body]'s position, relative to the origin. Throws an [ArgumentError] + /// for a body not in the world. + Vector3 positionOf(NativeBody body) { + _check(c.f3d_body_get_position(_live, body.raw, _out), body); + return _read3(); + } + + void setPosition(NativeBody body, Vector3 value) => _check( + c.f3d_body_set_position(_live, body.raw, value.x, value.y, value.z), + body, + value, + ); + + /// [body]'s position in the world's own coordinates, origin added. + WorldPoint worldPositionOf(NativeBody body) { + _check(c.f3d_body_get_world_position(_live, body.raw, _outDouble), body); + return (x: _outDouble[0], y: _outDouble[1], z: _outDouble[2]); + } + + /// [body]'s velocity, metres per second. + Vector3 velocityOf(NativeBody body) { + _check(c.f3d_body_get_velocity(_live, body.raw, _out), body); + return _read3(); + } + + void setVelocity(NativeBody body, Vector3 value) => _check( + c.f3d_body_set_velocity(_live, body.raw, value.x, value.y, value.z), + body, + value, + ); + + /// Radians per second about the world's axes. Nothing turns a body that + /// cannot: a point, a fixed body, or one whose rotation is locked. + Vector3 angularVelocityOf(NativeBody body) { + _check(c.f3d_body_get_angular_velocity(_live, body.raw, _out), body); + return _read3(); + } + + void setAngularVelocity(NativeBody body, Vector3 value) => _check( + c.f3d_body_set_angular_velocity(_live, body.raw, value.x, value.y, value.z), + body, + value, + ); + + /// How [body] is turned, from its own axes into the world's. + Quaternion orientationOf(NativeBody body) { + _check(c.f3d_body_get_orientation(_live, body.raw, _out), body); + return Quaternion(_out[0], _out[1], _out[2], _out[3]); + } + + /// Normalised on the way in; a zero quaternion is the identity. + void setOrientation(NativeBody body, Quaternion value) => _check( + c.f3d_body_set_orientation( + _live, + body.raw, + value.x, + value.y, + value.z, + value.w, + ), + body, + value, + ); + + /// Gives [body] a shape: its inertia follows from its mass, its surface + /// and drag from its size. + void setShape(NativeBody body, NativeShape shape) => _check( + c.f3d_body_set_shape( + _live, + body.raw, + shape.kind, + shape.first, + shape.second, + shape.third, + ), + body, + shape, + ); + + /// The principal moments of inertia, kg m², in the body's own axes. + Vector3 inertiaOf(NativeBody body) { + _check(c.f3d_body_get_inertia(_live, body.raw, _out), body); + return _read3(); + } + + /// The whole inertia tensor in the body's axes, kg m²: a hull's has + /// products of inertia off the diagonal. + Matrix3 inertiaTensorOf(NativeBody body) { + final out = c.F32s.alloc(6); + try { + _check(c.f3d_body_get_inertia_tensor(_live, body.raw, out), body); + return Matrix3( + out[0], + out[3], + out[4], // + out[3], + out[1], + out[5], // + out[4], + out[5], + out[2], + ); + } finally { + out.free(); + } + } + + /// Rounds [body]'s shape out by [radius]: the shape grown by a ball, so a + /// box gets rounded edges and corners. + void setRounding(NativeBody body, double radius) => + _check(c.f3d_body_set_rounding(_live, body.raw, radius), body, radius); + + /// Shapes [body] as [hull]. + void setHull(NativeBody body, NativeHull hull) => + _check(c.f3d_body_set_hull(_live, body.raw, hull.id), body, hull.id); + + /// Shapes a fixed [body] as [mesh]; an [ArgumentError] for one that is + /// not fixed. + void setMesh(NativeBody body, NativeMesh mesh) => + _check(c.f3d_body_set_mesh(_live, body.raw, mesh.id), body, mesh.id); + + /// Kilograms: less, once it has burnt. + double massOf(NativeBody body) { + _check(c.f3d_body_get_mass(_live, body.raw, _out), body); + return _out[0]; + } + + /// Keeps [body] from turning, whatever its shape, or lets it again. + void lockRotation(NativeBody body, {bool locked = true}) => + _check(c.f3d_body_lock_rotation(_live, body.raw, locked ? 1 : 0), body); + + /// Per second, the share of velocity and of spin taken away, each as + /// `1 / (1 + dt · damping)`. + void setDamping(NativeBody body, {double linear = 0, double angular = 0}) => + _check(c.f3d_body_set_damping(_live, body.raw, linear, angular), body, ( + linear, + angular, + )); + + /// The drag coefficient against the wind; nought takes the shape's own. + void setDrag(NativeBody body, double coefficient) => _check( + c.f3d_body_set_drag(_live, body.raw, coefficient), + body, + coefficient, + ); + + /// An impulse, N s, through the centre, or at [at] (relative to the + /// origin), which spins the body as well. + void applyImpulse(NativeBody body, Vector3 impulse, {Vector3? at}) { + if (at == null) { + _check( + c.f3d_body_apply_impulse( + _live, + body.raw, + impulse.x, + impulse.y, + impulse.z, + ), + body, + impulse, + ); + return; + } + _check( + c.f3d_body_apply_impulse_at( + _live, + body.raw, + impulse.x, + impulse.y, + impulse.z, + at.x, + at.y, + at.z, + ), + body, + (impulse, at), + ); + } + + /// A force, N, held over the next step and spent by it. + void addForce(NativeBody body, Vector3 force) => _check( + c.f3d_body_add_force(_live, body.raw, force.x, force.y, force.z), + body, + force, + ); + + /// A torque, N m about the world's axes, held over the next step. + void addTorque(NativeBody body, Vector3 torque) => _check( + c.f3d_body_add_torque(_live, body.raw, torque.x, torque.y, torque.z), + body, + torque, + ); + + /// What [body] is, [layer], and what it meets, [mask]: two bodies collide + /// when each one's layer has a bit in the other's mask. Defaults 1 and + /// every bit. + void setCollisionFilter( + NativeBody body, { + required int layer, + required int mask, + }) => _check( + c.f3d_body_set_collision_filter(_live, body.raw, layer, mask), + body, + ); + + /// Hard continuous collision for [body]: swept after the solve from where + /// the step began to where it ended, and put back at its first impact. + /// For what is small and fast. + void setBullet(NativeBody body, {bool bullet = true}) => + _check(c.f3d_body_set_bullet(_live, body.raw, bullet ? 1 : 0), body); + + /// Coulomb's coefficient, nought up; 0.6 for a new body. A pair slides on + /// the geometric mean of its two. + void setFriction(NativeBody body, double friction) => _check( + c.f3d_body_set_friction(_live, body.raw, friction), + body, + friction, + ); + + /// The share of the approach speed that comes back, nought to one; nought + /// for a new body. A pair bounces with the larger of its two, and nothing + /// bounces that met slower than a metre a second. + void setRestitution(NativeBody body, double restitution) => _check( + c.f3d_body_set_restitution(_live, body.raw, restitution), + body, + restitution, + ); + + /// Whether [body] sleeps. Bodies sleep and wake by islands: those joined + /// by their contacts sleep when all have been still for the sleep time, + /// and wake together when any of them moves. + bool isAsleep(NativeBody body) { + _check(c.f3d_body_is_valid(_live, body.raw), body); + return c.f3d_body_is_asleep(_live, body.raw) == 1; + } + + /// Wakes [body] and starts its sleep clock again. + void wake(NativeBody body) => _check(c.f3d_body_wake(_live, body.raw), body); + + // ------------------------------------------------------ heat and fire + + /// What [body] is made of. Its fuel is its mass times the material's fuel + /// fraction, counted from now. + void setMaterial(NativeBody body, NativeMaterial material) { + final m = c.coreAlloc(c.F3dMaterialLayout.size); + try { + c.writeF32(m + c.F3dMaterialLayout.specificHeat, material.specificHeat); + c.writeF32(m + c.F3dMaterialLayout.emissivity, material.emissivity); + c.writeF32( + m + c.F3dMaterialLayout.ignitionTemperature, + material.ignitionTemperature, + ); + c.writeF32( + m + c.F3dMaterialLayout.heatOfCombustion, + material.heatOfCombustion, + ); + c.writeF32(m + c.F3dMaterialLayout.burnRate, material.burnRate); + c.writeF32(m + c.F3dMaterialLayout.fuelFraction, material.fuelFraction); + c.writeF32(m + c.F3dMaterialLayout.flameFeedback, material.flameFeedback); + c.writeF32(m + c.F3dMaterialLayout.conductivity, material.conductivity); + _check(c.f3d_body_set_material(_live, body.raw, m), body, material); + } finally { + c.coreFree(m); + } + } + + /// Kelvin. + double temperatureOf(NativeBody body) { + _check(c.f3d_body_get_temperature(_live, body.raw, _out), body); + return _out[0]; + } + + /// Sets it; the next step decides whether that lights a fire or puts one + /// out. + void setTemperature(NativeBody body, double kelvin) => + _check(c.f3d_body_set_temperature(_live, body.raw, kelvin), body, kelvin); + + /// Joules into [body] over the next step, or out of it. + void addHeat(NativeBody body, double joules) => + _check(c.f3d_body_add_heat(_live, body.raw, joules), body, joules); + + /// Kilograms of water onto [body] at the air's temperature, or off it. + /// Water holds the body at its boiling point until it has boiled away. + void addWater(NativeBody body, double kilograms) => + _check(c.f3d_body_add_water(_live, body.raw, kilograms), body, kilograms); + + /// Kilograms of water on [body]. + double waterOf(NativeBody body) { + _check(c.f3d_body_get_water(_live, body.raw, _out), body); + return _out[0]; + } + + /// Kilograms that can still burn. + double fuelOf(NativeBody body) { + _check(c.f3d_body_get_fuel(_live, body.raw, _out), body); + return _out[0]; + } + + bool isBurning(NativeBody body) { + _check(c.f3d_body_is_burning(_live, body.raw, _outInt), body); + return _outInt[0] == 1; + } + + /// Watts the fire gave off as hot gas over the last step. + double heatReleaseOf(NativeBody body) { + _check(c.f3d_body_get_heat_release(_live, body.raw, _out), body); + return _out[0]; + } + + /// A refusal, said: a body that is not in the world, or a value the core + /// would not take. + void _check(int answer, NativeBody body, [Object? value]) { + if (answer != 0) return; + if (c.f3d_body_is_valid(_live, body.raw) == 0) { + throw ArgumentError.value(body.raw, 'body', 'not in this world'); + } + throw ArgumentError.value(value, 'value', 'not finite, or out of range'); + } +} diff --git a/packages/flutter3d_physics_native/pubspec.yaml b/packages/flutter3d_physics_native/pubspec.yaml new file mode 100644 index 000000000..f6e763023 --- /dev/null +++ b/packages/flutter3d_physics_native/pubspec.yaml @@ -0,0 +1,47 @@ +name: flutter3d_physics_native +description: "The physics core in C11: rigid bodies stepped natively through dart:ffi, and in the browser as WebAssembly. flutter3d_physics is its reference." +version: 0.1.0 +publish_to: none +repository: https://github.com/pleiondev/flutter3d +homepage: https://flutter3d.pleion.dev +issue_tracker: https://github.com/pleiondev/flutter3d/issues +resolution: workspace + +environment: + sdk: ">=3.12.0 <4.0.0" + +dependencies: + # The build hook: `native_toolchain_c` compiles `csrc/` with the C compiler + # a Flutter developer already has — Xcode's, the NDK's, MSVC — so a game + # depending on this package needs no other toolchain. `code_assets` and + # `hooks` are what the hook speaks; `logging` is what the builder reports + # through. + # The hook fetches wgpu-native for the GPU passes: `archive` unpacks it and + # `crypto` checks its sha256 against the one pinned in the hook. + archive: ^4.2.0 + code_assets: ^2.1.0 + crypto: ^3.0.6 + hooks: ^2.0.0 + logging: ^1.3.0 + native_toolchain_c: ^0.19.5 + vector_math: ^2.2.0 + # What NativeDynamics stands under: its bodies, colliders and world. Also + # the reference the tests hold a native world to, with a tolerance, since + # that one is in doubles. + flutter3d_physics: ^0.8.0 + +dev_dependencies: + test: ^1.25.0 + flutter_lints: ^6.0.0 + +# **Unpublished while P9 builds it.** The native core grows phase by phase — +# bodies first, then contacts, a solver, shapes, joints, CCD and the GPU +# passes — and it is published once it can stand under `CollisionWorld`. + +# The core as a WebAssembly module, for the browser: a Flutter web app +# serves it from assets/packages/flutter3d_physics_native/web/. +flutter: + assets: + - web/f3d_physics.wasm + - web/f3d_physics_threads.wasm + - web/f3d_worker.js diff --git a/packages/flutter3d_physics_native/test/bindings_test.dart b/packages/flutter3d_physics_native/test/bindings_test.dart new file mode 100644 index 000000000..a3c269f2e --- /dev/null +++ b/packages/flutter3d_physics_native/test/bindings_test.dart @@ -0,0 +1,144 @@ +/// The hand-written bindings agree with the header — P9. +/// +/// dart test test/bindings_test.dart +/// +/// The bindings are written by hand, so nothing but this keeps them and +/// `csrc/include/f3d_physics.h` — and the GPU's, `csrc/gpu/f3d_gpu.h` — +/// saying the same thing. +library; + +import 'dart:io'; + +import 'package:flutter3d_physics_native/src/core/core.dart' as c; +import 'package:flutter3d_physics_native/src/gpu_bindings.dart' as g; +import 'package:test/test.dart'; + +void main() { + final header = File('csrc/include/f3d_physics.h').readAsStringSync(); + + int define(String name) { + final match = RegExp('#define $name (\\d+)u').firstMatch(header); + if (match == null) fail('the header defines no $name'); + return int.parse(match.group(1)!); + } + + test('the ABI version is the header\'s and the library\'s', () { + // Mutation: bump F3D_ABI_VERSION in the header and not here. + expect(c.abiVersion, define('F3D_ABI_VERSION')); + expect(c.f3d_abi_version(), c.abiVersion); + }); + + test('a transform is as many floats as the header says', () { + expect(c.transformFloats, define('F3D_TRANSFORM_FLOATS')); + }); + + test('the body types are the header\'s numbers', () { + expect(header, contains('F3D_BODY_DYNAMIC = ${c.BodyType.dynamic},')); + expect(header, contains('F3D_BODY_FIXED = ${c.BodyType.fixed},')); + }); + + test('the generated calls and layouts are the header\'s', () { + // A function or a field added to the header and not generated is caught + // before anybody looks for it. + final check = Process.runSync('dart', [ + 'run', + 'tool/gen_core.dart', + '--check', + ]); + expect(check.exitCode, 0, reason: '${check.stdout}${check.stderr}'); + final declared = RegExp( + r'F3D_API [^;(]*?\b(f3d_\w+)\(', + ).allMatches(header).map((m) => m.group(1)!).toSet(); + for (final file in [ + 'lib/src/core/calls_native.g.dart', + 'lib/src/core/calls_web.g.dart', + ]) { + final bound = RegExp(r'^\S+ (f3d_\w+)\(', multiLine: true) + .allMatches(File(file).readAsStringSync()) + .map((m) => m.group(1)!) + .toSet(); + expect(bound, declared, reason: file); + } + }); + + test('the structs are laid out as the C compiler lays them out', () { + // The layouts are worked out in Dart for the float build; this asks + // cc for offsetof and sizeof of every field of every struct. + final layouts = RegExp( + r'abstract final class (F3d\w+)Layout \{(.*?)\}', + dotAll: true, + ).allMatches(File('lib/src/core/layout.g.dart').readAsStringSync()); + final expected = {}; + final source = StringBuffer() + ..write('#include \n#include \n') + ..write('#include "f3d_physics.h"\nint main(void) {\n'); + for (final l in layouts) { + final type = l.group(1)!; + for (final f in RegExp( + r'static const int (\w+) = (\d+);', + ).allMatches(l.group(2)!)) { + final name = f.group(1)!; + expected['$type.$name'] = int.parse(f.group(2)!); + final field = name.replaceAllMapped( + RegExp('[A-Z]'), + (m) => '_${m[0]!.toLowerCase()}', + ); + final what = name == 'size' + ? 'sizeof($type)' + : 'offsetof($type, $field)'; + source.write(' printf("$type.$name %zu\\n", $what);\n'); + } + } + source.write(' return 0;\n}\n'); + final dir = Directory.systemTemp.createTempSync('f3d_layout'); + addTearDown(() => dir.deleteSync(recursive: true)); + File('${dir.path}/layout.c').writeAsStringSync(source.toString()); + final built = Process.runSync('cc', [ + '-std=c11', + '-Icsrc/include', + '${dir.path}/layout.c', + '-o', + '${dir.path}/layout', + ]); + expect(built.exitCode, 0, reason: '${built.stderr}'); + final ran = Process.runSync('${dir.path}/layout', const []); + final actual = { + for (final line in '${ran.stdout}'.trim().split('\n')) + line.split(' ')[0]: int.parse(line.split(' ')[1]), + }; + expect(expected, isNotEmpty); + expect(actual, expected); + }, testOn: 'mac-os || linux'); + + test( + 'particles, debris, cloth and fluid are as many floats as the header says', + () { + expect(c.particleFloats, define('F3D_PARTICLE_FLOATS')); + expect(c.debrisInputFloats, define('F3D_DEBRIS_INPUT_FLOATS')); + expect(c.debrisFloats, define('F3D_DEBRIS_FLOATS')); + expect(c.debrisStaticFloats, define('F3D_DEBRIS_STATIC_FLOATS')); + expect(c.debrisMaxStatics, define('F3D_DEBRIS_MAX_STATICS')); + expect(c.clothFloats, define('F3D_CLOTH_FLOATS')); + expect(c.clothMaxBalls, define('F3D_CLOTH_MAX_BALLS')); + expect(c.fluidInputFloats, define('F3D_FLUID_INPUT_FLOATS')); + expect(c.fluidFloats, define('F3D_FLUID_FLOATS')); + }, + ); + + test('the GPU library\'s header and bindings agree', () { + final gpu = File('csrc/gpu/f3d_gpu.h').readAsStringSync(); + final version = RegExp( + r'#define F3D_GPU_ABI_VERSION (\d+)u', + ).firstMatch(gpu); + expect(int.parse(version!.group(1)!), g.gpuAbiVersion); + final declared = RegExp( + r'F3D_GPU_API [^;(]*?\b(f3d_\w+)\(', + ).allMatches(gpu).map((m) => m.group(1)!).toSet(); + final bound = RegExp(r'external \S+ (f3d_\w+)\(') + .allMatches(File('lib/src/gpu_bindings.dart').readAsStringSync()) + .map((m) => m.group(1)!) + .toSet(); + expect(declared, isNotEmpty); + expect(bound, declared); + }); +} diff --git a/packages/flutter3d_physics_native/test/c_unit_test.dart b/packages/flutter3d_physics_native/test/c_unit_test.dart new file mode 100644 index 000000000..c3143d3a4 --- /dev/null +++ b/packages/flutter3d_physics_native/test/c_unit_test.dart @@ -0,0 +1,138 @@ +/// The C core's own tests, built and run with the sanitisers — P9. +/// +/// dart test test/c_unit_test.dart +/// +/// `csrc/tests/*.c` hold the core's unit tests in C, so a test can reach +/// what the API does not show: an arena slot's generation, the allocator. +/// This builds each with every warning an error and the address and +/// undefined-behaviour sanitisers on, runs it, and fails on a failed check or +/// on anything a sanitiser reports — a use after free or an overflow in the +/// arena is caught here, not in a game. +/// +/// On Windows with MSVC, the compiler the hook builds the core with there, +/// run from a developer prompt so that `cl` is on the path: no sanitisers, +/// but the same checks and the same digests, and the pool on Win32 threads. +@TestOn('mac-os || linux || windows') +library; + +import 'dart:io'; + +import 'package:test/test.dart'; + +import '../hook/build.dart' show coreSources; + +/// Builds [source] with the core and [defines], runs it, and returns what +/// it printed, having failed the test on a build error, a failed check or +/// a sanitiser's report. +String _buildAndRun(String source, List defines) { + final scratch = Directory.systemTemp.createTempSync('f3d_ctest'); + addTearDown(() => scratch.deleteSync(recursive: true)); + final binary = Platform.isWindows + ? '${scratch.path}\\test.exe' + : '${scratch.path}/test'; + final built = Platform.isWindows + ? _buildWithMsvc(source, defines, scratch.path, binary) + : _buildWithCc(source, defines, binary); + expect(built.exitCode, 0, reason: '${built.stdout}${built.stderr}'); + final ran = Process.runSync(binary, const []); + expect(ran.exitCode, 0, reason: '${ran.stdout}${ran.stderr}'); + expect('${ran.stdout}', contains('checks passed')); + return '${ran.stdout}'; +} + +ProcessResult _buildWithMsvc( + String source, + List defines, + String scratch, + String binary, +) => Process.runSync('cl', [ + '/nologo', + '/std:c11', + '/O1', + '/W3', + // What the hook builds the core with: MSVC contracts nothing without + // /fp:fast. + '/fp:precise', + // M_PI, which MSVC's math.h hides without it. + '/D_USE_MATH_DEFINES', + '/D_CRT_SECURE_NO_WARNINGS', + ...defines, + '/Icsrc/include', + '/Icsrc/src', + ...coreSources, + source, + '/Fo$scratch\\', + '/Fe$binary', +]); + +ProcessResult _buildWithCc( + String source, + List defines, + String binary, +) => Process.runSync('cc', [ + '-std=c11', + // The sanitisers see as much at -O1, and the heaps of debris and + // cloth step in seconds instead of minutes. + '-O1', + '-Wall', + '-Wextra', + '-Werror', + '-pedantic', + '-ffp-contract=off', + '-fsanitize=address,undefined', + '-fno-sanitize-recover=all', + '-g', + ...defines, + // The pool's threads: in libc everywhere but older glibc. + if (Platform.isLinux) '-pthread', + '-Icsrc/include', + '-Icsrc/src', + ...coreSources, + source, + '-o', + binary, + // The tests' own sqrt and pow: in libSystem on Apple's, not in + // glibc's libc. + '-lm', +]); + +void main() { + final tests = Directory( + 'csrc/tests', + ).listSync().whereType().where((f) => f.path.endsWith('.c')).toList(); + + test('there are C tests to run', () => expect(tests, isNotEmpty)); + + // Each in both precisions: the doubles build is one a simulation can + // choose, so it is held to the same tests rather than only compiled. + for (final source in tests) { + for (final precision in const ['f32', 'f64']) { + final name = '${source.uri.pathSegments.last} ($precision)'; + test( + name, + () { + _buildAndRun(source.path, [ + if (precision == 'f64') '-DF3D_REAL_DOUBLE', + ]); + }, + // Heaps of debris, cloth and water under the sanitisers take + // seconds alone and much longer on a machine busy with other work. + timeout: const Timeout(Duration(minutes: 5)), + ); + } + } + + test( + 'the fast mode lands on the same bits on vectors as without', + timeout: const Timeout(Duration(minutes: 5)), + () { + // The fast mode solves a colour's contacts in lanes of a vector where + // the compiler has them, and a lane at a time where it does not + // (MSVC); every lane must do what one contact alone does. + String hash(List defines) => RegExp( + r'fast snapshot ([0-9a-f]{16})', + ).firstMatch(_buildAndRun('csrc/tests/test_fast.c', defines))!.group(1)!; + expect(hash(const ['-DF3D_NO_SIMD']), hash(const [])); + }, + ); +} diff --git a/packages/flutter3d_physics_native/test/native_cloth_test.dart b/packages/flutter3d_physics_native/test/native_cloth_test.dart new file mode 100644 index 000000000..ca10bb057 --- /dev/null +++ b/packages/flutter3d_physics_native/test/native_cloth_test.dart @@ -0,0 +1,255 @@ +// Cloth on the CPU and on the GPU — P9, phase 10: the core's own through +// the binding, and the compute shader's against it — point for point +// where nothing folds, and up to the first wrinkles in a sheet, then the +// same sheet in what it does — the read a frame late, and carried pins. +// The GPU half is skipped where there is no adapter, or no GPU library in +// the build. +import 'dart:math' as math; +import 'dart:typed_data'; + +import 'package:flutter3d_physics_native/flutter3d_physics_native.dart'; +import 'package:test/test.dart'; +import 'package:vector_math/vector_math.dart'; + +/// A sheet a metre square hanging from the two corners of its first row. +ClothMesh _sheet(int side) => ClothMesh.grid( + columns: side, + rows: side, + origin: Vector3(-0.5, 0.0, -0.5), + pinned: {0, side - 1}, +); + +/// How much longer than it started the longest structural edge of a +/// [side] × [side] sheet a metre wide is, as a fraction. +double _stretch(Float32List p, int side) { + var worst = 0.0; + for (var j = 0; j < side; j++) { + for (var i = 0; i + 1 < side; i++) { + final a = (j * side + i) * 4; + final b = a + 4; + final d = math.sqrt( + math.pow(p[a] - p[b], 2) + + math.pow(p[a + 1] - p[b + 1], 2) + + math.pow(p[a + 2] - p[b + 2], 2), + ); + worst = math.max(worst, d * (side - 1) - 1.0); + } + } + return worst; +} + +double _lowest(Float32List p) { + var low = double.infinity; + for (var i = 1; i < p.length; i += 4) { + low = math.min(low, p[i]); + } + return low; +} + +void main() { + test('a sheet hangs from its pins through the binding', () { + final cloth = NativeCloth(_sheet(12)); + addTearDown(cloth.dispose); + expect(cloth.pointCount, 144); + expect(cloth.colourCount, inInclusiveRange(12, 24)); + expect(cloth.read(), isNull); + final settings = ClothSettings(damping: 1.0); + for (var i = 0; i < 120; i++) { + cloth.step(settings, 1 / 60); + } + final frame = cloth.read()!; + expect(frame.step, 120); + final p = frame.points; + expect([p[0], p[1], p[2], p[3]], [-0.5, 0.0, -0.5, 0.0]); + expect(p[11 * 4 + 1], 0.0); + expect(_lowest(p), inInclusiveRange(-1.1, -0.8)); + expect(_stretch(p, 12), lessThan(0.02)); + expect(cloth.read(), isNull); + }); + + test('bad meshes, too many balls and a disposed cloth say so', () { + expect( + () => NativeCloth( + ClothMesh( + points: [Vector3.zero(), Vector3(1.0, 0.0, 0.0)], + inverseMasses: [1.0, 1.0], + edges: <(int, int)>[(1, 1)], + compliances: [0.0], + ), + ), + throwsArgumentError, + ); + final cloth = NativeCloth(_sheet(4)); + expect( + () => cloth.setBalls( + List.filled(17, (centre: Vector3.zero(), radius: 1.0)), + ), + throwsArgumentError, + ); + cloth.dispose(); + expect(() => cloth.read(), throwsStateError); + cloth.dispose(); + }); + + final gpu = NativeGpu.open(); + group( + 'on the GPU', + skip: gpu == null ? 'no GPU adapter, or no GPU library' : null, + () { + tearDownAll(() => gpu?.dispose()); + + test('swings, springs, drifts and rolls off a ball as the CPU does', () { + // Scenes nothing folds in, so point for point every tenth step — to + // half a millimetre, a swing's phase drifting by its rounding — a + // rigid pendulum, a damped spring in a wind, a rod dropped onto a + // ball off its top that slides off it, and loose points blown along + // the floor. + final scenes = + < + ( + ClothMesh, + ClothSettings, + List<({Vector3 centre, double radius})>, + ) + >[ + ( + ClothMesh( + points: [Vector3.zero(), Vector3(1.0, 0.0, 0.0)], + inverseMasses: [0.0, 1.0], + edges: <(int, int)>[(0, 1)], + compliances: [0.0], + ), + ClothSettings(damping: 0.0), + <({Vector3 centre, double radius})>[], + ), + ( + ClothMesh( + points: [Vector3.zero(), Vector3(0.0, -1.0, 0.0)], + inverseMasses: [0.0, 1.0], + edges: <(int, int)>[(0, 1)], + compliances: [1e-3], + ), + ClothSettings( + damping: 2.0, + wind: Vector3(3.0, 0.0, 0.0), + drag: 0.5, + ), + <({Vector3 centre, double radius})>[], + ), + ( + ClothMesh( + points: [ + Vector3(0.1, 1.0, 0.0), + Vector3(0.3, 1.0, 0.05), + ], + inverseMasses: [2.0, 2.0], + edges: <(int, int)>[(0, 1)], + compliances: [1e-6], + ), + ClothSettings(floorY: 0.0, friction: 0.2), + <({Vector3 centre, double radius})>[ + (centre: Vector3(0.0, 0.3, 0.0), radius: 0.3), + ], + ), + ( + ClothMesh( + points: [ + Vector3(0.0, 0.5, 0.0), + Vector3(0.3, 0.2, 0.0), + ], + inverseMasses: [1.0, 1.0], + edges: <(int, int)>[], + compliances: [], + ), + ClothSettings( + floorY: 0.1, + thickness: 0.02, + friction: 0.4, + wind: Vector3(2.0, 0.0, 1.0), + drag: 1.0, + ), + <({Vector3 centre, double radius})>[], + ), + ]; + for (final (mesh, settings, balls) in scenes) { + final cpu = NativeCloth(mesh); + final onGpu = gpu!.cloth(mesh); + cpu.setBalls(balls); + onGpu.setBalls(balls); + for (var step = 1; step <= 120; step++) { + cpu.step(settings, 1 / 60); + onGpu.step(settings, 1 / 60); + if (step % 10 != 0) continue; + final x = cpu.read()!.points; + final y = onGpu.read()!.points; + for (var i = 0; i < x.length; i++) { + expect(y[i], closeTo(x[i], 5e-4), reason: 'step $step, float $i'); + } + } + cpu.dispose(); + onGpu.dispose(); + } + }); + + test('hangs the sheet the CPU hangs', () { + final cpu = NativeCloth(_sheet(32)); + final onGpu = gpu!.cloth(_sheet(32)); + addTearDown(cpu.dispose); + addTearDown(onGpu.dispose); + final settings = ClothSettings(damping: 1.0); + for (var i = 0; i < 30; i++) { + cpu.step(settings, 1 / 60); + onGpu.step(settings, 1 / 60); + } + // Half a second in, falling and not yet folded: point for point. + final a = cpu.read()!.points; + final b = onGpu.read()!.points; + for (var i = 0; i < a.length; i++) { + expect(b[i], closeTo(a[i], 1e-4), reason: 'float $i'); + } + for (var i = 0; i < 150; i++) { + cpu.step(settings, 1 / 60); + onGpu.step(settings, 1 / 60); + } + // Wrinkled, each its own way: the same sheet. + final settledCpu = cpu.read()!.points; + final settled = onGpu.read()!.points; + expect( + [settled[0], settled[1], settled[2]], + [-0.5, 0.0, -0.5], + ); + // A sheet of 32² stretches some 4% at sixteen substeps, on either. + expect(_stretch(settled, 32), closeTo(_stretch(settledCpu, 32), 0.005)); + expect(_lowest(settled), closeTo(_lowest(settledCpu), 0.01)); + }); + + test('reads a frame late, and carries its pins', () { + final onGpu = gpu!.cloth(_sheet(8)); + addTearDown(onGpu.dispose); + expect(onGpu.read(wait: false), isNull); + final settings = ClothSettings(); + var last = 0; + for (var i = 1; i <= 20; i++) { + onGpu.step(settings, 1 / 60); + final late = onGpu.read(wait: false); + if (late != null) { + expect(late.step, inInclusiveRange(last + 1, i)); + last = late.step; + } + } + onGpu + ..movePoint(0, Vector3(-0.5, 1.0, -0.5)) + ..movePoint(7, Vector3(0.5, 1.0, -0.5)); + for (var i = 0; i < 60; i++) { + onGpu.step(settings, 1 / 60); + } + final p = onGpu.read()!; + expect(p.step, 80); + expect(p.points[1], 1.0); + expect(p.points[7 * 4 + 1], 1.0); + expect(p.points[63 * 4 + 1], greaterThan(0.0)); + expect(onGpu.read(), isNull); + }); + }, + ); +} diff --git a/packages/flutter3d_physics_native/test/native_contact_test.dart b/packages/flutter3d_physics_native/test/native_contact_test.dart new file mode 100644 index 000000000..18726d609 --- /dev/null +++ b/packages/flutter3d_physics_native/test/native_contact_test.dart @@ -0,0 +1,432 @@ +/// Contacts on a native world through `dart:ffi` — P9, phase 2. +/// +/// dart test test/native_contact_test.dart +/// +/// The C tests hold every pair of shapes against its closed form, turned; +/// these hold the axis-aligned pairs against `flutter3d_physics`, whose +/// contacts know no rotation, and what a game reads: the contacts, their +/// events and the filters. +library; + +import 'package:flutter3d_physics/flutter3d_physics.dart'; +import 'package:flutter3d_physics_native/flutter3d_physics_native.dart'; +import 'package:test/test.dart'; +import 'package:vector_math/vector_math.dart'; + +void main() { + late NativeWorld world; + setUp(() { + world = NativeWorld() + ..gravity = Vector3.zero() + ..setSleep(speed: 0.0, time: 0.0); + }); + tearDown(() => world.dispose()); + + NativeShape native(CollisionShape shape) => switch (shape) { + CollisionSphere(:final radius) => NativeShape.sphere(radius), + CollisionBox(:final halfExtents) => NativeShape.box(halfExtents), + _ => throw ArgumentError(shape), + }; + + test('unturned, the contacts are flutter3d_physics\' contacts', () { + // The reference reports one point per pair; the core reports a whole + // face for two boxes, every point at the reference's depth, along its + // normal. + final cases = <(CollisionShape, Vector3, CollisionShape, Vector3)>[ + ( + CollisionSphere(0.5), + Vector3(0.0, 0.95, 0.0), + CollisionSphere(0.5), + Vector3.zero(), + ), + ( + CollisionSphere(0.3), + Vector3(0.1, 0.0, 0.79), + CollisionSphere(0.5), + Vector3.zero(), + ), + ( + CollisionSphere(0.25), + Vector3(0.2, 0.74, 0.1), + CollisionBox(Vector3.all(0.5)), + Vector3.zero(), + ), + ( + CollisionSphere(0.25), + Vector3(0.65, 0.65, 0.0), + CollisionBox(Vector3.all(0.5)), + Vector3.zero(), + ), + ( + CollisionBox(Vector3(0.5, 0.25, 0.5)), + Vector3(0.2, 0.74, -0.1), + CollisionBox(Vector3(1.0, 0.5, 1.0)), + Vector3.zero(), + ), + ( + CollisionBox(Vector3.all(0.2)), + Vector3(1.19, 0.1, 0.0), + CollisionBox(Vector3.all(1.0)), + Vector3.zero(), + ), + ]; + for (final (sa, at, sb, bt) in cases) { + final w = NativeWorld()..gravity = Vector3.zero(); + addTearDown(w.dispose); + final a = w.addBody(position: at); + final b = w.addBody(position: bt, type: NativeBodyType.fixed); + w + ..setShape(a, native(sa)) + ..setShape(b, native(sb)) + ..step(1e-9); + final reference = Contact(); + contactBetween(sa, at, sb, bt, reference); + expect(reference.touching, isTrue); + final contacts = w.readContacts(); + expect(contacts, isNotEmpty, reason: '$sa at $at on $sb'); + for (final c in contacts) { + expect(c.a, a); + expect(c.b, b); + expect((c.normal - reference.normal).length, lessThan(1e-5)); + expect(c.depth, closeTo(reference.depth, 1e-5)); + } + } + }); + + test('a ball bounced off a wall begins and ends one contact', () { + // Mutation: compare the touching flags the wrong way round in the merge + // of `f3d_step_collide` — no contact begins. + world.setAir(temperature: 293.15, density: 1e-30); + final wall = world.addBody( + position: Vector3.zero(), + type: NativeBodyType.fixed, + ); + final ball = world.addBody(position: Vector3(-2.0, 0.0, 0.0), mass: 0.1); + world + ..setShape(wall, NativeShape.box(Vector3(0.1, 1.0, 1.0))) + ..setShape(ball, const NativeShape.sphere(0.1)) + ..setRestitution(ball, 1.0) + ..setVelocity(ball, Vector3(2.0, 0.0, 0.0)); + final seen = []; + var deepest = -1.0; + for (var i = 0; i < 400; i++) { + world.step(0.01); + seen.addAll(world.readEvents()); + for (final c in world.readContacts()) { + if (c.depth > deepest) deepest = c.depth; + } + } + expect(seen, [ + (body: wall, other: ball, kind: NativeEventKind.contactBegan), + (body: wall, other: ball, kind: NativeEventKind.contactEnded), + ]); + // It never got into the wall past the slop, and came back at the speed + // it arrived with. + expect(deepest, lessThan(0.005)); + expect(world.velocityOf(ball).x, closeTo(-2.0, 0.05)); + expect(world.readContacts(), isEmpty); + }); + + test( + 'a filter keeps two bodies from meeting, and the margin is the world\'s', + () { + final a = world.addBody(position: Vector3.zero()); + final b = world.addBody(position: Vector3(0.0, 0.21, 0.0)); + world + ..setShape(a, const NativeShape.sphere(0.1)) + ..setShape(b, const NativeShape.sphere(0.1)) + ..step(1e-9); + // A centimetre apart, inside the default two-centimetre margin. + expect(world.readContacts().single.depth, closeTo(-0.01, 1e-6)); + world + ..contactMargin = 0.0 + ..step(1e-9); + expect(world.readContacts(), isEmpty); + world + ..contactMargin = 0.02 + ..setCollisionFilter(a, layer: 1 << 1, mask: ~(1 << 2)) + ..setCollisionFilter(b, layer: 1 << 2, mask: ~0) + ..step(1e-9); + expect(world.readContacts(), isEmpty); + expect(() => world.contactMargin = -1.0, throwsArgumentError); + }, + ); + + test('heat crosses a contact, and a hot plate keeps its heat', () { + final steel = NativeMaterial.steel(); + final plate = world.addBody( + position: Vector3(0.0, -0.5, 0.0), + type: NativeBodyType.fixed, + mass: 0.0, + ); + final block = world.addBody(position: Vector3(0.0, 0.099, 0.0)); + world + ..setShape(plate, NativeShape.box(Vector3(1.0, 0.5, 1.0))) + ..setShape(block, NativeShape.box(Vector3.all(0.1))) + ..setMaterial(plate, steel) + ..setMaterial(block, steel) + ..setTemperature(plate, 500.0); + // About forty seconds to warm: 490 J/K through Holm's 11 W/K. + for (var i = 0; i < 1200; i++) { + world.step(0.1); + } + expect(world.temperatureOf(plate), 500.0); + expect(world.temperatureOf(block), greaterThan(400.0)); + expect(steel.conductivity, 50.0); + }); + + test('a box query finds what the tree holds, in slot order', () { + // Mutation: return the leaves in the order the tree walks them, in + // `found_one` — the answer depends on the tree's shape. + final balls = [ + for (var i = 0; i < 100; i++) + world.addBody(position: Vector3((i % 10) * 2.0, 0.0, (i ~/ 10) * 2.0)), + ]; + for (final b in balls) { + world.setShape(b, const NativeShape.sphere(0.5)); + } + world.addBody(position: Vector3.zero()); // A point: never found. + expect( + world.queryBox(Vector3(-1.0, -1.0, -1.0), Vector3(2.6, 1.0, 2.6)), + [balls[0], balls[1], balls[10], balls[11]], + ); + expect( + world.queryBox(Vector3(-100.0, -1.0, -100.0), Vector3(100.0, 1.0, 100.0)), + balls, + ); + expect(world.queryBox(Vector3.all(50.0), Vector3.all(51.0)), isEmpty); + }); + + test('cylinders, cones and hulls stand on a floor, through the binding', () { + // The C tests hold GJK, EPA and the manifolds against the closed forms; + // this holds what a game reads: each shape at rest at its own height. + final floor = world.addBody( + position: Vector3(0.0, -0.5, 0.0), + type: NativeBodyType.fixed, + mass: 0.0, + ); + world + ..setShape(floor, NativeShape.box(Vector3(20.0, 0.5, 20.0))) + ..setSleep(speed: 0.05, time: 0.5) + ..gravity = Vector3(0.0, -9.81, 0.0); + final hull = world.createHull([ + for (var i = 0; i < 8; i++) + Vector3( + 5.0 + ((i & 1) != 0 ? 0.5 : -0.5), + (i & 2) != 0 ? 0.5 : -0.5, + (i & 4) != 0 ? 0.5 : -0.5, + ), + Vector3(5.0, 0.0, 0.0), // Inside: not a corner. + ]); + expect(world.hullVertexCount(hull), 8); + expect(world.hullOffset(hull).x, closeTo(5.0, 1e-6)); + final cylinder = world.addBody(position: Vector3(0.0, 1.01, 0.0)); + final cone = world.addBody(position: Vector3(3.0, 0.26, 0.0)); + final cube = world.addBody(position: Vector3(-3.0, 0.51, 0.0)); + final rounded = world.addBody(position: Vector3(6.0, 0.51, 0.0)); + world + ..setShape(cylinder, const NativeShape.cylinder(0.3, 1.0)) + ..setShape(cone, const NativeShape.cone(0.4, 1.0)) + ..setHull(cube, hull) + ..setShape(rounded, NativeShape.box(Vector3.all(0.4))) + ..setRounding(rounded, 0.1); + for (var i = 0; i < 240; i++) { + world.step(1.0 / 60.0); + } + for (final (body, height) in <(NativeBody, double)>[ + (cylinder, 1.0), + (cone, 0.25), + (cube, 0.5), + (rounded, 0.5), + ]) { + expect(world.positionOf(body).y, closeTo(height, 0.01)); + expect(world.isAsleep(body), isTrue); + } + expect(world.inertiaTensorOf(cube).entry(0, 1), closeTo(0.0, 1e-6)); + expect( + () => world.createHull([Vector3.zero(), Vector3(1.0, 0.0, 0.0)]), + throwsArgumentError, + ); + }); + + test('a crate and a ball rest on a mesh floor, through the binding', () { + world + ..gravity = Vector3(0.0, -9.81, 0.0) + ..setSleep(speed: 0.05, time: 0.5); + // Four by four squares, two triangles each, their vertices shared. + final vertices = [ + for (var j = 0; j <= 4; j++) + for (var i = 0; i <= 4; i++) Vector3(i - 2.0, 0.0, j - 2.0), + ]; + final indices = [ + for (var j = 0; j < 4; j++) + for (var i = 0; i < 4; i++) ...[ + j * 5 + i, (j + 1) * 5 + i + 1, j * 5 + i + 1, // + j * 5 + i, (j + 1) * 5 + i, (j + 1) * 5 + i + 1, + ], + ]; + final mesh = world.createMesh(vertices, indices); + expect(world.meshTriangleCount(mesh), 32); + expect(world.meshInternalEdges(mesh), 40); + final ground = world.addBody( + position: Vector3.zero(), + type: NativeBodyType.fixed, + mass: 0.0, + ); + world.setMesh(ground, mesh); + final crate = world.addBody(position: Vector3(0.5, 0.51, 0.5)); + final ball = world.addBody(position: Vector3(-1.0, 0.31, -1.0)); + world + ..setShape(crate, NativeShape.box(Vector3.all(0.5))) + ..setShape(ball, const NativeShape.sphere(0.3)); + for (var i = 0; i < 180; i++) { + world.step(1.0 / 60.0); + } + expect(world.positionOf(crate).y, closeTo(0.5, 0.01)); + expect(world.positionOf(ball).y, closeTo(0.3, 0.01)); + expect(world.isAsleep(crate) && world.isAsleep(ball), isTrue); + expect( + () => world.setMesh(crate, mesh), + throwsArgumentError, + reason: 'a dynamic body cannot be a mesh', + ); + expect( + () => world.createMesh(vertices, [0, 1, 99]), + throwsArgumentError, + ); + }); + + test( + 'rays meet what flutter3d_physics\' rays meet, at the same distance', + () { + // Unturned balls and boxes, the shapes both worlds hold alike, and two + // hundred rays: the same nearest body, a hundredth of a millimetre + // apart. + final reference = CollisionWorld(); + final pairs = {}; + var seed = 7; + double next() { + seed = (seed * 1103515245 + 12345) & 0x7fffffff; + return seed / 0x7fffffff; + } + + for (var i = 0; i < 60; i++) { + final at = Vector3( + next() * 20 - 10, + next() * 20 - 10, + next() * 20 - 10, + ); + final CollisionShape shape = i.isEven + ? CollisionSphere(0.3 + next()) + : CollisionBox(Vector3(0.3 + next(), 0.3 + next(), 0.3 + next())); + final body = world.addBody(position: at, type: NativeBodyType.fixed); + world.setShape(body, native(shape)); + pairs[body] = reference.add( + Collider( + shape: shape, + position: at.clone(), + kind: ColliderKind.static, + ), + ); + } + final hit = RayHit(); + var met = 0; + for (var q = 0; q < 200; q++) { + final origin = Vector3( + next() * 30 - 15, + next() * 30 - 15, + next() * 30 - 15, + ); + final direction = Vector3(next() - 0.5, next() - 0.5, next() - 0.5) + ..normalize(); + final mine = world.rayCast(origin, direction, 40.0); + final theirs = reference.raycast(origin, direction, 40.0, hit); + // Neither sees a shape its ray starts inside, alike; a ray starting + // inside one is left out of the comparison. + if (world + .overlapShape(const NativeShape.sphere(1e-4), origin) + .isNotEmpty) { + continue; + } + expect(mine != null, theirs, reason: 'ray $q'); + if (mine == null) continue; + met++; + expect(pairs[mine.body], same(hit.collider), reason: 'ray $q'); + expect(mine.at, closeTo(hit.distance, 1e-5), reason: 'ray $q'); + expect((mine.normal - hit.normal).length, lessThan(1e-4)); + } + expect(met, greaterThan(10)); + // A cast and an overlap through the binding. + final first = pairs.keys.first; + final at = world.positionOf(first); + expect( + world.overlapShape(const NativeShape.sphere(0.01), at), + contains(first), + ); + final cast = world.castShape( + const NativeShape.sphere(0.1), + at + Vector3(0.0, 30.0, 0.0), + Vector3(0.0, -40.0, 0.0), + ); + expect(cast, isNotNull); + expect( + world.rayCastAll( + at + Vector3(0.0, 30.0, 0.0), + Vector3(0.0, -1.0, 0.0), + 60.0, + ), + isNotEmpty, + ); + }, + ); + + test('a character walks, slides along a wall and climbs a step', () { + world.gravity = Vector3(0.0, -9.81, 0.0); + final floor = world.addBody( + position: Vector3(0.0, -0.5, 0.0), + type: NativeBodyType.fixed, + ); + final wall = world.addBody( + position: Vector3(3.5, 5.0, 0.0), + type: NativeBodyType.fixed, + ); + final step = world.addBody( + position: Vector3(0.0, 0.15, 5.0), + type: NativeBodyType.fixed, + ); + world + ..setShape(floor, NativeShape.box(Vector3(50.0, 0.5, 50.0))) + ..setShape(wall, NativeShape.box(Vector3(0.5, 5.0, 50.0))) + ..setShape(step, NativeShape.box(Vector3(2.0, 0.15, 2.0))); + var at = Vector3(0.0, 3.0, 0.0); + var move = world.moveCharacter( + radius: 0.3, + halfHeight: 0.6, + position: at, + move: Vector3(0.0, -5.0, 0.0), + ); + expect(move.grounded, isTrue); + expect(move.ground, floor); + expect(move.position.y, closeTo(0.91, 2e-3)); + move = world.moveCharacter( + radius: 0.3, + halfHeight: 0.6, + position: move.position, + move: Vector3(4.0, 0.0, 1.0), + ); + expect(move.hitWall, isTrue); + expect(move.position.x, closeTo(2.69, 2e-3)); + at = Vector3(0.0, move.position.y, 2.0); + move = world.moveCharacter( + radius: 0.3, + halfHeight: 0.6, + position: at, + move: Vector3(0.0, 0.0, 2.0), + ); + expect(move.stepped, isTrue); + expect(move.ground, step); + expect(move.position.y, closeTo(1.21, 3e-3)); + expect(move.hitCeiling, isFalse); + expect(move.groundNormal.y, closeTo(1.0, 1e-4)); + }); +} diff --git a/packages/flutter3d_physics_native/test/native_debris_test.dart b/packages/flutter3d_physics_native/test/native_debris_test.dart new file mode 100644 index 000000000..d528dbb38 --- /dev/null +++ b/packages/flutter3d_physics_native/test/native_debris_test.dart @@ -0,0 +1,236 @@ +// Debris on the CPU and on the GPU — P9, phase 10: the core's own through +// the binding, and the compute shader's against it — the same to the GPU's +// rounding until bodies meet, then the same heap — and the read a frame +// late. The GPU half is skipped where there is no adapter, or no GPU +// library in the build. +import 'dart:math' as math; +import 'dart:typed_data'; + +import 'package:flutter3d_physics_native/flutter3d_physics_native.dart'; +import 'package:test/test.dart'; +import 'package:vector_math/vector_math.dart'; + +/// A pen two metres wide: a floor and four walls. +final List _pen = [ + DebrisPlane(Vector3(0.0, 1.0, 0.0), 0.0), + DebrisPlane(Vector3(1.0, 0.0, 0.0), -1.0), + DebrisPlane(Vector3(-1.0, 0.0, 0.0), -1.0), + DebrisPlane(Vector3(0.0, 0.0, 1.0), -1.0), + DebrisPlane(Vector3(0.0, 0.0, -1.0), -1.0), +]; + +/// A thousand balls of three sizes in ten layers above the pen. +List _pour() => [ + for (var i = 0; i < 1000; i++) + ( + position: Vector3( + -0.85 + (i % 10) * 0.19 + (i % 3) * 0.01, + 0.3 + (i ~/ 100) * 0.25, + -0.85 + ((i ~/ 10) % 10) * 0.19, + ), + velocity: Vector3(0.0, -1.0, 0.0), + radius: 0.05 + (i % 3) * 0.02, + mass: 1.0, + ), +]; + +double _meanHeight(Float32List bodies) { + var sum = 0.0; + for (var i = 0; i < bodies.length ~/ 8; i++) { + sum += bodies[i * 8 + 1]; + } + return sum / (bodies.length ~/ 8); +} + +/// How far into another any ball sits, over the smaller radius, and how +/// many are through the floor or a wall by more than a centimetre. +({double overlap, int leaked}) _heap(Float32List b) { + final n = b.length ~/ 8; + var overlap = 0.0; + var leaked = 0; + for (var i = 0; i < n; i++) { + final r = b[i * 8 + 7]; + if (b[i * 8 + 1] < r - 0.01 || + b[i * 8].abs() > 1 - r + 0.01 || + b[i * 8 + 2].abs() > 1 - r + 0.01) { + leaked++; + } + for (var j = i + 1; j < n; j++) { + final dx = b[i * 8] - b[j * 8]; + final dy = b[i * 8 + 1] - b[j * 8 + 1]; + final dz = b[i * 8 + 2] - b[j * 8 + 2]; + final depth = r + b[j * 8 + 7] - math.sqrt(dx * dx + dy * dy + dz * dz); + overlap = math.max(overlap, depth / math.min(r, b[j * 8 + 7])); + } + } + return (overlap: overlap, leaked: leaked); +} + +void main() { + test('a ball comes to rest on the floor through the binding', () { + final d = NativeDebris(2); + addTearDown(d.dispose); + expect(d.capacity, 2); + d.setStatics(_pen.sublist(0, 1)); + d.add([ + ( + position: Vector3(0.0, 0.5, 0.0), + velocity: Vector3.zero(), + radius: 0.1, + mass: 1.0, + ), + ]); + expect(d.read(), isNull); + final settings = DebrisSettings(restitution: 0.0); + for (var i = 0; i < 120; i++) { + d.step(settings, 1 / 60); + } + final frame = d.read()!; + expect(frame.step, 120); + expect(frame.bodies[1], closeTo(0.1, 0.001)); + expect(frame.bodies[7], closeTo(0.1, 1e-7)); + // The second slot is empty, and nothing new comes until a step. + expect(frame.bodies[15], 0.0); + expect(d.read(), isNull); + }); + + test('statics past sixty-four, and a disposed set, say so', () { + final d = NativeDebris(1); + expect( + () => d.setStatics(List.filled(65, _pen.first)), + throwsArgumentError, + ); + d.dispose(); + expect(() => d.read(), throwsStateError); + d.dispose(); + expect(() => NativeDebris(0), throwsArgumentError); + }); + + final gpu = NativeGpu.open(); + group( + 'on the GPU', + skip: gpu == null ? 'no GPU adapter, or no GPU library' : null, + () { + tearDownAll(() => gpu?.dispose()); + + test('reads a frame late, or waits', () { + final d = gpu!.debris(16); + addTearDown(d.dispose); + d.setStatics(_pen); + d.add(_pour().sublist(0, 16)); + expect(d.read(wait: false), isNull); + final settings = DebrisSettings(); + var last = 0; + for (var i = 1; i <= 30; i++) { + d.step(settings, 1 / 60); + final late = d.read(wait: false); + if (late != null) { + // Never a step not queued, never one older than the last read. + expect(late.step, lessThanOrEqualTo(i)); + expect(late.step, greaterThan(last)); + last = late.step; + } + } + expect(d.read()!.step, 30); + expect(d.read(), isNull); + }); + + test('lands in a groove and stacks as the CPU does', () { + // Scenes with no chaos in them, so body for body: a ball in a + // shallow groove, its contacts splitting its mass, and three balls + // of different sizes and masses stacked on a floor, each pair + // counting different contacts on either side. + const a = 0.35; + final scenes = <(List, List)>[ + ( + [ + DebrisPlane(Vector3(math.sin(a), math.cos(a), 0.0), 0.0), + DebrisPlane(Vector3(-math.sin(a), math.cos(a), 0.0), 0.0), + ], + [ + ( + position: Vector3(0.0, 1.0, 0.0), + velocity: Vector3.zero(), + radius: 0.1, + mass: 1.0, + ), + ], + ), + ( + _pen.sublist(0, 1), + [ + for (var i = 0; i < 3; i++) + ( + position: Vector3(0.0, 0.15 + i * 0.35, 0.0), + velocity: Vector3.zero(), + radius: 0.15 - i * 0.03, + mass: 4.0 - i * 1.5, + ), + ], + ), + ]; + for (final (statics, bodies) in scenes) { + final cpu = NativeDebris(bodies.length); + final onGpu = gpu!.debris(bodies.length); + for (final DebrisSystem d in [cpu, onGpu]) { + d.setStatics(statics); + d.add(bodies); + } + final settings = DebrisSettings(restitution: 0.0); + // Every tenth step, so a hop on landing that settles shows too. + for (var step = 1; step <= 120; step++) { + cpu.step(settings, 1 / 60); + onGpu.step(settings, 1 / 60); + if (step % 10 != 0) continue; + final x = cpu.read()!.bodies; + final y = onGpu.read()!.bodies; + for (var i = 0; i < x.length; i++) { + expect(y[i], closeTo(x[i], 1e-4), reason: 'step $step, float $i'); + } + } + cpu.dispose(); + onGpu.dispose(); + } + }); + + test('pours the heap the CPU pours', () { + final cpu = NativeDebris(1000); + final onGpu = gpu!.debris(1000); + addTearDown(cpu.dispose); + addTearDown(onGpu.dispose); + for (final DebrisSystem d in [cpu, onGpu]) { + d.setStatics(_pen); + d.add(_pour()); + } + final settings = DebrisSettings(restitution: 0.0); + for (var i = 0; i < 5; i++) { + cpu.step(settings, 1 / 60); + onGpu.step(settings, 1 / 60); + } + // Falling, before they meet: the same to the GPU's rounding. + final a = cpu.read()!.bodies; + final b = onGpu.read()!.bodies; + var worst = 0.0; + for (var i = 0; i < a.length; i++) { + worst = math.max(worst, (a[i] - b[i]).abs()); + } + expect(worst, lessThan(1e-4)); + for (var i = 0; i < 235; i++) { + cpu.step(settings, 1 / 60); + onGpu.step(settings, 1 / 60); + } + // Settled: body for body they went their own ways, but the heap is + // the same heap. + final settledCpu = cpu.read()!.bodies; + final settledGpu = onGpu.read()!.bodies; + expect( + _meanHeight(settledGpu), + closeTo(_meanHeight(settledCpu), 0.01 * _meanHeight(settledCpu)), + ); + final heap = _heap(settledGpu); + expect(heap.leaked, 0); + expect(heap.overlap, lessThan(0.1)); + }); + }, + ); +} diff --git a/packages/flutter3d_physics_native/test/native_dynamics_test.dart b/packages/flutter3d_physics_native/test/native_dynamics_test.dart new file mode 100644 index 000000000..f4e7578f6 --- /dev/null +++ b/packages/flutter3d_physics_native/test/native_dynamics_test.dart @@ -0,0 +1,320 @@ +// The core underneath flutter3d_physics' bodies — P9: the same scenes built +// once, through RigidDynamics, and stepped by the reference and by the core; +// what a game sees of them — where the crates come to rest, which way a +// push or a slope sends them, that they sleep — is held equal, within what +// two solvers in two precisions differ by. Then what only the mirror has +// to get right: an impulse, a teleport, a lift, a removal, a rollback. +import 'dart:typed_data'; + +import 'package:flutter3d_physics/flutter3d_physics.dart'; +import 'package:flutter3d_physics_native/flutter3d_physics_native.dart'; +import 'package:test/test.dart'; +import 'package:vector_math/vector_math.dart'; + +import 'scenes/dynamics_scene.dart'; + +typedef Backend = RigidDynamics Function(CollisionWorld world); + +final Map backends = { + 'Dynamics': (world) => Dynamics(world: world), + 'NativeDynamics': (world) { + final dynamics = NativeDynamics(world: world); + addTearDown(dynamics.dispose); + return dynamics; + }, +}; + +RigidBody crate( + RigidDynamics dynamics, + Vector3 at, { + double half = 0.5, + bool canRotate = false, +}) => dynamics.add( + RigidBody( + world: dynamics.world, + shape: CollisionBox(Vector3.all(half)), + position: at, + canRotate: canRotate, + ), +); + +void run(RigidDynamics dynamics, int steps) { + for (var i = 0; i < steps; i++) { + dynamics.step(1.0 / 60.0); + } +} + +void main() { + for (final MapEntry(key: name, value: make) in backends.entries) { + group(name, () { + test('a crate falls onto a floor and rests on it, asleep', () { + final world = CollisionWorld() + ..addBox(Vector3(0.0, -0.5, 0.0), Vector3(20.0, 1.0, 20.0)); + final dynamics = make(world); + final box = crate(dynamics, Vector3(0.0, 3.0, 0.0)); + run(dynamics, 180); + expect(box.position.y, closeTo(0.5, 0.02)); + expect(box.isAsleep, isTrue); + expect(box.collider.position.y, box.position.y); + }); + + test('a stack of five stands and sleeps', () { + final world = CollisionWorld() + ..addBox(Vector3(0.0, -0.5, 0.0), Vector3(20.0, 1.0, 20.0)); + final dynamics = make(world); + final stack = [ + for (var i = 0; i < 5; i++) + crate(dynamics, Vector3(0.0, 0.5 + i * 1.0, 0.0)), + ]; + run(dynamics, 300); + for (var i = 0; i < 5; i++) { + expect(stack[i].position.x, closeTo(0.0, 0.02)); + expect(stack[i].position.y, closeTo(0.5 + i, 0.05)); + } + expect(stack.every((b) => b.isAsleep), isTrue); + }); + + test('a walker pushes a crate the way it walks', () { + final world = CollisionWorld() + ..addBox(Vector3(0.0, -0.5, 0.0), Vector3(20.0, 1.0, 20.0)); + final dynamics = make(world); + final box = crate(dynamics, Vector3(0.0, 0.5, 0.0)); + final walker = world.add( + Collider( + shape: CollisionCapsule(radius: 0.3, halfHeight: 0.6), + position: Vector3(-0.81, 0.9, 0.0), + kind: ColliderKind.kinematic, + ), + ); + run(dynamics, 30); + dynamics.push(walker, Vector3(2.0, 0.0, 0.0)); + expect(box.velocity.x, closeTo(2.0, 1e-9)); + run(dynamics, 1); + expect(box.position.x, greaterThan(0.01)); + }); + + test('an impulse between steps is taken', () { + final world = CollisionWorld(); + final dynamics = make(world); + final ball = dynamics.add( + RigidBody( + world: world, + shape: CollisionSphere(0.25), + position: Vector3.zero(), + mass: 2.0, + ), + ); + dynamics.gravity.setZero(); + ball.applyImpulse(Vector3(4.0, 0.0, 0.0)); + run(dynamics, 60); + expect(ball.velocity.x, closeTo(2.0, 1e-6)); + expect(ball.position.x, closeTo(2.0, 1e-3)); + }); + + test('a crate rests on a height field where its surface is', () { + final heights = Float32List(9 * 9); + for (var row = 0; row < 9; row++) { + for (var column = 0; column < 9; column++) { + heights[row * 9 + column] = 0.1 * column; + } + } + final field = CollisionHeightfield( + columns: 9, + rows: 9, + cellSize: 1.0, + heights: heights, + ); + final world = CollisionWorld() + ..add(Collider(shape: field, position: Vector3.zero())); + final dynamics = make(world); + final box = crate(dynamics, Vector3(0.0, 3.0, 0.0), half: 0.25); + run(dynamics, 120); + final ground = field.heightAt(Vector3.zero(), 0.0, 0.0); + expect(box.position.y, closeTo(ground + 0.25, 0.15)); + expect(box.position.y, greaterThan(ground)); + }); + }); + } + + test('the mirrored scene hashes to the number the browser must match', () { + expect(dynamicsSceneHash(), dynamicsSceneExpected); + }); + + group('NativeDynamics alone', () { + late CollisionWorld world; + late NativeDynamics dynamics; + setUp(() { + world = CollisionWorld() + ..addBox(Vector3(0.0, -0.5, 0.0), Vector3(20.0, 1.0, 20.0)); + dynamics = NativeDynamics(world: world); + addTearDown(dynamics.dispose); + }); + + // The reference meets a wedge as its box, and a ball on it stays put. + test('a ball rolls down a wedge, downhill', () { + final world = CollisionWorld() + ..addBox(Vector3(0.0, -0.5, 0.0), Vector3(40.0, 1.0, 40.0)) + ..add( + Collider( + shape: CollisionWedge( + Vector3(2.0, 2.0, 2.0), + uphill: WedgeUphill.positiveX, + ), + position: Vector3(0.0, 2.0, 0.0), + ), + ); + final dynamics = NativeDynamics(world: world); + addTearDown(dynamics.dispose); + final ball = dynamics.add( + RigidBody( + world: world, + shape: CollisionSphere(0.25), + position: Vector3(1.0, 3.6, 0.0), + friction: 0.1, + ), + ); + run(dynamics, 90); + expect(ball.position.x, lessThan(0.0)); + }); + + test('a body put back by its own save() is where it was put', () { + final box = crate(dynamics, Vector3(0.0, 0.5, 0.0)); + run(dynamics, 10); + final saved = box.save(); + run(dynamics, 1); + box.collider.moveTo(Vector3(5.0, 4.0, 0.0)); + run(dynamics, 1); + expect(box.position.x, closeTo(5.0, 1e-6)); + expect(box.position.y, lessThan(4.0)); + box.restore(saved); + run(dynamics, 1); + expect(box.position.x, closeTo(0.0, 1e-6)); + expect(box.position.y, closeTo(0.5, 0.01)); + }); + + test('a lift carries the crate on it up', () { + final lift = world.add( + Collider( + shape: CollisionBox(Vector3(1.0, 0.25, 1.0)), + position: Vector3(5.0, 0.25, 0.0), + kind: ColliderKind.kinematic, + ), + ); + final box = crate(dynamics, Vector3(5.0, 1.0, 0.0)); + run(dynamics, 60); + expect(box.position.y, closeTo(1.0, 0.02)); + for (var i = 0; i < 60; i++) { + lift.moveTo(lift.position + Vector3(0.0, 1.0 / 60.0, 0.0)); + dynamics.step(1.0 / 60.0); + world.clearKinematicDeltas(); + } + expect(box.position.y, closeTo(2.0, 0.05)); + }); + + test('a wall taken out of the world is taken out of the core', () { + final wall = world.addBox(Vector3(2.0, 1.0, 0.0), Vector3(0.2, 2.0, 4.0)); + final ball = dynamics.add( + RigidBody( + world: world, + shape: CollisionSphere(0.25), + position: Vector3(0.0, 0.25, 0.0), + // Sliding, as it cannot turn: without friction, or the floor + // stops it before the wall does. + friction: 0.0, + ), + ); + ball.velocity.setValues(4.0, 0.0, 0.0); + run(dynamics, 60); + expect(ball.position.x, lessThan(2.0)); + world.remove(wall); + ball + ..wake() + ..velocity.setValues(4.0, 0.0, 0.0); + run(dynamics, 60); + expect(ball.position.x, greaterThan(2.5)); + final count = dynamics.native.bodyCount; + dynamics.remove(ball); + expect(dynamics.native.bodyCount, count - 1); + expect(world.movers, isNot(contains(ball.collider))); + }); + + test( + 'a crate that can turn tips off an edge; one that cannot, does not', + () { + final turning = crate( + dynamics, + Vector3(10.3, 0.5, 0.0), + canRotate: true, + ); + final upright = crate(dynamics, Vector3(10.3, 0.5, 5.0)); + run(dynamics, 120); + expect(turning.orientation.w.abs(), lessThan(0.99)); + expect(upright.orientation.w, 1.0); + }, + ); + + test('a restore puts right what came and went since the snapshot', () { + final wall = world.addBox(Vector3(3.0, 1.0, 0.0), Vector3(0.2, 2.0, 4.0)); + final ball = dynamics.add( + RigidBody( + world: world, + shape: CollisionSphere(0.25), + position: Vector3(0.0, 0.25, 0.0), + friction: 0.0, + ), + ); + run(dynamics, 2); + final saved = dynamics.saveState(); + expect(saved, isA()); + // Since: a crate added, the wall taken out, a door put up aside. + final late = crate(dynamics, Vector3(-3.0, 0.5, 0.0)); + world.remove(wall); + final door = world.add( + Collider( + shape: CollisionBox(Vector3(0.5, 1.0, 0.1)), + position: Vector3(0.0, 1.0, 8.0), + kind: ColliderKind.kinematic, + ), + ); + run(dynamics, 2); + dynamics.restoreState(saved); + // The core holds the floor, the ball and the late crate, and no wall. + expect(dynamics.native.bodyCount, 3); + expect(dynamics.handleOf(late), isNotNull); + ball + ..wake() + ..velocity.setValues(4.0, 0.0, 0.0); + run(dynamics, 60); + expect(ball.position.x, greaterThan(3.5)); + expect(late.position.y, closeTo(0.5, 0.02)); + // The door the snapshot never had stands again, and moves. + expect(dynamics.native.bodyCount, 4); + door.moveTo(Vector3(0.0, 1.0, 9.0)); + run(dynamics, 1); + dynamics.restoreState(null); + expect(dynamics.native.bodyCount, 4); + }); + + test('a snapshot restored steps on to the same bits', () { + final boxes = [ + for (var i = 0; i < 6; i++) + crate( + dynamics, + Vector3(i * 0.3, 1.0 + i * 1.1, 0.0), + canRotate: true, + ), + ]; + run(dynamics, 20); + final bytes = dynamics.snapshot(); + final then = [for (final b in boxes) b.save()].toString(); + run(dynamics, 40); + final first = [for (final b in boxes) b.save()].toString(); + dynamics.restore(bytes); + // The bodies are back at once, before the next step reads them. + expect([for (final b in boxes) b.save()].toString(), then); + run(dynamics, 40); + final again = [for (final b in boxes) b.save()].toString(); + expect(again, first); + }); + }); +} diff --git a/packages/flutter3d_physics_native/test/native_fluid_test.dart b/packages/flutter3d_physics_native/test/native_fluid_test.dart new file mode 100644 index 000000000..8a132853f --- /dev/null +++ b/packages/flutter3d_physics_native/test/native_fluid_test.dart @@ -0,0 +1,171 @@ +// Fluid on the CPU and on the GPU — P9, phase 10: the core's own through +// the binding, and the compute shader's against it — particle for +// particle for the first few steps, then the same water — and the read a +// frame late. The GPU half is skipped where there is no adapter, or no +// GPU library in the build. +import 'dart:math' as math; +import 'dart:typed_data'; + +import 'package:flutter3d_physics_native/flutter3d_physics_native.dart'; +import 'package:test/test.dart'; +import 'package:vector_math/vector_math.dart'; + +/// A tank a metre long, half a metre wide. +FluidSettings _tank() => FluidSettings( + tankMin: Vector3(-0.5, 0.0, -0.25), + tankMax: Vector3(0.5, 2.0, 0.25), +); + +/// A block of [n]³ particles 5 cm apart against the tank's low end. +List _dam(int n) => [ + for (var i = 0; i < n * n * n; i++) + ( + position: Vector3( + -0.475 + (i % n) * 0.05, + 0.025 + (i ~/ (n * n)) * 0.05, + -0.225 + ((i ~/ n) % n) * 0.05, + ), + velocity: Vector3.zero(), + ), +]; + +({double height, double front, double densest, double mean}) _shape( + Float32List p, +) { + var height = 0.0; + var front = -1.0; + var densest = 0.0; + var mean = 0.0; + final n = p.length ~/ 4; + for (var i = 0; i < n; i++) { + height += p[i * 4 + 1] / n; + front = math.max(front, p[i * 4]); + densest = math.max(densest, p[i * 4 + 3]); + mean += p[i * 4 + 3] / n; + } + return (height: height, front: front, densest: densest, mean: mean); +} + +void main() { + test('a dam breaks and settles through the binding', () { + final f = NativeFluid(1000, 0.05); + addTearDown(f.dispose); + expect(f.capacity, 1000); + expect(f.restDensity, closeTo(8078.2, 0.1)); + expect(f.read(), isNull); + f.add(_dam(10)); + expect(f.count, 1000); + final settings = _tank(); + for (var i = 0; i < 180; i++) { + f.step(settings, 1 / 60); + } + final frame = f.read()!; + expect(frame.step, 180); + final shape = _shape(frame.particles); + expect(shape.front, closeTo(0.475, 1e-4)); + // 0.125 m³ over half a square metre is 25 cm deep, its centre of + // mass half that, sloshing or not. + expect(shape.height, closeTo(0.125, 0.015)); + expect(shape.densest, lessThan(1.08)); + expect(f.read(), isNull); + }); + + test('bad spacing and a disposed fluid say so', () { + expect(() => NativeFluid(10, 0.0), throwsArgumentError); + final f = NativeFluid(10, 0.1)..dispose(); + expect(() => f.read(), throwsStateError); + f.dispose(); + }); + + final gpu = NativeGpu.open(); + group( + 'on the GPU', + skip: gpu == null ? 'no GPU adapter, or no GPU library' : null, + () { + tearDownAll(() => gpu?.dispose()); + + test('slides a viscous pair as the CPU does', () { + // Two particles passing each other, too few to push: viscosity + // alone, point for point every tenth step. + final pair = [ + (position: Vector3(0.0, 1.0, 0.0), velocity: Vector3(0.0, 0.0, 0.02)), + ( + position: Vector3(0.06, 1.0, 0.0), + velocity: Vector3(0.0, 0.0, -0.02), + ), + ]; + final cpu = NativeFluid(2, 0.05)..add(pair); + final onGpu = gpu!.fluid(2, 0.05)..add(pair); + addTearDown(cpu.dispose); + addTearDown(onGpu.dispose); + final settings = FluidSettings( + tankMin: Vector3(-1.0, 0.0, -1.0), + tankMax: Vector3(1.0, 2.0, 1.0), + gravity: Vector3.zero(), + viscosity: 0.5, + ); + for (var step = 1; step <= 60; step++) { + cpu.step(settings, 1 / 60); + onGpu.step(settings, 1 / 60); + if (step % 10 != 0) continue; + final x = cpu.read()!.particles; + final y = onGpu.read()!.particles; + for (var i = 0; i < x.length; i++) { + expect(y[i], closeTo(x[i], 1e-5), reason: 'step $step, float $i'); + } + } + }); + + test('breaks the dam the CPU breaks', () { + final cpu = NativeFluid(1000, 0.05); + final onGpu = gpu!.fluid(1000, 0.05); + addTearDown(cpu.dispose); + addTearDown(onGpu.dispose); + expect(onGpu.restDensity, closeTo(cpu.restDensity, 1e-3)); + cpu.add(_dam(10)); + onGpu.add(_dam(10)); + final settings = _tank(); + for (var i = 0; i < 5; i++) { + cpu.step(settings, 1 / 60); + onGpu.step(settings, 1 / 60); + } + // Slumping, before it splashes: particle for particle. + final a = cpu.read()!.particles; + final b = onGpu.read()!.particles; + for (var i = 0; i < a.length; i++) { + expect(b[i], closeTo(a[i], 1e-4), reason: 'float $i'); + } + for (var i = 0; i < 175; i++) { + cpu.step(settings, 1 / 60); + onGpu.step(settings, 1 / 60); + } + // Settled, each its own way: the same water. + final x = _shape(cpu.read()!.particles); + final y = _shape(onGpu.read()!.particles); + expect(y.front, closeTo(0.475, 1e-4)); + expect(y.height, closeTo(x.height, 0.01)); + expect(y.mean, closeTo(x.mean, 0.01)); + expect(y.densest, lessThan(1.08)); + }); + + test('reads a frame late', () { + final onGpu = gpu!.fluid(64, 0.05); + addTearDown(onGpu.dispose); + onGpu.add(_dam(4)); + expect(onGpu.read(wait: false), isNull); + final settings = _tank(); + var last = 0; + for (var i = 1; i <= 20; i++) { + onGpu.step(settings, 1 / 60); + final late = onGpu.read(wait: false); + if (late != null) { + expect(late.step, inInclusiveRange(last + 1, i)); + last = late.step; + } + } + expect(onGpu.read()!.step, 20); + expect(onGpu.read(), isNull); + }); + }, + ); +} diff --git a/packages/flutter3d_physics_native/test/native_heat_test.dart b/packages/flutter3d_physics_native/test/native_heat_test.dart new file mode 100644 index 000000000..d34db2e5a --- /dev/null +++ b/packages/flutter3d_physics_native/test/native_heat_test.dart @@ -0,0 +1,195 @@ +/// Heat, fire and wind on a native world through `dart:ffi` — P9. +/// +/// dart test test/native_heat_test.dart +/// +/// The C tests hold the arithmetic; these hold what a game reads through +/// the binding: a block of wood that catches, burns, smokes and is put out, +/// and the wind that carries and cools. +library; + +import 'dart:math' as math; +import 'dart:typed_data'; + +import 'package:flutter3d_physics_native/flutter3d_physics_native.dart'; +import 'package:test/test.dart'; +import 'package:vector_math/vector_math.dart'; + +void main() { + late NativeWorld world; + setUp(() => world = NativeWorld()); + tearDown(() => world.dispose()); + + NativeBody woodBlock() { + final b = world.addBody( + position: Vector3.zero(), + type: NativeBodyType.fixed, + mass: 0.6, + ); + world + ..setShape(b, NativeShape.box(Vector3.all(0.05))) + ..setMaterial(b, NativeMaterial.wood()); + return b; + } + + test( + 'the presets are the core\'s, and a material out of range is refused', + () { + final wood = NativeMaterial.wood(); + expect(wood.ignitionTemperature, closeTo(573.15, 1e-3)); + expect(wood.fuelFraction, closeTo(0.8, 1e-6)); + expect(NativeMaterial.steel().ignitionTemperature, 0.0); + expect(NativeMaterial.paper().ignitionTemperature, closeTo(506.15, 1e-3)); + final b = world.addBody(position: Vector3.zero(), mass: 2.0); + expect( + () => world.setMaterial( + b, + const NativeMaterial(specificHeat: 1000.0, fuelFraction: 1.0), + ), + throwsArgumentError, + ); + world.setMaterial(b, wood); + expect(world.fuelOf(b), closeTo(1.6, 1e-6)); + expect(world.temperatureOf(b), closeTo(293.15, 1e-4)); + }, + ); + + test('a block of wood catches, burns, smokes and burns out', () { + // Mutation: drop the flame's share in `f3d_step_heat` — the block cools + // from 600 K straight back below its ignition point and goes out. + final block = woodBlock(); + world.setTemperature(block, 600.0); + world.step(0.1); + expect(world.isBurning(block), isTrue); + expect(world.readEvents(), [ + (body: block, other: null, kind: NativeEventKind.ignited), + ]); + for (var i = 0; i < 600; i++) { + world.step(0.1); + } + expect(world.isBurning(block), isTrue); + // It heats itself well past its ignition point. + expect(world.temperatureOf(block), greaterThan(700.0)); + // Eleven grams a second per square metre of its 0.06 m², fifteen + // megajoules a kilogram, seven tenths of it leaving as gas. + const rate = 0.011 * 0.06; + expect(world.heatReleaseOf(block), closeTo(0.7 * rate * 1.5e7, 0.01)); + final fires = world.readFires(); + expect(fires.bodies, [block]); + expect(fires.fires[3], world.heatReleaseOf(block)); + expect(world.massOf(block), closeTo(0.6 - rate * 60.0, 1e-4)); + var steps = 0; + while (world.isBurning(block) && steps < 10000) { + world.step(0.1); + steps++; + } + expect(world.fuelOf(block), 0.0); + expect(world.massOf(block), closeTo(0.12, 1e-4)); + expect(world.readEvents().single.kind, NativeEventKind.burntOut); + expect(world.readFires().bodies, isEmpty); + }); + + test('water keeps wood from catching, and puts a fire out', () { + final wet = woodBlock(); + world.addWater(wet, 0.05); + expect(world.waterOf(wet), closeTo(0.05, 1e-7)); + var boiling = false; + while (world.waterOf(wet) > 0.0) { + world + ..addHeat(wet, 2000.0) + ..step(0.1); + expect(world.isBurning(wet), isFalse); + if (world.waterOf(wet) > 0.0) { + expect(world.temperatureOf(wet), lessThanOrEqualTo(373.15 + 1e-3)); + boiling |= world.temperatureOf(wet) > 373.14; + } + } + expect(boiling, isTrue); + while (!world.isBurning(wet)) { + world + ..addHeat(wet, 2000.0) + ..step(0.1); + } + world.readEvents(); + for (var i = 0; i < 100; i++) { + world.step(0.1); + } + world + ..addWater(wet, 0.5) + ..step(0.1); + expect(world.isBurning(wet), isFalse); + expect(world.readEvents(), [ + (body: wet, other: null, kind: NativeEventKind.extinguished), + ]); + }); + + test('a ball falls to its terminal speed in still air', () { + final ball = world.addBody(position: Vector3.zero(), mass: 0.05); + world + ..setSleep(speed: 0.0, time: 0.0) + ..setShape(ball, const NativeShape.sphere(0.1)); + for (var i = 0; i < 3000; i++) { + world.step(1.0 / 60.0); + } + final terminal = math.sqrt( + 2.0 * 0.05 * 9.81 / (1.204 * 0.47 * math.pi * 0.01), + ); + expect(-world.velocityOf(ball).y, closeTo(terminal, terminal * 1e-4)); + }); + + test('the wind carries what it blows on, and cools it', () { + world.gravity = Vector3.zero(); + world.setWindGrid( + origin: Vector3.zero(), + cell: 10.0, + nx: 2, + ny: 1, + nz: 1, + velocities: Float32List.fromList([0, 0, 0, 10, 0, 0]), + ); + expect(world.windAt(Vector3(5.0, 0.0, 0.0)).x, 5.0); + world.wind = Vector3(0.0, 0.0, 1.0); + expect(world.windAt(Vector3(10.0, 3.0, 0.0)), Vector3(10.0, 0.0, 1.0)); + final still = world.addBody( + position: Vector3.zero(), + type: NativeBodyType.fixed, + mass: 1.0, + ); + final windy = world.addBody( + position: Vector3(10.0, 0.0, 0.0), + type: NativeBodyType.fixed, + mass: 1.0, + ); + final leaf = world.addBody(position: Vector3(10.0, 0.0, 0.0), mass: 1e-3); + for (final b in [still, windy, leaf]) { + world + ..setShape(b, const NativeShape.sphere(0.05)) + ..setTemperature(b, 400.0); + } + for (var i = 0; i < 60; i++) { + world.step(1.0 / 60.0); + } + expect(world.temperatureOf(windy), lessThan(world.temperatureOf(still))); + expect(world.velocityOf(leaf).x, greaterThan(5.0)); + expect(world.velocityOf(leaf).x, lessThan(10.0)); + expect( + () => world.setWindGrid( + origin: Vector3.zero(), + cell: 1.0, + nx: 2, + ny: 1, + nz: 1, + velocities: Float32List(3), + ), + throwsArgumentError, + ); + world.clearWindGrid(); + expect(world.windAt(Vector3(10.0, 0.0, 0.0)), Vector3(0.0, 0.0, 1.0)); + expect( + () => world.setAir(temperature: -1.0, density: 1.0), + throwsArgumentError, + ); + world.setAir(temperature: 250.0, density: 1.3); + expect(world.airTemperature, 250.0); + expect(world.airDensity, closeTo(1.3, 1e-6)); + }); +} diff --git a/packages/flutter3d_physics_native/test/native_particles_test.dart b/packages/flutter3d_physics_native/test/native_particles_test.dart new file mode 100644 index 000000000..6d85740cc --- /dev/null +++ b/packages/flutter3d_physics_native/test/native_particles_test.dart @@ -0,0 +1,128 @@ +// Particles on the CPU and on the GPU — P9, phase 10: the core's own, and +// the compute shader's against them, to a GPU's rounding. The GPU half is +// skipped where there is no adapter, or no GPU library in the build. +import 'dart:typed_data'; + +import 'package:flutter3d_physics_native/flutter3d_physics_native.dart'; +import 'package:test/test.dart'; +import 'package:vector_math/vector_math.dart'; + +/// A thousand particles thrown every way from a grid above a floor. +List _spray() => [ + for (var i = 0; i < 1000; i++) + ( + position: Vector3( + (i % 10).toDouble(), + 1.0 + (i % 7) * 0.1, + (i ~/ 100).toDouble(), + ), + velocity: Vector3( + ((i * 37) % 11) - 5.0, + ((i * 13) % 9).toDouble(), + ((i * 7) % 5) - 2.0, + ), + life: 0.5 + (i % 30) * 0.1, + ), +]; + +final ParticleForces _weather = ParticleForces( + wind: Vector3(2.0, 0.0, 1.0), + drag: 0.5, + floorY: 0.0, + restitution: 0.6, + friction: 0.2, +); + +void main() { + test('a spark flies as semi-implicit Euler sums, and dies', () { + final p = NativeParticles(2); + addTearDown(p.dispose); + p.emit([ + (position: Vector3.zero(), velocity: Vector3(3.0, 4.0, 0.0), life: 0.49), + ]); + // It lives thirty steps of a sixtieth, then stays where it died. + p.step(ParticleForces(), 1 / 60, steps: 60); + final out = p.read(); + const h = 1 / 60; + expect(out[0], closeTo(3.0 * 30 * h, 1e-5)); + expect(out[1], closeTo(4.0 * 30 * h - 9.81 * h * h * 30 * 31 / 2, 1e-5)); + expect(out[3], closeTo(0.49 - 30 * h, 1e-5)); + // The second slot was never alive. + expect(out[4], 0.0); + expect(p.capacity, 2); + }); + + test('a floor turns a fall back, and the spray settles on it', () { + final p = NativeParticles(1000); + addTearDown(p.dispose); + p.emit(_spray()); + p.step(_weather, 1 / 60, steps: 120); + final out = p.read(); + for (var i = 0; i < 1000; i++) { + expect(out[i * 4 + 1], greaterThanOrEqualTo(0.0), reason: 'particle $i'); + } + }); + + test('a disposed set says so', () { + final p = NativeParticles(4)..dispose(); + expect(() => p.read(), throwsStateError); + p.dispose(); + expect(() => NativeParticles(0), throwsArgumentError); + }); + + final gpu = NativeGpu.open(); + group( + 'on the GPU', + skip: gpu == null ? 'no GPU adapter, or no GPU library' : null, + () { + tearDownAll(() => gpu?.dispose()); + + test('names its adapter', () { + expect(gpu!.adapterName, isNotEmpty); + }); + + test('steps the spray as the CPU does, to its rounding', () { + final cpu = NativeParticles(1000); + final onGpu = gpu!.particles(1000); + addTearDown(cpu.dispose); + addTearDown(onGpu.dispose); + cpu.emit(_spray()); + onGpu.emit(_spray()); + for (var s = 0; s < 4; s++) { + cpu.step(_weather, 1 / 60, steps: 30); + onGpu.step(_weather, 1 / 60, steps: 30); + } + final Float32List a = cpu.read(); + final Float32List b = onGpu.read(); + expect(b.length, a.length); + var worst = 0.0; + var moved = 0; + for (var i = 0; i < a.length; i++) { + worst = (a[i] - b[i]).abs() > worst ? (a[i] - b[i]).abs() : worst; + } + for (var i = 0; i < 1000; i++) { + if (a[i * 4 + 3] < 0.5 + (i % 30) * 0.1) moved++; + } + // Two seconds of a thousand bouncing particles: a GPU rounds its own + // way, but not by more than a tenth of a millimetre. + expect(worst, lessThan(1e-4)); + expect(moved, 1000); + }); + + test('emits round and round, as the CPU does', () { + final onGpu = gpu!.particles(3); + addTearDown(onGpu.dispose); + Particle at(double x) => ( + position: Vector3(x, 0.0, 0.0), + velocity: Vector3.zero(), + life: 1.0, + ); + onGpu.emit([at(1), at(2), at(3), at(4)]); + final out = onGpu.read(); + expect([out[0], out[4], out[8]], [4, 2, 3]); + onGpu.dispose(); + expect(() => onGpu.read(), throwsStateError); + }); + }, + ); +} diff --git a/packages/flutter3d_physics_native/test/native_solve_test.dart b/packages/flutter3d_physics_native/test/native_solve_test.dart new file mode 100644 index 000000000..05d0e58f6 --- /dev/null +++ b/packages/flutter3d_physics_native/test/native_solve_test.dart @@ -0,0 +1,349 @@ +/// The solver on a native world through `dart:ffi` — P9, phase 3. +/// +/// dart test test/native_solve_test.dart +/// +/// The C tests hold the solver against the physics: rest, stacks, bounce, +/// slopes, rolling, momentum. These hold it against `flutter3d_physics` +/// where the two can be compared — a crate dropped on a floor comes to rest +/// in the same place — and what a game sets through the binding. +library; + +import 'dart:math' as math; + +import 'package:flutter3d_physics/flutter3d_physics.dart'; +import 'package:flutter3d_physics_native/flutter3d_physics_native.dart'; +import 'package:test/test.dart'; +import 'package:vector_math/vector_math.dart'; + +void main() { + late NativeWorld world; + late NativeBody floor; + setUp(() { + world = NativeWorld()..setAir(temperature: 293.15, density: 1e-30); + floor = world.addBody( + position: Vector3(0.0, -0.5, 0.0), + type: NativeBodyType.fixed, + mass: 0.0, + ); + world.setShape(floor, NativeShape.box(Vector3(20.0, 0.5, 20.0))); + }); + tearDown(() => world.dispose()); + + test('a crate dropped on a floor rests where the reference rests it', () { + // Mutation: drop the warm start from the substep loop — the crate sinks + // five and a half centimetres under its own weight and never sleeps. + final reference = Dynamics( + world: CollisionWorld(), + gravity: Vector3(0.0, -9.81, 0.0), + ); + // The reference's floor is level geometry, a static collider. + reference.world.add( + Collider( + shape: CollisionBox(Vector3(20.0, 0.5, 20.0)), + position: Vector3(0.0, -0.5, 0.0), + kind: ColliderKind.static, + ), + ); + final dart = reference.add( + RigidBody( + world: reference.world, + shape: CollisionBox(Vector3.all(0.5)), + position: Vector3(0.0, 1.5, 0.0), + ), + ); + final crate = world.addBody(position: Vector3(0.0, 1.5, 0.0)); + world.setShape(crate, NativeShape.box(Vector3.all(0.5))); + for (var i = 0; i < 180; i++) { + reference.step(1.0 / 60.0); + world.step(1.0 / 60.0); + } + expect(world.positionOf(crate).y, closeTo(dart.position.y, 0.01)); + expect(world.positionOf(crate).y, closeTo(0.5, 0.006)); + expect(world.velocityOf(crate).length, lessThan(1e-3)); + expect(world.isAsleep(crate), isTrue); + }); + + test('a stack stands and sleeps, and a knock wakes all of it', () { + final crates = [ + for (var i = 0; i < 5; i++) + world.addBody(position: Vector3(0.0, 0.5 + i, 0.0)), + ]; + for (final c in crates) { + world.setShape(c, NativeShape.box(Vector3.all(0.5))); + } + for (var i = 0; i < 180; i++) { + world.step(1.0 / 60.0); + } + expect(crates.every(world.isAsleep), isTrue); + expect(world.positionOf(crates.last).y, closeTo(4.5, 0.05)); + world.readEvents(); + world + ..applyImpulse(crates.last, Vector3(0.5, 0.0, 0.0)) + ..step(1.0 / 60.0); + expect(crates.any(world.isAsleep), isFalse); + }); + + test('friction holds on a slope and restitution bounces, as set', () { + final ball = world.addBody(position: Vector3(0.0, 2.25, 0.0)); + world + ..setShape(ball, const NativeShape.sphere(0.25)) + ..setRestitution(ball, 0.8); + var up = 0.0; + for (var i = 0; i < 90; i++) { + world.step(1.0 / 60.0); + if (world.velocityOf(ball).y > up) up = world.velocityOf(ball).y; + } + expect(up, closeTo(0.8 * 6.26, 0.2)); + expect(() => world.setFriction(ball, -1.0), throwsArgumentError); + expect(() => world.setRestitution(ball, 2.0), throwsArgumentError); + expect(() => world.substeps = 0, throwsArgumentError); + world.substeps = 8; + }); + + test( + 'a pendulum on a hinge swings at 2π √(L / g), and a rod holds a weight', + () { + // The C tests hold every joint against its physics; this holds the + // binding: a joint made, read and taken away. + world + ..setSleep(speed: 0.0, time: 0.0) + ..substeps = 4; + final pivot = world.addBody( + position: Vector3(0.0, 3.0, 0.0), + type: NativeBodyType.fixed, + mass: 0.0, + ); + const start = 0.05; + final bob = world.addBody( + position: Vector3(math.sin(start), 3.0 - math.cos(start), 0.0), + ); + world.setShape(bob, const NativeShape.sphere(0.01)); + final hinge = world.createJoint( + NativeJointType.revolute, + pivot, + bob, + anchor: Vector3(0.0, 3.0, 0.0), + axis: Vector3(0.0, 0.0, 1.0), + ); + // A quarter period later it is at the bottom: the angle back to nought. + final quarter = 0.5 * math.pi * math.sqrt(1.0 / 9.81); + var t = 0.0; + while (t < quarter - 1e-9) { + world.step(1.0 / 240.0); + t += 1.0 / 240.0; + } + expect(world.jointValue(hinge), closeTo(-start, 2e-3)); + world.setJointLimits(hinge, (lower: -0.1, upper: 0.1)); + world.setJointMotor(hinge, (speed: 1.0, maxForce: 100.0)); + expect( + () => world.setJointLength(hinge, length: 1, least: 0, most: 1), + throwsArgumentError, + ); + expect(world.jointCount, 1); + expect(world.removeJoint(hinge), isTrue); + expect(world.containsJoint(hinge), isFalse); + // A rod holding a kilogram still: the force on it is its weight, up. + final weight = world.addBody(position: Vector3(5.0, 1.0, 0.0)); + world.setShape(weight, const NativeShape.sphere(0.1)); + final hook = world.addBody( + position: Vector3(5.0, 3.0, 0.0), + type: NativeBodyType.fixed, + mass: 0.0, + ); + final rod = world.createDistanceJoint( + hook, + weight, + anchorA: Vector3(5.0, 3.0, 0.0), + anchorB: Vector3(5.0, 1.0, 0.0), + ); + for (var i = 0; i < 120; i++) { + world.step(1.0 / 60.0); + } + expect(world.jointForce(rod).y, closeTo(9.81, 0.02)); + expect(world.jointValue(rod), closeTo(2.0, 1e-3)); + // Made a rope two and a half metres long: slack, the weight falls half + // a metre and hangs there, the rope taut, clear of the floor. + world + ..setJointLength(rod, length: 2.5, least: 0.0, most: 2.5) + ..setJointSpring(rod, (hertz: 0.0, damping: 0.0)); + for (var i = 0; i < 240; i++) { + world.step(1.0 / 60.0); + } + expect(world.jointValue(rod), closeTo(2.5, 2e-3)); + expect(world.positionOf(weight).y, closeTo(0.5, 2e-3)); + // Joined bodies pass through each other until told to collide. + world.setShape(hook, const NativeShape.sphere(0.5)); + world.setPosition(weight, Vector3(5.0, 3.2, 0.0)); + world.setVelocity(weight, Vector3.zero()); + bool touching() => world.readContacts().any( + (c) => {c.a, c.b}.containsAll([hook, weight]), + ); + world.step(1.0 / 60.0); + expect(touching(), isFalse); + world + ..setJointCollide(rod, collide: true) + ..step(1.0 / 60.0); + expect(touching(), isTrue); + }, + ); + + test('a ball joint holds a limb in its cone, its twist in limits', () { + // The binding of what a ragdoll's shoulder is made of: a limb a metre + // long hung by its end and knocked past level, a spin about it. + world.setSleep(speed: 0.0, time: 0.0); + final pivot = world.addBody( + position: Vector3(0.0, 3.0, 0.0), + type: NativeBodyType.fixed, + mass: 0.0, + ); + final limb = world.addBody(position: Vector3(0.0, 2.5, 0.0)); + world.setShape(limb, NativeShape.box(Vector3(0.05, 0.5, 0.05))); + final shoulder = world.createJoint( + NativeJointType.spherical, + pivot, + limb, + anchor: Vector3(0.0, 3.0, 0.0), + axis: Vector3(0.0, -1.0, 0.0), + ); + world + ..setJointCone(shoulder, 0.5) + ..setJointLimits(shoulder, (lower: -0.3, upper: 0.3)) + ..setVelocity(limb, Vector3(6.0, 0.0, 0.0)) + ..setAngularVelocity(limb, Vector3(0.0, 6.0, 12.0)); + var swing = 0.0; + var twist = 0.0; + for (var i = 0; i < 120; i++) { + world.step(1.0 / 60.0); + swing = math.max(swing, world.jointSwing(shoulder)); + twist = math.max(twist, world.jointValue(shoulder).abs()); + } + expect(swing, inInclusiveRange(0.45, 0.55)); + expect(twist, inInclusiveRange(0.25, 0.35)); + // Friction stops it swinging inside the cone. + world.setJointFriction(shoulder, 5.0); + for (var i = 0; i < 120; i++) { + world.step(1.0 / 60.0); + } + expect(world.angularVelocityOf(limb).length, lessThan(1e-3)); + // A cone, or friction, is a ball joint's alone. + final door = world.addBody(position: Vector3(3.0, 2.5, 0.0)); + final hinge = world.createJoint( + NativeJointType.revolute, + pivot, + door, + anchor: Vector3(0.0, 3.0, 0.0), + axis: Vector3(0.0, 0.0, 1.0), + ); + expect(() => world.setJointCone(hinge, 0.5), throwsArgumentError); + expect(() => world.setJointFriction(hinge, 1.0), throwsArgumentError); + expect(() => world.setJointCone(shoulder, 4.0), throwsArgumentError); + expect(world.jointSwing(hinge), 0.0); + }); + + test('a fast ball stops at a thin wall, softly or as a bullet', () { + // Mutation: drop the speculative reach from the pair's margin in + // `f3d_step_collide` — the soft ball goes through. + world.gravity = Vector3.zero(); + final wall = world.addBody( + position: Vector3(0.0, 5.0, 0.0), + type: NativeBodyType.fixed, + mass: 0.0, + ); + world.setShape(wall, NativeShape.box(Vector3(0.005, 2.0, 2.0))); + final soft = world.addBody(position: Vector3(-2.0, 5.0, 0.0)); + world + ..setShape(soft, const NativeShape.sphere(0.05)) + ..setVelocity(soft, Vector3(300.0, 0.0, 0.0)); + for (var i = 0; i < 10; i++) { + world.step(1.0 / 60.0); + } + expect(world.positionOf(soft).x, lessThan(0.0)); + world.speculative = false; + final bullet = world.addBody(position: Vector3(-2.0, 6.0, 0.0)); + world + ..setShape(bullet, const NativeShape.sphere(0.05)) + ..setBullet(bullet) + ..setVelocity(bullet, Vector3(500.0, 0.0, 0.0)); + for (var i = 0; i < 10; i++) { + world.step(1.0 / 60.0); + } + expect(world.positionOf(bullet).x, lessThan(0.0)); + }); + + test( + 'a heap steps to the same bits on four threads as on one, fast or not', + () { + // The C tests hold every shape and every thread count to the byte; this + // holds the binding. + final other = NativeWorld()..setAir(temperature: 293.15, density: 1e-30); + addTearDown(other.dispose); + expect(other.threads, 1); + other.threads = 4; + expect(other.threads, 4); + final heaps = <(NativeWorld, List)>[]; + for (final w in [world, other]) { + if (w != world) { + final ground = w.addBody( + position: Vector3(0.0, -0.5, 0.0), + type: NativeBodyType.fixed, + mass: 0.0, + ); + w.setShape(ground, NativeShape.box(Vector3(20.0, 0.5, 20.0))); + } + final bodies = [ + for (var i = 0; i < 60; i++) + w.addBody( + position: Vector3( + (i % 5) * 0.6 + (i % 3) * 0.02, + 0.5 + (i ~/ 25) * 0.6, + ((i ~/ 5) % 5) * 0.6, + ), + ), + ]; + for (var i = 0; i < bodies.length; i++) { + w.setShape( + bodies[i], + i.isEven + ? NativeShape.box(Vector3(0.25, 0.25, 0.25)) + : const NativeShape.sphere(0.27), + ); + } + heaps.add((w, bodies)); + } + for (var step = 0; step < 120; step++) { + world.step(1.0 / 60.0); + other.step(1.0 / 60.0); + } + for (var i = 0; i < heaps[0].$2.length; i++) { + expect( + other.positionOf(heaps[1].$2[i]), + world.positionOf(heaps[0].$2[i]), + ); + expect( + other.orientationOf(heaps[1].$2[i]), + world.orientationOf(heaps[0].$2[i]), + ); + } + // The fast mode: other bits, the same on four threads as on one. + for (final w in [world, other]) { + expect(w.fast, isFalse); + w.fast = true; + expect(w.fast, isTrue); + } + for (var step = 0; step < 60; step++) { + world.step(1.0 / 60.0); + other.step(1.0 / 60.0); + } + for (var i = 0; i < heaps[0].$2.length; i++) { + expect( + other.positionOf(heaps[1].$2[i]), + world.positionOf(heaps[0].$2[i]), + ); + } + expect(() => other.threads = 0, throwsArgumentError); + expect(() => other.threads = 65, throwsArgumentError); + other.threads = 1; + expect(other.threads, 1); + }, + ); +} diff --git a/packages/flutter3d_physics_native/test/native_world_test.dart b/packages/flutter3d_physics_native/test/native_world_test.dart new file mode 100644 index 000000000..53aaf2262 --- /dev/null +++ b/packages/flutter3d_physics_native/test/native_world_test.dart @@ -0,0 +1,366 @@ +/// A native world through `dart:ffi` — P9: bodies, their motion, sleep, +/// the origin and snapshots. +/// +/// dart test test/native_world_test.dart +library; + +import 'dart:math' as math; +import 'dart:typed_data'; + +import 'package:flutter3d_physics/flutter3d_physics.dart'; +import 'package:flutter3d_physics_native/flutter3d_physics_native.dart'; +import 'package:test/test.dart'; +import 'package:vector_math/vector_math.dart'; + +/// [value] rounded to the nearest float, as the core holds it. +double f32(double value) => (Float32List(1)..[0] = value)[0]; + +void main() { + late NativeWorld world; + setUp(() => world = NativeWorld()); + tearDown(() => world.dispose()); + + test('a new world is empty under standard gravity', () { + expect(world.bodyCount, 0); + expect(world.gravity.y, f32(-9.81)); + world.gravity = Vector3(1.0, 2.0, 3.0); + expect(world.gravity, Vector3(1.0, 2.0, 3.0)); + }); + + test('bodies come and go by handle, and a stale handle names nothing', () { + final a = world.addBody(position: Vector3(1.0, 2.0, 3.0)); + final b = world.addBody(position: Vector3.zero(), mass: 2.0); + expect(world.bodyCount, 2); + expect(world.contains(a) && world.contains(b), isTrue); + expect(world.positionOf(a), Vector3(1.0, 2.0, 3.0)); + + expect(world.removeBody(a), isTrue); + expect(world.removeBody(a), isFalse); + expect(world.contains(a), isFalse); + expect(() => world.positionOf(a), throwsArgumentError); + + // The freed slot is reused under a new generation. + final c = world.addBody(position: Vector3.zero()); + expect(c.slot, a.slot); + expect(c.generation, a.generation + 1); + expect(world.contains(a), isFalse); + expect(world.contains(c), isTrue); + }); + + test('a body the core refuses is refused with the reason', () { + expect( + () => world.addBody(position: Vector3.zero(), mass: 0.0), + throwsA(isA().having((e) => e.name, 'name', 'mass')), + ); + expect( + () => world.addBody(position: Vector3(double.nan, 0.0, 0.0)), + throwsA(isA().having((e) => e.name, 'name', 'position')), + ); + // A fixed body's mass is not read. + world.addBody( + position: Vector3.zero(), + type: NativeBodyType.fixed, + mass: 0.0, + ); + expect(world.bodyCount, 1); + }); + + test('a step is semi-implicit Euler: velocity, then position with it', () { + // One substep: the step's own algebra. + world.substeps = 1; + // Mutation: integrate the position before the velocity in + // `f3d_world_step` — y would stay at 10 after the first step. + world.gravity = Vector3(0.0, -10.0, 0.0); + final falling = world.addBody(position: Vector3(0.0, 10.0, 0.0)); + final pinned = world.addBody( + position: Vector3(0.0, 10.0, 0.0), + type: NativeBodyType.fixed, + ); + world.step(0.5); + expect(world.velocityOf(falling).y, -5.0); + expect(world.positionOf(falling).y, 7.5); + expect(world.positionOf(pinned).y, 10.0); + world + ..step(0.0) + ..step(-1.0) + ..step(double.nan); + expect(world.positionOf(falling).y, 7.5); + }); + + test('transforms come back in slot order with their handles', () { + final a = world.addBody(position: Vector3(1.0, 0.0, 0.0)); + final b = world.addBody(position: Vector3(2.0, 0.0, 0.0)); + final c = world.addBody(position: Vector3(3.0, 0.0, 0.0)); + world.removeBody(b); + final read = world.readTransforms(); + expect(read.bodies, [a, c]); + expect(read.transforms, hasLength(14)); + expect(read.transforms.sublist(0, 7), [1, 0, 0, 0, 0, 0, 1]); + expect(read.transforms[7], 3.0); + }); + + test('a disposed world refuses every call', () { + final other = NativeWorld()..dispose(); + expect(other.isDisposed, isTrue); + expect(() => other.bodyCount, throwsStateError); + expect(() => other.step(0.1), throwsStateError); + other.dispose(); // Twice is fine. + }); + + test('a free fall agrees with flutter3d_physics, the reference', () { + // Two seconds at sixty steps a second, from rest and thrown. The core is + // in floats and the reference in doubles, so this holds the behaviour to + // a tolerance rather than to the bit: the same integrator, the same + // order, the same answer to within float rounding. + // + // Mutation: integrate with the old velocity (explicit Euler) in the core + // — two seconds of it is 0.33 m off. + const dt = 1.0 / 60.0; + // The reference steps once a step; so does the core, given one substep. + world.substeps = 1; + final reference = Dynamics( + world: CollisionWorld(), + gravity: Vector3(0.0, -9.81, 0.0), + ); + final starts = <(Vector3, Vector3)>[ + (Vector3(0.0, 20.0, 0.0), Vector3.zero()), + (Vector3(-3.0, 5.0, 2.0), Vector3(4.0, 6.0, -1.5)), + ]; + final bodies = <(RigidBody, NativeBody)>[]; + for (final (position, velocity) in starts) { + final dart = reference.add( + RigidBody( + world: reference.world, + shape: CollisionBox(Vector3.all(0.1)), + position: position.clone(), + )..velocity.setFrom(velocity), + ); + final native = world.addBody(position: position); + world.setVelocity(native, velocity); + bodies.add((dart, native)); + } + for (var i = 0; i < 120; i++) { + reference.step(dt); + world.step(dt); + } + for (final (dart, native) in bodies) { + final a = dart.position; + final b = world.positionOf(native); + final scale = math.max(1.0, a.length); + expect((a - b).length / scale, lessThan(1e-4), reason: '$a vs $b'); + } + }); + + test('a box spun off its axes turns as flutter3d_physics turns it', () { + // The same momentum-keeping turn as `RigidBody.integrateOrientation`: + // a box spun about a tilted axis wobbles, and after four seconds of it + // the two still agree on where it faces and how it spins. + // + // Mutation: in `turn`, keep ω instead of reading it back out of L — + // the box stops wobbling and the spins part by a radian a second. + const dt = 1.0 / 120.0; + final reference = Dynamics( + world: CollisionWorld(), + gravity: Vector3.zero(), + ); + final half = Vector3(0.1, 0.4, 0.8); + final dart = reference.add( + RigidBody( + world: reference.world, + shape: CollisionBox(half), + position: Vector3.zero(), + mass: 2.0, + canRotate: true, + ), + ); + dart.angularVelocity.setValues(2.0, 0.5, 0.25); + world + ..gravity = Vector3.zero() + ..substeps = 1 + ..setSleep(speed: 0.0, time: 0.0); + final native = world.addBody(position: Vector3.zero(), mass: 2.0); + world + ..setShape(native, NativeShape.box(half)) + ..setDrag(native, 1e-30) + ..setAngularVelocity(native, Vector3(2.0, 0.5, 0.25)); + expect(world.inertiaOf(native).x, closeTo(dart.inertiaLocal.x, 1e-6)); + for (var i = 0; i < 480; i++) { + reference.step(dt); + world.step(dt); + } + final q = world.orientationOf(native); + final r = dart.orientation; + // q and −q are the same turn. + final dot = (q.x * r.x + q.y * r.y + q.z * r.z + q.w * r.w).abs(); + expect(dot, closeTo(1.0, 1e-4)); + expect( + (world.angularVelocityOf(native) - dart.angularVelocity).length, + lessThan(1e-3), + ); + expect( + (dart.angularVelocity - Vector3(2.0, 0.5, 0.25)).length, + greaterThan(0.1), + ); + }); + + test('an impulse off the centre spins the body as the reference does', () { + final reference = Dynamics( + world: CollisionWorld(), + gravity: Vector3.zero(), + ); + final dart = reference.add( + RigidBody( + world: reference.world, + shape: CollisionSphere(0.5), + position: Vector3.zero(), + mass: 3.0, + canRotate: true, + ), + )..applyImpulseAt(Vector3(0.0, 2.0, 1.0), Vector3(0.5, 0.0, 0.1)); + final native = world.addBody(position: Vector3.zero(), mass: 3.0); + world + ..setShape(native, const NativeShape.sphere(0.5)) + ..applyImpulse( + native, + Vector3(0.0, 2.0, 1.0), + at: Vector3(0.5, 0.0, 0.1), + ); + expect((world.velocityOf(native) - dart.velocity).length, lessThan(1e-6)); + expect( + (world.angularVelocityOf(native) - dart.angularVelocity).length, + lessThan(1e-5), + ); + }); + + test('forces, torques and damping, and what cannot turn does not', () { + world + ..gravity = Vector3.zero() + ..substeps = 1 + ..setAir(temperature: 293.15, density: 1e-30); + final b = world.addBody(position: Vector3.zero(), mass: 2.0); + world + ..addForce(b, Vector3(4.0, 0.0, 0.0)) + ..step(0.5); + expect(world.velocityOf(b).x, 1.0); + // A point has no inertia and does not turn. + world.setAngularVelocity(b, Vector3(1.0, 0.0, 0.0)); + expect(world.angularVelocityOf(b), Vector3.zero()); + world.setShape(b, const NativeShape.capsule(0.1, 0.5)); + expect(world.inertiaOf(b).y, lessThan(world.inertiaOf(b).x)); + world + ..setDamping(b, linear: 1.0) + ..step(1.0); + expect(world.velocityOf(b).x, 0.5); + world.lockRotation(b); + world + ..addTorque(b, Vector3(0.0, 1.0, 0.0)) + ..step(0.5); + expect(world.angularVelocityOf(b), Vector3.zero()); + expect( + () => world.setShape(b, const NativeShape.sphere(-1.0)), + throwsA(isA().having((e) => e.name, 'name', 'value')), + ); + expect(() => world.setDamping(b, linear: -1.0), throwsArgumentError); + world.setOrientation(b, Quaternion(0.0, 0.0, 0.0, 0.0)); + expect(world.orientationOf(b).w, 1.0); + world.setOrientation(b, Quaternion(0.0, 2.0, 0.0, 0.0)); + expect(world.orientationOf(b).y, 1.0); + }); + + test('a body at rest falls asleep and says so, and a push wakes it', () { + world.gravity = Vector3.zero(); + final b = world.addBody(position: Vector3.zero()); + world.setVelocity(b, Vector3(0.01, 0.0, 0.0)); + // Asleep at the start of the step after the half second is up. + for (var i = 0; i < 32; i++) { + world.step(1.0 / 60.0); + } + expect(world.isAsleep(b), isTrue); + expect(world.velocityOf(b), Vector3.zero()); + world.applyImpulse(b, Vector3(1.0, 0.0, 0.0)); + expect(world.isAsleep(b), isFalse); + expect(world.readEvents(), [ + (body: b, other: null, kind: NativeEventKind.slept), + (body: b, other: null, kind: NativeEventKind.woke), + ]); + expect(world.readEvents(), isEmpty); + expect(world.eventsDropped, 0); + }); + + test('an origin moved to a body far out gives it back its precision', () { + // Ten kilometres out a float is a millimetre wide. Moved to an origin + // beside it, a micrometre step is a step it can take, and nothing in + // the world moved. + world.gravity = Vector3.zero(); + final far = world.addBody(position: Vector3(10000.0, 0.0, 0.0)); + world.shiftOrigin(10000.0, 0.0, 0.0); + expect(world.origin, (x: 10000.0, y: 0.0, z: 0.0)); + expect(world.positionOf(far), Vector3.zero()); + expect(world.worldPositionOf(far).x, 10000.0); + world + ..setVelocity(far, Vector3(1e-6, 0.0, 0.0)) + ..step(1.0); + expect(world.worldPositionOf(far).x - 10000.0, closeTo(1e-6, 1e-12)); + }); + + test('a world restored from a snapshot steps to the same bytes', () { + // Mutation: leave the wind grid out of `f3d_world_snapshot_write` — + // the restored world reads no wind and the bodies part. + world.wind = Vector3(1.0, 0.0, 0.5); + world.setWindGrid( + origin: Vector3(-5.0, 0.0, -5.0), + cell: 5.0, + nx: 2, + ny: 1, + nz: 2, + velocities: Float32List.fromList([ + 1, + 0, + 0, + 0, + 0, + 2, + -1, + 0, + 0, + 0, + 3, + 0, + ]), + ); + for (var i = 0; i < 5; i++) { + final b = world.addBody( + position: Vector3(i * 1.0, i * 2.0, 0.0), + mass: 0.5 + i, + ); + world + ..setShape(b, NativeShape.box(Vector3(0.2, 0.3, 0.1))) + ..setAngularVelocity(b, Vector3(0.3 * i, 1.0, -0.7)) + ..setMaterial(b, NativeMaterial.wood()); + if (i == 1) world.setTemperature(b, 650.0); + } + for (var i = 0; i < 30; i++) { + world.step(1.0 / 60.0); + } + final saved = world.snapshot(); + final other = NativeWorld(); + addTearDown(other.dispose); + other + ..addBody(position: Vector3.zero()) + ..restore(saved); + expect(other.bodyCount, world.bodyCount); + for (var i = 0; i < 300; i++) { + world.step(1.0 / 60.0); + other.step(1.0 / 60.0); + } + expect(other.snapshot(), world.snapshot()); + expect(other.readEvents(), world.readEvents()); + // Not a snapshot: refused, and the world kept. + final before = other.snapshot(); + expect( + () => other.restore(Uint8List.fromList([1, 2, 3])), + throwsArgumentError, + ); + expect(other.snapshot(), before); + }); +} diff --git a/packages/flutter3d_physics_native/test/scenes/dynamics_scene.dart b/packages/flutter3d_physics_native/test/scenes/dynamics_scene.dart new file mode 100644 index 000000000..992bb5169 --- /dev/null +++ b/packages/flutter3d_physics_native/test/scenes/dynamics_scene.dart @@ -0,0 +1,108 @@ +// A scene of flutter3d_physics' bodies on NativeDynamics, for the tests +// that hold the browser to the native library through the mirror: a floor, +// a wedge, a lift, crates that can turn and crates that cannot, a ball +// down the wedge and a push — and every body's state afterwards, hashed. +import 'dart:typed_data'; + +import 'package:flutter3d_physics/flutter3d_physics.dart'; +import 'package:flutter3d_physics_native/flutter3d_physics_native.dart'; +import 'package:vector_math/vector_math.dart'; + +/// [h]'s low byte: h · 2²⁴ modulo 2³² is it times 2²⁴. +int low2(int h) => h % 256; + +/// What [dynamicsSceneHash] comes to: written natively, matched in Chrome. +const String dynamicsSceneExpected = '9e63068a'; + +/// Steps the scene and hashes every body's position, velocity, spin and +/// orientation by their float64 bytes, so that the browser's numbers and the +/// VM's — which print differently — are compared as what they are. +String dynamicsSceneHash() { + final world = CollisionWorld() + ..addBox(Vector3(0.0, -0.5, 0.0), Vector3(30.0, 1.0, 30.0)) + ..add( + Collider( + shape: CollisionWedge(Vector3(2.0, 2.0, 2.0)), + position: Vector3(6.0, 2.0, 0.0), + ), + ); + final lift = world.add( + Collider( + shape: CollisionBox(Vector3(1.0, 0.25, 1.0)), + position: Vector3(-6.0, 0.25, 0.0), + kind: ColliderKind.kinematic, + ), + ); + final dynamics = NativeDynamics(world: world); + final bodies = [ + for (var i = 0; i < 6; i++) + dynamics.add( + RigidBody( + world: world, + shape: CollisionBox(Vector3.all(0.4)), + position: Vector3(i * 0.35 - 1.0, 0.6 + i * 0.9, 0.1 * i), + canRotate: i.isEven, + ), + ), + dynamics.add( + RigidBody( + world: world, + shape: CollisionSphere(0.25), + position: Vector3(7.0, 3.6, 0.0), + friction: 0.1, + canRotate: true, + ), + ), + dynamics.add( + RigidBody( + world: world, + shape: CollisionBox(Vector3.all(0.4)), + position: Vector3(-6.0, 1.0, 0.0), + ), + ), + ]; + final walker = world.add( + Collider( + shape: CollisionCapsule(radius: 0.3, halfHeight: 0.3), + position: Vector3(-1.0, 0.6, -0.4 - 0.3 - 0.005), + kind: ColliderKind.kinematic, + ), + ); + for (var step = 0; step < 240; step++) { + if (step == 60) dynamics.push(walker, Vector3(0.0, 0.0, 2.0)); + if (step >= 90 && step < 150) { + lift.moveTo(lift.position + Vector3(0.0, 1.0 / 60.0, 0.0)); + } + dynamics.step(1.0 / 60.0); + world.clearKinematicDeltas(); + } + final numbers = Float64List(bodies.length * 13); + var at = 0; + for (final body in bodies) { + for (final v in [ + body.position, + body.velocity, + body.angularVelocity, + ]) { + numbers[at++] = v.x; + numbers[at++] = v.y; + numbers[at++] = v.z; + } + final q = body.orientation; + numbers[at++] = q.x; + numbers[at++] = q.y; + numbers[at++] = q.z; + numbers[at++] = q.w; + } + dynamics.dispose(); + // FNV-1a, 32 bits, in arithmetic exact in JavaScript's numbers: the xor + // on the low byte only, the product split as h·0x193 + h·2²⁴, each part + // under 2⁵³ where the whole is not. + var hash = 0x811c9dc5; + for (final byte in numbers.buffer.asUint8List()) { + final low = hash % 256; + hash = hash - low + (low ^ byte); + hash = (hash * 0x193 + low2(hash) * 0x1000000) % 0x100000000; + } + return hash.toRadixString(16).padLeft(8, '0'); +} diff --git a/packages/flutter3d_physics_native/test/scenes/shared_scene.dart b/packages/flutter3d_physics_native/test/scenes/shared_scene.dart new file mode 100644 index 000000000..b4eea6132 --- /dev/null +++ b/packages/flutter3d_physics_native/test/scenes/shared_scene.dart @@ -0,0 +1,92 @@ +/// A scene stepped natively and in the browser, and what its snapshot must +/// hash to on both — P9, phase 12. +/// +/// The hash is the same number from the native library through `dart:ffi` +/// and from the WebAssembly module through `dart:js_interop`: the core +/// steps to the same bits both ways, and the two Dart backends carry them +/// across unchanged. When the physics changes on purpose, the native test +/// says what the new number is. +library; + +import 'dart:typed_data'; + +import 'package:flutter3d_physics_native/flutter3d_physics_native.dart'; +import 'package:vector_math/vector_math.dart'; + +/// What [sharedSceneSnapshot]'s bytes hash to. +const String sharedSceneHash = 'd09df4aa'; + +/// A floor, a heap of boxes, balls, capsules and a hull, a hinged pair, +/// materials and wind, stepped two seconds on [threads]; its snapshot — +/// the same on any number. +Uint8List sharedSceneSnapshot({int threads = 1}) { + final world = NativeWorld()..wind = Vector3(1.0, 0.0, 0.5); + if (threads > 1) world.threads = threads; + try { + final floor = world.addBody( + position: Vector3(0.0, -0.5, 0.0), + type: NativeBodyType.fixed, + mass: 0.0, + ); + world.setShape(floor, NativeShape.box(Vector3(10.0, 0.5, 10.0))); + final hull = world.createHull([ + Vector3(0.3, 0.0, 0.0), + Vector3(-0.3, 0.0, 0.0), + Vector3(0.0, 0.4, 0.0), + Vector3(0.0, -0.2, 0.0), + Vector3(0.0, 0.0, 0.3), + Vector3(0.0, 0.0, -0.3), + ]); + for (var i = 0; i < 40; i++) { + final b = world.addBody( + position: Vector3( + (i % 5) * 0.7 - 1.4 + (i % 3) * 0.03, + 0.6 + (i ~/ 10) * 0.8, + ((i ~/ 5) % 2) * 0.7, + ), + mass: 1.0 + (i % 4) * 0.5, + ); + switch (i % 4) { + case 0: + world.setShape(b, NativeShape.box(Vector3(0.25, 0.2, 0.3))); + case 1: + world.setShape(b, const NativeShape.sphere(0.25)); + case 2: + world.setShape(b, const NativeShape.capsule(0.15, 0.2)); + default: + world.setHull(b, hull); + } + world.setAngularVelocity(b, Vector3((i % 5) * 0.4, 0.0, (i % 3) * 0.3)); + if (i % 7 == 0) world.setMaterial(b, NativeMaterial.wood()); + } + final a = world.addBody(position: Vector3(3.0, 2.0, 0.0)); + final b = world.addBody(position: Vector3(3.5, 2.0, 0.0)); + world + ..setShape(a, NativeShape.box(Vector3(0.2, 0.1, 0.1))) + ..setShape(b, NativeShape.box(Vector3(0.2, 0.1, 0.1))) + ..createJoint( + NativeJointType.revolute, + a, + b, + anchor: Vector3(3.25, 2.0, 0.0), + axis: Vector3(0.0, 0.0, 1.0), + ); + for (var i = 0; i < 120; i++) { + world.step(1 / 60); + } + return world.snapshot(); + } finally { + world.dispose(); + } +} + +/// FNV-1a over [bytes] in 32 bits, as hex: written with nothing past 2⁵³, +/// so dart2js works it out as the VM does. +String hashOf(Uint8List bytes) { + var h = 0x811c9dc5; + for (final byte in bytes) { + h = (h ^ byte) & 0xffffffff; + h = (((h << 24) & 0xffffffff) + h * 403) & 0xffffffff; + } + return h.toRadixString(16).padLeft(8, '0'); +} diff --git a/packages/flutter3d_physics_native/test/shared_scene_test.dart b/packages/flutter3d_physics_native/test/shared_scene_test.dart new file mode 100644 index 000000000..f56dce8a8 --- /dev/null +++ b/packages/flutter3d_physics_native/test/shared_scene_test.dart @@ -0,0 +1,18 @@ +// The shared scene natively — P9, phase 12: its snapshot hashes to the +// number `test/web_core_test.dart` holds the browser to. +@TestOn('vm') +library; + +import 'package:test/test.dart'; + +import 'scenes/shared_scene.dart'; + +void main() { + test('the shared scene hashes to the number the browser must match', () { + expect( + hashOf(sharedSceneSnapshot()), + sharedSceneHash, + reason: 'changed on purpose? set sharedSceneHash in test/scenes', + ); + }); +} diff --git a/packages/flutter3d_physics_native/test/wasm_test.dart b/packages/flutter3d_physics_native/test/wasm_test.dart new file mode 100644 index 000000000..ecda19b55 --- /dev/null +++ b/packages/flutter3d_physics_native/test/wasm_test.dart @@ -0,0 +1,391 @@ +/// The WebAssembly module steps to the bits the native library steps to — +/// P9. +/// +/// dart test test/wasm_test.dart +/// +/// The deterministic mode's promise, held across the two builds a game +/// ships: the module `tool/build_wasm.dart` makes, run in node, and the +/// library the hook makes, run through `dart:ffi`, given the same bodies and +/// the same steps, must agree to the last bit of every float — not to a +/// tolerance. Skipped where there is no node, no clang with the wasm32 +/// target, or no wasm linker. +@TestOn('mac-os || linux') +library; + +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter3d_physics_native/flutter3d_physics_native.dart'; +import 'package:test/test.dart'; +import 'package:vector_math/vector_math.dart'; + +import '../tool/build_wasm.dart'; + +/// The scenario both builds run: five bodies, thrown at awkward speeds +/// under tilted gravity, six hundred steps of a sixtieth of a second. Each +/// gets a shape, a spin and wood to be made of; the second is lit and the +/// fourth wet, and a wind grid blows through them all, so the comparison +/// reaches the turn, the drag, the heat and the fire. +const List> _bodies = >[ + // position xyz, velocity xyz, shape kind, size a b c, spin xyz, kelvin, + // water + [0.0, 20.0, 0.0, 0.0, 0.0, 0.0, 1, 0.2, 0, 0, 0, 0, 0, 293.15, 0], + [ + -3.0, + 5.0, + 2.0, + 4.1, + 6.3, + -1.7, + 2, + 0.1, + 0.3, + 0.5, + 2, + 0.5, + 0.25, + 650, + 0, + ], + [ + 1.25, + 0.5, + -7.5, + -0.3, + 11.9, + 0.01, + 3, + 0.1, + 0.4, + 0, + -1, + 3, + 0.1, + 293.15, + 0, + ], + [ + 100.0, + -2.0, + 3.3, + 0.7, + 0.2, + -9.9, + 2, + 0.05, + 0.05, + 0.05, + 0, + 0, + 7, + 500, + 0.02, + ], + [ + 0.001, + 0.002, + 0.003, + 1e-3, + -2e-4, + 3e-5, + 4, + 0.2, + 0.3, + 0, + 0.5, + 0, + 0, + 293.15, + 0, + ], +]; +const List _gravity = [0.3, -9.81, -0.07]; +const List _wind = [1.5, 0.0, -0.5]; + +/// Two by one by two samples, five metres apart, from (−5, 0, −5). +const List _grid = [1, 0, 0, 0, 0, 2, -1, 0.5, 0, 0, 3, 0]; +const int _steps = 600; +const double _dt = 1.0 / 60.0; + +const String _runner = r''' +const fs = require('fs'); +const { Worker, isMainThread, workerData } = require('worker_threads'); +if (!isMainThread) { + // A worker: an instance of the threads module on the main one's memory, + // with a stack of its own, waiting in the core for its share of a pass. + const { module, memory, index, top } = workerData; + const w = new WebAssembly.Instance(module, { env: { memory } }); + w.exports.__stack_pointer.value = top; + w.exports.f3d_worker_main(index); + return; +} +(async () => { +const [wasmPath, scenarioJson] = process.argv.slice(2); +const { bodies, gravity, wind, grid, steps, dt, threads } = JSON.parse(scenarioJson); +const module = new WebAssembly.Module(fs.readFileSync(wasmPath)); +let memory = null; +let instance; +if (threads > 1) { + memory = new WebAssembly.Memory({ initial: 256, maximum: 32768, shared: true }); + instance = new WebAssembly.Instance(module, { env: { memory } }); + for (let i = 0; i < threads - 1; i++) { + const stack = instance.exports.f3d_buffer_alloc(1 << 20); + new Worker(__filename, { workerData: { module, memory, index: i, top: stack + (1 << 20) } }); + } + while (instance.exports.f3d_wasm_workers_ready() < threads - 1) { + await new Promise((r) => setTimeout(r, 5)); + } +} else { + instance = new WebAssembly.Instance(module, {}); +} +const f = instance.exports; +const heap = () => (memory || f.memory).buffer; +const world = f.f3d_world_create(); +if (threads > 1 && f.f3d_world_set_threads(world, threads) !== 1) throw new Error('no threads'); +f.f3d_world_set_gravity(world, gravity[0], gravity[1], gravity[2]); +f.f3d_world_set_wind(world, wind[0], wind[1], wind[2]); +const gridPtr = f.f3d_buffer_alloc(grid.length * 4); +new Float32Array(heap(), gridPtr, grid.length).set(grid); +f.f3d_world_set_wind_grid(world, -5, 0, -5, 5, 2, 1, 2, gridPtr); +f.f3d_buffer_free(gridPtr); +const material = f.f3d_buffer_alloc(8 * 4); +f.f3d_material_preset(1, material); +for (const b of bodies) { + const h = f.f3d_body_create(world, 0, b[0], b[1], b[2], 1.0); + f.f3d_body_set_velocity(world, h, b[3], b[4], b[5]); + f.f3d_body_set_shape(world, h, b[6], b[7], b[8], b[9]); + f.f3d_body_set_angular_velocity(world, h, b[10], b[11], b[12]); + f.f3d_body_set_material(world, h, material); + f.f3d_body_set_temperature(world, h, b[13]); + f.f3d_body_add_water(world, h, b[14]); +} +f.f3d_buffer_free(material); +const floor = f.f3d_body_create(world, 1, 0, -1, 0, 0); +f.f3d_body_set_shape(world, floor, 2, 200, 1, 200); +const meshVerts = [-20, 0, -20, 20, 0, -20, 20, 0, 20, -20, 0, 20, 0, 3, 0]; +const meshTris = [0, 4, 1, 1, 4, 2, 2, 4, 3, 3, 4, 0]; +const vp = f.f3d_buffer_alloc(meshVerts.length * 4); +new Float32Array(heap(), vp, meshVerts.length).set(meshVerts); +const tp = f.f3d_buffer_alloc(meshTris.length * 4); +new Uint32Array(heap(), tp, meshTris.length).set(meshTris); +const mesh = f.f3d_world_create_mesh(world, vp, 5, tp, 4); +f.f3d_buffer_free(vp); +f.f3d_buffer_free(tp); +const hill = f.f3d_body_create(world, 1, 0, 0, 0, 0); +f.f3d_body_set_mesh(world, hill, mesh); +const pivot = f.f3d_body_create(world, 1, -6, 4, 0, 0); +const bob = f.f3d_body_create(world, 0, -5, 4, 0, 1); +f.f3d_body_set_shape(world, bob, 1, 0.2, 0, 0); +const swing = f.f3d_joint_create(world, 2, pivot, bob, -6, 4, 0, 0, 0, 1); +f.f3d_joint_set_limits(world, swing, 1, -1.2, 1.2); +for (let i = 0; i < steps; i++) f.f3d_world_step(world, dt); +const size = f.f3d_world_snapshot_size(world); +const ptr = f.f3d_buffer_alloc(size); +f.f3d_world_snapshot_write(world, ptr, size); +const bytes = new Uint8Array(heap(), ptr, size); +console.log(Buffer.from(bytes).toString('base64')); +f.f3d_buffer_free(ptr); +f.f3d_world_destroy(world); +process.exit(0); +})(); +'''; + +bool _available(String executable, List args) { + try { + return Process.runSync(executable, args).exitCode == 0; + } on ProcessException { + return false; + } +} + +NativeShape _shape(List b) => switch (b[6].toInt()) { + 1 => NativeShape.sphere(b[7]), + 2 => NativeShape.box(Vector3(b[7], b[8], b[9])), + 3 => NativeShape.capsule(b[7], b[8]), + 4 => NativeShape.cylinder(b[7], b[8]), + _ => NativeShape.point, +}; + +void main() { + final missing = [ + if (!_available('node', ['--version'])) 'node', + if (!_available('clang', ['--version'])) 'clang', + if (findWasmLinker() == null) 'a wasm linker', + ]; + + test('the module and the native library step to the same bits', () { + // Not the transforms only: the whole snapshot, every body's spin, + // temperature, fuel and water, every event, byte for byte. The layouts + // are the same on wasm32 and a 64-bit target — no pointer lives in what + // a snapshot copies — so the bytes have to be. + // + // Mutation: drop -ffp-contract=off from `hook/build.dart` — clang fuses + // the position update's multiply and add into one rounding on arm64, + // which WebAssembly has no scalar instruction for, and the bits part. + final scratch = Directory.systemTemp.createTempSync('f3d_wasm_test'); + addTearDown(() => scratch.deleteSync(recursive: true)); + final wasm = '${scratch.path}/f3d.wasm'; + buildWasm(root: Directory.current.path, out: wasm); + _sameBits(wasm, scratch); + }, skip: missing.isEmpty ? false : 'no ${missing.join(', ')}'); + + test('the module the package ships steps to the same bits', () { + // Hooks do not build for the browser, so `web/f3d_physics.wasm` is built + // by hand — `dart run tool/build_wasm.dart` — and kept in the package. + // Changing the core and not rebuilding it fails here. + final scratch = Directory.systemTemp.createTempSync('f3d_wasm_test'); + addTearDown(() => scratch.deleteSync(recursive: true)); + _sameBits('web/f3d_physics.wasm', scratch); + }, skip: _available('node', ['--version']) ? false : 'no node'); + + test('the threads module steps to the same bits on four workers', () { + // Its workers are node's, each an instance of the module on the one + // shared memory; the world shares its passes among them, and lands on + // the native library's bytes as on one thread. + final scratch = Directory.systemTemp.createTempSync('f3d_wasm_test'); + addTearDown(() => scratch.deleteSync(recursive: true)); + _sameBits('web/f3d_physics_threads.wasm', scratch, threads: 4); + }, skip: _available('node', ['--version']) ? false : 'no node'); + + test('the module imports nothing and exports the API and the shim', () { + final scratch = Directory.systemTemp.createTempSync('f3d_wasm_test'); + addTearDown(() => scratch.deleteSync(recursive: true)); + final wasm = '${scratch.path}/f3d.wasm'; + buildWasm(root: Directory.current.path, out: wasm); + final script = + 'const m = new WebAssembly.Module(require("fs").readFileSync(' + '${jsonEncode(wasm)}));' + 'console.log(JSON.stringify({imports: WebAssembly.Module.imports(m)' + '.length, exports: WebAssembly.Module.exports(m).map(e => e.name)}))'; + final listed = Process.runSync('node', ['-e', script]); + expect(listed.exitCode, 0, reason: '${listed.stderr}'); + final answer = + jsonDecode('${listed.stdout}'.trim()) as Map; + expect(answer['imports'], 0, reason: 'a module with no C library'); + final header = File('csrc/include/f3d_physics.h').readAsStringSync(); + final declared = RegExp( + r'F3D_API [^;(]*?\b(f3d_\w+)\(', + ).allMatches(header).map((m) => m.group(1)!).toSet(); + // And the shim's: each call that takes or gives a 64-bit value again, + // in 32-bit halves, for the browser's numbers. + final shimmed = RegExp(r'F3D_API [^;(]*?\b(f3d_\w+)\(') + .allMatches(File('csrc/wasm/f3d_wasm_shim.c').readAsStringSync()) + .map((m) => m.group(1)!) + .toSet(); + expect(shimmed, contains('f3d_wasm_high')); + expect((answer['exports'] as List).toSet(), { + 'memory', + ...declared, + ...shimmed, + }); + }, skip: missing.isEmpty ? false : 'no ${missing.join(', ')}'); +} + +/// Steps the scenario in the module at [wasm], run in node, and in the +/// native library, and holds the two snapshots to the same bytes. +void _sameBits(String wasm, Directory scratch, {int threads = 1}) { + final module = File(wasm).readAsBytesSync(); + expect(module, isNotEmpty); + + final runner = File('${scratch.path}/run.js')..writeAsStringSync(_runner); + final ran = Process.runSync('node', [ + runner.path, + wasm, + jsonEncode({ + 'bodies': _bodies, + 'gravity': _gravity, + 'wind': _wind, + 'grid': _grid, + 'steps': _steps, + 'dt': _dt, + 'threads': threads, + }), + ]); + expect(ran.exitCode, 0, reason: '${ran.stderr}'); + final fromWasm = base64Decode('${ran.stdout}'.trim()); + + final world = NativeWorld(); + addTearDown(world.dispose); + world + ..gravity = Vector3(_gravity[0], _gravity[1], _gravity[2]) + ..wind = Vector3(_wind[0], _wind[1], _wind[2]) + ..setWindGrid( + origin: Vector3(-5.0, 0.0, -5.0), + cell: 5.0, + nx: 2, + ny: 1, + nz: 2, + velocities: Float32List.fromList(_grid), + ); + final wood = NativeMaterial.wood(); + for (final b in _bodies) { + final body = world.addBody(position: Vector3(b[0], b[1], b[2])); + world + ..setVelocity(body, Vector3(b[3], b[4], b[5])) + ..setShape(body, _shape(b)) + ..setAngularVelocity(body, Vector3(b[10], b[11], b[12])) + ..setMaterial(body, wood) + ..setTemperature(body, b[13]) + ..addWater(body, b[14]); + } + // A floor they fall through, with no solver yet: contacts, events and + // heat across them, all in the comparison. + final floor = world.addBody( + position: Vector3(0.0, -1.0, 0.0), + type: NativeBodyType.fixed, + mass: 0.0, + ); + world.setShape(floor, NativeShape.box(Vector3(200.0, 1.0, 200.0))); + // And a mesh hill on it, a pyramid of four triangles, for the bodies + // to land on and roll off. + final hill = world.addBody( + position: Vector3.zero(), + type: NativeBodyType.fixed, + mass: 0.0, + ); + world.setMesh( + hill, + world.createMesh( + [ + Vector3(-20.0, 0.0, -20.0), + Vector3(20.0, 0.0, -20.0), + Vector3(20.0, 0.0, 20.0), + Vector3(-20.0, 0.0, 20.0), + Vector3(0.0, 3.0, 0.0), + ], + [0, 4, 1, 1, 4, 2, 2, 4, 3, 3, 4, 0], + ), + ); + // And a hinged pendulum with limits, for the joints. + final pivot = world.addBody( + position: Vector3(-6.0, 4.0, 0.0), + type: NativeBodyType.fixed, + mass: 0.0, + ); + final bob = world.addBody(position: Vector3(-5.0, 4.0, 0.0)); + world.setShape(bob, const NativeShape.sphere(0.2)); + final swing = world.createJoint( + NativeJointType.revolute, + pivot, + bob, + anchor: Vector3(-6.0, 4.0, 0.0), + axis: Vector3(0.0, 0.0, 1.0), + ); + world.setJointLimits(swing, (lower: -1.2, upper: 1.2)); + for (var i = 0; i < _steps; i++) { + world.step(_dt); + } + final fromNative = world.snapshot(); + + expect(fromWasm.length, fromNative.length); + expect(fromWasm, orderedEquals(fromNative)); + // And the scenario reached what it was built to: a fire, a turn. + expect( + world.readEvents().map((e) => e.kind), + containsAll([ + NativeEventKind.ignited, + NativeEventKind.contactBegan, + ]), + ); +} diff --git a/packages/flutter3d_physics_native/test/web_core_test.dart b/packages/flutter3d_physics_native/test/web_core_test.dart new file mode 100644 index 000000000..e0dc16d6a --- /dev/null +++ b/packages/flutter3d_physics_native/test/web_core_test.dart @@ -0,0 +1,148 @@ +// The core in the browser — P9, phase 12: the WebAssembly module loaded +// through `dart:js_interop`, the same API on it as natively — bodies that +// fall and rest, events, a ray, a snapshot and back, the CPU systems — and +// the shared scene stepped to the very bytes the native library steps it +// to. +// +// dart test -p chrome test/web_core_test.dart +// dart test -p chrome -c dart2wasm test/web_core_test.dart +@TestOn('browser') +library; + +import 'package:flutter3d_physics_native/flutter3d_physics_native.dart'; +import 'package:test/test.dart'; +import 'package:vector_math/vector_math.dart'; + +import 'scenes/shared_scene.dart'; + +void main() { + // The test runner serves the package root one up from the test's page. + setUpAll(() => loadPhysicsCore(url: '../web/f3d_physics.wasm')); + + test('loads once, and has no GPU', () async { + expect(physicsCoreLoaded, isTrue); + await loadPhysicsCore(url: 'nowhere'); + expect(NativeGpu.open(), isNull); + }); + + test('a crate falls onto a floor and rests there', () { + final world = NativeWorld(); + addTearDown(world.dispose); + final floor = world.addBody( + position: Vector3(0.0, -0.5, 0.0), + type: NativeBodyType.fixed, + mass: 0.0, + ); + world.setShape(floor, NativeShape.box(Vector3(10.0, 0.5, 10.0))); + final crate = world.addBody(position: Vector3(0.0, 2.0, 0.0)); + world.setShape(crate, NativeShape.box(Vector3(0.5, 0.5, 0.5))); + expect(world.bodyCount, 2); + expect(crate.slot, 1); + for (var i = 0; i < 240; i++) { + world.step(1 / 60); + } + expect(world.positionOf(crate).y, closeTo(0.5, 0.01)); + final events = world.readEvents(); + expect( + events.where( + (e) => e.kind == NativeEventKind.contactBegan && e.body == floor, + ), + isNotEmpty, + ); + final hit = world.rayCast( + Vector3(0.0, 5.0, 0.0), + Vector3(0.0, -1.0, 0.0), + 10.0, + )!; + expect(hit.body, crate); + expect(hit.at, closeTo(4.0, 0.01)); + final transforms = world.readTransforms(); + expect(transforms.bodies, [floor, crate]); + expect(transforms.transforms[8], closeTo(0.5, 0.01)); + }); + + test('a slot used again names a new body, its handle 64 bits', () { + // A handle's high half is its slot's generation: nought for a slot's + // first body, so only a slot used again crosses a non-zero one — in and + // out of the module as two halves, and through memory as a 64-bit word. + final world = NativeWorld(); + addTearDown(world.dispose); + final first = world.addBody(position: Vector3(0.0, 1.0, 0.0)); + expect(world.removeBody(first), isTrue); + final second = world.addBody(position: Vector3(2.0, 1.0, 0.0)); + expect(second.slot, first.slot); + expect(second.generation, greaterThan(first.generation)); + expect(world.contains(first), isFalse); + expect(world.contains(second), isTrue); + expect(world.positionOf(second).x, closeTo(2.0, 1e-6)); + expect(world.readTransforms().bodies, [second]); + expect(world.removeBody(first), isFalse); + }); + + test('a snapshot restores to the byte, and a disposed world says so', () { + final world = NativeWorld(); + final ball = world.addBody(position: Vector3(0.0, 1.0, 0.0)); + world + ..setShape(ball, const NativeShape.sphere(0.25)) + ..setMaterial(ball, NativeMaterial.rubber()); + world.step(1 / 60); + final saved = world.snapshot(); + world.step(1 / 60); + final after = world.snapshot(); + world.restore(saved); + world.step(1 / 60); + expect(world.snapshot(), after); + world.dispose(); + expect(() => world.step(1 / 60), throwsStateError); + }); + + test('particles, debris, cloth and water run on the CPU', () { + final particles = NativeParticles(4) + ..emit([ + (position: Vector3.zero(), velocity: Vector3(1.0, 0.0, 0.0), life: 1.0), + ]) + ..step(ParticleForces(), 1 / 60, steps: 30); + expect(particles.read()[0], closeTo(0.5, 1e-5)); + particles.dispose(); + final debris = NativeDebris(1) + ..setStatics([DebrisPlane(Vector3(0.0, 1.0, 0.0), 0.0)]) + ..add([ + ( + position: Vector3(0.0, 0.5, 0.0), + velocity: Vector3.zero(), + radius: 0.1, + mass: 1.0, + ), + ]); + for (var i = 0; i < 120; i++) { + debris.step(DebrisSettings(restitution: 0.0), 1 / 60); + } + expect(debris.read()!.bodies[1], closeTo(0.1, 0.001)); + debris.dispose(); + final cloth = NativeCloth( + ClothMesh.grid(columns: 6, rows: 6, pinned: {0, 5}), + ); + for (var i = 0; i < 60; i++) { + cloth.step(ClothSettings(), 1 / 60); + } + expect(cloth.read()!.points[1], 0.0); + cloth.dispose(); + final water = NativeFluid(8, 0.05) + ..add([ + (position: Vector3(0.0, 1.0, 0.0), velocity: Vector3.zero()), + ]); + water.step( + FluidSettings( + tankMin: Vector3(-1.0, 0.0, -1.0), + tankMax: Vector3(1.0, 2.0, 1.0), + ), + 1 / 60, + ); + expect(water.read()!.particles[1], lessThan(1.0)); + water.dispose(); + }); + + test('the shared scene steps to the native library\'s bytes', () { + expect(hashOf(sharedSceneSnapshot()), sharedSceneHash); + }); +} diff --git a/packages/flutter3d_physics_native/test/web_dynamics_test.dart b/packages/flutter3d_physics_native/test/web_dynamics_test.dart new file mode 100644 index 000000000..44a984a2e --- /dev/null +++ b/packages/flutter3d_physics_native/test/web_dynamics_test.dart @@ -0,0 +1,22 @@ +// flutter3d_physics' bodies on the core in the browser — P9: NativeDynamics +// on the WebAssembly module, under both web compilers, steps the mirrored +// scene to the hash the native library steps it to. +// +// dart test -p chrome test/web_dynamics_test.dart +// dart test -p chrome -c dart2wasm test/web_dynamics_test.dart +@TestOn('browser') +library; + +import 'package:flutter3d_physics_native/flutter3d_physics_native.dart'; +import 'package:test/test.dart'; + +import 'scenes/dynamics_scene.dart'; + +void main() { + // The test runner serves the package root one up from the test's page. + setUpAll(() => loadPhysicsCore(url: '../web/f3d_physics.wasm')); + + test('the mirrored scene steps to the native library\'s hash', () { + expect(dynamicsSceneHash(), dynamicsSceneExpected); + }); +} diff --git a/packages/flutter3d_physics_native/test/web_threads_test.dart b/packages/flutter3d_physics_native/test/web_threads_test.dart new file mode 100644 index 000000000..ecedcd1ab --- /dev/null +++ b/packages/flutter3d_physics_native/test/web_threads_test.dart @@ -0,0 +1,41 @@ +// The core's threads in the browser — P9, phase 12: the threads build on a +// shared memory, three Web Workers beside the page, and a world stepping on +// four threads to the same bytes as the native library on one. +// +// Needs SharedArrayBuffer, which a page has when served cross-origin +// isolated; skipped where there is none. Chrome is asked for it: +// +// CHROME_EXECUTABLE=tool/chrome_sab.sh -p chrome \ +// test/web_threads_test.dart +@TestOn('browser') +library; + +import 'package:flutter3d_physics_native/flutter3d_physics_native.dart'; +import 'package:test/test.dart'; + +import 'scenes/shared_scene.dart'; + +void main() { + setUpAll( + () => loadPhysicsCore( + url: '../web/f3d_physics.wasm', + threads: 4, + threadsUrl: '../web/f3d_physics_threads.wasm', + workerUrl: '../web/f3d_worker.js', + ), + ); + + test('the shared scene steps on four threads to the native bytes', () { + if (physicsCoreThreads == 1) { + markTestSkipped('no SharedArrayBuffer: the page is not isolated'); + return; + } + expect(physicsCoreThreads, 4); + expect(hashOf(sharedSceneSnapshot(threads: 4)), sharedSceneHash); + final world = NativeWorld(); + addTearDown(world.dispose); + world.threads = 4; + expect(world.threads, 4); + expect(() => world.threads = 5, throwsArgumentError); + }); +} diff --git a/packages/flutter3d_physics_native/tool/build_wasm.dart b/packages/flutter3d_physics_native/tool/build_wasm.dart new file mode 100644 index 000000000..fbb719c5a --- /dev/null +++ b/packages/flutter3d_physics_native/tool/build_wasm.dart @@ -0,0 +1,168 @@ +/// Builds the physics core as a WebAssembly module — P9. +/// +/// dart run tool/build_wasm.dart [out.wasm] +/// +/// Into `web/f3d_physics.wasm` by default: the module the package ships, as +/// an asset a Flutter web app serves and `loadPhysicsCore()` fetches. Hooks +/// do not build for the browser, so it is built here and kept in the +/// package; `test/wasm_test.dart` holds it to the core it was built from. +/// +/// Hooks build code for the native targets only, so the browser's module is +/// built here, from the same sources and with the same flags as +/// `hook/build.dart`: no fused multiply-add contraction and no fast math, so +/// the module steps to the bits the native library steps to. Freestanding, +/// with no C library: `f3d_memory_wasm.c` is the allocator and the module +/// imports nothing. +/// +/// Needs a clang with the wasm32 target (Apple's has it) and a wasm linker: +/// `wasm-ld` on the path, or the `rust-lld` a Rust toolchain ships, which is +/// `wasm-ld` under `-flavor wasm`. +library; + +import 'dart:io'; + +/// The core's sources for the WebAssembly build: the world, and the +/// allocator in place of the C library's. +const List wasmSources = [ + 'csrc/src/f3d_world.c', + 'csrc/src/f3d_motion.c', + 'csrc/src/f3d_heat.c', + 'csrc/src/f3d_snapshot.c', + 'csrc/src/f3d_collide.c', + 'csrc/src/f3d_narrow.c', + 'csrc/src/f3d_solve.c', + 'csrc/src/f3d_tree.c', + 'csrc/src/f3d_convex.c', + 'csrc/src/f3d_hull.c', + 'csrc/src/f3d_mesh.c', + 'csrc/src/f3d_joint.c', + 'csrc/src/f3d_ccd.c', + 'csrc/src/f3d_query.c', + 'csrc/src/f3d_particles.c', + 'csrc/src/f3d_debris.c', + 'csrc/src/f3d_cloth.c', + 'csrc/src/f3d_fluid.c', + 'csrc/src/f3d_pool.c', + 'csrc/src/f3d_memory_wasm.c', + 'csrc/wasm/f3d_wasm_shim.c', +]; + +/// The compiler flags the module is built with. +const List wasmFlags = [ + '--target=wasm32', + '-std=c11', + '-O2', + '-ffreestanding', + '-nostdlib', + '-ffp-contract=off', + '-fno-fast-math', + '-fvisibility=hidden', + '-Icsrc/include', + '-Icsrc/src', +]; + +/// A wasm linker's command line prefix, or null when there is none. +List? findWasmLinker() { + bool runs(String executable, List args) { + try { + return Process.runSync(executable, args).exitCode == 0; + } on ProcessException { + return false; + } + } + + if (runs('wasm-ld', ['--version'])) return ['wasm-ld']; + final home = Platform.environment['HOME']; + if (home == null) return null; + final toolchains = Directory('$home/.rustup/toolchains'); + if (!toolchains.existsSync()) return null; + for (final entity in toolchains.listSync(recursive: true)) { + if (entity is File && entity.path.endsWith('/bin/rust-lld')) { + return [entity.path, '-flavor', 'wasm']; + } + } + return null; +} + +/// What the threads build adds: atomics and bulk memory to compile, and a +/// shared memory the host makes and hands every instance — the main one +/// and each worker's — with the stack pointer exported for the host to +/// give each worker a stack of its own. +const List wasmThreadFlags = [ + '-matomics', + '-mbulk-memory', + '-DF3D_WASM_THREADS', +]; + +const List wasmThreadLinkFlags = [ + '--shared-memory', + '--import-memory', + '--initial-memory=16777216', + '--max-memory=2147483648', + '--export=__stack_pointer', +]; + +/// Builds the module into [out], from the package root [root]: with +/// [threads], the threads build. Throws a +/// [StateError] naming what is missing or what failed. +void buildWasm({required String root, required String out, bool threads = false}) { + final linker = findWasmLinker(); + if (linker == null) { + throw StateError( + 'no wasm linker: install lld (wasm-ld), or a Rust toolchain, whose ' + 'rust-lld links wasm', + ); + } + final scratch = Directory.systemTemp.createTempSync('f3d_wasm'); + try { + final objects = []; + for (final source in wasmSources) { + final object = + '${scratch.path}/${source.split('/').last.replaceAll('.c', '.o')}'; + final compiled = Process.runSync('clang', [ + ...wasmFlags, + if (threads) ...wasmThreadFlags, + '-c', + source, + '-o', + object, + ], workingDirectory: root); + if (compiled.exitCode != 0) { + throw StateError('clang failed on $source:\n${compiled.stderr}'); + } + objects.add(object); + } + final linked = Process.runSync(linker.first, [ + ...linker.skip(1), + '--no-entry', + '--export-dynamic', + '--strip-debug', + if (threads) ...wasmThreadLinkFlags, + '-o', + out, + ...objects, + ], workingDirectory: root); + if (linked.exitCode != 0) { + throw StateError('linking failed:\n${linked.stderr}${linked.stdout}'); + } + } finally { + scratch.deleteSync(recursive: true); + } +} + +/// Both modules by default, the single-threaded one and the threads one; +/// or the one at the path given (`--threads` for the threads build). +void main(List args) { + final paths = args.where((a) => !a.startsWith('--')).toList(); + final builds = paths.isEmpty + ? <(String, bool)>[ + ('web/f3d_physics.wasm', false), + ('web/f3d_physics_threads.wasm', true), + ] + : <(String, bool)>[(paths.first, args.contains('--threads'))]; + for (final (out, threads) in builds) { + File(out).parent.createSync(recursive: true); + buildWasm(root: Directory.current.path, out: out, threads: threads); + stdout.writeln('wrote $out (${File(out).lengthSync()} bytes)'); + } +} diff --git a/packages/flutter3d_physics_native/tool/chrome_sab.sh b/packages/flutter3d_physics_native/tool/chrome_sab.sh new file mode 100755 index 000000000..892785d12 --- /dev/null +++ b/packages/flutter3d_physics_native/tool/chrome_sab.sh @@ -0,0 +1,21 @@ +#!/bin/sh +# Chrome with SharedArrayBuffer, for test/web_threads_test.dart. +# +# A page has SharedArrayBuffer only when served cross-origin isolated, and +# the test runner's pages are not, so this asks Chrome for it outright. The +# runner starts whatever CHROME_EXECUTABLE names, so: +# +# CHROME_EXECUTABLE=tool/chrome_sab.sh dart test -p chrome test/web_threads_test.dart +# +# A platform defined in dart_test.yaml would say the same, but `flutter +# test` refuses a package whose dart_test.yaml names a browser. +for chrome in "$F3D_CHROME" \ + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \ + "$(command -v google-chrome)" "$(command -v google-chrome-stable)" \ + "$(command -v chromium)" "$(command -v chromium-browser)"; do + if [ -n "$chrome" ] && [ -x "$chrome" ]; then + exec "$chrome" --enable-features=SharedArrayBuffer "$@" + fi +done +echo "chrome_sab.sh: no Chrome found; set F3D_CHROME" >&2 +exit 1 diff --git a/packages/flutter3d_physics_native/tool/digest_platforms.sh b/packages/flutter3d_physics_native/tool/digest_platforms.sh new file mode 100755 index 000000000..96cc0c3f7 --- /dev/null +++ b/packages/flutter3d_physics_native/tool/digest_platforms.sh @@ -0,0 +1,97 @@ +#!/bin/bash +# The core's digests on the platforms a Mac can reach — P9, phase 13. +# +# tool/digest_platforms.sh [linux] [android] [ios] [windows] +# +# csrc/tests/test_digest.c steps seven scenes and fails unless they hash to +# the numbers written in it; this builds it, in both precisions, for: +# +# linux Debian in Docker on arm64, x86-64 and 32-bit ARM, with gcc and +# clang each (the last under emulation: minutes); +# android arm64 with the NDK's clang, run on the emulator or device adb +# sees (ANDROID_HOME, or ~/Library/Android/sdk); +# ios the simulator, booted if none is; +# windows x86-64 with mingw-w64 in Docker, run under wine, with +# test_threads.c beside it for the pool on Win32 threads (under +# emulation: many minutes). +# +# MSVC is CI's (`physics-native-windows`), through test/c_unit_test.dart. +# With no argument, all four. +set -euo pipefail +cd "$(dirname "$0")/.." + +SOURCES=$(sed -n '/coreSources = /,/];/p' hook/build.dart | grep -o 'csrc/src/[a-z_0-9]*\.c' | tr '\n' ' ') +FLAGS='-std=c11 -O2 -w -ffp-contract=off -fno-fast-math -Icsrc/include -Icsrc/src' +SCRATCH=$(mktemp -d) +trap 'rm -rf "$SCRATCH"' EXIT +failed=0 + +# One line per build: where, which compiler and precision, and the result. +report() { + local where=$1 result=$2 + echo "$where: $result" + case "$result" in *"checks passed"*) ;; *) failed=1 ;; esac +} + +linux() { + local platform + for platform in linux/arm64 linux/amd64 linux/arm/v7; do + # Read from a process substitution, not a pipe: a loop at a pipe's end + # runs in a subshell, and what report() sets would be lost with it. + while IFS='|' read -r where result; do report "$where" "$result"; done < <(docker run --rm --platform "$platform" -v "$PWD:/src" -w /src debian:trixie bash -c " + apt-get update -qq >/dev/null && apt-get install -y -qq gcc clang >/dev/null 2>&1 + for cc in gcc clang; do + for p in '' -DF3D_REAL_DOUBLE; do + \$cc $FLAGS -pthread \$p csrc/tests/test_digest.c $SOURCES -lm -o /tmp/digest && + echo \"$platform \$cc \${p:-f32}|\$(/tmp/digest 2>&1 | tail -1)\" + done + done") + done +} + +android() { + local sdk=${ANDROID_HOME:-$HOME/Library/Android/sdk} + local ndk + ndk=$(ls -d "$sdk"/ndk/*/toolchains/llvm/prebuilt/*/bin | tail -1) + local adb=$sdk/platform-tools/adb p + for p in '' -DF3D_REAL_DOUBLE; do + # shellcheck disable=SC2086 + "$ndk/aarch64-linux-android26-clang" $FLAGS $p csrc/tests/test_digest.c $SOURCES -lm -o "$SCRATCH/digest" + "$adb" push "$SCRATCH/digest" /data/local/tmp/f3d_digest >/dev/null + report "android arm64 ${p:-f32}" "$("$adb" shell /data/local/tmp/f3d_digest 2>&1 | tail -1)" + done +} + +ios() { + local sdk device p + sdk=$(xcrun --sdk iphonesimulator --show-sdk-path) + device=$(xcrun simctl list devices booted | grep -o '[0-9A-F-]\{36\}' | head -1 || true) + if [ -z "$device" ]; then + device=$(xcrun simctl list devices available | grep -i iphone | grep -o '[0-9A-F-]\{36\}' | head -1) + xcrun simctl boot "$device" + xcrun simctl bootstatus "$device" -b >/dev/null + fi + for p in '' -DF3D_REAL_DOUBLE; do + # shellcheck disable=SC2086 + xcrun --sdk iphonesimulator clang -target arm64-apple-ios17.0-simulator -isysroot "$sdk" \ + $FLAGS $p csrc/tests/test_digest.c $SOURCES -o "$SCRATCH/digest" + report "ios simulator ${p:-f32}" "$(xcrun simctl spawn "$device" "$SCRATCH/digest" 2>&1 | tail -1)" + done +} + +windows() { + while IFS='|' read -r where result; do report "$where" "$result"; done < <(docker run --rm --platform linux/amd64 -v "$PWD:/src" -w /src debian:trixie bash -c " + apt-get update -qq >/dev/null && apt-get install -y -qq gcc-mingw-w64-x86-64 wine wine64 >/dev/null 2>&1 + export WINEDEBUG=-all + for t in test_digest test_threads; do + for p in '' -DF3D_REAL_DOUBLE; do + x86_64-w64-mingw32-gcc $FLAGS -D_USE_MATH_DEFINES -static \$p csrc/tests/\$t.c $SOURCES -o /tmp/t.exe && + echo \"windows (wine) \$t \${p:-f32}|\$(wine /tmp/t.exe 2>&1 | tail -1)\" + done + done") +} + +targets=("$@") +[ ${#targets[@]} -eq 0 ] && targets=(linux android ios windows) +for target in "${targets[@]}"; do "$target"; done +exit $failed diff --git a/packages/flutter3d_physics_native/tool/gen_core.dart b/packages/flutter3d_physics_native/tool/gen_core.dart new file mode 100644 index 000000000..8de56da8b --- /dev/null +++ b/packages/flutter3d_physics_native/tool/gen_core.dart @@ -0,0 +1,443 @@ +/// Writes the core's calls and struct layouts from its header — P9, phase 12. +/// +/// dart run tool/gen_core.dart # write them +/// dart run tool/gen_core.dart --check # exit 1 if they are out of date +/// +/// The Dart side reaches the core the same way natively and in the browser: +/// a function a C function, every pointer an address — a native one through +/// `dart:ffi`, an offset into the WebAssembly module's memory on the web — +/// and the structs it fills as offsets into a block of that memory. This +/// reads `csrc/include/f3d_physics.h` and writes `lib/src/core/` from it, so +/// the header is the one place a signature is written. Assumes the build the +/// Dart side ships: f3d_real a float. +library; + +import 'dart:io'; + +/// What the generator writes, by path. +Map generate(String header) { + final text = header.replaceAll(RegExp(r'/\*.*?\*/', dotAll: true), ''); + final functions = _functions(text); + final structs = _structs(text); + return { + 'lib/src/core/calls_native.g.dart': _native(functions), + 'lib/src/core/calls_web.g.dart': _web(functions), + 'lib/src/core/layout.g.dart': _layout(structs), + 'csrc/wasm/f3d_wasm_shim.c': shim(header), + }; +} + +/// [text] with Unix line ends: a Windows checkout has the sources and the +/// generated files with CRLF, and the generated text is the same text. +String _lf(String text) => text.replaceAll('\r\n', '\n'); + +/// [files] as `dart format` leaves them, so that formatting the package +/// does not make them out of date. +Map _formatted(Map files) { + final dir = Directory.systemTemp.createTempSync('f3d_gen'); + try { + final paths = {}; + var i = 0; + final kept = {}; + for (final entry in files.entries) { + if (!entry.key.endsWith('.dart')) { + kept[entry.key] = entry.value; + continue; + } + final f = File('${dir.path}/f${i++}.dart') + ..writeAsStringSync(entry.value); + paths[entry.key] = f; + } + final result = Process.runSync('dart', ['format', dir.path]); + if (result.exitCode != 0) throw StateError('dart format: ${result.stderr}'); + return { + for (final entry in paths.entries) + entry.key: _lf(entry.value.readAsStringSync()), + ...kept, + }; + } finally { + dir.deleteSync(recursive: true); + } +} + +void main(List args) { + final files = _formatted( + generate(_lf(File('csrc/include/f3d_physics.h').readAsStringSync())), + ); + final check = args.contains('--check'); + var stale = false; + for (final entry in files.entries) { + final file = File(entry.key); + final current = file.existsSync() ? _lf(file.readAsStringSync()) : ''; + if (current == entry.value) continue; + if (check) { + stderr.writeln( + '${entry.key} is out of date: run dart run tool/gen_core.dart', + ); + stale = true; + } else { + file + ..createSync(recursive: true) + ..writeAsStringSync(entry.value); + stdout.writeln('wrote ${entry.key}'); + } + } + if (stale) exitCode = 1; +} + +/// A C type as the generator sorts them. +enum _Kind { real, double, int32, uint32, uint64, pointer, none } + +final class _Param { + _Param(this.kind, this.name); + final _Kind kind; + final String name; +} + +final class _Function { + _Function(this.name, this.returns, this.params); + final String name; + final _Kind returns; + final List<_Param> params; +} + +/// The calls that may run long or allocate — a step, a world made or freed, +/// a query that walks the tree — go through the runtime's usual transition; +/// the rest are leaf calls. +const Set _notLeaf = { + 'f3d_world_create', 'f3d_world_destroy', 'f3d_world_step', // + 'f3d_world_set_threads', 'f3d_world_ray_cast', 'f3d_world_ray_cast_all', + 'f3d_world_overlap_shape', 'f3d_world_cast_shape', 'f3d_world_move_character', + 'f3d_world_create_hull', 'f3d_world_create_mesh', 'f3d_joint_create', + 'f3d_joint_create_distance', 'f3d_joint_destroy', 'f3d_world_restore', + 'f3d_body_create', 'f3d_particles_create', 'f3d_particles_destroy', + 'f3d_debris_create', 'f3d_debris_destroy', 'f3d_debris_step', + 'f3d_cloth_create', 'f3d_cloth_destroy', 'f3d_cloth_step', + 'f3d_fluid_create', 'f3d_fluid_destroy', 'f3d_fluid_step', + 'f3d_buffer_alloc', 'f3d_buffer_free', +}; + +_Kind _kind(String type) { + final t = type.replaceAll('const ', '').trim(); + if (t.endsWith('*')) return _Kind.pointer; + switch (t) { + case 'void': + return _Kind.none; + case 'f3d_real': + return _Kind.real; + case 'double': + return _Kind.double; + case 'int': + case 'F3dBodyType': + case 'F3dShapeKind': + case 'F3dJointType': + case 'F3dMaterialKind': + return _Kind.int32; + case 'uint32_t': + return _Kind.uint32; + case 'uint64_t': + case 'F3dBody': + case 'F3dJoint': + return _Kind.uint64; + } + throw FormatException('a type the generator does not know: $type'); +} + +List<_Function> _functions(String text) { + final found = <_Function>[]; + for (final m in RegExp(r'F3D_API\s+([^;#]*?)\s*;').allMatches(text)) { + final declaration = m.group(1)!.split(RegExp(r'\s+')).join(' '); + final parts = RegExp( + r'^(.*?)\b(f3d_\w+)\s*\((.*)\)$', + ).firstMatch(declaration); + if (parts == null) throw FormatException('cannot read: $declaration'); + final params = <_Param>[]; + for (final raw in parts.group(3)!.split(',')) { + final p = raw.trim(); + if (p.isEmpty || p == 'void') continue; + final name = RegExp(r'(\w+)$').firstMatch(p)!.group(1)!; + params.add(_Param(_kind(p.substring(0, p.length - name.length)), name)); + } + found.add(_Function(parts.group(2)!, _kind(parts.group(1)!), params)); + } + return found; +} + +String _camel(String c) { + final words = c.split('_'); + return words.first + + words.skip(1).map((w) => w[0].toUpperCase() + w.substring(1)).join(); +} + +const String _banner = + '// Generated by tool/gen_core.dart from csrc/include/f3d_physics.h: do not edit.\n'; + +String _native(List<_Function> functions) { + String ffi(_Kind k) => switch (k) { + _Kind.real => 'Float', + _Kind.double => 'Double', + _Kind.int32 => 'Int32', + _Kind.uint32 => 'Uint32', + _Kind.uint64 => 'Uint64', + _Kind.pointer => 'Pointer', + _Kind.none => 'Void', + }; + String raw(_Kind k) => switch (k) { + _Kind.real || _Kind.double => 'double', + _Kind.pointer => 'Pointer', + _Kind.none => 'void', + _ => 'int', + }; + String dart(_Kind k) => switch (k) { + _Kind.real || _Kind.double => 'double', + _Kind.none => 'void', + _ => 'int', + }; + final b = StringBuffer() + ..write(_banner) + ..write( + '//\n// The core\'s calls natively: through dart:ffi, a pointer an address.\n', + ) + ..write('// ignore_for_file: non_constant_identifier_names\n') + ..write( + "@DefaultAsset('package:flutter3d_physics_native/src/core/calls_native.g.dart')\n", + ) + ..write('library;\n\n') + ..write("import 'dart:ffi';\n"); + for (final f in functions) { + final leaf = _notLeaf.contains(f.name) ? '' : 'isLeaf: true'; + final sig = + '${ffi(f.returns)} Function(${f.params.map((p) => ffi(p.kind)).join(', ')})'; + final rawParams = f.params + .map((p) => '${raw(p.kind)} ${_camel(p.name)}') + .join(', '); + final params = f.params + .map((p) => '${dart(p.kind)} ${_camel(p.name)}') + .join(', '); + final args = f.params + .map( + (p) => p.kind == _Kind.pointer + ? 'Pointer.fromAddress(${_camel(p.name)})' + : _camel(p.name), + ) + .join(', '); + final call = '_${f.name}($args)'; + final body = f.returns == _Kind.pointer ? '$call.address' : call; + b + ..write( + "\n@Native<$sig>(symbol: '${f.name}'${leaf.isEmpty ? '' : ', $leaf'})\n", + ) + ..write('external ${raw(f.returns)} _${f.name}($rawParams);\n') + ..write('${dart(f.returns)} ${f.name}($params) => $body;\n'); + } + return b.toString(); +} + +String _web(List<_Function> functions) { + // A 64-bit value crosses into the module as two 32-bit halves through a + // shim of its own, f3d_wasm_shim.c, written with this; the rest are + // numbers as they are. + String js(_Kind k) => k == _Kind.none ? 'void' : 'JSNumber'; + final b = StringBuffer() + ..write(_banner) + ..write( + '//\n// The core\'s calls in the browser: the WebAssembly module\'s exports, a\n', + ) + ..write( + '// pointer an offset into its memory, a 64-bit handle two 32-bit halves.\n', + ) + ..write('// ignore_for_file: non_constant_identifier_names\n') + ..write('library;\n\n') + ..write("import 'dart:js_interop';\n\n") + ..write("import 'module_web.dart';\n\n") + ..write('extension type _Exports(JSObject _) implements JSObject {\n'); + for (final f in functions) { + final params = []; + for (final p in f.params) { + if (p.kind == _Kind.uint64) { + params + ..add('JSNumber ${_camel(p.name)}Low') + ..add('JSNumber ${_camel(p.name)}High'); + } else { + params.add('JSNumber ${_camel(p.name)}'); + } + } + final name = _wide(f) ? '${f.name}__w' : f.name; + b.write( + ' @JS(\'$name\')\n external ${js(f.returns)} ${f.name}(${params.join(', ')});\n', + ); + } + b + ..write('}\n\n') + ..write('_Exports get _x => coreExports as _Exports;\n'); + for (final f in functions) { + final params = f.params + .map( + (p) => + '${p.kind == _Kind.real || p.kind == _Kind.double ? 'double' : 'int'} ${_camel(p.name)}', + ) + .join(', '); + final args = []; + for (final p in f.params) { + final n = _camel(p.name); + if (p.kind == _Kind.uint64) { + args + ..add('lowHalf($n).toJS') + ..add('highHalf($n).toJS'); + } else { + args.add('$n.toJS'); + } + } + final call = '_x.${f.name}(${args.join(', ')})'; + final String body; + switch (f.returns) { + case _Kind.none: + body = call; + case _Kind.real: + case _Kind.double: + body = '$call.toDartDouble'; + case _Kind.uint64: + body = 'joinHalves($call.toDartInt, highResult())'; + case _Kind.uint32: + case _Kind.pointer: + body = '$call.toDartInt.toUnsigned(32)'; + case _Kind.int32: + body = '$call.toDartInt'; + } + final returns = switch (f.returns) { + _Kind.none => 'void', + _Kind.real || _Kind.double => 'double', + _ => 'int', + }; + b.write('$returns ${f.name}($params) => $body;\n'); + } + return b.toString(); +} + +/// Whether a call takes or gives a 64-bit value, and so goes through the +/// shim. +bool _wide(_Function f) => + f.returns == _Kind.uint64 || f.params.any((p) => p.kind == _Kind.uint64); + +/// The shim the WebAssembly build exports in place of each call that takes +/// or gives a 64-bit value: halves in, the low half out and the high half +/// kept for f3d_wasm_high. +String shim(String header) { + final text = header.replaceAll(RegExp(r'/\*.*?\*/', dotAll: true), ''); + final b = StringBuffer() + ..write( + '/* Generated by tool/gen_core.dart from f3d_physics.h: do not edit.\n', + ) + ..write( + ' *\n * The WebAssembly build\'s exports for the calls that take or give a\n', + ) + ..write( + ' * 64-bit value, which JavaScript would have as a BigInt: two 32-bit halves\n', + ) + ..write( + ' * in, and out the low half, the high one kept for f3d_wasm_high. */\n', + ) + ..write('#include "f3d_physics.h"\n\n') + ..write('static uint32_t g_high;\n\n') + ..write('F3D_API uint32_t f3d_wasm_high(void) { return g_high; }\n'); + String c(_Kind k) => switch (k) { + _Kind.real => 'f3d_real', + _Kind.double => 'double', + _Kind.int32 => 'int', + _Kind.uint32 => 'uint32_t', + _Kind.uint64 => 'uint64_t', + _Kind.pointer => 'void *', + _Kind.none => 'void', + }; + for (final f in _functions(text).where(_wide)) { + final params = []; + final args = []; + for (final p in f.params) { + if (p.kind == _Kind.uint64) { + params + ..add('uint32_t ${p.name}_low') + ..add('uint32_t ${p.name}_high'); + args.add('((uint64_t)${p.name}_high << 32) | ${p.name}_low'); + } else { + params.add('${c(p.kind)} ${p.name}'); + args.add(p.kind == _Kind.pointer ? '(void *)${p.name}' : p.name); + } + } + final ret = f.returns == _Kind.uint64 ? 'uint32_t' : c(f.returns); + final call = '${f.name}(${args.join(', ')})'; + final body = switch (f.returns) { + _Kind.uint64 => + ' const uint64_t v = $call;\n g_high = (uint32_t)(v >> 32);\n return (uint32_t)v;', + _Kind.none => ' $call;', + _ => ' return $call;', + }; + b.write( + '\nF3D_API $ret ${f.name}__w(${params.isEmpty ? 'void' : params.join(', ')}) {\n$body\n}\n', + ); + } + return b.toString(); +} + +final class _Field { + _Field(this.name, this.kind, this.count); + final String name; + final _Kind kind; + final int count; +} + +Map> _structs(String text) { + final out = >{}; + for (final m in RegExp( + r'typedef struct (F3d\w+) \{(.*?)\} \1;', + dotAll: true, + ).allMatches(text)) { + final fields = <_Field>[]; + for (final line in m.group(2)!.split(';')) { + final f = line.trim(); + if (f.isEmpty) continue; + final parts = RegExp(r'^(\w+)\s+(\w+)(?:\[(\d+)\])?$').firstMatch(f); + if (parts == null) { + throw FormatException('a field the generator cannot read: $f'); + } + fields.add( + _Field( + parts.group(2)!, + _kind(parts.group(1)!), + int.parse(parts.group(3) ?? '1'), + ), + ); + } + out[m.group(1)!] = fields; + } + return out; +} + +String _layout(Map> structs) { + int size(_Kind k) => k == _Kind.uint64 || k == _Kind.double ? 8 : 4; + final b = StringBuffer() + ..write(_banner) + ..write( + '//\n// The structs the Dart side fills, as byte offsets into a block of the\n', + ) + ..write( + '// core\'s memory, laid out as C lays them out for the float build.\n', + ) + ..write('library;\n'); + for (final entry in structs.entries) { + var at = 0; + var align = 4; + b.write( + '\n/// `${entry.key}`.\nabstract final class ${entry.key}Layout {\n', + ); + for (final f in entry.value) { + final s = size(f.kind); + if (s > align) align = s; + at = (at + s - 1) ~/ s * s; + b.write(' static const int ${_camel(f.name)} = $at;\n'); + at += s * f.count; + } + at = (at + align - 1) ~/ align * align; + b.write(' static const int size = $at;\n}\n'); + } + return b.toString(); +} diff --git a/packages/flutter3d_physics_native/web/f3d_physics.wasm b/packages/flutter3d_physics_native/web/f3d_physics.wasm new file mode 100755 index 000000000..00ccf7aa9 Binary files /dev/null and b/packages/flutter3d_physics_native/web/f3d_physics.wasm differ diff --git a/packages/flutter3d_physics_native/web/f3d_physics_threads.wasm b/packages/flutter3d_physics_native/web/f3d_physics_threads.wasm new file mode 100755 index 000000000..169cab74d Binary files /dev/null and b/packages/flutter3d_physics_native/web/f3d_physics_threads.wasm differ diff --git a/packages/flutter3d_physics_native/web/f3d_worker.js b/packages/flutter3d_physics_native/web/f3d_worker.js new file mode 100644 index 000000000..020eacd78 --- /dev/null +++ b/packages/flutter3d_physics_native/web/f3d_worker.js @@ -0,0 +1,12 @@ +// A worker for the physics core's threads module — P9, phase 12. +// +// Started by loadPhysicsCore(threads: n), n − 1 of them: each makes an +// instance of the module on the one shared memory, takes the stack it was +// given, and waits in the core for its share of each pass. It never comes +// back; the page ends it. +self.onmessage = (event) => { + const { module, memory, index, top } = event.data; + const instance = new WebAssembly.Instance(module, { env: { memory } }); + instance.exports.__stack_pointer.value = top; + instance.exports.f3d_worker_main(index); +}; diff --git a/packages/flutter3d_shaders/CHANGELOG.md b/packages/flutter3d_shaders/CHANGELOG.md index db4f338c5..4e3c05007 100644 --- a/packages/flutter3d_shaders/CHANGELOG.md +++ b/packages/flutter3d_shaders/CHANGELOG.md @@ -1,3 +1,74 @@ +## Unreleased + +- **`CausticSurface`, `CausticPhotonVertex` and `CausticPhoton`**, the stages + of `ShadowSettings.caustics`; `ShadowTransmittance` stops a caster's light + when its photons are followed. +- `ShadowTransmittance` reads the material's base colour map, and neither it + nor the stored transmittance is held to one; `ShadowFactor` reads up to + four, so a caster can brighten as well as darken. +- **`ShadowTransmittance`**, the stage a see-through caster is drawn into the + sun's atlas with (`ShadowSettings.translucentCasters`), and + `light_transmittance` in `surface.glsl`, which `ShadowFactor` sets and the + light loop multiplies the sun by. +- **A masked cutoff above one is drawn as coverage** — `P7`: `ReadSurface` + keeps the fragment and sharpens its alpha over a pixel with `fwidth`. A + plain cutoff leaves the surviving fragment's alpha at one. + +- **`FogInfo.projection`, `Orthographic`, `TowardsEye`** — `P7`: the lit + models, the planar reflection's Fresnel and `EyeDistance` ask whether the + camera is orthographic. `light_shafts.frag` starts its march on the eye's + plane. + +- **`FragInfo.debug_view` and `WriteDebugView`** in `surface.glsl` — `P6`: + every lit model asks it before writing its light. NaN is found by + comparison, because `impellerc`'s GLSL ES output has no bit casts. + `CompositeInfo.lens` y and z say whether a debug view is on and where it + starts. + +- **`LensFlare`**, with the `LensFlareInfo` block, and a `lens` member + appended to `CompositeInfo` for the distortion. +- **Three stages for SMAA 1x**: `SmaaEdges`, `SmaaWeights` and `SmaaBlend`, + with the `SmaaInfo` block. A bundle must answer to them; the renderer falls + back to FXAA when one does not. + +**A decal stage.** `post/decal.frag` (`Decal`) paints up to sixteen +projected boxes, reading four pictures, over the point the surface buffer +names under each pixel. It writes a factor and a term for two blends, the +albedo swapped under the light the albedo buffer lets it read back, and the +colour an unlit surface and an emissive decal add. + +**Two stages for `P4`.** `lighting/planar_reflection.frag` lays a mirrored +picture over a reflector's surface, read by the fragment's place in its view +and weighted by Schlick's Fresnel; it declares no surface buffer, as +`xray.frag` does not. `post/render_texture_encode.frag` turns a camera's +light into the sRGB bytes a material's map is read as, turning the rows over +where the backend draws its first row at the bottom. Both are in the bundle, +in `kRequiredShaders` and in `stageBindings`, `uniformBlocks` and +`typed_blocks.dart`. + +**`SkyPhysical` and `SkyPhysicalVertex`**, the physical sky: single +scattering by molecules and haze marched per pixel, sixteen samples along the +view and eight towards the sun from each, the disc and the stars dimmed by the +air, and the ground below the horizon. The air travels on the vertices, as the +gradient's preset does. + +**`ApplyFog` integrates a height fog.** `FogInfo.eye.w` carries the falloff +and `FogInfo.fog.w` the density at the eye; a falloff of nought takes the old +path unchanged. + +**The WebGL2 and WebGPU translators live here now, behind +`translate.dart` and `compile.dart`.** `flutter3d_build`'s material step has +to translate a project's materials from a hook process that cannot resolve a +package declaring the Flutter SDK, and `flutter3d_webgl` and +`flutter3d_webgpu` both declare one. None of the moved files ever needed the +SDK. `translate.dart` holds the pure parts: `translateGlsl` and +`resolveIncludes`, `prepareStage`, and the writers for both sections. +`compile.dart` adds the parts that need a machine: `loadShaders` (which now +takes `from:`, and whose `ShaderSet` carries the `root` it read), +`compileStage` through glslang and naga, and `bundleVaryingLocations`, which +used to be private to `pack_wgsl_section.dart`. The barrel exports neither, +so an application carries no compiler. + ## 0.8.2+1 **Resolves on Flutter 3.44 and Dart 3.12.0.** The constraints asked for Dart diff --git a/packages/flutter3d_shaders/lib/compile.dart b/packages/flutter3d_shaders/lib/compile.dart new file mode 100644 index 000000000..ad0007ad2 --- /dev/null +++ b/packages/flutter3d_shaders/lib/compile.dart @@ -0,0 +1,14 @@ +/// Everything [translate.dart] has, and the half that needs a machine: the +/// engine's sources read off disk, glslang and naga run as processes, and a +/// bundle's varyings numbered against the engine's. +/// +/// For build scripts and build hooks only. An application never imports this: +/// it carries no shader compiler, and `dart:io` is no help in a browser. +/// +/// [translate.dart]: translate.dart +library; + +export 'src/bundle_varyings.dart'; +export 'src/source_package.dart'; +export 'src/wgsl_compiler.dart'; +export 'translate.dart'; diff --git a/packages/flutter3d_shaders/lib/flutter3d_shaders.dart b/packages/flutter3d_shaders/lib/flutter3d_shaders.dart index 17ed19cf1..ac4a5c769 100644 --- a/packages/flutter3d_shaders/lib/flutter3d_shaders.dart +++ b/packages/flutter3d_shaders/lib/flutter3d_shaders.dart @@ -41,6 +41,10 @@ const List kRequiredShaders = [ (name: 'FullscreenVertex', fragment: false), (name: 'Easu', fragment: true), (name: 'Fxaa', fragment: true), + (name: 'SmaaEdges', fragment: true), + (name: 'SmaaWeights', fragment: true), + (name: 'SmaaBlend', fragment: true), + (name: 'LensFlare', fragment: true), (name: 'LocalExposure', fragment: true), (name: 'LocalExposureBlur', fragment: true), (name: 'Lambert', fragment: true), @@ -66,6 +70,8 @@ const List kRequiredShaders = [ (name: 'PbrLayered', fragment: true), (name: 'PolylineVertex', fragment: false), (name: 'ProbePrefilter', fragment: true), + (name: 'PlanarReflection', fragment: true), + (name: 'RenderTextureEncode', fragment: true), (name: 'Reflections', fragment: true), (name: 'Ssao', fragment: true), (name: 'Splat', fragment: true), @@ -92,10 +98,15 @@ const List kRequiredShaders = [ (name: 'VelocityNeighborMax', fragment: true), (name: 'MotionBlur', fragment: true), (name: 'ViewportShade', fragment: true), + (name: 'Decal', fragment: true), (name: 'ShadowDepth', fragment: true), (name: 'ShadowDistance', fragment: true), (name: 'ShadowDepthMasked', fragment: true), (name: 'ShadowDistanceMasked', fragment: true), + (name: 'ShadowTransmittance', fragment: true), + (name: 'CausticSurface', fragment: true), + (name: 'CausticPhotonVertex', fragment: false), + (name: 'CausticPhoton', fragment: true), (name: 'ShadowCopy', fragment: true), (name: 'EvsmFilter', fragment: true), (name: 'ShadowTileReset', fragment: true), @@ -104,6 +115,8 @@ const List kRequiredShaders = [ (name: 'SkyCube', fragment: true), (name: 'SkyVertex', fragment: false), (name: 'SkyCubeVertex', fragment: false), + (name: 'SkyPhysical', fragment: true), + (name: 'SkyPhysicalVertex', fragment: false), (name: 'Toon', fragment: true), (name: 'Unlit', fragment: true), (name: 'VertexTextureProbe', fragment: true), diff --git a/packages/flutter3d_shaders/lib/src/bundle_varyings.dart b/packages/flutter3d_shaders/lib/src/bundle_varyings.dart new file mode 100644 index 000000000..24078121b --- /dev/null +++ b/packages/flutter3d_shaders/lib/src/bundle_varyings.dart @@ -0,0 +1,72 @@ +/// Varying locations for a loadable bundle, held against the engine's own. +/// +/// **A loaded stage is paired with a stage it was not compiled beside.** A +/// bundle's fragment stage runs against the engine's vertex stages, WebGPU +/// does not link, and the two modules are joined by `@location` alone — so a +/// varying's number has to be the number the engine already committed to +/// `flutter3d_webgpu`'s `lib/engine_shaders.dart`. Numbering a bundle on its +/// own would give `v_normal` whatever index its own family handed out, the +/// pipeline would still build, and the fragment stage would read normals out +/// of some other varying's slot. +/// +/// Written for `flutter3d_webgpu/tool/pack_wgsl_section.dart` and moved here +/// when `flutter3d_build` needed the same answer for a project's materials +/// (P8): two copies of this rule would be two rules, and the one that drifted +/// would draw wrongly rather than fail. +library; + +import 'glsl_to_wgsl.dart'; +import 'glsl_translate.dart'; +import 'source_package.dart'; + +/// One stage of a bundle, with its `#include`s already expanded. +typedef BundleStageSource = ({String file, bool fragment, String resolved}); + +/// A location per varying name, agreeing with [engine]'s. +/// +/// Computed over both manifests, then held against the engine's alone. Throws +/// [WgslPrepareError] when the bundle would move one of the engine's +/// varyings: the failure is a refusal to pack rather than a section that +/// would compile and read the wrong slot. +Map bundleVaryingLocations( + ShaderSet engine, + Iterable stages, +) { + final theirs = >[ + for (final entry in engine.stages.values) + scanVaryings( + _resolveEngine(engine, entry.file), + from: entry.file, + fragment: entry.fragment, + ), + ]; + final ours = >[ + for (final stage in stages) + scanVaryings(stage.resolved, from: stage.file, fragment: stage.fragment), + ]; + + final engineOnly = assignVaryingLocations(theirs); + final together = assignVaryingLocations(>[...theirs, ...ours]); + + for (final entry in engineOnly.entries) { + if (together[entry.key] == entry.value) continue; + throw WgslPrepareError( + 'this bundle moves the engine\'s varying "${entry.key}" from location ' + '${entry.value} to ${together[entry.key]}. A stage packed here is paired ' + 'with a stage the engine already compiled, and WebGPU joins the two by ' + 'location alone, so the numbering cannot be renegotiated by a bundle. ' + 'A varying declared beside the engine\'s in one stage joins their ' + 'family and renumbers it — give it a name of its own, or declare it in ' + 'a stage that shares nothing with the engine\'s.', + ); + } + return together; +} + +String _resolveEngine(ShaderSet engine, String file) { + try { + return resolveIncludes(engine.sources[file]!, engine.sources, from: file); + } on GlslTranslateError catch (error) { + throw WgslPrepareError('the engine\'s $file: ${error.message}'); + } +} diff --git a/packages/flutter3d_webgpu/lib/src/glsl_to_wgsl.dart b/packages/flutter3d_shaders/lib/src/glsl_to_wgsl.dart similarity index 96% rename from packages/flutter3d_webgpu/lib/src/glsl_to_wgsl.dart rename to packages/flutter3d_shaders/lib/src/glsl_to_wgsl.dart index d4cb9c576..49b4e5205 100644 --- a/packages/flutter3d_webgpu/lib/src/glsl_to_wgsl.dart +++ b/packages/flutter3d_shaders/lib/src/glsl_to_wgsl.dart @@ -13,22 +13,20 @@ /// is the only part that needs a process, and by then the shape of the answer /// is already settled. /// -/// Under `lib/src/` and not under `tool/`, the way -/// `flutter3d_webgl/lib/src/glsl_translate.dart` is: nothing a consumer builds -/// reaches it, since `lib/engine_shaders.dart` is a table and not a -/// translation, and a test can import it. The browser test runner serves a -/// package from `test/` and cannot read a sibling directory, so a translator in -/// `tool/` is a translator no test in this package could name. +/// **Here, in `flutter3d_shaders`, rather than in `flutter3d_webgpu` where it +/// was written** — P8. A project's build hook compiles its own materials into +/// a WebGPU section, and that hook is a process with no Flutter SDK to +/// resolve; `flutter3d_webgpu` declares one, so nothing under its `lib/` is +/// reachable from there at all. The translator never needed the SDK, only the +/// package it lived in did. `package:flutter3d_shaders/translate.dart` is the +/// door; the WebGPU backend reads the section this feeds and keeps everything +/// that talks to a browser. /// /// **The `#include`s are already resolved by the time anything here runs, and -/// the caller resolves them.** `resolveIncludes` belongs to -/// `flutter3d_webgl/lib/src/glsl_translate.dart` and is reused rather than -/// rewritten — a second answer to "which headers does this shader really pull -/// in" is how two backends drift apart in the one place neither can see. It is -/// called from `tool/generate_shaders.dart` and from the tests rather than from -/// here because that package is a *dev* dependency: a backend that depended on -/// another backend at run time would not be two backends, and a file under -/// `lib/` may only import what ships. +/// the caller resolves them** — with `resolveIncludes` from +/// `glsl_translate.dart` beside this file, reused rather than rewritten: a +/// second answer to "which headers does this shader really pull in" is how two +/// backends drift apart in the one place neither can see. /// /// ## Why the source has to be edited at all /// @@ -199,16 +197,16 @@ Set scanVaryings( /// /// ## Why not simply sort every name in the manifest /// -/// Because there are seventeen of them and a location must be below +/// Because there are twenty-four of them and a location must be below /// `maxInterStageShaderVariables`, which is sixteen. One flat numbering runs -/// out by one name. +/// out eight names short. /// /// Two names only ever have to agree when some stage declares both — that is /// what makes them share a pipeline. So the names are grouped into families by /// exactly that relation and each family is numbered from zero: the mesh /// varyings and the sky varyings never meet in one stage, and there is no -/// pipeline in which their numbers could collide. Four families come out of the -/// manifest today, the largest with eight names in it. +/// pipeline in which their numbers could collide. Five families come out of the +/// manifest today, the largest, the sky's, with twelve names in it. /// /// Deterministic throughout — every list is sorted before it is numbered — /// because `tool/ci.sh` regenerates the shader table and diffs it, and a diff --git a/packages/flutter3d_webgl/lib/src/glsl_translate.dart b/packages/flutter3d_shaders/lib/src/glsl_translate.dart similarity index 96% rename from packages/flutter3d_webgl/lib/src/glsl_translate.dart rename to packages/flutter3d_shaders/lib/src/glsl_translate.dart index 4d26eb3a3..554af2b66 100644 --- a/packages/flutter3d_webgl/lib/src/glsl_translate.dart +++ b/packages/flutter3d_shaders/lib/src/glsl_translate.dart @@ -14,6 +14,11 @@ /// Pure text in, pure text out, so every rule below is testable without a /// browser. What it cannot check is whether the result *compiles*; that is what /// the harness is for. +/// +/// **In `flutter3d_shaders` since P8**, moved out of `flutter3d_webgl` for the +/// reason `glsl_to_wgsl.dart` beside it gives: a project's build hook +/// translates its materials with it, and cannot resolve a package that +/// declares the Flutter SDK. library; /// A source file the translator can read. diff --git a/packages/flutter3d_webgpu/lib/src/source_package.dart b/packages/flutter3d_shaders/lib/src/source_package.dart similarity index 73% rename from packages/flutter3d_webgpu/lib/src/source_package.dart rename to packages/flutter3d_shaders/lib/src/source_package.dart index ddb25bd4d..0eb670797 100644 --- a/packages/flutter3d_webgpu/lib/src/source_package.dart +++ b/packages/flutter3d_shaders/lib/src/source_package.dart @@ -6,27 +6,25 @@ /// somebody added a `.frag` and forgot to name it in the manifest — which is /// the day the engine starts asking for a stage no backend has. /// -/// The twin of `flutter3d_webgl/tool/source_package.dart`, and copied rather -/// than shared: a `tool/` directory is a program and not a library, so nothing -/// under one package's `tool/` is importable from another's. Twenty lines of -/// pub plumbing is a cheaper duplicate than making one backend's build scripts -/// part of another backend's published surface. +/// Reached through `package:flutter3d_shaders/compile.dart`, never the barrel: +/// it reads files, and `dart:io` compiles for the web to stubs that throw, +/// which is why a browser test importing it still loads. /// -/// **`dart:io` under `lib/`, which reads oddly in a backend that only runs in a -/// browser.** It is here rather than in `tool/` because the browser test runner -/// serves a package from `test/` and cannot read a sibling directory, so a test -/// may only import what is under `lib/`. Nothing this package exports reaches -/// it, so nothing a consumer builds carries it; and `dart:io` compiles for the -/// web to stubs that throw, which is why the browser run gets through this file -/// rather than failing on it. +/// **`from` is P8's.** A build hook runs with the working directory wherever +/// the Flutter tool left it, and has to find `flutter3d_shaders` through the +/// project it is building rather than through itself. library; import 'dart:convert'; import 'dart:io'; /// Every shader file, keyed the way an `#include <…>` spells it, beside the -/// manifest's entry points in the order it lists them. +/// manifest's entry points in the order it lists them, and the `shaders/` +/// directory both were read from — which `impellerc` needs as an include +/// root, and which has to be this one rather than one found again, or two +/// sections of one bundle could be built against two trees. typedef ShaderSet = ({ + String root, Map sources, Map stages, }); @@ -35,8 +33,8 @@ typedef ShaderSet = ({ /// /// Throws [StateError] with a message worth showing a person when the package /// is not there or has no `shaders/` in it. -ShaderSet loadShaders() { - final root = packageRoot('flutter3d_shaders'); +ShaderSet loadShaders({String? from}) { + final root = packageRoot('flutter3d_shaders', from: from); final shaders = Directory('$root/shaders'); if (!shaders.existsSync()) throw StateError('no shaders/ in $root'); @@ -72,11 +70,11 @@ ShaderSet loadShaders() { stages[name] = (file: file, fragment: entry['type'] == 'fragment'); }); - return (sources: sources, stages: stages); + return (root: shaders.path, sources: sources, stages: stages); } /// The root directory of [name], through `.dart_tool/package_config.json` at or -/// above the working directory. +/// above [from], the working directory when it is null. /// /// The one mapping pub guarantees for path, git and hosted dependencies alike. /// A relative path would work here and break the day this package is consumed @@ -84,8 +82,9 @@ ShaderSet loadShaders() { /// /// `rootUri` is a URI and relative for a workspace member, resolved against /// `.dart_tool/` — which is why this is `Uri.resolve` and not a string join. -String packageRoot(String name) { - var dir = Directory.current; +String packageRoot(String name, {String? from}) { + final start = Directory(from ?? Directory.current.path).absolute; + var dir = start; while (true) { final config = File('${dir.path}/.dart_tool/package_config.json'); if (config.existsSync()) { @@ -107,9 +106,7 @@ String packageRoot(String name) { } final parent = dir.parent; if (parent.path == dir.path) { - throw StateError( - 'no .dart_tool/package_config.json above ${Directory.current.path}', - ); + throw StateError('no .dart_tool/package_config.json above ${start.path}'); } dir = parent; } diff --git a/packages/flutter3d_webgl/lib/src/webgl_bundle_section.dart b/packages/flutter3d_shaders/lib/src/webgl_bundle_section.dart similarity index 72% rename from packages/flutter3d_webgl/lib/src/webgl_bundle_section.dart rename to packages/flutter3d_shaders/lib/src/webgl_bundle_section.dart index 5dc4766c4..9417711f3 100644 --- a/packages/flutter3d_webgl/lib/src/webgl_bundle_section.dart +++ b/packages/flutter3d_shaders/lib/src/webgl_bundle_section.dart @@ -1,15 +1,18 @@ /// The WebGL section of a `ShaderBundle`: GLSL ES 3.00 sources by name. /// /// A JSON document, `{"vertex": {name: source}, "fragment": {name: source}}`, -/// as UTF-8. Text rather than anything compiled, because that is what this -/// backend compiles from: the browser is the compiler, and there is nothing -/// to run ahead of time except the translation from the engine's GLSL, which -/// `tool/pack_shaders.dart` does when it writes the section. +/// as UTF-8. Text rather than anything compiled, because that is what the +/// WebGL backend compiles from: the browser is the compiler, and there is +/// nothing to run ahead of time except the translation from the engine's GLSL, +/// which `flutter3d_webgl/tool/pack_shaders.dart` and `flutter3d_build`'s +/// material step do when they write the section. /// -/// **No `package:web` here, on purpose.** The packer runs on the Dart VM, -/// where a browser binding does not load, and it has to write exactly what -/// the device reads. One file both import is how the two stay the same -/// document. +/// **No `package:web` here, on purpose.** The packers run on the Dart VM, +/// where a browser binding does not load, and they have to write exactly what +/// the device reads. One file every side imports is how they stay the same +/// document — which is also why it lives in `flutter3d_shaders` rather than in +/// `flutter3d_webgl`: a build hook cannot resolve a package that declares the +/// Flutter SDK. library; import 'dart:convert'; diff --git a/packages/flutter3d_webgpu/lib/src/wgsl_compiler.dart b/packages/flutter3d_shaders/lib/src/wgsl_compiler.dart similarity index 96% rename from packages/flutter3d_webgpu/lib/src/wgsl_compiler.dart rename to packages/flutter3d_shaders/lib/src/wgsl_compiler.dart index 2f2dd7e6d..e78b413cb 100644 --- a/packages/flutter3d_webgpu/lib/src/wgsl_compiler.dart +++ b/packages/flutter3d_shaders/lib/src/wgsl_compiler.dart @@ -35,10 +35,9 @@ /// the packer already made. The packer numbers them, so the reflection is a /// by-product of the numbering rather than a reconstruction of it. /// -/// Under `lib/src/` for the reason `source_package.dart` gives: the browser -/// test runner cannot read a directory beside `test/`, so a build-time file no -/// test can import is a build-time file no test can measure. Nothing this -/// package exports reaches it. +/// Reached through `package:flutter3d_shaders/compile.dart` only, never the +/// package's barrel: it runs processes, and nothing a consumer's application +/// builds has a reason to carry that. library; import 'dart:io'; diff --git a/packages/flutter3d_webgpu/lib/src/wgsl_section.dart b/packages/flutter3d_shaders/lib/src/wgsl_section.dart similarity index 78% rename from packages/flutter3d_webgpu/lib/src/wgsl_section.dart rename to packages/flutter3d_shaders/lib/src/wgsl_section.dart index 159d73691..723ac051c 100644 --- a/packages/flutter3d_webgpu/lib/src/wgsl_section.dart +++ b/packages/flutter3d_shaders/lib/src/wgsl_section.dart @@ -1,36 +1,29 @@ /// What a packer writes into a bundle's `webgpu` section, as text. /// -/// The reading half of this document is `lib/src/webgpu_bundle_section.dart`, -/// and that file says its own most valuable line out loud: no `package:web` -/// here and no `dart:js_interop`, so that the packer on the VM and the device -/// in the browser read one document rather than two transcriptions of it. +/// The reading half of this document is `flutter3d_webgpu`'s +/// `lib/src/webgpu_bundle_section.dart`, and that file says its own most +/// valuable line out loud: no `package:web` here and no `dart:js_interop`, so +/// that the packer on the VM and the device in the browser read one document +/// rather than two transcriptions of it. /// /// **This is the writing half, and it could not be that same file.** The codec -/// there reaches `flutter3d_hardware`'s barrel for `VertexFormat`, the barrel -/// reaches `package:flutter`, and a bundle is packed by a `dart run` script -/// with no `dart:ui` under it — `flutter3d_webgl/tool/pack_shaders.dart` is one -/// and `tool/pack_wgsl_section.dart` beside this file is its sibling. Importing -/// the codec from either is a compile error naming `dart:ui`, which is the -/// measurement that put these twenty lines of `jsonEncode` here rather than in -/// the codec. +/// there reaches `flutter3d_hardware`'s barrel for `VertexFormat`, and a bundle +/// is packed by a process with no `dart:ui` under it — +/// `flutter3d_webgl/tool/pack_shaders.dart`, +/// `flutter3d_webgpu/tool/pack_wgsl_section.dart`, and since P8 a project's +/// own build hook through `flutter3d_build`. That last one cannot even resolve +/// `flutter3d_webgpu`, which declares the Flutter SDK, and is why this writer +/// lives in `flutter3d_shaders` now rather than beside its reader. /// -/// What holds the two halves together instead is `test/wgsl_section_test.dart`, -/// which writes a document with every field populated and reads it back with -/// `decodeWebGpuSection`, field for field. A writer nobody read back would be -/// the real duplication — the JSON would be right about the shape and wrong -/// about a spelling, and the first thing to notice would be a bundle refused on -/// somebody's machine. +/// What holds the two halves together instead is `flutter3d_webgpu`'s +/// `test/wgsl_section_test.dart`, which writes a document with every field +/// populated and reads it back with `decodeWebGpuSection`, field for field. A +/// writer nobody read back would be the real duplication — the JSON would be +/// right about the shape and wrong about a spelling, and the first thing to +/// notice would be a bundle refused on somebody's machine. /// -/// **Under `lib/src/` and not under `tool/`**, which reads oddly for a file only -/// a build script calls, and is the same decision `glsl_to_wgsl.dart`, -/// `wgsl_compiler.dart` and `source_package.dart` all record: the browser test -/// runner serves this package from `test/` and cannot read a sibling directory, -/// so a build-time file under `tool/` is a build-time file no test can measure. -/// It was written there first and the whole Chrome pass failed to compile on -/// the import, which is as clear a measurement of that rule as it gets. Nothing -/// this package exports reaches this file, so nothing a consumer builds carries -/// it; and it imports `dart:convert` and the translator alone, so the round trip -/// above runs in a browser as readily as on the VM. +/// It imports `dart:convert` and the translator alone, so the round trip above +/// runs in a browser as readily as on the VM. /// /// One spelling in particular is why that test earns its keep. /// [PreparedSampler.dimension] carries a `WebGpuTextureDimension`'s *Dart* name diff --git a/packages/flutter3d_shaders/lib/stage_bindings.dart b/packages/flutter3d_shaders/lib/stage_bindings.dart index acb4ee93d..e4ab490f1 100644 --- a/packages/flutter3d_shaders/lib/stage_bindings.dart +++ b/packages/flutter3d_shaders/lib/stage_bindings.dart @@ -49,6 +49,12 @@ stageBindings = blocks, Set samplers})>{ blocks: {'CameraVelocityInfo'}, samplers: {'surface_texture'}, ), + 'CausticPhoton': (blocks: {}, samplers: {}), + 'CausticPhotonVertex': ( + blocks: {'CausticInfo'}, + samplers: {'caustic_back', 'caustic_depth', 'caustic_front'}, + ), + 'CausticSurface': (blocks: {}, samplers: {}), 'Composite': ( blocks: {'CompositeInfo', 'FragCoordInfo'}, samplers: { @@ -71,6 +77,17 @@ stageBindings = blocks, Set samplers})>{ ), 'DebugLine': (blocks: {}, samplers: {}), 'DebugLineVertex': (blocks: {'LineInfo'}, samplers: {}), + 'Decal': ( + blocks: {'DecalInfo'}, + samplers: { + 'albedo_texture', + 'decal_texture_0', + 'decal_texture_1', + 'decal_texture_2', + 'decal_texture_3', + 'surface_texture', + }, + ), 'DepthOfField': ( blocks: {'DofInfo', 'FragCoordInfo'}, samplers: {'coc_tile_texture', 'scene_texture', 'surface_texture'}, @@ -139,6 +156,10 @@ stageBindings = blocks, Set samplers})>{ 'shadow_texture', }, ), + 'LensFlare': ( + blocks: {'LensFlareInfo'}, + samplers: {'bloom_texture'}, + ), 'LightShafts': ( blocks: {'FragCoordInfo', 'NoiseInfo', 'ShaftInfo'}, samplers: { @@ -291,6 +312,10 @@ stageBindings = blocks, Set samplers})>{ 'sheen_texture', }, ), + 'PlanarReflection': ( + blocks: {'FogInfo', 'PlanarReflectionInfo'}, + samplers: {'reflection_texture'}, + ), 'PolylineVertex': ( blocks: {'FrameInfo', 'MaterialParams'}, samplers: {}, @@ -316,6 +341,10 @@ stageBindings = blocks, Set samplers})>{ 'surface_texture', }, ), + 'RenderTextureEncode': ( + blocks: {'RenderTextureInfo'}, + samplers: {'source_texture'}, + ), 'SceneColourCopy': ( blocks: {'SceneCopyInfo'}, samplers: {'source_texture'}, @@ -336,10 +365,28 @@ stageBindings = blocks, Set samplers})>{ ), 'ShadowTileReset': (blocks: {}, samplers: {}), 'ShadowTileResetVertex': (blocks: {}, samplers: {}), + 'ShadowTransmittance': ( + blocks: {'TransmittanceInfo'}, + samplers: {'base_color_texture'}, + ), 'Sky': (blocks: {}, samplers: {}), 'SkyCube': (blocks: {}, samplers: {'sky_texture'}), 'SkyCubeVertex': (blocks: {}, samplers: {}), + 'SkyPhysical': (blocks: {}, samplers: {}), + 'SkyPhysicalVertex': (blocks: {}, samplers: {}), 'SkyVertex': (blocks: {}, samplers: {}), + 'SmaaBlend': ( + blocks: {'SmaaInfo'}, + samplers: {'blend_texture', 'source_texture'}, + ), + 'SmaaEdges': ( + blocks: {'SmaaInfo'}, + samplers: {'source_texture'}, + ), + 'SmaaWeights': ( + blocks: {'SmaaInfo'}, + samplers: {'area_texture', 'edges_texture'}, + ), 'Splat': (blocks: {'FogInfo'}, samplers: {}), 'SplatHashed': ( blocks: {'FogInfo', 'SplatHashInfo'}, diff --git a/packages/flutter3d_shaders/lib/translate.dart b/packages/flutter3d_shaders/lib/translate.dart new file mode 100644 index 000000000..999ad864c --- /dev/null +++ b/packages/flutter3d_shaders/lib/translate.dart @@ -0,0 +1,18 @@ +/// The engine's GLSL in the dialects the web backends read: GLSL ES 3.00 for +/// WebGL2, and the prepared GLSL and reflection WebGPU's section is made of. +/// +/// **Pure text in, text out**, with no `dart:io` anywhere under it, so a +/// browser test and a backend's own library can import it as readily as a +/// build script. What runs a compiler is `compile.dart`. +/// +/// **Here rather than in the two backends that wrote it** — P8. A project's +/// build hook compiles its own materials for every backend, and the hook is a +/// process that cannot resolve a package declaring the Flutter SDK, which +/// both `flutter3d_webgl` and `flutter3d_webgpu` do. None of these files ever +/// needed the SDK; only the packages they lived in did. +library; + +export 'src/glsl_to_wgsl.dart'; +export 'src/glsl_translate.dart'; +export 'src/webgl_bundle_section.dart'; +export 'src/wgsl_section.dart'; diff --git a/packages/flutter3d_shaders/lib/typed_blocks.dart b/packages/flutter3d_shaders/lib/typed_blocks.dart index 87b4624dc..6c8ae6235 100644 --- a/packages/flutter3d_shaders/lib/typed_blocks.dart +++ b/packages/flutter3d_shaders/lib/typed_blocks.dart @@ -66,6 +66,51 @@ final class CameraVelocityInfoBlock extends UniformBlock { }; } +/// `CausticInfo`. +final class CausticInfoBlock extends UniformBlock { + CausticInfoBlock() : super('CausticInfo'); + + /// `map_to_world`: Matrix, at byte 0. + final Float32List mapToWorld = Float32List(16); + + /// `world_to_map`: Matrix, at byte 64. + final Float32List worldToMap = Float32List(16); + + /// `world_to_tile`: Matrix, at byte 128. + final Float32List worldToTile = Float32List(16); + + /// `world_to_clip`: Matrix, at byte 192. + final Float32List worldToClip = Float32List(16); + + /// `grid`: Vector4, at byte 256. + final Float32List grid = Float32List(4); + + /// `light`: Vector4, at byte 272. + final Float32List light = Float32List(4); + + /// `optics`: Vector4, at byte 288. + final Float32List optics = Float32List(4); + + /// `tint`: Vector4, at byte 304. + final Float32List tint = Float32List(4); + + /// `attenuation`: Vector4, at byte 320. + final Float32List attenuation = Float32List(4); + + @override + late final Map members = { + 'map_to_world': mapToWorld, + 'world_to_map': worldToMap, + 'world_to_tile': worldToTile, + 'world_to_clip': worldToClip, + 'grid': grid, + 'light': light, + 'optics': optics, + 'tint': tint, + 'attenuation': attenuation, + }; +} + /// `CompositeInfo`. final class CompositeInfoBlock extends UniformBlock { CompositeInfoBlock() : super('CompositeInfo'); @@ -97,6 +142,9 @@ final class CompositeInfoBlock extends UniformBlock { /// `contact`: Vector4, at byte 128. final Float32List contact = Float32List(4); + /// `lens`: Vector4, at byte 144. + final Float32List lens = Float32List(4); + @override late final Map members = { 'params': params, @@ -108,6 +156,7 @@ final class CompositeInfoBlock extends UniformBlock { 'gamma': gamma, 'gain': gain, 'contact': contact, + 'lens': lens, }; } @@ -207,6 +256,67 @@ final class ConvolveInfoBlock extends UniformBlock { }; } +/// `DecalInfo`. +final class DecalInfoBlock extends UniformBlock { + DecalInfoBlock() : super('DecalInfo'); + + /// `inverse_view_projection`: Matrix, at byte 0. + final Float32List inverseViewProjection = Float32List(16); + + /// `camera`: Vector4, at byte 64. + final Float32List camera = Float32List(4); + + /// `forward`: Vector4, at byte 80. + final Float32List forward = Float32List(4); + + /// `params`: Vector4, at byte 96. + final Float32List params = Float32List(4); + + /// `view`: Vector4, at byte 112. + final Float32List view = Float32List(4); + + /// `slots`: 4 × Vector4, at byte 128. + final Float32List slots = Float32List(16); + + /// `axis_x`: 16 × Vector4, at byte 192. + final Float32List axisX = Float32List(64); + + /// `axis_y`: 16 × Vector4, at byte 448. + final Float32List axisY = Float32List(64); + + /// `axis_z`: 16 × Vector4, at byte 704. + final Float32List axisZ = Float32List(64); + + /// `region`: 16 × Vector4, at byte 960. + final Float32List region = Float32List(64); + + /// `color`: 16 × Vector4, at byte 1216. + final Float32List color = Float32List(64); + + /// `fade`: 16 × Vector4, at byte 1472. + final Float32List fade = Float32List(64); + + /// `emissive`: 16 × Vector4, at byte 1728. + final Float32List emissive = Float32List(64); + + @override + late final Map members = { + 'inverse_view_projection': inverseViewProjection, + 'camera': camera, + 'forward': forward, + 'params': params, + 'view': view, + 'slots': slots, + 'axis_x': axisX, + 'axis_y': axisY, + 'axis_z': axisZ, + 'region': region, + 'color': color, + 'fade': fade, + 'emissive': emissive, + }; +} + /// `DepthPyramidInfo`. final class DepthPyramidInfoBlock extends UniformBlock { DepthPyramidInfoBlock() : super('DepthPyramidInfo'); @@ -326,11 +436,15 @@ final class FogInfoBlock extends UniformBlock { /// `forward`: Vector4, at byte 32. final Float32List forward = Float32List(4); + /// `projection`: Vector4, at byte 48. + final Float32List projection = Float32List(4); + @override late final Map members = { 'fog': fog, 'eye': eye, 'forward': forward, + 'projection': projection, }; } @@ -425,6 +539,9 @@ final class FragInfoBlock extends UniformBlock { /// `target_origin`: Vector4, at byte 880. final Float32List targetOrigin = Float32List(4); + /// `debug_view`: Vector4, at byte 896. + final Float32List debugView = Float32List(4); + @override late final Map members = { 'light_position': lightPosition, @@ -446,6 +563,7 @@ final class FragInfoBlock extends UniformBlock { 'ambient_ground': ambientGround, 'shadow_bias': shadowBias, 'target_origin': targetOrigin, + 'debug_view': debugView, }; } @@ -590,6 +708,23 @@ final class LayerInfoBlock extends UniformBlock { }; } +/// `LensFlareInfo`. +final class LensFlareInfoBlock extends UniformBlock { + LensFlareInfoBlock() : super('LensFlareInfo'); + + /// `params`: Vector4, at byte 0. + final Float32List params = Float32List(4); + + /// `more`: Vector4, at byte 16. + final Float32List more = Float32List(4); + + @override + late final Map members = { + 'params': params, + 'more': more, + }; +} + /// `LightListInfo`. final class LightListInfoBlock extends UniformBlock { LightListInfoBlock() : super('LightListInfo'); @@ -816,6 +951,27 @@ final class ParticleMeshInfoBlock extends UniformBlock { }; } +/// `PlanarReflectionInfo`. +final class PlanarReflectionInfoBlock extends UniformBlock { + PlanarReflectionInfoBlock() : super('PlanarReflectionInfo'); + + /// `view`: Vector4, at byte 0. + final Float32List view = Float32List(4); + + /// `params`: Vector4, at byte 16. + final Float32List params = Float32List(4); + + /// `tint`: Vector4, at byte 32. + final Float32List tint = Float32List(4); + + @override + late final Map members = { + 'view': view, + 'params': params, + 'tint': tint, + }; +} + /// `PointShadow`. final class PointShadowBlock extends UniformBlock { PointShadowBlock() : super('PointShadow'); @@ -957,6 +1113,19 @@ final class ReflectionInfoBlock extends UniformBlock { }; } +/// `RenderTextureInfo`. +final class RenderTextureInfoBlock extends UniformBlock { + RenderTextureInfoBlock() : super('RenderTextureInfo'); + + /// `params`: Vector4, at byte 0. + final Float32List params = Float32List(4); + + @override + late final Map members = { + 'params': params, + }; +} + /// `SceneCopyInfo`. final class SceneCopyInfoBlock extends UniformBlock { SceneCopyInfoBlock() : super('SceneCopyInfo'); @@ -1112,6 +1281,19 @@ final class SkinInfoBlock extends UniformBlock { }; } +/// `SmaaInfo`. +final class SmaaInfoBlock extends UniformBlock { + SmaaInfoBlock() : super('SmaaInfo'); + + /// `params`: Vector4, at byte 0. + final Float32List params = Float32List(4); + + @override + late final Map members = { + 'params': params, + }; +} + /// `SoftParticleInfo`. final class SoftParticleInfoBlock extends UniformBlock { SoftParticleInfoBlock() : super('SoftParticleInfo'); @@ -1250,6 +1432,27 @@ final class TileMaxInfoBlock extends UniformBlock { }; } +/// `TransmittanceInfo`. +final class TransmittanceInfoBlock extends UniformBlock { + TransmittanceInfoBlock() : super('TransmittanceInfo'); + + /// `color`: Vector4, at byte 0. + final Float32List color = Float32List(4); + + /// `light`: Vector4, at byte 16. + final Float32List light = Float32List(4); + + /// `params`: Vector4, at byte 32. + final Float32List params = Float32List(4); + + @override + late final Map members = { + 'color': color, + 'light': light, + 'params': params, + }; +} + /// `VelocityInfo`. final class VelocityInfoBlock extends UniformBlock { VelocityInfoBlock() : super('VelocityInfo'); diff --git a/packages/flutter3d_shaders/lib/uniform_blocks.dart b/packages/flutter3d_shaders/lib/uniform_blocks.dart index bc5e0f742..2a3dd3158 100644 --- a/packages/flutter3d_shaders/lib/uniform_blocks.dart +++ b/packages/flutter3d_shaders/lib/uniform_blocks.dart @@ -23,6 +23,7 @@ uniformBlocks = >>{ 'fog': (offset: 0, byteLength: 16, elements: 1, type: 'Vector4'), 'eye': (offset: 16, byteLength: 16, elements: 1, type: 'Vector4'), 'forward': (offset: 32, byteLength: 16, elements: 1, type: 'Vector4'), + 'projection': (offset: 48, byteLength: 16, elements: 1, type: 'Vector4'), }, 'FragInfo': { 'light_position': ( @@ -119,6 +120,7 @@ uniformBlocks = >>{ elements: 1, type: 'Vector4', ), + 'debug_view': (offset: 896, byteLength: 16, elements: 1, type: 'Vector4'), }, 'IrradianceInfo': { 'origin': (offset: 0, byteLength: 16, elements: 1, type: 'Vector4'), @@ -194,6 +196,34 @@ uniformBlocks = >>{ 'forward': (offset: 144, byteLength: 16, elements: 1, type: 'Vector4'), }, }, + 'CausticPhotonVertex': >{ + 'CausticInfo': { + 'map_to_world': (offset: 0, byteLength: 64, elements: 1, type: 'Matrix'), + 'world_to_map': (offset: 64, byteLength: 64, elements: 1, type: 'Matrix'), + 'world_to_tile': ( + offset: 128, + byteLength: 64, + elements: 1, + type: 'Matrix', + ), + 'world_to_clip': ( + offset: 192, + byteLength: 64, + elements: 1, + type: 'Matrix', + ), + 'grid': (offset: 256, byteLength: 16, elements: 1, type: 'Vector4'), + 'light': (offset: 272, byteLength: 16, elements: 1, type: 'Vector4'), + 'optics': (offset: 288, byteLength: 16, elements: 1, type: 'Vector4'), + 'tint': (offset: 304, byteLength: 16, elements: 1, type: 'Vector4'), + 'attenuation': ( + offset: 320, + byteLength: 16, + elements: 1, + type: 'Vector4', + ), + }, + }, 'Composite': >{ 'CompositeInfo': { 'params': (offset: 0, byteLength: 16, elements: 1, type: 'Vector4'), @@ -210,6 +240,7 @@ uniformBlocks = >>{ 'gamma': (offset: 96, byteLength: 16, elements: 1, type: 'Vector4'), 'gain': (offset: 112, byteLength: 16, elements: 1, type: 'Vector4'), 'contact': (offset: 128, byteLength: 16, elements: 1, type: 'Vector4'), + 'lens': (offset: 144, byteLength: 16, elements: 1, type: 'Vector4'), }, 'FragCoordInfo': { 'origin': (offset: 0, byteLength: 16, elements: 1, type: 'Vector4'), @@ -256,6 +287,33 @@ uniformBlocks = >>{ ), }, }, + 'Decal': >{ + 'DecalInfo': { + 'inverse_view_projection': ( + offset: 0, + byteLength: 64, + elements: 1, + type: 'Matrix', + ), + 'camera': (offset: 64, byteLength: 16, elements: 1, type: 'Vector4'), + 'forward': (offset: 80, byteLength: 16, elements: 1, type: 'Vector4'), + 'params': (offset: 96, byteLength: 16, elements: 1, type: 'Vector4'), + 'view': (offset: 112, byteLength: 16, elements: 1, type: 'Vector4'), + 'slots': (offset: 128, byteLength: 64, elements: 4, type: 'Vector4'), + 'axis_x': (offset: 192, byteLength: 256, elements: 16, type: 'Vector4'), + 'axis_y': (offset: 448, byteLength: 256, elements: 16, type: 'Vector4'), + 'axis_z': (offset: 704, byteLength: 256, elements: 16, type: 'Vector4'), + 'region': (offset: 960, byteLength: 256, elements: 16, type: 'Vector4'), + 'color': (offset: 1216, byteLength: 256, elements: 16, type: 'Vector4'), + 'fade': (offset: 1472, byteLength: 256, elements: 16, type: 'Vector4'), + 'emissive': ( + offset: 1728, + byteLength: 256, + elements: 16, + type: 'Vector4', + ), + }, + }, 'DepthOfField': >{ 'DofInfo': { 'lens': (offset: 0, byteLength: 16, elements: 1, type: 'Vector4'), @@ -310,6 +368,7 @@ uniformBlocks = >>{ 'fog': (offset: 0, byteLength: 16, elements: 1, type: 'Vector4'), 'eye': (offset: 16, byteLength: 16, elements: 1, type: 'Vector4'), 'forward': (offset: 32, byteLength: 16, elements: 1, type: 'Vector4'), + 'projection': (offset: 48, byteLength: 16, elements: 1, type: 'Vector4'), }, 'FragInfo': { 'light_position': ( @@ -406,6 +465,7 @@ uniformBlocks = >>{ elements: 1, type: 'Vector4', ), + 'debug_view': (offset: 896, byteLength: 16, elements: 1, type: 'Vector4'), }, 'LightListInfo': { 'list': (offset: 0, byteLength: 16, elements: 1, type: 'Vector4'), @@ -464,6 +524,7 @@ uniformBlocks = >>{ 'fog': (offset: 0, byteLength: 16, elements: 1, type: 'Vector4'), 'eye': (offset: 16, byteLength: 16, elements: 1, type: 'Vector4'), 'forward': (offset: 32, byteLength: 16, elements: 1, type: 'Vector4'), + 'projection': (offset: 48, byteLength: 16, elements: 1, type: 'Vector4'), }, 'FragInfo': { 'light_position': ( @@ -560,6 +621,7 @@ uniformBlocks = >>{ elements: 1, type: 'Vector4', ), + 'debug_view': (offset: 896, byteLength: 16, elements: 1, type: 'Vector4'), }, 'IrradianceInfo': { 'origin': (offset: 0, byteLength: 16, elements: 1, type: 'Vector4'), @@ -601,6 +663,12 @@ uniformBlocks = >>{ 'params3': (offset: 2560, byteLength: 16, elements: 1, type: 'Vector4'), }, }, + 'LensFlare': >{ + 'LensFlareInfo': { + 'params': (offset: 0, byteLength: 16, elements: 1, type: 'Vector4'), + 'more': (offset: 16, byteLength: 16, elements: 1, type: 'Vector4'), + }, + }, 'LightShafts': >{ 'FragCoordInfo': { 'origin': (offset: 0, byteLength: 16, elements: 1, type: 'Vector4'), @@ -796,6 +864,7 @@ uniformBlocks = >>{ 'fog': (offset: 0, byteLength: 16, elements: 1, type: 'Vector4'), 'eye': (offset: 16, byteLength: 16, elements: 1, type: 'Vector4'), 'forward': (offset: 32, byteLength: 16, elements: 1, type: 'Vector4'), + 'projection': (offset: 48, byteLength: 16, elements: 1, type: 'Vector4'), }, }, 'ObjectId': >{ @@ -808,12 +877,16 @@ uniformBlocks = >>{ 'FogInfo': { 'fog': (offset: 0, byteLength: 16, elements: 1, type: 'Vector4'), 'eye': (offset: 16, byteLength: 16, elements: 1, type: 'Vector4'), + 'forward': (offset: 32, byteLength: 16, elements: 1, type: 'Vector4'), + 'projection': (offset: 48, byteLength: 16, elements: 1, type: 'Vector4'), }, }, 'ParticleMesh': >{ 'FogInfo': { 'fog': (offset: 0, byteLength: 16, elements: 1, type: 'Vector4'), 'eye': (offset: 16, byteLength: 16, elements: 1, type: 'Vector4'), + 'forward': (offset: 32, byteLength: 16, elements: 1, type: 'Vector4'), + 'projection': (offset: 48, byteLength: 16, elements: 1, type: 'Vector4'), }, }, 'ParticleMeshVertex': >{ @@ -857,6 +930,8 @@ uniformBlocks = >>{ 'FogInfo': { 'fog': (offset: 0, byteLength: 16, elements: 1, type: 'Vector4'), 'eye': (offset: 16, byteLength: 16, elements: 1, type: 'Vector4'), + 'forward': (offset: 32, byteLength: 16, elements: 1, type: 'Vector4'), + 'projection': (offset: 48, byteLength: 16, elements: 1, type: 'Vector4'), }, 'LightListInfo': { 'list': (offset: 0, byteLength: 16, elements: 1, type: 'Vector4'), @@ -921,6 +996,8 @@ uniformBlocks = >>{ 'FogInfo': { 'fog': (offset: 0, byteLength: 16, elements: 1, type: 'Vector4'), 'eye': (offset: 16, byteLength: 16, elements: 1, type: 'Vector4'), + 'forward': (offset: 32, byteLength: 16, elements: 1, type: 'Vector4'), + 'projection': (offset: 48, byteLength: 16, elements: 1, type: 'Vector4'), }, 'LightListInfo': { 'list': (offset: 0, byteLength: 16, elements: 1, type: 'Vector4'), @@ -963,6 +1040,8 @@ uniformBlocks = >>{ 'FogInfo': { 'fog': (offset: 0, byteLength: 16, elements: 1, type: 'Vector4'), 'eye': (offset: 16, byteLength: 16, elements: 1, type: 'Vector4'), + 'forward': (offset: 32, byteLength: 16, elements: 1, type: 'Vector4'), + 'projection': (offset: 48, byteLength: 16, elements: 1, type: 'Vector4'), }, 'SoftParticleInfo': { 'target': (offset: 0, byteLength: 16, elements: 1, type: 'Vector4'), @@ -974,12 +1053,16 @@ uniformBlocks = >>{ 'FogInfo': { 'fog': (offset: 0, byteLength: 16, elements: 1, type: 'Vector4'), 'eye': (offset: 16, byteLength: 16, elements: 1, type: 'Vector4'), + 'forward': (offset: 32, byteLength: 16, elements: 1, type: 'Vector4'), + 'projection': (offset: 48, byteLength: 16, elements: 1, type: 'Vector4'), }, }, 'ParticleTexturedSoft': >{ 'FogInfo': { 'fog': (offset: 0, byteLength: 16, elements: 1, type: 'Vector4'), 'eye': (offset: 16, byteLength: 16, elements: 1, type: 'Vector4'), + 'forward': (offset: 32, byteLength: 16, elements: 1, type: 'Vector4'), + 'projection': (offset: 48, byteLength: 16, elements: 1, type: 'Vector4'), }, 'SoftParticleInfo': { 'target': (offset: 0, byteLength: 16, elements: 1, type: 'Vector4'), @@ -1002,6 +1085,7 @@ uniformBlocks = >>{ 'fog': (offset: 0, byteLength: 16, elements: 1, type: 'Vector4'), 'eye': (offset: 16, byteLength: 16, elements: 1, type: 'Vector4'), 'forward': (offset: 32, byteLength: 16, elements: 1, type: 'Vector4'), + 'projection': (offset: 48, byteLength: 16, elements: 1, type: 'Vector4'), }, 'FragInfo': { 'light_position': ( @@ -1098,6 +1182,7 @@ uniformBlocks = >>{ elements: 1, type: 'Vector4', ), + 'debug_view': (offset: 896, byteLength: 16, elements: 1, type: 'Vector4'), }, 'IrradianceInfo': { 'origin': (offset: 0, byteLength: 16, elements: 1, type: 'Vector4'), @@ -1144,6 +1229,7 @@ uniformBlocks = >>{ 'fog': (offset: 0, byteLength: 16, elements: 1, type: 'Vector4'), 'eye': (offset: 16, byteLength: 16, elements: 1, type: 'Vector4'), 'forward': (offset: 32, byteLength: 16, elements: 1, type: 'Vector4'), + 'projection': (offset: 48, byteLength: 16, elements: 1, type: 'Vector4'), }, 'FragInfo': { 'light_position': ( @@ -1240,6 +1326,7 @@ uniformBlocks = >>{ elements: 1, type: 'Vector4', ), + 'debug_view': (offset: 896, byteLength: 16, elements: 1, type: 'Vector4'), }, 'IrradianceInfo': { 'origin': (offset: 0, byteLength: 16, elements: 1, type: 'Vector4'), @@ -1325,6 +1412,19 @@ uniformBlocks = >>{ 'params3': (offset: 2560, byteLength: 16, elements: 1, type: 'Vector4'), }, }, + 'PlanarReflection': >{ + 'FogInfo': { + 'fog': (offset: 0, byteLength: 16, elements: 1, type: 'Vector4'), + 'eye': (offset: 16, byteLength: 16, elements: 1, type: 'Vector4'), + 'forward': (offset: 32, byteLength: 16, elements: 1, type: 'Vector4'), + 'projection': (offset: 48, byteLength: 16, elements: 1, type: 'Vector4'), + }, + 'PlanarReflectionInfo': { + 'view': (offset: 0, byteLength: 16, elements: 1, type: 'Vector4'), + 'params': (offset: 16, byteLength: 16, elements: 1, type: 'Vector4'), + 'tint': (offset: 32, byteLength: 16, elements: 1, type: 'Vector4'), + }, + }, 'PolylineVertex': >{ 'FrameInfo': { 'mvp': (offset: 0, byteLength: 64, elements: 1, type: 'Matrix'), @@ -1393,6 +1493,11 @@ uniformBlocks = >>{ ), }, }, + 'RenderTextureEncode': >{ + 'RenderTextureInfo': { + 'params': (offset: 0, byteLength: 16, elements: 1, type: 'Vector4'), + }, + }, 'SceneColourCopy': >{ 'SceneCopyInfo': { 'params': (offset: 0, byteLength: 16, elements: 1, type: 'Vector4'), @@ -1422,16 +1527,42 @@ uniformBlocks = >>{ 'light': (offset: 0, byteLength: 16, elements: 1, type: 'Vector4'), }, }, + 'ShadowTransmittance': >{ + 'TransmittanceInfo': { + 'color': (offset: 0, byteLength: 16, elements: 1, type: 'Vector4'), + 'light': (offset: 16, byteLength: 16, elements: 1, type: 'Vector4'), + 'params': (offset: 32, byteLength: 16, elements: 1, type: 'Vector4'), + }, + }, + 'SmaaBlend': >{ + 'SmaaInfo': { + 'params': (offset: 0, byteLength: 16, elements: 1, type: 'Vector4'), + }, + }, + 'SmaaEdges': >{ + 'SmaaInfo': { + 'params': (offset: 0, byteLength: 16, elements: 1, type: 'Vector4'), + }, + }, + 'SmaaWeights': >{ + 'SmaaInfo': { + 'params': (offset: 0, byteLength: 16, elements: 1, type: 'Vector4'), + }, + }, 'Splat': >{ 'FogInfo': { 'fog': (offset: 0, byteLength: 16, elements: 1, type: 'Vector4'), 'eye': (offset: 16, byteLength: 16, elements: 1, type: 'Vector4'), + 'forward': (offset: 32, byteLength: 16, elements: 1, type: 'Vector4'), + 'projection': (offset: 48, byteLength: 16, elements: 1, type: 'Vector4'), }, }, 'SplatHashed': >{ 'FogInfo': { 'fog': (offset: 0, byteLength: 16, elements: 1, type: 'Vector4'), 'eye': (offset: 16, byteLength: 16, elements: 1, type: 'Vector4'), + 'forward': (offset: 32, byteLength: 16, elements: 1, type: 'Vector4'), + 'projection': (offset: 48, byteLength: 16, elements: 1, type: 'Vector4'), }, 'SplatHashInfo': { 'frame': (offset: 0, byteLength: 16, elements: 1, type: 'Vector4'), @@ -1486,6 +1617,7 @@ uniformBlocks = >>{ 'fog': (offset: 0, byteLength: 16, elements: 1, type: 'Vector4'), 'eye': (offset: 16, byteLength: 16, elements: 1, type: 'Vector4'), 'forward': (offset: 32, byteLength: 16, elements: 1, type: 'Vector4'), + 'projection': (offset: 48, byteLength: 16, elements: 1, type: 'Vector4'), }, 'FragInfo': { 'light_position': ( @@ -1582,6 +1714,7 @@ uniformBlocks = >>{ elements: 1, type: 'Vector4', ), + 'debug_view': (offset: 896, byteLength: 16, elements: 1, type: 'Vector4'), }, 'IrradianceInfo': { 'origin': (offset: 0, byteLength: 16, elements: 1, type: 'Vector4'), @@ -1628,6 +1761,7 @@ uniformBlocks = >>{ 'fog': (offset: 0, byteLength: 16, elements: 1, type: 'Vector4'), 'eye': (offset: 16, byteLength: 16, elements: 1, type: 'Vector4'), 'forward': (offset: 32, byteLength: 16, elements: 1, type: 'Vector4'), + 'projection': (offset: 48, byteLength: 16, elements: 1, type: 'Vector4'), }, 'FragInfo': { 'light_position': ( @@ -1724,6 +1858,7 @@ uniformBlocks = >>{ elements: 1, type: 'Vector4', ), + 'debug_view': (offset: 896, byteLength: 16, elements: 1, type: 'Vector4'), }, }, 'Velocity': >{ @@ -1963,6 +2098,7 @@ uniformBlocks = >>{ 'fog': (offset: 0, byteLength: 16, elements: 1, type: 'Vector4'), 'eye': (offset: 16, byteLength: 16, elements: 1, type: 'Vector4'), 'forward': (offset: 32, byteLength: 16, elements: 1, type: 'Vector4'), + 'projection': (offset: 48, byteLength: 16, elements: 1, type: 'Vector4'), }, 'FragInfo': { 'light_position': ( @@ -2059,6 +2195,7 @@ uniformBlocks = >>{ elements: 1, type: 'Vector4', ), + 'debug_view': (offset: 896, byteLength: 16, elements: 1, type: 'Vector4'), }, }, }; diff --git a/packages/flutter3d_shaders/shaders/flutter3d.shaderbundle.json b/packages/flutter3d_shaders/shaders/flutter3d.shaderbundle.json index e0181a153..8d82d96fd 100644 --- a/packages/flutter3d_shaders/shaders/flutter3d.shaderbundle.json +++ b/packages/flutter3d_shaders/shaders/flutter3d.shaderbundle.json @@ -11,6 +11,10 @@ "type": "fragment", "file": "shaders/lighting/xray.frag" }, + "PlanarReflection": { + "type": "fragment", + "file": "shaders/lighting/planar_reflection.frag" + }, "Lambert": { "type": "fragment", "file": "shaders/lighting/lambert.frag" @@ -79,6 +83,22 @@ "type": "fragment", "file": "shaders/post/fxaa.frag" }, + "SmaaEdges": { + "type": "fragment", + "file": "shaders/post/smaa_edges.frag" + }, + "SmaaWeights": { + "type": "fragment", + "file": "shaders/post/smaa_weights.frag" + }, + "SmaaBlend": { + "type": "fragment", + "file": "shaders/post/smaa_blend.frag" + }, + "LensFlare": { + "type": "fragment", + "file": "shaders/post/lens_flare.frag" + }, "MrtProbe": { "type": "fragment", "file": "shaders/post/mrt_probe.frag" @@ -239,10 +259,18 @@ "type": "fragment", "file": "shaders/post/viewport_shade.frag" }, + "Decal": { + "type": "fragment", + "file": "shaders/post/decal.frag" + }, "ProbePrefilter": { "type": "fragment", "file": "shaders/post/probe_prefilter.frag" }, + "RenderTextureEncode": { + "type": "fragment", + "file": "shaders/post/render_texture_encode.frag" + }, "ShadowDistance": { "type": "fragment", "file": "shaders/lighting/shadow_distance.frag" @@ -255,6 +283,22 @@ "type": "fragment", "file": "shaders/lighting/shadow_distance_masked.frag" }, + "CausticSurface": { + "type": "fragment", + "file": "shaders/lighting/caustic_surface.frag" + }, + "CausticPhotonVertex": { + "type": "vertex", + "file": "shaders/lighting/caustic_photon.vert" + }, + "CausticPhoton": { + "type": "fragment", + "file": "shaders/lighting/caustic_photon.frag" + }, + "ShadowTransmittance": { + "type": "fragment", + "file": "shaders/lighting/shadow_transmittance.frag" + }, "ShadowCopy": { "type": "fragment", "file": "shaders/lighting/shadow_copy.frag" @@ -287,6 +331,14 @@ "type": "fragment", "file": "shaders/sky_cube.frag" }, + "SkyPhysicalVertex": { + "type": "vertex", + "file": "shaders/sky_physical.vert" + }, + "SkyPhysical": { + "type": "fragment", + "file": "shaders/sky_physical.frag" + }, "Luminance": { "type": "fragment", "file": "shaders/post/luminance.frag" diff --git a/packages/flutter3d_shaders/shaders/impostor.vert b/packages/flutter3d_shaders/shaders/impostor.vert index 2517df8cd..f99b2d7c8 100644 --- a/packages/flutter3d_shaders/shaders/impostor.vert +++ b/packages/flutter3d_shaders/shaders/impostor.vert @@ -58,6 +58,9 @@ out vec2 v_texcoord; out vec4 v_tangent; out vec4 v_color; out vec2 v_lightmap_uv; +/// `P8`: an instance's own four numbers, a material's `instance` — from +/// slot 1 in the instanced stage, nought in every other. +out vec4 v_instance; vec4 MvpRow(int r) { return vec4(frame_info.mvp[0][r], frame_info.mvp[1][r], @@ -94,6 +97,7 @@ void main() { v_texcoord = texcoord; v_color = vec4(d, color.a); v_lightmap_uv = vec2(0.0); + v_instance = vec4(0.0); gl_Position = frame_info.mvp * vec4(corner, 1.0); } diff --git a/packages/flutter3d_shaders/shaders/lib/color.glsl b/packages/flutter3d_shaders/shaders/lib/color.glsl index b646208a5..5f09617c0 100644 --- a/packages/flutter3d_shaders/shaders/lib/color.glsl +++ b/packages/flutter3d_shaders/shaders/lib/color.glsl @@ -100,6 +100,13 @@ bool g_debug_surface_on = false; /// A global for the reason [g_debug_surface] is one. bool g_premultiply = false; +/// How much of what is already behind a blended surface comes through it — +/// a thin pane of glass, `M3`: what the blend multiplies the target by is +/// one minus the alpha, so the alpha written is the coverage less this share +/// of it, while the colour stays weighted by the coverage alone. Nought for +/// everything else, which writes what it always wrote. +float g_pass_through = 0.0; + // **A stage that needs none of this must be able to declare none of it.** On // Vulkan both stages' descriptors are merged into one set layout, and two // bindings with the same number in it is not a layout the specification @@ -116,11 +123,13 @@ bool g_premultiply = false; /// way to move offsets nobody expected to move. Three vec4s is a cheap price /// for not touching any of that. uniform FogInfo { - /// rgb: linear fog colour. w: density per metre, zero for no fog. + /// rgb: linear fog colour. w: density per metre at the eye, zero for no + /// fog. vec4 fog; /// xyz: camera position in world space. Duplicated from FragInfo so this - /// block stands alone; a vec3 is cheaper than a coupling. + /// block stands alone; a vec3 is cheaper than a coupling. w: how fast the + /// fog thins upwards, per metre — `P5`, see [ApplyFog]; nought is flat fog. vec4 eye; /// xyz: the direction the camera looks, as a unit vector in world space. @@ -131,15 +140,24 @@ uniform FogInfo { /// question [eye] does — where the camera is and which way it faces — and /// this is the block `color.glsl` can see. vec4 forward; + + /// x: one when the camera's projection is orthographic, nought when it is + /// perspective — `P7`, see [Orthographic]. y, z, w unused. Appended, so + /// every offset above stays where four backends agree on it. + vec4 projection; } fog_info; -/// How far this fragment is from the eye, in world metres. +/// Whether the camera is orthographic — `P7`. /// -/// What the fog fades by. Distance rather than depth, because fog is a -/// property of the air between two points and does not care which way the -/// camera happens to face. -float EyeDistance() { return distance(v_world_position, fog_info.eye.xyz); } +/// **What every eye-relative quantity has to ask first.** Through a +/// perspective lens the rays meet at the eye, so the way to the eye and the +/// distance to it are the fragment's own; through an orthographic one the +/// rays are parallel and the eye is only where the camera was put along its +/// axis, which moves nothing in the picture. Reading the eye's position as if +/// it were a point the light travels to slides highlights across the frame +/// as the camera pans and lays fog in rings round a point nobody sees. +bool Orthographic() { return fog_info.projection.x > 0.5; } /// How far this fragment is *along the view axis*, in world metres. /// @@ -154,6 +172,27 @@ float ViewDepth() { return dot(v_world_position - fog_info.eye.xyz, fog_info.forward.xyz); } +/// How far this fragment is from the eye, in world metres. +/// +/// What the fog fades by. Distance rather than depth, because fog is a +/// property of the air between two points and does not care which way the +/// camera happens to face — through a perspective lens. Through an +/// orthographic one every ray starts on the eye's plane, so the air a ray +/// crosses is its depth from that plane, never less than nought. +float EyeDistance() { + return Orthographic() ? max(ViewDepth(), 0.0) + : distance(v_world_position, fog_info.eye.xyz); +} + +/// The unit direction from this fragment back along the ray that reached +/// it: to the eye through a perspective lens, against the view axis through +/// an orthographic one — `P7`. What a highlight, a Fresnel term and a +/// reflection are measured from. +vec3 TowardsEye() { + return Orthographic() ? -fog_info.forward.xyz + : normalize(fog_info.eye.xyz - v_world_position); +} + #else // F3D_NO_FOG // The same two questions, answered without the block: a stage that declares no @@ -246,6 +285,16 @@ void WriteSurfaceGeometry(float roughness) { /// /// Exponential rather than linear, because linear fog has a visible plane /// where it starts and a dungeon corridor is exactly where that shows. +/// +/// **Height fog — `P5`.** `fog_info.fog.w` is the density at the eye and +/// `fog_info.eye.w` how fast it falls off upwards, per metre. The density +/// along the ray is then `density · e^(−falloff · Δy · t)` for t from nought +/// to one, and its mean over the ray is `(1 − e^(−k)) / k` with +/// `k = falloff · Δy`: an exact integral, so a fog lying on the ground costs +/// one exponential here and no march. Below a thousandth `k` is answered by +/// the first two terms of the same series, because the quotient there is +/// two nearly equal numbers divided by a small one. A falloff of nought skips +/// all of it, which keeps a flat fog the same to the bit. vec3 ApplyFog(vec3 color) { #ifdef F3D_NO_FOG return color; @@ -253,6 +302,11 @@ vec3 ApplyFog(vec3 color) { float density = fog_info.fog.w; if (density <= 0.0) return color; float d = EyeDistance(); + float falloff = fog_info.eye.w; + if (falloff > 0.0) { + float k = falloff * (v_world_position.y - fog_info.eye.y); + density *= abs(k) > 1e-3 ? (1.0 - exp(-k)) / k : 1.0 - 0.5 * k; + } return mix(fog_info.fog.rgb, color, clamp(exp(-density * d), 0.0, 1.0)); #endif } @@ -307,7 +361,8 @@ void WriteWeightedBlended() { /// which is exact, so an opaque draw writes what it always wrote. void WriteSurface(vec3 linearColor, float alpha, float roughness) { float weight = g_premultiply ? alpha : 1.0; - frag_color = vec4(ApplyFog(linearColor) * weight, alpha); + frag_color = vec4(ApplyFog(linearColor) * weight, + alpha * (1.0 - g_pass_through)); WriteSurfaceGeometry(roughness); WriteWeightedBlended(); } diff --git a/packages/flutter3d_shaders/shaders/lib/contributor_eye.glsl b/packages/flutter3d_shaders/shaders/lib/contributor_eye.glsl new file mode 100644 index 000000000..81b65834c --- /dev/null +++ b/packages/flutter3d_shaders/shaders/lib/contributor_eye.glsl @@ -0,0 +1,54 @@ +// The eye and the fog, for the stages that share none of the lit path's +// headers — particles, mesh particles, splats. +// +// They have a vertex layout of their own and none of the lit shaders' +// varyings, so `color.glsl` does not apply; this is the part of it they need: +// the `FogInfo` block as the lit stages declare it, and the two eye-relative +// questions asked of a world position passed in rather than of a varying. +// +// **All four members, `P7`.** The block used to stop after `eye`, and through +// an orthographic camera the fog then lay in rings round the eye's position +// — a point the picture does not depend on — and a mesh particle's faces were +// lit by how squarely they faced it. A stage that declares this block has to +// be bound all four, which is what the contributors do. + +#ifndef CONTRIBUTOR_EYE_GLSL_ +#define CONTRIBUTOR_EYE_GLSL_ + +uniform FogInfo { + /// rgb: linear fog colour. w: density per metre at the eye, zero for no + /// fog. + vec4 fog; + + /// xyz: camera position in world space. + vec4 eye; + + /// xyz: the direction the camera looks, as a unit vector in world space. + vec4 forward; + + /// x: one when the camera's projection is orthographic, nought when it is + /// perspective. + vec4 projection; +} +fog_info; + +/// How much air lies between [world] and the eye, in world metres: the +/// distance to it through a perspective lens, the depth from its plane +/// through an orthographic one, where every ray starts on that plane. +float FogDistance(vec3 world) { + return fog_info.projection.x > 0.5 + ? max(dot(world - fog_info.eye.xyz, fog_info.forward.xyz), 0.0) + : distance(world, fog_info.eye.xyz); +} + +/// The unit direction from [world] back along the ray that reached it: to +/// the eye through a perspective lens, against the view axis through an +/// orthographic one. Nought where [world] is the eye. +vec3 TowardsEyeFrom(vec3 world) { + if (fog_info.projection.x > 0.5) return -fog_info.forward.xyz; + vec3 to_eye = fog_info.eye.xyz - world; + float length_to_eye = length(to_eye); + return length_to_eye > 0.0 ? to_eye / length_to_eye : vec3(0.0); +} + +#endif // CONTRIBUTOR_EYE_GLSL_ diff --git a/packages/flutter3d_shaders/shaders/lib/particle.glsl b/packages/flutter3d_shaders/shaders/lib/particle.glsl index 7227e080a..92884c1b1 100644 --- a/packages/flutter3d_shaders/shaders/lib/particle.glsl +++ b/packages/flutter3d_shaders/shaders/lib/particle.glsl @@ -28,23 +28,7 @@ in vec3 v_world_position; out vec4 frag_color; -/// The lit shaders' block, declared again because this shader shares none of -/// their headers — it has a different vertex layout and none of their varyings. -/// -/// **The first two members of it, not all three.** `color.glsl` carries a -/// `forward` beside these, for the view axis the surface buffer measures its -/// depths along; a particle writes no surface buffer, so it neither declares -/// that member nor is bound one. The two blocks share a name and not a shape, -/// which is fine — they belong to different programs — and a check that binds -/// this one has to bind what it declares. -uniform FogInfo { - /// rgb: linear fog colour. w: density per metre, zero for no fog. - vec4 fog; - - /// xyz: camera position in world space. - vec4 eye; -} -fog_info; +#include void main() { // Distance from the middle of the quad, where the corners sit at 1. @@ -65,7 +49,7 @@ void main() { float fogged = 1.0; if (fog_info.fog.w > 0.0) { fogged = clamp( - exp(-fog_info.fog.w * distance(v_world_position, fog_info.eye.xyz)), + exp(-fog_info.fog.w * FogDistance(v_world_position)), 0.0, 1.0); } diff --git a/packages/flutter3d_shaders/shaders/lib/particle_six_way.glsl b/packages/flutter3d_shaders/shaders/lib/particle_six_way.glsl index 49dcc3047..93590f6a0 100644 --- a/packages/flutter3d_shaders/shaders/lib/particle_six_way.glsl +++ b/packages/flutter3d_shaders/shaders/lib/particle_six_way.glsl @@ -48,16 +48,7 @@ out vec4 frag_color; uniform sampler2D six_way_positive; uniform sampler2D six_way_negative; -/// Declared again, as in the other particle stages, since this shares none of -/// the lit path's headers. -uniform FogInfo { - /// rgb: linear fog colour. w: density per metre, zero for no fog. - vec4 fog; - - /// xyz: camera position in world space. - vec4 eye; -} -fog_info; +#include uniform SixWayInfo { /// xyz: the quad's right, which is the camera's, in world space. @@ -120,7 +111,7 @@ void main() { float fogged = 1.0; if (fog_info.fog.w > 0.0) { fogged = clamp( - exp(-fog_info.fog.w * distance(v_world_position, fog_info.eye.xyz)), + exp(-fog_info.fog.w * FogDistance(v_world_position)), 0.0, 1.0); } diff --git a/packages/flutter3d_shaders/shaders/lib/particle_textured.glsl b/packages/flutter3d_shaders/shaders/lib/particle_textured.glsl index aef661d5e..2b21c0b42 100644 --- a/packages/flutter3d_shaders/shaders/lib/particle_textured.glsl +++ b/packages/flutter3d_shaders/shaders/lib/particle_textured.glsl @@ -27,16 +27,7 @@ out vec4 frag_color; uniform sampler2D particle_texture; -/// Declared again for the same reason the other particle stages declare it: -/// this shader shares none of the lit path's headers. -uniform FogInfo { - /// rgb: linear fog colour. w: density per metre, zero for no fog. - vec4 fog; - - /// xyz: camera position in world space. - vec4 eye; -} -fog_info; +#include void main() { // `texture`, not `textureLod`. The level is chosen from the derivative the @@ -52,7 +43,7 @@ void main() { float fogged = 1.0; if (fog_info.fog.w > 0.0) { fogged = clamp( - exp(-fog_info.fog.w * distance(v_world_position, fog_info.eye.xyz)), + exp(-fog_info.fog.w * FogDistance(v_world_position)), 0.0, 1.0); } diff --git a/packages/flutter3d_shaders/shaders/lib/pbr.glsl b/packages/flutter3d_shaders/shaders/lib/pbr.glsl index e1aed95f0..c52e9eddf 100644 --- a/packages/flutter3d_shaders/shaders/lib/pbr.glsl +++ b/packages/flutter3d_shaders/shaders/lib/pbr.glsl @@ -56,7 +56,8 @@ uniform LayerInfo { /// `KHR_materials_dispersion` — `M3`. vec4 transmission; - /// rgb: the volume's attenuation colour, linear. w: unused. + /// rgb: the volume's attenuation colour, linear. w: one when the volume + /// is a convex body — `MaterialExtensions.convexVolume`. vec4 attenuation; /// x: `KHR_materials_iridescence`, y: the film's index of refraction, z: @@ -154,6 +155,16 @@ vec3 g_transmittance = vec3(1.0); float g_iridescence = 0.0; vec3 g_irid_fresnel = vec3(0.04); +/// Whether this is a thin pane over whatever is behind it — `M3`: a blended +/// surface that transmits and has no volume. Light crosses a thin wall +/// without bending, so what is behind it is exactly what the target already +/// holds where it is drawn, and the blend lets that through +/// ([g_pass_through]) rather than the shader reading it from the copy. The +/// copy is taken before any transmissive draw, so read from it, the liquid +/// in a glass tube vanished behind the tube's own wall, which showed the +/// table where the liquid stood. +bool g_pane = false; + /// Whether the index is `KHR_materials_ior`'s nought: the value its /// specular-glossiness migration writes, which means an index of infinity — /// a Fresnel of one at every angle, and no dispersion. @@ -200,6 +211,15 @@ void ReadLayers(Surface s) { // `M3`: the coat map's other two lanes. g_transmission = clamp(layer_info.transmission.x * coatTexel.b, 0.0, 1.0); g_thickness = max(layer_info.transmission.y * coatTexel.a, 0.0); + // `MaterialExtensions.convexVolume`: the thickness is the body's depth + // through its middle, and a ray crosses as much of it as squarely as the + // bent ray meets the surface — Filament's solid sphere. Kept above nought, + // where nought means a thin wall. + if (layer_info.attenuation.w > 0.5 && g_thickness > 0.0) { + vec3 bent = refract(-s.v, s.n, 1.0 / RefractionIor()); + g_thickness = max(g_thickness * max(-dot(s.n, bent), 0.0), + 1e-4 * g_thickness); + } // Beer's law over the thickness: what is left of each colour after the // attenuation distance is the attenuation colour. float distance = layer_info.transmission.z; @@ -732,7 +752,9 @@ void main() { // `M3`: without an environment the light passing through is the flat // ambient too, less what the medium takes — unless the scene behind is // there to be read, when that share is the scene instead (below). - ambient *= mix(vec3(1.0), SceneColourBound() ? vec3(0.0) : g_transmittance, + g_pane = g_premultiply && g_transmission > 0.0 && g_thickness <= 0.0; + ambient *= mix(vec3(1.0), + SceneColourBound() || g_pane ? vec3(0.0) : g_transmittance, g_transmission); #endif @@ -814,7 +836,7 @@ void main() { // and the scene's added below. vec3 reflects = mix(g_f0_dielectric, g_irid_fresnel, g_iridescence) * ab.x + g_f90 * ab.y; - vec3 through = SceneColourBound() + vec3 through = SceneColourBound() || g_pane ? vec3(0.0) : TransmittedRadiance(s, levels) * g_transmittance * (vec3(1.0) - min(reflects, vec3(1.0))); @@ -839,13 +861,18 @@ void main() { // copy of it — less what the dielectric reflects and what the medium // takes, tinted by the base colour. Light already, so neither the ambient // strength nor the occlusion scales it. - if (SceneColourBound()) { - vec2 sceneAb = EnvBrdf(s.roughness, s.n_dot_v); - vec3 sceneReflects = - mix(g_f0_dielectric, g_irid_fresnel, g_iridescence) * sceneAb.x + - g_f90 * sceneAb.y; - ambient += diffuseColor * SceneBehind(s) * g_transmittance * - (vec3(1.0) - min(sceneReflects, vec3(1.0))) * g_transmission; + vec2 sceneAb = EnvBrdf(s.roughness, s.n_dot_v); + vec3 sceneReflects = + mix(g_f0_dielectric, g_irid_fresnel, g_iridescence) * sceneAb.x + + g_f90 * sceneAb.y; + vec3 passes = diffuseColor * g_transmittance * + (vec3(1.0) - min(sceneReflects, vec3(1.0))) * g_transmission; + if (g_pane) { + // One alpha for the three colours: a pane's tint is kept as its mean, + // which is what clear and faintly tinted glass needs. + g_pass_through = clamp((passes.r + passes.g + passes.b) / 3.0, 0.0, 1.0); + } else if (SceneColourBound()) { + ambient += SceneBehind(s) * passes; } #endif // The light the level's walls throw on each other, baked: diffuse only, @@ -869,19 +896,15 @@ void main() { // emission included — glTF's own layering. The direct light was scaled in // `ShadeLight`. vec3 sheenAmbient = g_sheen * g_sheen_albedo * sheenIncoming * s.occlusion; - WriteSurface( - AccumulateLights(s) * s.occlusion + - (ambient * g_sheen_scale + sheenAmbient + s.emissive) * - g_coat_through + - coatAmbient, - s.alpha, - s.roughness); + vec3 lit = AccumulateLights(s) * s.occlusion + + (ambient * g_sheen_scale + sheenAmbient + s.emissive) * + g_coat_through + + coatAmbient; #else - WriteSurface( - AccumulateLights(s) * s.occlusion + ambient + s.emissive, - s.alpha, - s.roughness); + vec3 lit = AccumulateLights(s) * s.occlusion + ambient + s.emissive; #endif + if (WriteDebugView(s, lit)) return; + WriteSurface(lit, s.alpha, s.roughness); } #endif // PBR_GLSL_ diff --git a/packages/flutter3d_shaders/shaders/lib/shadow.glsl b/packages/flutter3d_shaders/shaders/lib/shadow.glsl index 50981ea4a..c293c1b6f 100644 --- a/packages/flutter3d_shaders/shaders/lib/shadow.glsl +++ b/packages/flutter3d_shaders/shaders/lib/shadow.glsl @@ -78,7 +78,16 @@ float ShadowFactor(Surface s, LightSample light, int lightIndex) { // whole class of missing-shadow bug, and the last cascade is fitted to the // entire scene, so the fall-through always terminates somewhere real. int cascadeCount = int(frag_info.shadow_cascades.z + 0.5); + // `P7`: by depth along the view axis through an orthographic lens, whose + // cascades are slabs of the view's box rather than spheres about the eye. +#ifdef F3D_NO_FOG float viewDistance = length(v_world_position - frag_info.camera_position.xyz); +#else + float viewDistance = + Orthographic() + ? ViewDepth() + : length(v_world_position - frag_info.camera_position.xyz); +#endif int cascade = 0; if (cascadeCount > 1 && viewDistance > frag_info.shadow_cascades.x) cascade = 1; if (cascadeCount > 2 && viewDistance > frag_info.shadow_cascades.y) cascade = 2; @@ -219,6 +228,19 @@ float ShadowFactor(Surface s, LightSample light, int lightIndex) { // kernel, and how far under minus one the value sits is the light-bleeding // cut. A sign rather than another uniform, for the reason the softness // itself rides here. + // **What the see-through casters let through** — before the filter, whose + // soft path leaves early where it finds no blocker. One bilinear tap: a + // translucent caster's shadow is light shaded rather than light stopped, + // and its edge is softened by the filtering the tap already gets. Green + // and blue hold what was taken from red and green, alpha what was left of + // blue — the layout `shadow_transmittance.frag` explains. + if (frag_info.shadow_bias.w > 0.5) { + vec4 stored = textureLod(shadow_texture, clamp(uv, tileLo, tileHi), 0.0); + // Up to four: light a caster gathered, not only light it stopped. + vec3 through = clamp(vec3(1.0 - stored.g, 1.0 - stored.b, stored.a), 0.0, 4.0); + light_transmittance = mix(vec3(1.0), through, clamp(strength, 0.0, 1.0)); + } + float softness = frag_info.ambient_ground.w; float lit = 0.0; if (softness < 0.0) { diff --git a/packages/flutter3d_shaders/shaders/lib/surface.glsl b/packages/flutter3d_shaders/shaders/lib/surface.glsl index 5303f0ddf..4160c6d37 100644 --- a/packages/flutter3d_shaders/shaders/lib/surface.glsl +++ b/packages/flutter3d_shaders/shaders/lib/surface.glsl @@ -77,8 +77,9 @@ uniform FragInfo { vec4 material; /// x: alpha cutoff (negative when the material is not masked: -1 opaque, - /// -0.5 blended, -2 hashed), y: normal scale, z: occlusion strength, - /// w: emissive strength. + /// -0.5 blended, -2 hashed; above one the cutoff plus one, drawn as + /// coverage — `P7`), y: normal scale, z: occlusion strength, w: emissive + /// strength. vec4 material2; /// x: exposure, y: active light count, z: index of the shadow-casting light. @@ -133,7 +134,9 @@ uniform FragInfo { vec4 ambient_ground; /// x, y, z: the depth bias of each cascade, in that cascade's own normalized - /// depth. w unused. + /// depth. w: one when the atlas carries what see-through casters let + /// through and this draw is to be shaded by it, nought otherwise — + /// `ShadowSettings.translucentCasters`; see `ShadowFactor`. /// /// `ShadowSettings.bias` is one number and a cascade's depth range is not: /// a near cascade is stretched towards the light when a caster stands @@ -154,6 +157,15 @@ uniform FragInfo { /// while a temporal resolve runs, minus one otherwise — `S3`, which steps /// the soft shadow's rotation by it. vec4 target_origin; + + /// x: which debug view replaces the light — `P6`, `DebugView.code`, nought + /// for none. y: where it starts, as a share of the target's width from the + /// left; nought is the whole frame. z: the target's width in pixels, which + /// turns the share into a column. w unused. + /// + /// Appended for the reason `ambient_sky` was: every offset above stays + /// where the four backends already agree on it. + vec4 debug_view; } frag_info; @@ -244,8 +256,25 @@ Surface ReadSurface() { // number in a block six shaders share, and -1 already meant "not masked"; // anything more negative was free. See [MaterialAlphaMode.hashed]. float cutoff = frag_info.material2.x; - if (cutoff >= 0.0) { + if (cutoff > 1.0) { + // **Coverage instead of a cut — `P7`.** One above the cutoff says the + // pass multisamples and turns this fragment's alpha into the share of + // samples it covers, so nothing is discarded: the alpha is sharpened to + // run from nought to one across about a pixel either side of the cutoff, + // and the resolve smooths the edge as it smooths a triangle's. Unsharpened, + // a texture's soft alpha would cover half the samples of every pixel it + // fades across and draw a screen door. Branched on a uniform, so the + // derivative is taken in uniform control flow, as WGSL requires. + float edge = cutoff - 1.0; + s.alpha = clamp((s.alpha - edge) / max(fwidth(s.alpha), 1e-4) + 0.5, + 0.0, 1.0); + } else if (cutoff >= 0.0) { if (s.alpha < cutoff) discard; + // What survives the cut is a surface, and opaque: the texture's alpha has + // done its work. Written as it was, it went into the frame's alpha, and + // whatever read the frame as premultiplied — a golden's capture — divided + // the colour by it and lit the inside of every leaf towards its rim. + s.alpha = 1.0; } else if (cutoff < -1.5) { // **Stochastic instead of a threshold.** A leaf texture at 40% opacity is // either entirely there or entirely gone under a fixed cutoff, so a fern @@ -282,7 +311,15 @@ Surface ReadSurface() { // double-sided material ever draws a back face, since everything else has // them culled. if (!gl_FrontFacing) s.n = -s.n; +#ifdef F3D_NO_FOG + // The stages without the fog block — shadows and the id pass — light + // nothing, and keep the eye's point. s.v = normalize(frag_info.camera_position.xyz - v_world_position); +#else + // `P7`: against the view axis through an orthographic lens, where the + // eye's point is only where the camera was put. + s.v = TowardsEye(); +#endif // Clamped away from zero: a grazing view direction otherwise divides by zero // in the specular visibility term. s.n_dot_v = max(dot(s.n, s.v), 1e-4); @@ -672,6 +709,15 @@ LightSample SampleLight(int index, Surface s) { /// returns `ShadowFactor(...)`; an unlit one returns 1. float LightVisibility(Surface s, LightSample light, int index); +/// What the see-through casters between the sun and this fragment let +/// through, per channel — `ShadowSettings.translucentCasters`. Set by +/// `ShadowFactor` for the light it shadows and reset to one before every +/// light, so a model that samples no shadow map, and every light but the +/// sun, leaves it white. A colour beside the visibility rather than folded +/// into it, because visibility is one number in every model and coloured +/// light is not. +vec3 light_transmittance = vec3(1.0); + /// A model's per-light term, defined by each fragment shader. /// /// A prototype here and the definition in the model is what lets the loop below @@ -1090,6 +1136,7 @@ vec3 AccumulateLights(Surface s) { if (i >= count) break; LightSample light = SampleLight(i, s); if (light.n_dot_l <= 0.0) continue; + light_transmittance = vec3(1.0); // A light from the list has no shadow row to read — see `LightHasShadow`. // A branch rather than something folded into the two calls, because both // index tables eight entries wide and the ninth light would read past them @@ -1099,10 +1146,71 @@ vec3 AccumulateLights(Surface s) { PointShadowFactor(v_world_position, s.n, i) : 1.0; if (visibility <= 0.0) continue; - total += ShadeLight(s, light) * light.radiance * light.n_dot_l * visibility; + total += ShadeLight(s, light) * light.radiance * light.n_dot_l * visibility * + light_transmittance; } return total; } +/// Whether any channel of [c] is NaN or infinite. +/// +/// **Comparisons, not `isnan` and not the bits.** WGSL has no `isNan`, and +/// reading the exponent through `floatBitsToUint` takes `impellerc` down in +/// its GLSL ES output, which has no bit casts. A NaN is the one value unequal +/// to itself, and an infinity the one above every finite float. +bool NonFinite(vec3 c) { + return any(notEqual(c, c)) || any(greaterThan(abs(c), vec3(3.0e38))); +} + +/// `P6`: the material channel `FragInfo.debug_view` asks for, written in +/// place of [lit]. False, and nothing written, when no view is on or the +/// fragment sits left of the split; the caller then writes the light. +/// +/// **Display values, through the same exits the light takes.** The channel +/// is what an artist would read off the texture — an albedo as its sRGB +/// colour, a roughness as a grey — converted to linear so the composite's +/// encode hands it back unchanged; the composite leaves this side of the +/// split out of the exposure and the tone curve (`CompositeInfo.lens.y`). +/// The surface buffer and the weighted-blended targets are written as +/// [WriteSurface] writes them, so a debug view changes what the frame shows +/// and nothing the passes after it read. No fog: a channel seen through fog +/// is not the channel. +/// +/// [lit] is read by one view only, [DebugView.nonFinite], which shows a NaN +/// or an infinity as magenta over the light's own luminance in grey. +bool WriteDebugView(Surface s, vec3 lit) { + float view = frag_info.debug_view.x; + if (view < 0.5) return false; + if (gl_FragCoord.x < frag_info.debug_view.y * frag_info.debug_view.z) { + return false; + } + int code = int(view + 0.5); + vec3 shown = vec3(0.0); + if (code == 1) { + shown = LinearToSrgb(clamp(s.albedo, vec3(0.0), vec3(1.0))); + } else if (code == 2) { + shown = s.n * 0.5 + vec3(0.5); + } else if (code == 3) { + shown = vec3(clamp(s.roughness, 0.0, 1.0)); + } else if (code == 4) { + shown = vec3(clamp(s.metallic, 0.0, 1.0)); + } else if (code == 5) { + shown = vec3(clamp(s.occlusion, 0.0, 1.0)); + } else if (code == 6) { + shown = LinearToSrgb(clamp(s.emissive, vec3(0.0), vec3(1.0))); + } else if (code == 7) { + shown = vec3(fract(MapUv(kMapBaseColor)), 0.0); + } else if (code == 8) { + float grey = dot(LinearToSrgb(clamp(lit, vec3(0.0), vec3(1.0))), + vec3(0.2126, 0.7152, 0.0722)); + shown = NonFinite(lit) ? vec3(1.0, 0.0, 1.0) : vec3(grey * 0.5); + } + float weight = g_premultiply ? s.alpha : 1.0; + frag_color = vec4(SrgbToLinear(shown) * weight, s.alpha); + WriteSurfaceGeometry(s.roughness); + WriteWeightedBlended(); + return true; +} + #endif // SURFACE_GLSL_ diff --git a/packages/flutter3d_shaders/shaders/lighting/blinn_phong.frag b/packages/flutter3d_shaders/shaders/lighting/blinn_phong.frag index 8f9b5ea37..b666bbf6b 100644 --- a/packages/flutter3d_shaders/shaders/lighting/blinn_phong.frag +++ b/packages/flutter3d_shaders/shaders/lighting/blinn_phong.frag @@ -30,8 +30,7 @@ void main() { // output here. ApplyMetallicRoughnessMap(s); vec3 ambient = s.albedo * (s.ambient + SampleLightmap()) * s.occlusion; - WriteSurface( - AccumulateLights(s) * s.occlusion + ambient + s.emissive, - s.alpha, - s.roughness); + vec3 lit = AccumulateLights(s) * s.occlusion + ambient + s.emissive; + if (WriteDebugView(s, lit)) return; + WriteSurface(lit, s.alpha, s.roughness); } diff --git a/packages/flutter3d_shaders/shaders/lighting/caustic_photon.frag b/packages/flutter3d_shaders/shaders/lighting/caustic_photon.frag new file mode 100644 index 000000000..d2ea78953 --- /dev/null +++ b/packages/flutter3d_shaders/shaders/lighting/caustic_photon.frag @@ -0,0 +1,28 @@ +#version 460 core + +// A photon's quad, added into the sun's atlas — `ShadowSettings.caustics`. +// +// The atlas's green and blue hold what was taken from red and green, and +// its alpha what was left of blue (`shadow_transmittance.frag`). The pass +// blends colour as destination minus source and alpha as destination plus +// source, so this gives back light: red comes in as nought and the depth in +// it is untouched. +// +// The falloff is (1 − r²)², whose integral over the quad is π/3 of its area; +// the vertex stage has already divided by that, so photons spread evenly add +// up to the light that fell on them. + +precision highp float; + +in vec4 v_color; +in vec2 v_uv; + +out vec4 frag_color; + +void main() { + float r2 = dot(v_uv, v_uv); + if (r2 >= 1.0) discard; + float k = (1.0 - r2) * (1.0 - r2); + vec3 e = v_color.rgb * k; + frag_color = vec4(0.0, e.r, e.g, e.b); +} diff --git a/packages/flutter3d_shaders/shaders/lighting/caustic_photon.vert b/packages/flutter3d_shaders/shaders/lighting/caustic_photon.vert new file mode 100644 index 000000000..1faf27c50 --- /dev/null +++ b/packages/flutter3d_shaders/shaders/lighting/caustic_photon.vert @@ -0,0 +1,213 @@ +#version 460 core + +// One photon of sunlight through a refracting caster — `ShadowSettings. +// caustics`. +// +// **Where the light goes, worked out per photon, and splatted where it +// lands.** The caster has been drawn into two small maps as the sun sees it: +// its near faces and its far faces, each a normal and a depth +// (`caustic_surface.frag`). One photon per texel of that map comes in along +// the light, is bent into the caster by Snell's law at the near face, crosses +// it to the far face — as far as the two depths say, along the bent ray — and +// is bent out again there; it loses what Fresnel reflects at each face and +// what the volume absorbs over the length it travelled, and is lost entirely +// where it meets the far face too steeply to leave. Then it is followed to +// whatever receives it, by stepping along its new direction against the +// atlas's own depth (copied, since the atlas is what this draws into). +// +// **How big its splat is comes from where its neighbours land** — ray +// differentials. The photon one texel over and the one one row down are +// followed too, and the two offsets to where they land span the parallelogram +// this photon's share of the light was spread over: wide and faint where the +// caster spread the beam, small and bright where it gathered it. Its energy +// is divided by that area, so light is neither made nor lost by the splat, +// and a quad is never smaller than a couple of texels, or it could fall +// between texel centres and add nothing. +// +// One instance per photon: the instance index is the texel. A photon that +// starts on no surface, is totally reflected, leaves almost sideways or finds +// no receiver is drawn off the tile. +// +// `textureLod` at texel centres throughout, never `texelFetch`, which the +// Impeller shader compiler does not accept in a vertex stage — see +// `lib/morph.glsl`. + +precision highp float; + +/// One corner of the photon's quad, from −1 to 1 on each axis. +in vec2 corner; + +/// What the photon carries, already divided by the area it covers, in rgb. +/// The particles' names for the particles' pair, which is what a photon is: +/// the varyings are numbered once across every stage, and two new names +/// would be two more for every family to keep apart. +out vec4 v_color; + +/// Where in its quad this vertex is, for the falloff. +out vec2 v_uv; + +uniform CausticInfo { + /// The caster's map back to the world, and the world into it: the + /// cascade's matrix cropped to the caster's footprint, in the convention + /// the atlas is sampled in (window depth, rows from the top). + mat4 map_to_world; + mat4 world_to_map; + + /// The world into the cascade's tile, sampled the same way, and the world + /// into the tile's clip space, drawn the backend's way. + mat4 world_to_tile; + mat4 world_to_clip; + + /// x: photons along each side of the map. y: the least a quad reaches + /// either way, in clip units. z, w: the spacing of photons across and + /// down, in clip units. + vec4 grid; + + /// xyz: the way the light travels, in the world. w unused. + vec4 light; + + /// x: the caster's index of refraction. y: its reflectance head-on. + /// z: its attenuation distance, nought for none. w unused. + vec4 optics; + + /// rgb: its transmission times its base colour. w unused. + vec4 tint; + + /// rgb: its attenuation colour. w unused. + vec4 attenuation; +} +caustic_info; + +uniform sampler2D caustic_front; +uniform sampler2D caustic_back; +uniform sampler2D caustic_depth; + +/// A point's place in a map whose matrix produced [p], rows from the top — +/// the same reading `shadow.glsl` makes of the atlas. +vec2 MapUv(vec4 p) { return vec2(p.x * 0.5 + 0.5, 0.5 - p.y * 0.5); } + +float Schlick(float f0, float cosine) { + float c = clamp(1.0 - cosine, 0.0, 1.0); + return f0 + (1.0 - f0) * c * c * c * c * c; +} + +/// The photon starting at [uv] of the maps, followed to where it lands: +/// xy where, in clip space, and w one if it lands at all. [energy] is what +/// it carries. +vec4 Follow(vec2 uv, out vec3 energy) { + energy = vec3(0.0); + vec4 front = textureLod(caustic_front, uv, 0.0); + if (front.a >= 1.0 || dot(front.xyz, front.xyz) < 0.25) return vec4(0.0); + + vec2 ndc = vec2(uv.x * 2.0 - 1.0, (0.5 - uv.y) * 2.0); + vec3 entry = (caustic_info.map_to_world * vec4(ndc, front.a, 1.0)).xyz; + // Metres along the light for one unit of stored depth. + float range = length((caustic_info.map_to_world * vec4(0.0, 0.0, 1.0, 0.0)).xyz); + + vec3 l = normalize(caustic_info.light.xyz); + vec3 n1 = normalize(front.xyz); + if (dot(n1, l) > 0.0) n1 = -n1; + float index = max(caustic_info.optics.x, 1.0); + vec3 inside = refract(l, n1, 1.0 / index); + + // A far face behind this texel, or the photon started on the caster's + // rim, where the two maps disagree about whether there is anything. + vec4 back = textureLod(caustic_back, uv, 0.0); + if (back.a <= front.a) return vec4(0.0); + float across = (back.a - front.a) * range; + vec3 exit = entry + inside * (across / max(dot(inside, l), 0.2)); + vec4 there = textureLod( + caustic_back, clamp(MapUv(caustic_info.world_to_map * vec4(exit, 1.0)), 0.0, 1.0), 0.0); + vec3 n2 = dot(there.xyz, there.xyz) > 0.25 ? normalize(there.xyz) : normalize(back.xyz); + if (dot(n2, inside) < 0.0) n2 = -n2; + vec3 out_ray = refract(inside, -n2, index); + if (dot(out_ray, out_ray) < 1e-6) return vec4(0.0); + out_ray = normalize(out_ray); + // Leaving almost sideways it lands far off and faint, and following it + // there against a depth map is where the search goes wrong. + if (dot(out_ray, l) < 0.3) return vec4(0.0); + + float f0 = caustic_info.optics.y; + energy = caustic_info.tint.rgb * + (1.0 - Schlick(f0, abs(dot(l, n1)))) * + (1.0 - Schlick(f0, abs(dot(out_ray, n2)))); + float fading = caustic_info.optics.z; + if (fading > 0.0) { + energy *= pow(max(caustic_info.attenuation.rgb, vec3(1e-4)), + vec3(length(exit - entry) / fading)); + } + + // Out to whatever receives it: step along the ray by what the atlas says + // is left between here and the first opaque surface below. + vec3 p = exit; + float down = max(dot(out_ray, l), 0.05); + for (int k = 0; k < 4; k++) { + vec4 q = caustic_info.world_to_tile * vec4(p, 1.0); + float receiver = textureLod(caustic_depth, clamp(MapUv(q), 0.0, 1.0), 0.0).r; + float advance = (receiver - q.z) * range / down; + if (k == 0) advance = max(advance, 0.0); + p += out_ray * advance; + } + // Only where the search settled on a surface. + vec4 landed = caustic_info.world_to_tile * vec4(p, 1.0); + float under = textureLod(caustic_depth, clamp(MapUv(landed), 0.0, 1.0), 0.0).r; + if (abs(under - landed.z) * range > 0.02) return vec4(0.0); + + vec4 clip = caustic_info.world_to_clip * vec4(p, 1.0); + return vec4(clip.xy / clip.w, 0.0, 1.0); +} + +/// [a], made at least [least] long, in its own direction or [fallback]'s. +vec2 AtLeast(vec2 a, vec2 fallback, float least) { + float size = length(a); + if (size < 1e-9) return normalize(fallback) * least; + return size < least ? a * (least / size) : a; +} + +void main() { + v_uv = corner; + v_color = vec4(0.0); + // Off the tile until it is known to land somewhere. + gl_Position = vec4(4.0, 4.0, 0.5, 1.0); + + float n = caustic_info.grid.x; + float id = float(gl_InstanceIndex); + float column = mod(id, n); + float row = floor(id / n); + vec2 uv = vec2((column + 0.5) / n, (row + 0.5) / n); + float texel = 1.0 / n; + + vec3 energy; + vec4 here = Follow(uv, energy); + if (here.w < 0.5) return; + + // Where the neighbours land, one texel across and one down — or the + // other way, where this photon is on the edge — or, failing both, where + // they would have landed with nothing in the way. + vec2 spacing_x = vec2(caustic_info.grid.z, 0.0); + vec2 spacing_y = vec2(0.0, caustic_info.grid.w); + vec3 unused; + vec4 next_x = Follow(uv + vec2(texel, 0.0), unused); + vec2 a = next_x.w > 0.5 ? next_x.xy - here.xy : spacing_x; + if (next_x.w < 0.5) { + vec4 prev_x = Follow(uv - vec2(texel, 0.0), unused); + if (prev_x.w > 0.5) a = here.xy - prev_x.xy; + } + vec4 next_y = Follow(uv + vec2(0.0, texel), unused); + vec2 b = next_y.w > 0.5 ? next_y.xy - here.xy : spacing_y; + if (next_y.w < 0.5) { + vec4 prev_y = Follow(uv - vec2(0.0, texel), unused); + if (prev_y.w > 0.5) b = here.xy - prev_y.xy; + } + // Half again, so neighbouring quads overlap, and never under the least. + float least = caustic_info.grid.y; + a = AtLeast(1.5 * a, spacing_x, least); + b = AtLeast(1.5 * b, spacing_y, least); + float area = max(abs(a.x * b.y - a.y * b.x), least * least); + + gl_Position = vec4(here.xy + corner.x * a + corner.y * b, 0.5, 1.0); + // The photon's own share of the light, over what the falloff covers: + // (1 − r²)² integrates to π/3 of the area the quad's disc maps to. + float share = caustic_info.grid.z * caustic_info.grid.w; + v_color = vec4(energy * share / (1.0471976 * area), 0.0); +} diff --git a/packages/flutter3d_shaders/shaders/lighting/caustic_surface.frag b/packages/flutter3d_shaders/shaders/lighting/caustic_surface.frag new file mode 100644 index 000000000..cc517ad8e --- /dev/null +++ b/packages/flutter3d_shaders/shaders/lighting/caustic_surface.frag @@ -0,0 +1,19 @@ +#version 460 core + +// One side of a refracting caster, as the sun sees it — `ShadowSettings. +// caustics`. The surface's normal in the world, and its depth along the light +// in the same units the shadow atlas stores. +// +// Drawn twice into a small map of the caster alone: once nearest-first with +// the faces turned towards the light, once farthest-first with the faces +// turned away. `caustic_photon.vert` reads both, which is all a ray needs to +// be followed in at one side and out at the other. + +#define F3D_NO_SURFACE_BUFFER +#define F3D_NO_FOG + +#include + +void main() { + frag_color = vec4(normalize(v_normal), gl_FragCoord.z); +} diff --git a/packages/flutter3d_shaders/shaders/lighting/impostor.frag b/packages/flutter3d_shaders/shaders/lighting/impostor.frag index e0781ce28..809703bbe 100644 --- a/packages/flutter3d_shaders/shaders/lighting/impostor.frag +++ b/packages/flutter3d_shaders/shaders/lighting/impostor.frag @@ -116,5 +116,7 @@ void main() { frag_info.material.z; vec3 ambient = s.albedo * s.ambient; - WriteSurface(AccumulateLights(s) + ambient, 1.0, 1.0); + vec3 lit = AccumulateLights(s) + ambient; + if (WriteDebugView(s, lit)) return; + WriteSurface(lit, 1.0, 1.0); } diff --git a/packages/flutter3d_shaders/shaders/lighting/lambert.frag b/packages/flutter3d_shaders/shaders/lighting/lambert.frag index 2703e50a1..76ade5a2f 100644 --- a/packages/flutter3d_shaders/shaders/lighting/lambert.frag +++ b/packages/flutter3d_shaders/shaders/lighting/lambert.frag @@ -22,8 +22,7 @@ void main() { // roughness, so sampling it would leave a slot the compiler then drops. ApplyCommonMaps(s); vec3 ambient = s.albedo * (s.ambient + SampleLightmap()) * s.occlusion; - WriteSurface( - AccumulateLights(s) * s.occlusion + ambient + s.emissive, - s.alpha, - s.roughness); + vec3 lit = AccumulateLights(s) * s.occlusion + ambient + s.emissive; + if (WriteDebugView(s, lit)) return; + WriteSurface(lit, s.alpha, s.roughness); } diff --git a/packages/flutter3d_shaders/shaders/lighting/particle_mesh.frag b/packages/flutter3d_shaders/shaders/lighting/particle_mesh.frag index 1d4bcccfb..82a22bfb2 100644 --- a/packages/flutter3d_shaders/shaders/lighting/particle_mesh.frag +++ b/packages/flutter3d_shaders/shaders/lighting/particle_mesh.frag @@ -27,27 +27,19 @@ in vec3 v_normal; out vec4 frag_color; -/// The same block the other particle stage declares, for the same reason: this -/// shader shares none of the lit shaders' headers. -uniform FogInfo { - /// rgb: linear fog colour. w: density per metre, zero for no fog. - vec4 fog; - - /// xyz: camera position in world space. - vec4 eye; -} -fog_info; +#include void main() { - vec3 to_eye = fog_info.eye.xyz - v_world_position; - float distance_to_eye = length(to_eye); + // Back along the ray that reached the fragment: to the eye, or against the + // view axis through an orthographic lens — `P7`. + vec3 towards_eye = TowardsEyeFrom(v_world_position); // `abs`, not `max(dot, 0)`. Nothing here is culled — a particle mesh is seen // from every side as it tumbles — so a back face is as visible as a front // one, and clamping would make half of every shard go black rather than dim. vec3 n = normalize(v_normal); - float facing = distance_to_eye > 0.0 - ? abs(dot(n, to_eye / distance_to_eye)) + float facing = dot(towards_eye, towards_eye) > 0.0 + ? abs(dot(n, towards_eye)) : 1.0; // Never all the way to zero. A silhouette edge is exactly perpendicular to @@ -60,7 +52,8 @@ void main() { // additive particle *add* fog to the wall behind it. float fogged = 1.0; if (fog_info.fog.w > 0.0) { - fogged = clamp(exp(-fog_info.fog.w * distance_to_eye), 0.0, 1.0); + fogged = clamp( + exp(-fog_info.fog.w * FogDistance(v_world_position)), 0.0, 1.0); } frag_color = vec4(v_color.rgb * v_color.a * intensity * fogged, 1.0); diff --git a/packages/flutter3d_shaders/shaders/lighting/planar_reflection.frag b/packages/flutter3d_shaders/shaders/lighting/planar_reflection.frag new file mode 100644 index 000000000..ff3f2ff89 --- /dev/null +++ b/packages/flutter3d_shaders/shaders/lighting/planar_reflection.frag @@ -0,0 +1,84 @@ +#version 460 core + +// `P4`: a planar reflector's surface, drawn a second time over itself with +// the picture a mirrored camera took of the world above it. +// +// **Laid over the surface rather than inside its lighting.** The reflection +// is read by the pixel's place on screen, not by anything the material +// knows, so the stage that reads it needs nothing a lit model has: no maps, +// no lights, no shadows. Putting it inside the lit models would have been a +// sampler and a block added to six stages that every other draw pays to +// declare, and a seventh header permutation to keep byte-identical. As a +// draw of its own it costs only the surfaces that reflect, and a frame +// without a reflector compiles and binds exactly what it did. +// +// Drawn with the depth test `lessEqual` and no depth write, straight after +// the opaque half, so it lands on exactly the pixels the surface itself won +// and nothing in front of the surface is painted. Blended source-over with +// the colour premultiplied, as every blended draw here is. +// +// **No second output**, for the reason `xray.frag` gives at length: a blend +// protects attachment zero only, and only on the backends whose `setBlend` +// honours an attachment index. The surface buffer keeps describing the +// surface underneath, which is the truth about it. +// +// **And not `lib/surface.glsl`**, which `xray.frag` does include: that header +// declares the base colour sampler for every stage that takes it, and a stage +// that never reads it has it dropped from the Metal function while reflection +// still reports it, which is the slot `metal_bindings_test.dart` exists to +// catch. The two things wanted from `FragInfo` come another way: the eye is +// `FogInfo`'s, and the target's rows ride in this stage's own block. +#define F3D_NO_SURFACE_BUFFER +#include +#include + +/// What the mirrored camera saw, in linear light, the size of the view it +/// was taken for or a fraction of it. +uniform sampler2D reflection_texture; + +uniform PlanarReflectionInfo { + /// xy: the view's top-left corner in pixels of the target, counted from + /// the top. zw: the view's size in pixels. + vec4 view; + + /// x: the reflectance straight on, Schlick's F0 — one for a mirror, about + /// 0.02 for water. y: the strength the reflection is laid on with, which + /// scales the whole Fresnel term. z: the target's rows where its first row + /// is the bottom of the picture, nought where it is the top — see + /// `FragCoordFromTop`. w unused. + vec4 params; + + /// rgb: a linear tint the reflected light is multiplied by. w unused. + vec4 tint; +} +planar_info; + +void main() { + // The same pixel the mirrored camera drew, by place on screen: its + // projection is the view's own, so the texel under this fragment is the + // reflected point behind it. Counted from the top on every backend, and + // turned back where the texture's first row is the bottom of the picture. + float rows = planar_info.params.z; + vec2 pixel = FragCoordFromTop(rows); + vec2 uv = (pixel - planar_info.view.xy) / planar_info.view.zw; + if (rows > 0.0) uv.y = 1.0 - uv.y; + vec3 reflected = + textureLod(reflection_texture, uv, 0.0).rgb * planar_info.tint.rgb; + + // Schlick's Fresnel on the geometric normal, facing the eye: water reflects + // a little looking down into it and nearly everything at a grazing angle, + // and a mirror's F0 of one makes the term one everywhere. + vec3 n = normalize(v_normal); + if (!gl_FrontFacing) n = -n; + vec3 v = TowardsEye(); + float cosine = clamp(dot(n, v), 0.0, 1.0); + float f0 = planar_info.params.x; + float grazing = 1.0 - cosine; + float grazing2 = grazing * grazing; + float fresnel = f0 + (1.0 - f0) * grazing2 * grazing2 * grazing; + float alpha = clamp(fresnel * planar_info.params.y, 0.0, 1.0); + + // Fogged as the surface is: the reflection is light leaving the surface + // and crosses the same air to the eye. Premultiplied for the blend. + frag_color = vec4(ApplyFog(reflected) * alpha, alpha); +} diff --git a/packages/flutter3d_shaders/shaders/lighting/shadow_transmittance.frag b/packages/flutter3d_shaders/shaders/lighting/shadow_transmittance.frag new file mode 100644 index 000000000..e7e472308 --- /dev/null +++ b/packages/flutter3d_shaders/shaders/lighting/shadow_transmittance.frag @@ -0,0 +1,96 @@ +#version 460 core + +// What a see-through caster lets through to the sun's shadow map — +// `ShadowSettings.translucentCasters`. +// +// Drawn after the opaque casters into the same atlas, depth-tested against +// them and writing no depth of its own, so a pane behind a wall adds nothing +// and two panes in a row both count. +// +// **The atlas's other three channels, in a layout chosen so that every other +// stage keeps writing exactly what it wrote.** Red stays the depth. Green and +// blue hold how much of red and green is *taken away*, and alpha how much of +// blue is *let through*: an opaque caster writes (z, 0, 0, 1) and so says +// "nothing taken" without knowing this stage exists, and so does the static +// tile's copy. The pass blends green and blue as `src + dst·(1 − src)`, which +// is what absorption does over layers — 1 − (1 − a)(1 − b) — and leaves red +// alone because red comes in as nought; alpha it multiplies. +// +// What one surface lets through: +// +// * the share of it that is not there at all, 1 − opacity; +// * of the share that is, what its transmission passes, tinted by its +// colour; +// * and of that, what is not reflected away at its surface, from the +// Schlick term of its index — which is what makes the rim of a glass, met +// at a grazing angle, darker than its middle. +// +// Where refracted light lands — the bright line down a tube's shadow — is +// not here: that needs the ray followed through both surfaces, and this +// stage sees one at a time. What it does offer is the means to say so from +// outside: the material's base colour map multiplies what it lets through, +// and neither is held to one, so a caster that is only a shadow — a card +// marked `ShadowCastingMode.shadowsOnly`, carrying a picture of where the +// light went — can darken the floor where it went away and brighten it, +// past one, where it gathered. +// +// The body's share is taken as a square root because a closed caster is +// recorded from both of its sides: light crosses its body once, and its two +// surfaces between them give it once. A caster recorded from one side only +// comes out lighter than it is, never darker. + +#define F3D_NO_SURFACE_BUFFER +#define F3D_NO_FOG + +#include + +/// The material's base colour map, or a white texel when it has none. +uniform sampler2D base_color_texture; + +uniform TransmittanceInfo { + /// rgb: the colour light comes out of it, not held to one: the base colour, and for a + /// material that transmits, times what its volume leaves after its + /// thickness. a: its opacity — one for a material that is not blended, and + /// for one that transmits, whose alpha describes its look rather than + /// holes in it. + vec4 color; + + /// xyz: the direction towards the light, in the world. w unused. + vec4 light; + + /// x: the transmission, nought for a material that has none. y: the + /// reflectance head-on, ((n − 1) / (n + 1))² of its index. z: one when its + /// photons are followed and its light is given back by them, nought + /// otherwise. w unused. + vec4 params; +} +transmittance_info; + +void main() { + float opacity = clamp(transmittance_info.color.a, 0.0, 1.0); + float transmission = clamp(transmittance_info.params.x, 0.0, 1.0); + vec3 tint = max(transmittance_info.color.rgb, vec3(0.0)) * + texture(base_color_texture, v_texcoord).rgb; + vec3 body = vec3(1.0 - opacity) + opacity * transmission * tint; + + float facing = clamp( + abs(dot(normalize(v_normal), transmittance_info.light.xyz)), 0.0, 1.0); + float f0 = clamp(transmittance_info.params.y, 0.0, 1.0); + float fresnel = f0 + (1.0 - f0) * pow(1.0 - facing, 5.0); + // **What is reflected is lost to the shadow only as far as it is turned + // away.** Light meeting a surface at an angle θ to its normal and + // reflected leaves deflected by π − 2θ: back where it came from head-on, + // and hardly turned at all at grazing, where Fresnel reflects nearly all + // of it — it carries on down and lands beside where it would have. Taken + // as lost, that grazing light gave a clear glass tube's shadow a dark + // outline as wide as a texel or two, where a real one has a hairline. The + // share lost is 1 − cos(π − 2θ) = 2 cos²θ, held to one: whole from about + // 45° to head-on, nothing at grazing. + float lost = fresnel * min(2.0 * facing * facing, 1.0); + + vec3 through = sqrt(max(body, vec3(0.0))) * (1.0 - lost); + // A caster whose photons are followed (`ShadowSettings.caustics`) stops + // all of the light here; the photon pass gives it back where it lands. + if (transmittance_info.params.z > 0.5) through = vec3(0.0); + frag_color = vec4(0.0, 1.0 - through.r, 1.0 - through.g, through.b); +} diff --git a/packages/flutter3d_shaders/shaders/lighting/splat.frag b/packages/flutter3d_shaders/shaders/lighting/splat.frag index 33da889c4..5b1785e5d 100644 --- a/packages/flutter3d_shaders/shaders/lighting/splat.frag +++ b/packages/flutter3d_shaders/shaders/lighting/splat.frag @@ -30,18 +30,7 @@ in vec3 v_world_position; out vec4 frag_color; -/// The same block the particle stage declares, for the same reason it declares -/// it: a different vertex layout and none of the lit shaders' varyings, so none -/// of their headers apply. Two members, not three — nothing here writes a -/// surface buffer, so there is no view axis to measure a depth along. -uniform FogInfo { - /// rgb: linear fog colour. w: density per metre, zero for no fog. - vec4 fog; - - /// xyz: camera position in world space. - vec4 eye; -} -fog_info; +#include void main() { // `exp(-½ dᵀd)` with d already in standard deviations, which is what the @@ -68,7 +57,7 @@ void main() { vec3 colour = v_color.rgb; if (fog_info.fog.w > 0.0) { float visibility = clamp( - exp(-fog_info.fog.w * distance(v_world_position, fog_info.eye.xyz)), + exp(-fog_info.fog.w * FogDistance(v_world_position)), 0.0, 1.0); colour = mix(fog_info.fog.rgb, colour, visibility); diff --git a/packages/flutter3d_shaders/shaders/lighting/splat_hashed.frag b/packages/flutter3d_shaders/shaders/lighting/splat_hashed.frag index 5da5b3a7b..b08c47aa9 100644 --- a/packages/flutter3d_shaders/shaders/lighting/splat_hashed.frag +++ b/packages/flutter3d_shaders/shaders/lighting/splat_hashed.frag @@ -52,15 +52,7 @@ in vec3 v_world_position; out vec4 frag_color; -/// The block `splat.frag` declares, for the fog mix it makes. -uniform FogInfo { - /// rgb: linear fog colour. w: density per metre, zero for no fog. - vec4 fog; - - /// xyz: camera position in world space. - vec4 eye; -} -fog_info; +#include /// `EngineTables.blueNoise`: 32 slices of 64 × 64 in an 8 × 4 atlas. uniform sampler2D blue_noise_texture; @@ -131,7 +123,7 @@ void main() { vec3 colour = v_color.rgb; if (fog_info.fog.w > 0.0) { float visibility = clamp( - exp(-fog_info.fog.w * distance(v_world_position, fog_info.eye.xyz)), + exp(-fog_info.fog.w * FogDistance(v_world_position)), 0.0, 1.0); colour = mix(fog_info.fog.rgb, colour, visibility); diff --git a/packages/flutter3d_shaders/shaders/lighting/toon.frag b/packages/flutter3d_shaders/shaders/lighting/toon.frag index 803930c1e..e874a98de 100644 --- a/packages/flutter3d_shaders/shaders/lighting/toon.frag +++ b/packages/flutter3d_shaders/shaders/lighting/toon.frag @@ -42,9 +42,8 @@ void main() { float rim = pow(1.0 - s.n_dot_v, 3.0) * frag_info.material.w; vec3 ambient = s.albedo * (s.ambient + SampleLightmap()) * s.occlusion; - WriteSurface( - AccumulateLights(s) * s.occlusion + ambient + vec3(rim * 0.35) + - s.emissive, - s.alpha, - s.roughness); + vec3 lit = AccumulateLights(s) * s.occlusion + ambient + vec3(rim * 0.35) + + s.emissive; + if (WriteDebugView(s, lit)) return; + WriteSurface(lit, s.alpha, s.roughness); } diff --git a/packages/flutter3d_shaders/shaders/lighting/unlit.frag b/packages/flutter3d_shaders/shaders/lighting/unlit.frag index 6673ab2f7..d8e8b5c45 100644 --- a/packages/flutter3d_shaders/shaders/lighting/unlit.frag +++ b/packages/flutter3d_shaders/shaders/lighting/unlit.frag @@ -34,5 +34,6 @@ void main() { // The albedo is already linear, and an unlit surface is best // understood as emitting exactly it, so it goes into the HDR // target as light like everything else. + if (WriteDebugView(s, s.albedo)) return; WriteSurface(s.albedo, s.alpha); } diff --git a/packages/flutter3d_shaders/shaders/mesh.vert b/packages/flutter3d_shaders/shaders/mesh.vert index 9711af3f1..a9f8dda1c 100644 --- a/packages/flutter3d_shaders/shaders/mesh.vert +++ b/packages/flutter3d_shaders/shaders/mesh.vert @@ -38,6 +38,9 @@ out vec2 v_texcoord; out vec4 v_tangent; out vec4 v_color; out vec2 v_lightmap_uv; +/// `P8`: an instance's own four numbers, a material's `instance` — from +/// slot 1 in the instanced stage, nought in every other. +out vec4 v_instance; void main() { // Morphed first and in the mesh's own space, which is the order glTF @@ -69,6 +72,7 @@ void main() { mirrored ? -morphed_tangent.w : morphed_tangent.w); v_color = color; v_lightmap_uv = vec2(0.0); + v_instance = vec4(0.0); gl_Position = frame_info.mvp * vec4(morphed_position, 1.0); } diff --git a/packages/flutter3d_shaders/shaders/mesh_instanced.vert b/packages/flutter3d_shaders/shaders/mesh_instanced.vert index 419980800..70ef73441 100644 --- a/packages/flutter3d_shaders/shaders/mesh_instanced.vert +++ b/packages/flutter3d_shaders/shaders/mesh_instanced.vert @@ -30,6 +30,8 @@ in vec4 i_row1; in vec4 i_row2; /// Multiplied into the vertex colour, so a batch of one mesh can vary its tint. in vec4 i_color; +/// The instance's own four numbers — `P8`, `InstancedMeshNode.setInstanceData`. +in vec4 i_data; uniform FrameInfo { mat4 mvp; @@ -44,6 +46,9 @@ out vec2 v_texcoord; out vec4 v_tangent; out vec4 v_color; out vec2 v_lightmap_uv; +/// `P8`: an instance's own four numbers, a material's `instance` — from +/// slot 1 in the instanced stage, nought in every other. +out vec4 v_instance; void main() { // Columns from rows: GLSL matrices are column-major, so the constructor is @@ -84,5 +89,6 @@ void main() { mirrored ? -morphed_tangent.w : morphed_tangent.w); v_color = color * i_color; v_lightmap_uv = vec2(0.0); + v_instance = i_data; gl_Position = frame_info.mvp * local; } diff --git a/packages/flutter3d_shaders/shaders/mesh_lightmapped.vert b/packages/flutter3d_shaders/shaders/mesh_lightmapped.vert index 3e21eb524..06d23630e 100644 --- a/packages/flutter3d_shaders/shaders/mesh_lightmapped.vert +++ b/packages/flutter3d_shaders/shaders/mesh_lightmapped.vert @@ -30,6 +30,9 @@ out vec2 v_texcoord; out vec4 v_tangent; out vec4 v_color; out vec2 v_lightmap_uv; +/// `P8`: an instance's own four numbers, a material's `instance` — from +/// slot 1 in the instanced stage, nought in every other. +out vec4 v_instance; void main() { vec3 morphed_position = position; @@ -47,6 +50,7 @@ void main() { mirrored ? -morphed_tangent.w : morphed_tangent.w); v_color = vec4(1.0); v_lightmap_uv = color.xy; + v_instance = vec4(0.0); gl_Position = frame_info.mvp * vec4(morphed_position, 1.0); } diff --git a/packages/flutter3d_shaders/shaders/mesh_skinned.vert b/packages/flutter3d_shaders/shaders/mesh_skinned.vert index 7d8ec37e3..4ca72a81b 100644 --- a/packages/flutter3d_shaders/shaders/mesh_skinned.vert +++ b/packages/flutter3d_shaders/shaders/mesh_skinned.vert @@ -53,6 +53,9 @@ out vec2 v_texcoord; out vec4 v_tangent; out vec4 v_color; out vec2 v_lightmap_uv; +/// `P8`: an instance's own four numbers, a material's `instance` — from +/// slot 1 in the instanced stage, nought in every other. +out vec4 v_instance; /// [joint] as an index into `joint_matrices`, kept inside the array. /// @@ -117,6 +120,7 @@ void main() { v_texcoord = texcoord; v_color = color; v_lightmap_uv = vec2(0.0); + v_instance = vec4(0.0); gl_Position = frame_info.mvp * (skin * vec4(morphed_position, 1.0)); } diff --git a/packages/flutter3d_shaders/shaders/polyline.vert b/packages/flutter3d_shaders/shaders/polyline.vert index 0cb5901cc..3c4c9ad00 100644 --- a/packages/flutter3d_shaders/shaders/polyline.vert +++ b/packages/flutter3d_shaders/shaders/polyline.vert @@ -84,6 +84,9 @@ out vec2 v_texcoord; out vec4 v_tangent; out vec4 v_color; out vec2 v_lightmap_uv; +/// `P8`: an instance's own four numbers, a material's `instance` — from +/// slot 1 in the instanced stage, nought in every other. +out vec4 v_instance; // The w below which a point is treated as at the eye. Dividing by a w near zero // sends a neighbour to infinity, and dividing by a negative one mirrors it @@ -155,4 +158,5 @@ void main() { v_tangent = vec4(1.0, 0.0, 0.0, 1.0); v_color = color; v_lightmap_uv = vec2(0.0); + v_instance = vec4(0.0); } diff --git a/packages/flutter3d_shaders/shaders/post/composite.frag b/packages/flutter3d_shaders/shaders/post/composite.frag index ab44a4346..11686356c 100644 --- a/packages/flutter3d_shaders/shaders/post/composite.frag +++ b/packages/flutter3d_shaders/shaders/post/composite.frag @@ -116,9 +116,35 @@ uniform CompositeInfo { /// offset above unchanged and the four backends do not have to agree about /// anything they had not already agreed on. vec4 contact; + + /// x: radial lens distortion — `P2`, barrel above nought, pincushion + /// below, nought off exactly. y: one while a debug view is on — `P6`, + /// nought otherwise. z: where the debug view starts, as a share of the + /// width from the left. w: unclaimed. Appended for the reason `contact` + /// was. + vec4 lens; } composite_info; +/// Where the lens bends [at] to: radially, by `1 + k·r²` on the frame's own +/// aspect, scaled so the frame's border stays on the frame — `P2`. +/// +/// **Normalised on the border, not on the corner alone.** A barrel (k above +/// nought) magnifies the middle and pulls everything else inwards, so the +/// corners are what reach furthest and are held there. A pincushion does the +/// opposite, and normalising it on the corners would send the middles of the +/// edges past the frame and stretch the last row of texels across them; it is +/// held at the edge nearest the middle instead, and the corners come in. +vec2 Distort(vec2 at, float k) { + float aspect = max(composite_info.look_more.w, 1e-4); + vec2 fromCentre = at - vec2(0.5); + vec2 onFrame = fromCentre * vec2(aspect, 1.0); + float r2 = dot(onFrame, onFrame); + float border = k > 0.0 ? 0.25 * (aspect * aspect + 1.0) + : 0.25 * min(aspect * aspect, 1.0); + return vec2(0.5) + fromCentre * (1.0 + k * r2) / (1.0 + k * border); +} + /// Rec. 709 luma, which is what the sRGB primaries weight to. float Luma(vec3 color) { return dot(color, vec3(0.2126, 0.7152, 0.0722)); } @@ -418,18 +444,25 @@ void main() { // // The offset grows from the centre outwards, which is what a real lens does: // a ray through the middle of the glass is not dispersed at all. + // + // `P2`: the lens's own bend, before anything is read, so the scene and + // everything laid over it — the glow, the occlusion, the contact shadow, + // the local exposure — bend together. Nought leaves `uv` the very value + // `v_uv` is, which every golden in the repository depends on. + float distortion = composite_info.lens.x; + vec2 uv = distortion != 0.0 ? Distort(v_uv, distortion) : v_uv; float dispersion = composite_info.look.w; vec4 scene; if (dispersion > 0.0) { - vec2 fromCentre = v_uv - vec2(0.5); + vec2 fromCentre = uv - vec2(0.5); vec2 step_uv = fromCentre * dispersion; - scene = texture(scene_texture, v_uv); - scene.r = texture(scene_texture, v_uv + step_uv).r; - scene.b = texture(scene_texture, v_uv - step_uv).b; + scene = texture(scene_texture, uv); + scene.r = texture(scene_texture, uv + step_uv).r; + scene.b = texture(scene_texture, uv - step_uv).b; } else { - scene = texture(scene_texture, v_uv); + scene = texture(scene_texture, uv); } - vec3 bloom = texture(bloom_texture, v_uv).rgb; + vec3 bloom = texture(bloom_texture, uv).rgb; // Four taps in a 2×2, which is not a general-purpose blur: the occlusion pass // rotates its kernel by the parity of the pixel, leaving a 2×2 pattern, and @@ -438,10 +471,10 @@ void main() { // one without the other either leaves the pattern or smears the contact // shadows this whole pass exists to draw. vec2 half_texel = composite_info.ao_texel.xy * 0.5; - vec4 occlusion = 0.25 * (texture(ao_texture, v_uv + vec2(half_texel.x, half_texel.y)) + - texture(ao_texture, v_uv + vec2(-half_texel.x, half_texel.y)) + - texture(ao_texture, v_uv + vec2(half_texel.x, -half_texel.y)) + - texture(ao_texture, v_uv + vec2(-half_texel.x, -half_texel.y))); + vec4 occlusion = 0.25 * (texture(ao_texture, uv + vec2(half_texel.x, half_texel.y)) + + texture(ao_texture, uv + vec2(-half_texel.x, half_texel.y)) + + texture(ao_texture, uv + vec2(half_texel.x, -half_texel.y)) + + texture(ao_texture, uv + vec2(-half_texel.x, -half_texel.y))); // The share left open is in a; the occlusion methods write it into every // channel, and the indirect one keeps its light in rgb. float ao = occlusion.a; @@ -461,7 +494,7 @@ void main() { // the whole point of a contact shadow is the first few centimetres at the // join, and a half-resolution one is the seam it exists to draw, blurred // away. - float contact = texture(contact_shadow_texture, v_uv).r; + float contact = texture(contact_shadow_texture, uv).r; ao *= mix(1.0, contact, clamp(composite_info.contact.x, 0.0, 1.0)); // Applied to the scene and **not** to the bloom, which is the whole reason @@ -484,7 +517,7 @@ void main() { // `R7`: each place of the scene at the exposure that shows it best, before // the glow is added and the curve applied. Bilinear from an eighth of the // frame: the stops were blurred wide, so there is no edge in them to keep. - float localStops = texture(local_exposure_texture, v_uv).r; + float localStops = texture(local_exposure_texture, uv).r; vec3 exposed = scene.rgb * exp2(localStops * composite_info.contact.w); vec3 color = exposed * ao + bloom * composite_info.params.y; @@ -639,4 +672,16 @@ void main() { } frag_color = vec4(encoded, scene.a); + + // `P6`: right of the split the materials wrote display values, not light — + // see `WriteDebugView` in `surface.glsl` — so the scene goes to the screen + // through the encode alone, with no exposure, curve, glow or grade on it. + // Chosen here rather than returned from the top: an early return on a + // per-pixel condition would leave every read above in non-uniform control + // flow, which WGSL refuses. + if (composite_info.lens.y > 0.5 && v_uv.x >= composite_info.lens.z) { + vec4 raw = textureLod(scene_texture, v_uv, 0.0); + frag_color = vec4(LinearToSrgb(clamp(raw.rgb, vec3(0.0), vec3(1.0))), + raw.a); + } } diff --git a/packages/flutter3d_shaders/shaders/post/decal.frag b/packages/flutter3d_shaders/shaders/post/decal.frag new file mode 100644 index 000000000..b0783a574 --- /dev/null +++ b/packages/flutter3d_shaders/shaders/post/decal.frag @@ -0,0 +1,251 @@ +#version 460 core + +// Projected box decals — `P3`: a picture laid onto whatever geometry stands +// inside a box, as if it were part of that geometry's material. +// +// **The point under a pixel comes from the surface buffer, because it can +// come from nowhere else.** flutter_gpu cannot sample a depth attachment, so a +// decal cannot be the usual box drawn against the depth it would read. The +// scene pass already wrote a world normal and a depth along the view axis into +// its second attachment, and `WorldAt` turns the two back into a point, as +// the reflections and the occlusion do. +// +// **A material, not a sticker over the lit picture.** The light a surface was +// lit by is recovered from what the scene pass left: the lit colour over the +// albedo buffer's colour is the light that reached the point, shadows, +// probes and the sky's share included. The decal's colour replaces the +// albedo under that same light, so a decal in shadow is in shadow and a decal +// under a red lamp is red. It is what keeps this a fullscreen pass of two +// draws rather than a second lighting model with every light and map bound. +// +// **Two draws with one stage, because a pixel needs two terms.** The new +// colour is the old one times a factor (the albedo swap) plus a term (an +// unlit stack and an emitted light), and one blend cannot multiply and add. +// The first draw writes the factor under a multiplying blend, the second the +// term under an adding one; `params.w` says which. +// +// What the albedo cannot say is stated rather than guessed: a surface that +// wrote no albedo (unlit, or black) has no light to borrow, and there the +// decal is laid over the picture at its own colour. The surface's emission, +// its fog and its specular highlight are scaled with the light, which is +// close for a matte wall and wrong for a mirror. +precision highp float; + +in vec2 v_uv; + +out vec4 frag_color; + +uniform sampler2D surface_texture; +uniform sampler2D albedo_texture; + +/// Four pictures a draw may read, each a slot a decal names. A slot nobody +/// names is bound to a one-texel white, for the reason `lib/pbr.glsl` gives: +/// a declared sampler nobody binds is a crash on Metal. +uniform sampler2D decal_texture_0; +uniform sampler2D decal_texture_1; +uniform sampler2D decal_texture_2; +uniform sampler2D decal_texture_3; + +/// Decals per draw. Must match `_DecalBatch.maxDecals` in +/// `renderer_decal_pass.dart`; a frame with more draws again over what the +/// first draw left. +#define kMaxDecals 16 + +/// How little albedo still names a light. Below it the decal stops borrowing +/// the surface's light and is laid on at its own colour, through a ramp +/// rather than a step so a dark texture does not show a seam where it +/// crosses. +#define kAlbedoFloor 0.08 + +uniform DecalInfo { + /// Screen to world, carrying the framebuffer origin — see `UvFromNdc` in + /// `post/reflections.frag`. + mat4 inverse_view_projection; + /// xyz: the camera's position. w: unused. + vec4 camera; + /// xyz: the direction the camera looks; with [camera] it names the planes + /// the surface buffer's depths are measured against. w: unused. + vec4 forward; + /// x: how many decals. y, z: one texel of the target. w: which term this + /// draw writes, nought the factor and one the term. + vec4 params; + /// The view this draw paints, in the target's texture coordinates: xy + /// where it starts, zw how much of the target it spans. The surface buffer + /// holds every view side by side and the matrix above is this one's. + vec4 view; + /// Per slot: xy its size in texels, zw unused. + vec4 slots[4]; + /// The rows of each decal's world-to-box matrix: the box is the unit cube + /// about its origin, and its picture faces up its y axis. + vec4 axis_x[kMaxDecals]; + vec4 axis_y[kMaxDecals]; + vec4 axis_z[kMaxDecals]; + /// The part of its slot's picture a decal shows: xy where it starts in + /// texture coordinates, zw how far it reaches. + vec4 region[kMaxDecals]; + /// rgb: the tint, linear. a: the opacity. + vec4 color[kMaxDecals]; + /// x: the slot, negative for none. y: the cosine of the angle from the + /// box's up at which the decal is gone, z: the cosine width over which it + /// fades in from there. w: the share of the box's half height, from its + /// top and bottom faces, over which it fades out. + vec4 fade[kMaxDecals]; + /// rgb: light the painted colour emits, as a multiple of it. w: unused. + vec4 emissive[kMaxDecals]; +} +decal_info; + +vec3 DecodeOctahedral(vec2 e) { + e = e * 2.0 - 1.0; + vec3 n = vec3(e.xy, 1.0 - abs(e.x) - abs(e.y)); + float t = max(-n.z, 0.0); + n.x += n.x >= 0.0 ? -t : t; + n.y += n.y >= 0.0 ? -t : t; + return normalize(n); +} + +vec3 SrgbToLinear(vec3 srgb) { + return mix(srgb / 12.92, pow((srgb + vec3(0.055)) / 1.055, vec3(2.4)), + step(vec3(0.04045), srgb)); +} + +/// The world point at [uv], [depth] metres along the view axis: the ray +/// through the pixel, crossing the plane the depth names. See `WorldAt` in +/// `post/reflections.frag`, which this is, but for [uv] being the target's +/// rather than the view's. +vec3 WorldAt(vec2 uv, float depth) { + vec2 inView = (uv - decal_info.view.xy) / decal_info.view.zw; + vec2 xy = vec2(inView.x * 2.0 - 1.0, 1.0 - inView.y * 2.0); + vec4 nearH = decal_info.inverse_view_projection * vec4(xy, 0.0, 1.0); + vec4 farH = decal_info.inverse_view_projection * vec4(xy, 1.0, 1.0); + vec3 origin = nearH.xyz / nearH.w; + vec3 along = normalize(farH.xyz / farH.w - origin); + vec3 axis = decal_info.forward.xyz; + return origin + + along * ((depth - dot(origin - decal_info.camera.xyz, axis)) / + dot(along, axis)); +} + +/// How far the world moves across one texel along [step], taken toward the +/// neighbour nearer in depth. +/// +/// **Read from the buffer rather than from `dFdx`**, so the software backend, +/// which has no quads to difference, picks the same level. The nearer +/// neighbour, because the far one across a silhouette belongs to another +/// surface, and its distance would choose the blurriest level for a pixel +/// that is in the middle of a decal. +vec3 WorldStep(vec2 step, float depth, vec3 at) { + vec4 ahead = textureLod(surface_texture, v_uv + step, 0.0); + vec4 behind = textureLod(surface_texture, v_uv - step, 0.0); + float aheadGap = ahead.a > 0.0 ? abs(ahead.a - depth) : 1e30; + float behindGap = behind.a > 0.0 ? abs(behind.a - depth) : 1e30; + vec3 forward = WorldAt(v_uv + step, ahead.a) - at; + vec3 backward = at - WorldAt(v_uv - step, behind.a); + vec3 chosen = aheadGap <= behindGap ? forward : backward; + // Nothing drawn on either side: no footprint, which is the base level. + return min(aheadGap, behindGap) < 1e29 ? chosen : vec3(0.0); +} + +vec4 SampleSlot(float slot, vec2 uv, float lod) { + // A chain of selects rather than an index: GLSL ES 3.00 cannot index an + // array of samplers with anything but a constant. + vec4 picture = vec4(1.0); + if (slot > 2.5) { + picture = textureLod(decal_texture_3, uv, lod); + } else if (slot > 1.5) { + picture = textureLod(decal_texture_2, uv, lod); + } else if (slot > 0.5) { + picture = textureLod(decal_texture_1, uv, lod); + } else if (slot > -0.5) { + picture = textureLod(decal_texture_0, uv, lod); + } + return picture; +} + +void main() { + bool factor = decal_info.params.w < 0.5; + vec4 surface = textureLod(surface_texture, v_uv, 0.0); + + // What leaves the pixel as it was, for each draw: a factor of one, a term of + // nought. Alpha is not touched by either blend. + vec3 keepFactor = vec3(1.0); + vec3 keepTerm = vec3(0.0); + if (surface.a <= 0.0) { + frag_color = vec4(factor ? keepFactor : keepTerm, 1.0); + return; + } + + float depth = surface.a; + vec3 at = WorldAt(v_uv, depth); + vec3 normal = DecodeOctahedral(surface.rg); + vec3 dx = WorldStep(vec2(decal_info.params.y, 0.0), depth, at); + vec3 dy = WorldStep(vec2(0.0, decal_info.params.z), depth, at); + vec3 albedo = + SrgbToLinear(textureLod(albedo_texture, v_uv, 0.0).rgb); + + // Three stacks, decal over decal in order: the albedo the decals leave, the + // share of the picture an unlit stack leaves, and that stack's colour — + // premultiplied, as an over composite keeps it — and the emitted light. + vec3 painted = albedo; + float kept = 1.0; + vec3 laid = vec3(0.0); + vec3 emitted = vec3(0.0); + int count = int(decal_info.params.x + 0.5); + for (int i = 0; i < kMaxDecals; i++) { + if (i >= count) break; + vec4 rowX = decal_info.axis_x[i]; + vec4 rowY = decal_info.axis_y[i]; + vec4 rowZ = decal_info.axis_z[i]; + vec3 local = vec3(dot(rowX.xyz, at) + rowX.w, dot(rowY.xyz, at) + rowY.w, + dot(rowZ.xyz, at) + rowZ.w); + if (any(greaterThan(abs(local), vec3(0.5)))) continue; + + vec4 fade = decal_info.fade[i]; + // The box's up in the world is the gradient of its y, which is the row + // itself; normalised, it stays the up under a box scaled unevenly. + float facing = dot(normal, normalize(rowY.xyz)); + float byAngle = clamp((facing - fade.y) / max(fade.z, 1e-4), 0.0, 1.0); + float byDepth = fade.w > 0.0 + ? clamp((0.5 - abs(local.y)) / (fade.w * 0.5), 0.0, 1.0) + : 1.0; + + // Seen from above, along the box's down, x runs right and z runs down + // the picture: the picture is not mirrored on the face it is stamped on. + vec4 region = decal_info.region[i]; + vec2 uv = region.xy + (local.xz + 0.5) * region.zw; + + // The level from the footprint, as the software backend's sampler + // chooses one: the larger axis, in texels, over one pixel. + float slot = fade.x; + vec2 size = decal_info.slots[int(clamp(slot, 0.0, 3.0))].xy; + vec2 alongX = vec2(dot(rowX.xyz, dx), dot(rowZ.xyz, dx)) * region.zw; + vec2 alongY = vec2(dot(rowX.xyz, dy), dot(rowZ.xyz, dy)) * region.zw; + float footprint = max(max(abs(alongX.x), abs(alongY.x)) * size.x, + max(abs(alongX.y), abs(alongY.y)) * size.y); + float lod = footprint > 1.0 ? log2(footprint) : 0.0; + + vec4 picture = SampleSlot(slot, uv, lod); + vec4 tint = decal_info.color[i]; + vec3 colour = SrgbToLinear(picture.rgb) * tint.rgb; + float alpha = clamp(picture.a * tint.a * byAngle * byDepth, 0.0, 1.0); + + painted = mix(painted, colour, alpha); + kept *= 1.0 - alpha; + laid = mix(laid, colour, alpha); + emitted = mix(emitted, colour * decal_info.emissive[i].rgb, alpha); + } + + // How far the albedo names the light: one at the floor and over it. + float named = clamp(max(albedo.r, max(albedo.g, albedo.b)) / kAlbedoFloor, + 0.0, 1.0); + // The change of albedo under the light it was lit by, as a factor: the new + // albedo over the old where the old is over the floor, and exactly one + // where no decal reached, whatever the albedo. Written as one plus the + // change rather than as the ratio, because under the floor the ratio is not + // one for an untouched pixel and the ramp below would darken every dark + // surface in the frame. + vec3 swap = vec3(1.0) + (painted - albedo) / max(albedo, vec3(kAlbedoFloor)); + vec3 multiply = swap * named + vec3(kept * (1.0 - named)); + vec3 add = laid * (1.0 - named) + emitted; + frag_color = vec4(factor ? multiply : add, 1.0); +} diff --git a/packages/flutter3d_shaders/shaders/post/lens_flare.frag b/packages/flutter3d_shaders/shaders/post/lens_flare.frag new file mode 100644 index 000000000..f87f9bca1 --- /dev/null +++ b/packages/flutter3d_shaders/shaders/post/lens_flare.frag @@ -0,0 +1,88 @@ +#version 460 core + +// Lens flare, added to the glow — `P2`. +// +// A bright light in the frame reflects between the elements of a lens and +// lands again as a row of ghosts on the line through the middle of the +// frame, on the far side, and as a ring round the middle. Both are drawn +// here from the glow itself: the bloom chain has already picked out what is +// bright and spread it, so reading it at the mirrored positions is what puts +// a ghost where the light's reflection would fall, and nothing that is not +// bright enough to bloom ever flares. +// +// Each ghost is weighted by how near the middle it lands, so ghosts thin out +// towards the edges and one that would land outside the frame is not drawn +// — no wrapping round, which reads as a light on the wrong side. The three +// channels are taken a little apart along the ghost's line, which is the +// colour fringe a coated lens leaves on its reflections. +// +// Drawn into the glow rather than into the picture so the composite adds it +// with the glow, at the glow's own intensity and before the tone map: a +// flare is light, and it saturates the way the rest of the light does. +// +// `flutter3d_cpu`'s `LensFlareShader` is this, line for line. +precision highp float; + +in vec2 v_uv; + +out vec4 frag_color; + +/// The glow, as the bloom chain finished it. +uniform sampler2D bloom_texture; + +uniform LensFlareInfo { + /// x: how much flare, against the glow it is drawn from. y: how many + /// ghosts, up to eight. z: how far apart they fall, as a fraction of the + /// way from the light to the middle. w: the halo's radius, as a fraction + /// of the frame's height. + vec4 params; + + /// x: how far apart the channels of a ghost are taken, in uv. y: the + /// halo's strength against the ghosts'. z: the frame's aspect, width over + /// height. w: unclaimed. + vec4 more; +} +lens_flare_info; + +/// The glow at [at], its channels taken [spread] apart along [along]. +vec3 Fringed(vec2 at, vec2 along, float spread) { + return vec3(textureLod(bloom_texture, at + along * spread, 0.0).r, + textureLod(bloom_texture, at, 0.0).g, + textureLod(bloom_texture, at - along * spread, 0.0).b); +} + +/// How much a reflection landing at [at] keeps: all of it in the middle, +/// falling off to nothing at the corners and beyond. +float Falloff(vec2 at, float power) { + float d = length(vec2(0.5) - at) / 0.70710678; + return pow(max(1.0 - d, 0.0), power); +} + +void main() { + vec3 glow = textureLod(bloom_texture, v_uv, 0.0).rgb; + float intensity = lens_flare_info.params.x; + // The reflection of the point at `v_uv` lands on the far side of the + // middle, so a ghost at `v_uv` comes from the light at `flipped`. + vec2 flipped = vec2(1.0) - v_uv; + vec2 towardMiddle = (vec2(0.5) - flipped) * lens_flare_info.params.z; + float reach = length(towardMiddle); + vec2 along = reach > 1e-6 ? towardMiddle / reach : vec2(0.0); + float spread = lens_flare_info.more.x; + + vec3 flare = vec3(0.0); + int ghosts = int(lens_flare_info.params.y + 0.5); + for (int i = 0; i < 8; i++) { + if (i >= ghosts) break; + vec2 at = flipped + towardMiddle * float(i); + flare += Fringed(at, along, spread) * Falloff(at, 10.0); + } + + // The ring: every light at one radius from the middle lands on it. + float aspect = max(lens_flare_info.more.z, 1e-4); + vec2 halo = along * vec2(1.0 / aspect, 1.0) * lens_flare_info.params.w; + vec2 haloAt = flipped + halo; + flare += Fringed(haloAt, along, spread) * Falloff(haloAt, 5.0) * + lens_flare_info.more.y; + + frag_color = vec4(glow + flare * intensity, 1.0); +} diff --git a/packages/flutter3d_shaders/shaders/post/light_shafts.frag b/packages/flutter3d_shaders/shaders/post/light_shafts.frag index 99cf2e290..a8112f0d5 100644 --- a/packages/flutter3d_shaders/shaders/post/light_shafts.frag +++ b/packages/flutter3d_shaders/shaders/post/light_shafts.frag @@ -141,15 +141,26 @@ void main() { vec2 xy = vec2(v_uv.x * 2.0 - 1.0, 1.0 - v_uv.y * 2.0); vec4 nearH = shaft_info.inverse_view_projection * vec4(xy, 0.0, 1.0); vec4 farH = shaft_info.inverse_view_projection * vec4(xy, 1.0, 1.0); - vec3 origin = nearH.xyz / nearH.w; - vec3 along = normalize(farH.xyz / farH.w - origin); + vec3 nearPoint = nearH.xyz / nearH.w; + vec3 along = normalize(farH.xyz / farH.w - nearPoint); + float cosine = max(dot(along, shaft_info.forward.xyz), 1e-4); + + // **From the eye's plane, not the near plane** — `P7`, as + // `volumetric_fog.frag` already starts. The surface buffer's depth is + // measured from the eye, so a march from the near plane ran the near + // distance past the surface. Through a perspective lens that is a tenth of + // a metre; through an orthographic one the near plane can stand anywhere, + // behind the eye included, and the march ended that far off the wall. + vec3 origin = nearPoint - + along * (dot(nearPoint - shaft_info.camera.xyz, + shaft_info.forward.xyz) / + cosine); // How far there is air. The surface buffer holds depth along the view axis // in metres, so the distance along *this* ray is that over the cosine // between the two — a ray at the corner of the frame travels further than // the axis does to reach the same plane. float surfaceDepth = texture(surface_texture, v_uv).a; - float cosine = max(dot(along, shaft_info.forward.xyz), 1e-4); float toSurface = surfaceDepth > 0.0 ? surfaceDepth / cosine : 1e9; float distance = min(shaft_info.camera.w, toSurface); if (distance <= 0.0) { diff --git a/packages/flutter3d_shaders/shaders/post/render_texture_encode.frag b/packages/flutter3d_shaders/shaders/post/render_texture_encode.frag new file mode 100644 index 000000000..d45e81f02 --- /dev/null +++ b/packages/flutter3d_shaders/shaders/post/render_texture_encode.frag @@ -0,0 +1,50 @@ +#version 460 core + +// `P4`: what a `RenderTexture`'s camera saw, turned from linear light into +// the sRGB bytes a material's map is read as. +// +// **Encoded because every map is decoded.** A material reads its base colour +// and its emission through `SrgbToLinear`, so a texture holding linear light +// would be darkened a second time in every midtone by whatever showed it. In +// eight bits rather than half floats for the same reason: it is a picture, +// sampled like any other picture. +// +// **Exposure, and no tone map.** The texture is drawn into a frame that is +// tone mapped itself, so a curve applied here would be applied twice; what +// is above one after the camera's own exposure is clipped, as a screen +// clips it. +precision highp float; + +in vec2 v_uv; + +out vec4 frag_color; + +uniform sampler2D source_texture; + +uniform RenderTextureInfo { + /// x: the exposure the light is multiplied by before it is clipped. y: one + /// where the backend's first row is the bottom of a picture it draws. zw + /// unused. + vec4 params; +} +encode_info; + +vec3 LinearToSrgb(vec3 linear) { + return mix( + linear * 12.92, + 1.055 * pow(max(linear, vec3(0.0)), vec3(1.0 / 2.4)) - vec3(0.055), + step(vec3(0.0031308), linear)); +} + +void main() { + // **Written with the top of the picture in the first row, on every + // backend**, which is how a picture loaded from a file is uploaded and so + // how every material reads its maps. WebGL2 draws a picture with its + // bottom in the first row, so there the rows are turned over on the way + // through; a copy that kept them would show a monitor upside down in the + // browser alone. + vec2 uv = v_uv; + if (encode_info.params.y > 0.5) uv.y = 1.0 - uv.y; + vec3 light = textureLod(source_texture, uv, 0.0).rgb * encode_info.params.x; + frag_color = vec4(LinearToSrgb(clamp(light, vec3(0.0), vec3(1.0))), 1.0); +} diff --git a/packages/flutter3d_shaders/shaders/post/smaa_blend.frag b/packages/flutter3d_shaders/shaders/post/smaa_blend.frag new file mode 100644 index 000000000..264130fa8 --- /dev/null +++ b/packages/flutter3d_shaders/shaders/post/smaa_blend.frag @@ -0,0 +1,50 @@ +#version 460 core + +// SMAA 1x, last of three passes — `P1`: each pixel moved towards its +// neighbour across the edge by the share the second pass found. +// +// Four shares reach a pixel: its own for its top and left sides, and its +// right and bottom neighbours' far shares for the sides it shares with them. +// The larger pair wins — across or along — and the pixel is two filtered +// taps, each pulled that fraction of a texel towards its side. +// +// `flutter3d_cpu`'s `SmaaBlendShader` is this, line for line. +precision highp float; + +in vec2 v_uv; + +out vec4 frag_color; + +/// The finished picture, filtered. +uniform sampler2D source_texture; + +/// The second pass's shares. +uniform sampler2D blend_texture; + +uniform SmaaInfo { + /// x, y: one texel. z, w: the first pass's, unread here. + vec4 params; +} +smaa_info; + +void main() { + vec2 texel = smaa_info.params.xy; + float right = textureLod(blend_texture, v_uv + vec2(texel.x, 0.0), 0.0).a; + float bottom = textureLod(blend_texture, v_uv + vec2(0.0, texel.y), 0.0).g; + vec4 mine = textureLod(blend_texture, v_uv, 0.0); + float top = mine.r; + float left = mine.b; + if (right + bottom + top + left < 1e-5) { + frag_color = textureLod(source_texture, v_uv, 0.0); + return; + } + + bool across = max(right, left) > max(bottom, top); + float toward = across ? right : bottom; + float away = across ? left : top; + vec2 axis = across ? vec2(texel.x, 0.0) : vec2(0.0, texel.y); + vec4 first = textureLod(source_texture, v_uv + toward * axis, 0.0); + vec4 second = textureLod(source_texture, v_uv - away * axis, 0.0); + float total = toward + away; + frag_color = first * (toward / total) + second * (away / total); +} diff --git a/packages/flutter3d_shaders/shaders/post/smaa_edges.frag b/packages/flutter3d_shaders/shaders/post/smaa_edges.frag new file mode 100644 index 000000000..4ada3437f --- /dev/null +++ b/packages/flutter3d_shaders/shaders/post/smaa_edges.frag @@ -0,0 +1,56 @@ +#version 460 core + +// SMAA 1x, first of three passes — `P1`: where the luma steps. +// +// Red marks a step across a pixel's left side, green across its top. The +// other two passes read nothing else, so this is where the method decides +// what an edge is: a step of at least `params.z` in luma, and not much +// smaller than the largest step beside it. That second test is what keeps +// SMAA off the inside of a high-contrast texture, which FXAA would smooth as +// though it were a staircase. +// +// `flutter3d_cpu`'s `SmaaEdgesShader` is this, line for line. +precision highp float; + +in vec2 v_uv; + +out vec4 frag_color; + +/// The finished picture, tone mapped and encoded. +uniform sampler2D source_texture; + +uniform SmaaInfo { + /// x, y: one texel. z: the smallest luma step that is an edge. w: how + /// many times smaller than the largest step beside it a step may be and + /// still count. + vec4 params; +} +smaa_info; + +/// Rec. 709 weights on the encoded picture. +float Luma(vec2 at) { + vec3 c = textureLod(source_texture, at, 0.0).rgb; + return dot(c, vec3(0.2126, 0.7152, 0.0722)); +} + +void main() { + vec2 texel = smaa_info.params.xy; + float middle = Luma(v_uv); + float left = Luma(v_uv + vec2(-texel.x, 0.0)); + float top = Luma(v_uv + vec2(0.0, -texel.y)); + vec2 delta = abs(vec2(middle - left, middle - top)); + vec2 edges = step(vec2(smaa_info.params.z), delta); + if (edges.x + edges.y == 0.0) { + frag_color = vec4(0.0); + return; + } + + float right = abs(middle - Luma(v_uv + vec2(texel.x, 0.0))); + float bottom = abs(middle - Luma(v_uv + vec2(0.0, texel.y))); + float leftLeft = abs(left - Luma(v_uv + vec2(-2.0 * texel.x, 0.0))); + float topTop = abs(top - Luma(v_uv + vec2(0.0, -2.0 * texel.y))); + float largest = max(max(max(delta.x, delta.y), max(right, bottom)), + max(leftLeft, topTop)); + edges *= step(vec2(largest), smaa_info.params.w * delta); + frag_color = vec4(edges, 0.0, 0.0); +} diff --git a/packages/flutter3d_shaders/shaders/post/smaa_weights.frag b/packages/flutter3d_shaders/shaders/post/smaa_weights.frag new file mode 100644 index 000000000..76e1ea127 --- /dev/null +++ b/packages/flutter3d_shaders/shaders/post/smaa_weights.frag @@ -0,0 +1,122 @@ +#version 460 core + +// SMAA 1x, second of three passes — `P1`: how far each pixel along an edge +// moves. +// +// From every pixel on an edge, a walk to both ends of the run it belongs +// to: the run ends where the edge stops or where an edge crosses it. Which +// side of each end a crossing stands on says what shape the staircase is — +// a step down, a step up, a U — and the line behind that shape, and this +// pixel's place along it, say how much of the pixel the line covers. That +// area is precomputed: the engine's `smaaArea` table, 16×16 texels for each +// pair of end codes, indexed by the square roots of the two distances. +// +// Red and green: the top edge's two shares, this pixel's and the one +// above's. Blue and alpha: the left edge's, this pixel's and the one to the +// left's. Orthogonal edges only; no diagonal search, no corner rounding. +// +// `flutter3d_cpu`'s `SmaaWeightsShader` is this, line for line. +precision highp float; + +in vec2 v_uv; + +out vec4 frag_color; + +/// The first pass's edges: red across the left side, green across the top. +uniform sampler2D edges_texture; + +/// The engine's `smaaArea` table, 80×80, filtered. +uniform sampler2D area_texture; + +uniform SmaaInfo { + /// x, y: one texel. z, w: the first pass's, unread here. + vec4 params; +} +smaa_info; + +/// The most pixels a search walks from the one it starts at, either way. +const int kSmaaMaxSearch = 32; + +vec4 Edges(vec2 offset) { + return textureLod(edges_texture, v_uv + offset * smaa_info.params.xy, 0.0); +} + +/// The table's texel for the two ends' codes and the square roots of the +/// two distances, filtered between square roots. +vec2 Area(float first, float last, float codeFirst, float codeLast) { + vec2 at = (16.0 * vec2(codeFirst, codeLast) + sqrt(vec2(first, last)) + 0.5) / + 80.0; + return textureLod(area_texture, at, 0.0).rg; +} + +void main() { + vec4 here = Edges(vec2(0.0)); + vec4 weights = vec4(0.0); + + if (here.g > 0.5) { + // Along a top edge, to the left: an end where this pixel's own left + // side is crossed, or where the next one has no top edge. + int first = kSmaaMaxSearch; + for (int i = 0; i < kSmaaMaxSearch; i++) { + if (Edges(vec2(-float(i), 0.0)).r > 0.5 || + Edges(vec2(-float(i) - 1.0, 0.0)).g < 0.5) { + first = i; + break; + } + } + int last = kSmaaMaxSearch; + for (int i = 0; i < kSmaaMaxSearch; i++) { + vec4 next = Edges(vec2(float(i) + 1.0, 0.0)); + if (next.r > 0.5 || next.g < 0.5) { + last = i; + break; + } + } + // A crossing on the near side of the edge counts three, on the far + // side one: the codes the bilinear fetch of the reference gives. + float f = float(first); + float l = float(last); + float codeFirst = first == kSmaaMaxSearch + ? 0.0 + : 3.0 * Edges(vec2(-f, 0.0)).r + + Edges(vec2(-f, -1.0)).r; + float codeLast = last == kSmaaMaxSearch + ? 0.0 + : 3.0 * Edges(vec2(l + 1.0, 0.0)).r + + Edges(vec2(l + 1.0, -1.0)).r; + weights.rg = Area(f, l, codeFirst, codeLast); + } + + if (here.r > 0.5) { + // Along a left edge, upwards and then down. + int first = kSmaaMaxSearch; + for (int i = 0; i < kSmaaMaxSearch; i++) { + if (Edges(vec2(0.0, -float(i))).g > 0.5 || + Edges(vec2(0.0, -float(i) - 1.0)).r < 0.5) { + first = i; + break; + } + } + int last = kSmaaMaxSearch; + for (int i = 0; i < kSmaaMaxSearch; i++) { + vec4 next = Edges(vec2(0.0, float(i) + 1.0)); + if (next.g > 0.5 || next.r < 0.5) { + last = i; + break; + } + } + float f = float(first); + float l = float(last); + float codeFirst = first == kSmaaMaxSearch + ? 0.0 + : 3.0 * Edges(vec2(0.0, -f)).g + + Edges(vec2(-1.0, -f)).g; + float codeLast = last == kSmaaMaxSearch + ? 0.0 + : 3.0 * Edges(vec2(0.0, l + 1.0)).g + + Edges(vec2(-1.0, l + 1.0)).g; + weights.ba = Area(f, l, codeFirst, codeLast); + } + + frag_color = weights; +} diff --git a/packages/flutter3d_shaders/shaders/sky_physical.frag b/packages/flutter3d_shaders/shaders/sky_physical.frag new file mode 100644 index 000000000..fcd7cbb2a --- /dev/null +++ b/packages/flutter3d_shaders/shaders/sky_physical.frag @@ -0,0 +1,202 @@ +#version 460 core + +// The sky as air: sunlight scattered once by molecules (Rayleigh) and by haze +// (Mie) along the view ray, the sun's disc seen through what the air leaves of +// it, stars at night, and the ground below the horizon. +// +// **Marched per pixel rather than read from a precomputed table.** A table is +// what a sky this size usually becomes, and it is two render passes into +// float targets before the scene, plus a sampler the sky's pipeline would have +// to be measured taking — `sky.vert` says why nothing is assumed about what +// reaches it. Sixteen samples along the view and eight towards the sun at each +// is 144 exponentials a pixel, paid only where no geometry covers the sky, +// because the stage runs after the opaque half and fails the depth test under +// it. `SkySettings.physical` documents the cost where a caller will read it. +// +// **Single scattering only**, and that is visible: the horizon at noon comes +// out a pale cyan rather than white, because light scattered twice is what +// whitens it. Measured against a 128 × 64 march of the same model, the +// march here is within two per cent of it at the zenith and four at the +// horizon. Two choices buy that. The view samples are spaced quadratically, +// short near the eye where a horizontal ray spends its densest air; spaced +// evenly, sixteen of them put the noon horizon's blue at less than half of +// what it is. And each sample is dimmed by the air up to its middle rather +// than its far end, which was another fifteen per cent. +// +// The same arithmetic runs in Dart twice: `PhysicalSky.radiance`, for a fog +// colour, a sunlight colour and an environment map built from the sky, and +// `SkyPhysicalShader` in the software rasteriser. A test evaluates both. +precision highp float; + +in vec3 v_ray; +in vec4 v_rayleigh; +in vec4 v_mie; +in vec4 v_sun; +in vec4 v_planet; +in vec4 v_stars; +in vec4 v_disc; + +layout(location = 0) out vec4 frag_color; + +// No surface output, for the reason `sky.frag` records at length: on Impeller, +// a second output in a single-attachment pass took the process down. + +const int kViewSteps = 16; +const int kLightSteps = 8; +const float kPi = 3.14159265; + +/// How far a ray from radius [r], at cosine [mu] to the local up, runs before +/// it leaves a sphere of [radius]; negative when it misses. +/// +/// `(r - radius) * (r + radius)` rather than `r * r - radius * radius`: the +/// two squares are 4·10⁷ km² apiece and their difference near the ground is a +/// few hundred, which the subtraction of the squares loses most of in single +/// precision. +float Leave(float r, float mu, float radius) { + float b = r * mu; + float d = b * b - (r - radius) * (r + radius); + return d < 0.0 ? -1.0 : -b + sqrt(d); +} + +/// How far the same ray runs before it meets the ground, negative when it +/// does not. +float Meet(float r, float mu, float radius) { + float b = r * mu; + float d = b * b - (r - radius) * (r + radius); + return d < 0.0 ? -1.0 : -b - sqrt(d); +} + +/// The air between [p] and space towards [s]: x molecules, y haze, each as a +/// length of air at ground density. +vec2 SunwardAir(vec3 p, vec3 s) { + float r = length(p); + float span = Leave(r, dot(p, s) / r, v_planet.y); + float stride = span / float(kLightSteps); + vec2 air = vec2(0.0); + for (int j = 0; j < kLightSteps; j++) { + vec3 q = p + s * ((float(j) + 0.5) * stride); + float h = length(q) - v_planet.x; + air += exp(-h / vec2(v_rayleigh.w, v_mie.z)) * stride; + } + return air; +} + +/// What a length of air lets through, per channel. +vec3 Through(vec2 air) { + return exp(-(v_rayleigh.rgb * air.x + vec3(v_mie.y * air.y))); +} + +/// A hash of three small whole numbers into [0, 1), with no sine in it. +/// +/// `fract(sin(x) * 43758.5)` is the usual one and it is wrong here: the sine +/// of a large argument is computed differently by every GPU and by the +/// software rasteriser, so the stars would be in different places on each +/// backend. This is products and `fract`s of numbers below a few thousand, +/// which single precision answers alike everywhere to the last few bits. +float Hash(vec3 p) { + p = fract(p * 0.1031); + p += dot(p, p.zyx + 31.32); + return fract((p.x + p.y) * p.z); +} + +/// The stars in direction [d]: a grid of cells on each face of a cube, a share +/// [v_stars.y] of them holding a star at a place and a brightness of its own. +float StarField(vec3 d) { + vec3 a = abs(d); + float face; + vec2 uv; + if (a.x >= a.y && a.x >= a.z) { + face = d.x > 0.0 ? 0.0 : 1.0; + uv = d.zy / a.x; + } else if (a.y >= a.z) { + face = d.y > 0.0 ? 2.0 : 3.0; + uv = d.xz / a.y; + } else { + face = d.z > 0.0 ? 4.0 : 5.0; + uv = d.xy / a.z; + } + vec2 grid = (uv * 0.5 + 0.5) * v_stars.z; + vec2 cell = floor(grid); + vec3 key = vec3(cell, face); + if (Hash(key) >= v_stars.y) return 0.0; + vec2 centre = vec2(Hash(key + vec3(17.0, 0.0, 0.0)), + Hash(key + vec3(0.0, 29.0, 0.0))) * 0.6 + 0.2; + float off = length(grid - cell - centre); + float magnitude = Hash(key + vec3(0.0, 0.0, 13.0)); + return (1.0 - smoothstep(0.0, 0.35, off)) * + (0.15 + 0.85 * magnitude * magnitude * magnitude); +} + +void main() { + vec3 d = normalize(v_ray); + vec3 s = v_sun.xyz; + vec3 eye = vec3(0.0, v_planet.z, 0.0); + + float ground = Meet(v_planet.z, d.y, v_planet.x); + bool grounded = ground > 0.0; + float span = grounded ? ground : Leave(v_planet.z, d.y, v_planet.y); + + vec2 seenAir = vec2(0.0); + vec3 molecules = vec3(0.0); + vec3 haze = vec3(0.0); + for (int i = 0; i < kViewSteps; i++) { + float a0 = float(i) / float(kViewSteps); + float a1 = float(i + 1) / float(kViewSteps); + float t = span * 0.5 * (a0 * a0 + a1 * a1); + float stride = span * (a1 * a1 - a0 * a0); + vec3 p = eye + d * t; + float r = length(p); + vec2 air = exp(-(r - v_planet.x) / vec2(v_rayleigh.w, v_mie.z)) * stride; + seenAir += air; + // In the planet's shadow: this sample is lit by nothing. + if (Meet(r, dot(p, s) / r, v_planet.x) > 0.0) continue; + vec3 through = Through(seenAir - 0.5 * air + SunwardAir(p, s)); + molecules += air.x * through; + haze += air.y * through; + } + + float mu = dot(d, s); + float g = v_mie.w; + float gg = g * g; + float rayleighPhase = 3.0 / (16.0 * kPi) * (1.0 + mu * mu); + // Cornette–Shanks, which is Henyey–Greenstein with the Rayleigh term's + // shape folded in. g is held below one by the renderer, so the base of the + // power never reaches nought. + float miePhase = 3.0 / (8.0 * kPi) * ((1.0 - gg) * (1.0 + mu * mu)) / + ((2.0 + gg) * pow(1.0 + gg - 2.0 * g * mu, 1.5)); + + vec3 colour = v_sun.w * (molecules * v_rayleigh.rgb * rayleighPhase + + haze * (v_mie.x * miePhase)); + vec3 seen = Through(seenAir); + + if (grounded) { + // The ground, lit by what the air leaves of the sun and seen through the + // air in front of it. Lambertian, and with no light from the sky itself, + // so a ground in the sun's shadow is black; it is what fills the lower + // half of an environment map built from this sky, not a terrain. + vec3 p = eye + d * span; + float lit = dot(normalize(p), s); + if (lit > 0.0) { + colour += seen * Through(SunwardAir(p, s)) * + (v_planet.w / kPi * lit * v_sun.w); + } + } else { + // The disc, through the air in front of it: this is what turns it red + // at sunset with no colour anybody chose. `sky.frag` explains the + // guard and why the soft edge travels as a difference. + float disc = v_disc.y > 0.0 + ? smoothstep(v_disc.x - v_disc.y, v_disc.x, mu) + : step(v_disc.x, mu); + colour += seen * (disc * v_disc.z); + + // Stars, as the sun goes down: a fade from the sun six degrees above + // the horizon to eleven below, rather than the sky's own brightness, + // which would need the exposure this stage does not know. + float night = 1.0 - smoothstep(-0.2, 0.1, s.y); + if (v_stars.x > 0.0 && night > 0.0) { + colour += seen * (v_stars.x * night * StarField(d)); + } + } + + frag_color = vec4(colour, 1.0); +} diff --git a/packages/flutter3d_shaders/shaders/sky_physical.vert b/packages/flutter3d_shaders/shaders/sky_physical.vert new file mode 100644 index 000000000..8d6d64688 --- /dev/null +++ b/packages/flutter3d_shaders/shaders/sky_physical.vert @@ -0,0 +1,63 @@ +#version 460 core + +// Vertex stage for the physical sky: the gradient sky's triangle, carrying the +// air instead of three colours. +// +// **Its own stage rather than `sky.vert` read under other names.** The data +// travels the same way and for the same reason — `sky.vert` sets out what was +// measured: on Impeller a uniform block never reaches the sky's pipeline and a +// vertex attribute does — and it is the same size, six vec4s after the ray. But +// a varying is matched by name between the stages, and a fragment stage reading +// `v_zenith` as a scattering coefficient is one nobody could maintain. +// +// The depth is `sky.vert`'s 0.999999, for `sky.vert`'s reason: the far plane +// less a hair, so the pass's own `less` passes against a cleared buffer and +// fails against anything already drawn. +// +// Lengths are in kilometres here, not metres. The planet's radius is 6360 of +// them, and in metres its square is 4·10¹³, which single precision holds to +// within a few units — enough to put the horizon a pixel off where the ray +// grazes it. The renderer converts; see `Renderer._skyVertexBytes`. +precision highp float; + +layout(location = 0) in vec2 position; + +// The world-space view ray at this corner. +layout(location = 1) in vec3 corner_ray; + +/// rgb: Rayleigh scattering at the ground, per km. a: its scale height, km. +layout(location = 2) in vec4 rayleigh; +/// x: Mie scattering at the ground, per km. y: Mie extinction, per km. +/// z: its scale height, km. w: Henyey–Greenstein's g. +layout(location = 3) in vec4 mie; +/// xyz: unit vector pointing at the sun. w: the sunlight entering the air. +layout(location = 4) in vec4 sun; +/// x: the planet's radius. y: the top of the air's. z: the eye's, all km. +/// w: the ground's albedo. +layout(location = 5) in vec4 planet; +/// x: how bright the stars are. y: the share of cells holding one. +/// z: cells across a face of the cube they are scattered on. w: unused. +layout(location = 6) in vec4 stars; +/// x: cosine of the disc's angular radius. y: how much softer its edge is, +/// as a difference of cosines. z: how bright the disc is. w: unused. +layout(location = 7) in vec4 disc; + +out vec3 v_ray; +out vec4 v_rayleigh; +out vec4 v_mie; +out vec4 v_sun; +out vec4 v_planet; +out vec4 v_stars; +out vec4 v_disc; + +void main() { + v_ray = corner_ray; + v_rayleigh = rayleigh; + v_mie = mie; + v_sun = sun; + v_planet = planet; + v_stars = stars; + v_disc = disc; + + gl_Position = vec4(position, 0.999999, 1.0); +} diff --git a/packages/flutter3d_sim/CHANGELOG.md b/packages/flutter3d_sim/CHANGELOG.md index 3317e7358..a1fca6053 100644 --- a/packages/flutter3d_sim/CHANGELOG.md +++ b/packages/flutter3d_sim/CHANGELOG.md @@ -1,3 +1,128 @@ +## Unreleased + +- **A brush can say where it draws.** `Brush.drawOrder`, `drawOrder` in the + document and written only when it is not nought, is the engine's + `MeshNode.drawOrder` for level geometry: a water surface after the floor + under it, a decal brush over a wall. Brushes at different places in the + order are never one batch, since a batch draws as one; a breach keeps the + order of the brush it cut. + +- **A `.f3drun` carries the levels edited under the run.** + `Demo.levelSwaps` holds each one as a `DemoLevelSwap`: the step it took + effect before and the whole document, since the edited level exists in no + asset a replay could look up. On reading, the document is checked against + the hash written beside it, and swaps out of step order or past the end of + the tape are refused. A run with swaps is written as format 2, so an older + build refuses it instead of replaying it into a divergence; a run without + any is still written as 1. `f3drun_info` lists the swaps. +- **`DigestTrace.forgetAfter`** drops the checkpoints after a step, for a + run that was lived again from there. +- **`LevelPatch` carries an edit as the rows that changed.** + `LevelPatch.between` matches brushes, lights and entities by the digest + of each row (a deleted brush is one edit, not every row after it), takes + materials by name and every other key whole. `applyTo` refuses a level + other than the one the patch was made against, a row that is not the one + it names, and a result that is not the level it was meant to make; when + it applies, it says what changed without comparing the two documents. + `LevelPatch.staleCode` is the error a game answers a refused patch with. +- **Behaviour trees and utility choices as data.** `BehaviourTree.read` + takes a JSON document of `sequence`, `selector`, `utility`, `invert`, + `alwaysSucceed`, `cooldown` and leaves, and answers a tree or every + problem with where it is. Leaves and utility considerations are registered + by kind in `BehaviourKinds`, which comes with the ones the engine's `Mind` + can already do (`goToFocus`, `goTo`, `wait`, `seesFocus`, `check`, `set`, + `markFocus`, …). `BehaviourBrain` runs a tree; everything it knows is a + `Blackboard` component, so a snapshot or a rewind brings a decision back + half made. A board ticked by another tree starts again rather than resuming + at node numbers that now name something else. +- **`bisectTapes` finds where two runs part.** Each side is a + `ReplaySide` — a start, a tape and the simulation's step, restore and + capture — that keeps the states it has been asked for and plays on from + the nearest. The search compares digests and reads the full snapshots + once, at the step it names; it says whether that step's input differed, + and through an `EntityLayout` which entity and component moved. + `bracketFromTraces` narrows the search to one checkpoint interval of two + `DigestTrace`s. +- **`EntityTracks`** reads a run as one lane per component of each entity, + holding only the steps a value changed and closing a lane when the + component goes. `EntityLayout.ecs` reads an `EcsWorld.save()`, + `EntityLayout.rows` one row per entity. `RewindBuffer.oldestStep` is the + left end of a scrubber. +- **A save says what version of the game wrote it, and an old one is + migrated.** `SaveSchema` is a game's list of migrations, and its version is + their count, so the version cannot move without one. `upgrade` brings an + older run up and refuses a newer one, saying it is newer, rather than + misreading it. `SaveRecord` is the save document: level, snapshot, schema, + step and a digest of the run. `SaveRecord.read` never throws. +- **`resolveSaves` decides between two copies of a save** by digest and step, + against the digest both last agreed on: the same run is in sync, a side + still at the base lost to the one that moved, otherwise the further run + wins, and two different runs equally far along go to the player. +- **A run leaves the machine only with the player's yes.** + `TelemetryConsent` keeps the answer with the wording it was given to and + when; a grant to an older wording does not count, and a damaged settings + file reads as not asked. `TelemetryUpload.prepare` is the only way to build + an upload and refuses without consent; it drops `Demo.recordedBy`, and the + consent travels with the run so a server can refuse one that has none. + `TelemetryUploader` checks consent on every send; `HttpTelemetrySink` + posts through a `JsonPost` the caller hands in, so this package still + imports no network. +- **`resimulate` plays a demo again and says what it did.** It checks the + level hash, the starting state and every checkpoint, and returns a sealed + `Resimulation`: the level changed, the start differs, the replay diverged + (with the step), or it retraced, with the run, its outcome and a trail of + positions. +- **`Heatmap`** bins trails into cells, counting samples and distinct runs, + and marks where runs were lost. Its JSON is the playtest report's, so the + editor reads both. + +- **Photo mode's camera.** `PhotoCamera` flies with the world paused: look, + tilt, zoom, and moves along its own axes with up being the world's. It is + held on a tether round where the player stood, inside the level's box when + there is one, and out of the walls by sweeping each move and sliding along + what it meets. It starts by sweeping out from the player to where the game's + camera was, so a chase camera left behind a wall does not start the photo + there. `shouldPause` takes `photoMode`, which pauses whatever the pointer and + the pad say, since both are flying the camera. + +- **A level bakes into a navigation mesh as well as a grid.** + `NavMesh.bake` and `NavMesh.bakeLevel` voxelise the brushes and the + `Heightfield`, keep the floors an agent fits on and can step between + (`NavMeshConfig`: height, step, radius, slope), erode them by the radius, + cut them into regions, outline those, and cut the outlines into convex + polygons with their neighbours and an area each. Unlike `NavGrid` it keeps + a walkway and the floor under it, and walks up a ramp rather than reading + it as a riser. Integers from the voxeliser on, so `NavMesh.digest` is the + same on every platform; the VM and Chrome agree on six scenes, and the + test holds them. The mesh is eroded by `NavGrid`'s own clearance rule and + covers exactly the cells a flow field for the same body accepts. It is + the first part of N2; the path search over it comes next. + +- **A level can be shared behind a short code.** `ShareBundle` is a level + document, its hash and optionally a `.f3drun` through it, refused when the + run was recorded in another version of the level. `RunService` speaks the + `v1/shares` routes over a transport the game hands in, so the package + still has no dependency for it, and answers every call with `ServiceDone` + or `ServiceRefused` rather than throwing. `cloud/server` speaks this + protocol. +- **A number tuned while the game runs is on the tape.** `InputState.tune` + sets a tunable for one step, `InputFrame.tunes` records it, playback + applies it, and `Tunables` is the step's side: named values with defaults, + taken from the input before the step reads them, saved into a snapshot so + a rewind comes back with the old value. A run tuned as it was played + replays like any other. +- **`firstDifferingPath` moved here from `flutter3d_net`**, which still + exports it. **`RewindBuffer.keyframesAfter`** reads the snapshots held. + +- **`diffLevel` says who has to act on an edit.** It compares two versions + of a level part by part and splits the change: lights, materials, fog and + music can be patched into a running scene; brushes, entities, the ground, + recipes and the next level are the simulation's, and go through a timeline + branch. Conservative on purpose: a brush that only changed material is + still the simulation's, since its surface falls back to its material. +- **`RewindBuffer.rebaseAt`** makes one keyframe the oldest thing held, for a + change to the world the snapshots do not carry. + ## 0.8.1+1 **Resolves on Flutter 3.44 and Dart 3.12.0.** The constraints asked for Dart diff --git a/packages/flutter3d_sim/bin/f3drun_info.dart b/packages/flutter3d_sim/bin/f3drun_info.dart index 52f5f4c81..1d895cc8a 100644 --- a/packages/flutter3d_sim/bin/f3drun_info.dart +++ b/packages/flutter3d_sim/bin/f3drun_info.dart @@ -66,4 +66,7 @@ void main(List args) { print('buildStamp: ${demo.buildStamp}'); print('platform: ${demo.platform ?? '(unknown)'}'); print('recordedBy: ${demo.recordedBy ?? '(anonymous)'}'); + for (final swap in demo.levelSwaps) { + print('levelSwap: step ${swap.step}, ${swap.levelHash}'); + } } diff --git a/packages/flutter3d_sim/lib/flutter3d_sim.dart b/packages/flutter3d_sim/lib/flutter3d_sim.dart index d808a5931..bc2ed3939 100644 --- a/packages/flutter3d_sim/lib/flutter3d_sim.dart +++ b/packages/flutter3d_sim/lib/flutter3d_sim.dart @@ -45,10 +45,13 @@ export 'src/actors/actor.dart'; export 'src/actors/actor_components.dart'; export 'src/actors/actor_hurt.dart'; export 'src/actors/actor_system.dart'; +export 'src/actors/behaviour_brain.dart'; +export 'src/actors/behaviour_tree.dart'; export 'src/actors/brain.dart'; export 'src/actors/damageable.dart'; export 'src/actors/health.dart'; export 'src/camera/camera_rig.dart'; +export 'src/camera/photo_camera.dart'; export 'src/camera/rig_tuning.dart'; export 'src/ecs/ecs_world.dart'; export 'src/ecs/entity.dart'; @@ -56,6 +59,7 @@ export 'src/ecs/entity_remap.dart'; export 'src/input/game_action.dart'; export 'src/input/input_state.dart'; export 'src/input/input_tape.dart'; +export 'src/input/tunables.dart'; export 'src/level/breaches.dart'; export 'src/level/brush_geometry.dart'; export 'src/level/data_source.dart'; @@ -66,7 +70,9 @@ export 'src/level/heightfield_tiles.dart'; export 'src/level/json_reader.dart'; export 'src/level/level.dart'; export 'src/level/level_collision.dart'; +export 'src/level/level_diff.dart'; export 'src/level/level_issue.dart'; +export 'src/level/level_patch.dart'; export 'src/level/level_validator.dart'; export 'src/level/level_visibility.dart'; export 'src/level/lightmap.dart'; @@ -93,18 +99,31 @@ export 'src/nav/flow_field.dart'; export 'src/nav/jump_links.dart'; export 'src/nav/nav_grid.dart'; export 'src/nav/navigation.dart'; +export 'src/nav/navmesh/navmesh.dart'; +export 'src/nav/navmesh/navmesh_config.dart'; export 'src/physics/layers.dart'; export 'src/save/data_source_trace.dart'; export 'src/save/demo.dart'; +export 'src/save/entity_tracks.dart'; +export 'src/save/first_differing_path.dart'; export 'src/save/game_random.dart'; export 'src/save/replay.dart'; export 'src/save/rewind.dart'; export 'src/save/run_stats.dart'; +export 'src/save/save_record.dart'; +export 'src/save/save_schema.dart'; export 'src/save/scoring.dart'; export 'src/save/snapshot.dart'; export 'src/save/state_digest.dart'; export 'src/save/step_time_trace.dart'; export 'src/save/tally.dart'; +export 'src/save/tape_bisect.dart'; +export 'src/share/run_service.dart'; +export 'src/share/share_bundle.dart'; +export 'src/telemetry/heatmap.dart'; +export 'src/telemetry/resimulation.dart'; +export 'src/telemetry/telemetry_consent.dart'; +export 'src/telemetry/telemetry_upload.dart'; export 'src/world/exit.dart'; export 'src/world/key_ring.dart'; export 'src/world/light_fixture.dart'; diff --git a/packages/flutter3d_sim/lib/src/actors/actor_components.dart b/packages/flutter3d_sim/lib/src/actors/actor_components.dart index 80b27584b..96a2a8e0e 100644 --- a/packages/flutter3d_sim/lib/src/actors/actor_components.dart +++ b/packages/flutter3d_sim/lib/src/actors/actor_components.dart @@ -27,6 +27,7 @@ library; import 'package:flutter3d_physics/flutter3d_physics.dart'; import '../ecs/ecs_world.dart'; +import 'blackboard.dart'; import 'brain.dart'; import 'health.dart'; @@ -130,4 +131,7 @@ void registerActorComponents(EcsWorld entities) { if (data is Map) value.brain.restore(data.cast()); }, ); + // With the rest, so that a behaviour tree's state is in the save of any + // world an actor system stands on, whoever made the world. + registerBlackboard(entities); } diff --git a/packages/flutter3d_sim/lib/src/actors/actor_system.dart b/packages/flutter3d_sim/lib/src/actors/actor_system.dart index 8b5b6e6a2..59a04cce9 100644 --- a/packages/flutter3d_sim/lib/src/actors/actor_system.dart +++ b/packages/flutter3d_sim/lib/src/actors/actor_system.dart @@ -47,7 +47,6 @@ import '../ecs/ecs_world.dart'; import '../ecs/entity.dart'; import '../loop/game_event.dart'; import '../math/motion.dart'; -import '../math/portable_math.dart'; import '../math/tolerances.dart'; import '../nav/jump_links.dart'; import '../nav/navigation.dart'; diff --git a/packages/flutter3d_sim/lib/src/actors/behaviour_brain.dart b/packages/flutter3d_sim/lib/src/actors/behaviour_brain.dart new file mode 100644 index 000000000..a8c372c54 --- /dev/null +++ b/packages/flutter3d_sim/lib/src/actors/behaviour_brain.dart @@ -0,0 +1,100 @@ +/// A [Brain] that runs a behaviour tree, keeping everything it knows on the +/// actor's [Blackboard]. +/// +/// ## Nothing on the brain +/// +/// [save] is the default empty map, and that is the design rather than an +/// omission. Where the actor is in its tree, what it heard and when, how long +/// it has waited: all of it is a component, so the ECS save writes it and a +/// rewind restores it without this class being asked. A brain that kept even +/// one field would put that field outside every snapshot that forgot to call +/// `save` on it. +/// +/// ## What it notices for the tree +/// +/// The engine tells a brain about noise and pain, and a tree cannot override +/// a method. So this writes them down, where a leaf or a consideration can +/// read them: [heard] (where), [heardAt] and [hurtAt] (the board's clock when +/// it happened). `since` is the consideration that turns "heard at" into +/// "heard recently". +library; + +import 'package:vector_math/vector_math.dart'; + +import 'actor.dart'; +import 'actor_system.dart'; +import 'behaviour_tree.dart'; +import 'brain.dart'; + +final class BehaviourBrain extends Brain { + BehaviourBrain(this.tree); + + /// Shared by every actor that runs it; see [BehaviourTree]. + final BehaviourTree tree; + + /// Where the last noise this actor heard came from, as a point. + static const String heard = 'heard'; + + /// The board's clock when it was heard. + static const String heardAt = 'heardAt'; + + /// The board's clock when this actor was last hurt and lived. + static const String hurtAt = 'hurtAt'; + + /// [actor]'s board, made on the spot when it has none. + /// + /// Made here rather than at spawn so that `ActorSystem.spawn` stays the one + /// way an actor is built; a game that wants values on the board before the + /// first decision sets a [Blackboard] on the entity itself. + static Blackboard boardOf(Actor actor) { + final present = actor.entities.get(actor.entity); + if (present != null) return present; + final made = Blackboard(); + actor.entities.set(actor.entity, made); + return made; + } + + @override + void think(Mind it) => tree.tick(it, boardOf(it.actor)); + + @override + void act(Mind it) { + final board = boardOf(it.actor); + board.clock += it.dt; + tree.act(it, board); + } + + @override + void onNoise(Mind it, Vector3 at) { + final board = boardOf(it.actor)..setPoint(heard, at); + board.set(heardAt, board.clock); + } + + @override + void onHurt(Mind it, double amount) { + final board = boardOf(it.actor); + board.set(hurtAt, board.clock); + } + + /// The path [actor] took last time it decided, root first, or empty when it + /// does not run a tree or has not decided yet. + /// + /// **Reads the board and never makes one**, unlike [boardOf]: this is what + /// an overlay calls between frames, and an overlay that put a component on + /// an entity would change the next snapshot by being switched on. + static List pathOf(Actor actor) => + switch ((actor.brain, actor.entities.get(actor.entity))) { + (final BehaviourBrain brain, final Blackboard board) => + brain.tree.pathOf(board), + _ => const [], + }; + + /// Where [actor]'s running leaf is taking it, or null. Reads, like + /// [pathOf]. + static Vector3? goalOf(Actor actor, ActorSystem system) => + switch ((actor.brain, actor.entities.get(actor.entity))) { + (final BehaviourBrain brain, final Blackboard board) => + brain.tree.goalOf((actor: actor, system: system, board: board)), + _ => null, + }; +} diff --git a/packages/flutter3d_sim/lib/src/actors/behaviour_kinds.dart b/packages/flutter3d_sim/lib/src/actors/behaviour_kinds.dart new file mode 100644 index 000000000..cd00be305 --- /dev/null +++ b/packages/flutter3d_sim/lib/src/actors/behaviour_kinds.dart @@ -0,0 +1,391 @@ +/// What a leaf kind in a behaviour tree document means, and the ones every +/// game gets. +/// +/// ## Registered by kind, and why not by type +/// +/// A document says `{"kind": "goTo", "key": "post"}`, and something has to +/// turn that string into code. A table from name to builder is that something, +/// and it is the same table whoever wrote the document — a level designer, a +/// mod, an agent over MCP. A game adds its own kinds to it next to the +/// standard ones, and a document naming a kind nobody registered is refused +/// with the list of kinds that are, rather than failing at the first tick. +/// +/// ## What is standard +/// +/// Only what the engine's [Mind] can already do: walk to the focus or to a +/// point, wait, look, jump, and read and write the board. What any of it is +/// *for* — an attack, a pickup, a conversation — is the game's, registered +/// beside these. +/// +/// Walking to a point is straight, sliding off walls, because that is what +/// [Mind.steerTowards] does; walking to the focus goes round corners on the +/// level's flow field. A navigation mesh that routes to any point is a +/// separate piece of work, and when it lands it replaces `goTo` here. +library; + +import 'package:vector_math/vector_math.dart'; + +import 'behaviour_tree.dart'; +import 'brain.dart'; + +/// One leaf kind's code. +/// +/// **Shared by every actor running the tree, so it keeps no state of its +/// own.** What a leaf needs to remember while it runs goes in +/// [BehaviourContext.memory], which is on the actor's board and in its +/// snapshot; a field here would be one value for every actor at once and +/// gone on a restore. +abstract base class BehaviourLeaf { + const BehaviourLeaf(); + + /// Decides, on the steps the actor thinks on. + BehaviourStatus tick(BehaviourContext context); + + /// Moves, every step this leaf is the one running. Thinking is throttled + /// for an actor far from the focus and moving is not; a leaf that only + /// steered in [tick] would stutter out there. + void act(BehaviourContext context) {} + + /// Where this leaf is taking the actor, for the debug overlay. + Vector3? goal(BehaviourView view) => null; +} + +/// How much one consideration likes an option, from nought to one. +typedef Consideration = double Function(BehaviourContext context); + +typedef LeafBuilder = BehaviourLeaf Function(BehaviourParams params); +typedef ConsiderationBuilder = Consideration Function(BehaviourParams params); + +/// A node's own fields, read with the problems written down rather than +/// thrown, so that a document is checked in one pass. +final class BehaviourParams { + BehaviourParams(this._row, this._where, this._problems); + + final Map _row; + final String _where; + final List _problems; + + /// The number [name], or [fallback] when it is absent. Absent with no + /// fallback, or not a number, is a problem, and the answer is nought. + double number(String name, [double? fallback]) { + final value = _row[name]; + if (value is num) return value.toDouble(); + if (value == null && fallback != null) return fallback; + _problems.add( + value == null + ? '$_where: ${_row['kind']} needs a number "$name"' + : '$_where: "$name" is a number, not $value', + ); + return 0.0; + } + + /// The number [name], or null when it is absent. + double? optionalNumber(String name) { + final value = _row[name]; + if (value == null) return null; + return number(name); + } + + /// The string [name]. Absent, or not a string, is a problem. + String text(String name) { + final value = _row[name]; + if (value is String) return value; + _problems.add('$_where: ${_row['kind']} needs a string "$name"'); + return ''; + } + + /// Whatever [name] holds, unread. + Object? value(String name) => _row[name]; +} + +final class BehaviourKinds { + /// The standard kinds, which a game adds its own to. + BehaviourKinds() { + _standardLeaves(); + _standardConsiderations(); + } + + /// No kinds at all, for a game that wants none of the standard ones. + BehaviourKinds.none(); + + /// The kinds the tree reader handles itself, which no leaf may take. + static const Set composites = { + 'sequence', + 'selector', + 'utility', + 'invert', + 'alwaysSucceed', + 'cooldown', + }; + + final Map _leaves = {}; + final Map _considerations = + {}; + + /// Says what the leaf kind [kind] is. + /// + /// Throws for a composite's name, and for a kind already registered unless + /// [replace] says that is meant — a game swapping the standard `goTo` for + /// one that routes is the case for it. Thrown, because this is a game's + /// own setup and a clash there is a mistake in the code, not in a document. + void leaf(String kind, LeafBuilder build, {bool replace = false}) { + if (composites.contains(kind)) { + throw ArgumentError.value(kind, 'kind', 'is a composite the reader owns'); + } + if (!replace && _leaves.containsKey(kind)) { + throw ArgumentError.value(kind, 'kind', 'is already a leaf kind'); + } + _leaves[kind] = build; + } + + /// Says what the consideration kind [kind] is. See [leaf] for [replace]. + void consideration( + String kind, + ConsiderationBuilder build, { + bool replace = false, + }) { + if (!replace && _considerations.containsKey(kind)) { + throw ArgumentError.value(kind, 'kind', 'is already a consideration'); + } + _considerations[kind] = build; + } + + LeafBuilder? leafBuilder(String kind) => _leaves[kind]; + + ConsiderationBuilder? considerationBuilder(String kind) => + _considerations[kind]; + + /// The leaf kinds and composites, sorted, for a refusal to list. + String describeKnown() => + ([...composites, ..._leaves.keys]..sort()).join(', '); + + String describeKnownConsiderations() => + (_considerations.keys.toList()..sort()).join(', '); + + void _standardLeaves() { + leaf('wait', (p) => _Wait(p.number('seconds'))); + leaf('goToFocus', (p) => _GoToFocus(p.number('within', 1.0))); + leaf('goTo', (p) => _GoTo(p.text('key'), p.number('within', 0.5))); + leaf('seesFocus', (p) => const _SeesFocus()); + leaf('focusWithin', (p) => _FocusWithin(p.number('distance'))); + leaf( + 'check', + (p) => _Check( + p.text('key'), + p.optionalNumber('above'), + p.optionalNumber('below'), + ), + ); + leaf('set', (p) => _Set(p.text('key'), p.value('value'))); + leaf('markFocus', (p) => _MarkFocus(p.text('key'))); + leaf('jump', (p) => const _Jump()); + } + + void _standardConsiderations() { + consideration('constant', (p) { + final value = p.number('value'); + return (_) => value; + }); + consideration('focusDistance', (p) { + final from = p.number('from'); + final to = p.number('to'); + return (c) => ramp(c.mind.distance, from, to); + }); + consideration( + 'seesFocus', + (p) => + (c) => c.mind.canSee() ? 1.0 : 0.0, + ); + consideration('health', (p) { + final from = p.number('from', 0.0); + final to = p.number('to', 1.0); + return (c) { + final health = c.mind.actor.health; + if (health == null || health.maximum <= 0.0) return 1.0; + return ramp(health.current / health.maximum, from, to); + }; + }); + consideration('blackboard', (p) { + final key = p.text('key'); + final from = p.number('from'); + final to = p.number('to'); + return (c) => switch (c.board.number(key)) { + final double value => ramp(value, from, to), + null => 0.0, + }; + }); + consideration('since', (p) { + final key = p.text('key'); + final from = p.number('from'); + final to = p.number('to'); + return (c) => switch (c.board.number(key)) { + final double at => ramp(c.clock - at, from, to), + null => 0.0, + }; + }); + } +} + +/// [value] mapped onto nought to one: nought at [from], one at [to], clamped +/// outside. [from] above [to] turns it round, so "near is good" is +/// `from: 10, to: 2`. Equal ends are a step at that value. +double ramp(double value, double from, double to) { + if (from == to) return value >= to ? 1.0 : 0.0; + final t = (value - from) / (to - from); + return t <= 0.0 ? 0.0 : (t >= 1.0 ? 1.0 : t); +} + +void _faceHeading(Mind it) { + final heading = it.heading; + it.turnTowards(heading.x, heading.z); +} + +final class _Wait extends BehaviourLeaf { + const _Wait(this.seconds); + final double seconds; + + @override + BehaviourStatus tick(BehaviourContext context) { + final start = switch (context.memory) { + final num at => at.toDouble(), + _ => context.clock, + }; + context.memory = start; + return context.clock - start >= seconds + ? BehaviourStatus.success + : BehaviourStatus.running; + } +} + +final class _GoToFocus extends BehaviourLeaf { + const _GoToFocus(this.within); + final double within; + + @override + BehaviourStatus tick(BehaviourContext context) => + context.mind.distance <= within + ? BehaviourStatus.success + : BehaviourStatus.running; + + @override + void act(BehaviourContext context) { + context.mind.steerTowardsFocus(); + _faceHeading(context.mind); + } + + @override + Vector3? goal(BehaviourView view) => view.system.focus.clone(); +} + +final class _GoTo extends BehaviourLeaf { + _GoTo(this.key, this.within); + final String key; + final double within; + + /// Scratch, which is safe to share: the step is single-threaded and the + /// point is read and used inside one call. + final Vector3 _at = Vector3.zero(); + + @override + BehaviourStatus tick(BehaviourContext context) { + final position = context.mind.actor.position; + if (position == null || !context.board.point(key, _at)) { + return BehaviourStatus.failure; + } + final dx = _at.x - position.x; + final dz = _at.z - position.z; + return dx * dx + dz * dz <= within * within + ? BehaviourStatus.success + : BehaviourStatus.running; + } + + @override + void act(BehaviourContext context) { + if (!context.board.point(key, _at)) return; + context.mind.steerTowards(_at); + _faceHeading(context.mind); + } + + @override + Vector3? goal(BehaviourView view) { + final at = Vector3.zero(); + return view.board.point(key, at) ? at : null; + } +} + +final class _SeesFocus extends BehaviourLeaf { + const _SeesFocus(); + + @override + BehaviourStatus tick(BehaviourContext context) => + context.mind.canSee() ? BehaviourStatus.success : BehaviourStatus.failure; +} + +final class _FocusWithin extends BehaviourLeaf { + const _FocusWithin(this.distance); + final double distance; + + @override + BehaviourStatus tick(BehaviourContext context) => + context.mind.distance <= distance + ? BehaviourStatus.success + : BehaviourStatus.failure; +} + +/// Succeeds when [key] holds something other than `false` — or, given +/// [above] or [below], a number past them. +final class _Check extends BehaviourLeaf { + const _Check(this.key, this.above, this.below); + final String key; + final double? above; + final double? below; + + @override + BehaviourStatus tick(BehaviourContext context) { + final board = context.board; + final holds = switch ((above, below)) { + (null, null) => board.flag(key), + _ => switch (board.number(key)) { + null => false, + final double value => + (above == null || value > above!) && + (below == null || value < below!), + }, + }; + return holds ? BehaviourStatus.success : BehaviourStatus.failure; + } +} + +final class _Set extends BehaviourLeaf { + const _Set(this.key, this.value); + final String key; + final Object? value; + + @override + BehaviourStatus tick(BehaviourContext context) { + context.board.set(key, value); + return BehaviourStatus.success; + } +} + +/// Writes where the focus is now, for a search of where it was last seen. +final class _MarkFocus extends BehaviourLeaf { + const _MarkFocus(this.key); + final String key; + + @override + BehaviourStatus tick(BehaviourContext context) { + context.board.setPoint(key, context.mind.focus); + return BehaviourStatus.success; + } +} + +final class _Jump extends BehaviourLeaf { + const _Jump(); + + @override + BehaviourStatus tick(BehaviourContext context) { + context.mind.jump(); + return BehaviourStatus.success; + } +} diff --git a/packages/flutter3d_sim/lib/src/actors/behaviour_tree.dart b/packages/flutter3d_sim/lib/src/actors/behaviour_tree.dart new file mode 100644 index 000000000..3a02a31ef --- /dev/null +++ b/packages/flutter3d_sim/lib/src/actors/behaviour_tree.dart @@ -0,0 +1,562 @@ +/// A decision written down as data: a behaviour tree, with utility choices +/// among its nodes. +/// +/// ## Data, read once, shared +/// +/// A tree is a JSON document — `{"kind": "selector", "children": [...]}` — +/// read by [BehaviourTree.read] against a [BehaviourKinds] that says what each +/// leaf kind is. A level, a mod or an agent can write one without compiling +/// anything, and two actors running the same tree share it: everything that +/// differs between them is on their [Blackboard], which is a component and is +/// therefore in every snapshot. +/// +/// Nodes are numbered in the order the document lists them, and the number is +/// what a blackboard remembers. That is why a board carries the digest of the +/// tree it was ticked by ([Blackboard.tree]): under an edited tree the numbers +/// name other nodes, and resuming there would run something nobody chose. +/// +/// ## The composites, and the one that is missing +/// +/// * `sequence` — children in order until one does not succeed. **It +/// remembers** which child was running and resumes there, so a sequence +/// that walked somewhere and is now waiting does not walk there again. +/// * `selector` — the first child that does not fail. **It does not +/// remember**: every tick asks again from the first child, so a higher +/// priority that becomes true takes over from a lower one that is running. +/// Remembering here is the classic way to write a guard that never fires +/// until the patrol it interrupts happens to finish. +/// * `utility` — scores every option and runs the best (see [_Utility]). +/// * `invert`, `alwaysSucceed` and `cooldown` — one child each. +/// +/// No `parallel`. An actor has one body and it walks one way: two running +/// leaves would both steer and the last one would win, decided by the order of +/// a list. A condition that must hold while something runs is a `selector` +/// with the condition's branch first, which is checked every tick anyway. +library; + +import 'package:vector_math/vector_math.dart'; + +import '../save/state_digest.dart'; +import 'actor.dart'; +import 'actor_system.dart'; +import 'behaviour_kinds.dart'; +import 'blackboard.dart'; +import 'brain.dart'; + +export 'behaviour_kinds.dart'; +export 'blackboard.dart'; + +/// What a leaf may look at while it ticks or acts. +/// +/// One per tree and reused, because a tick happens for every thinking actor +/// on every thinking step and allocating one each time is garbage in the step. +final class BehaviourContext { + BehaviourContext._(); + + /// The actor's mind: its body, its focus, and what it may ask for. + late Mind mind; + + late Blackboard board; + + /// Which node is ticking, by its number in the tree. + int node = 0; + + /// Seconds the actor has been acting. See [Blackboard.clock]. + double get clock => board.clock; + + /// This node's own memory, kept while it is on the running path and + /// forgotten when it leaves it. JSON-shaped, like everything on the board. + Object? get memory => board.memory[node]; + set memory(Object? value) { + if (value == null) { + board.memory.remove(node); + } else { + board.memory[node] = value; + } + } +} + +/// What the overlay may look at outside a step, when there is no mind. +typedef BehaviourView = ({Actor actor, ActorSystem system, Blackboard board}); + +/// One node on the running path, for whoever draws or prints it. +typedef BehaviourPathStep = ({ + int node, + String kind, + String label, + BehaviourStatus status, +}); + +/// What [BehaviourTree.read] made of a document: a tree, or why not. +final class BehaviourTreeRead { + const BehaviourTreeRead._(this.tree, this.problems); + + /// The tree, when the document had no problems. + final BehaviourTree? tree; + + /// Every problem, each naming where in the document it is — all of them, + /// rather than the first, so that a document is fixed in one pass. + final List problems; +} + +final class BehaviourTree { + BehaviourTree._(this._root, this._nodes, this.digestHex, this.json) + : _statusOf = List.filled(_nodes.length, null); + + /// Reads [json] against [kinds]. + /// + /// A refusal is an answer here rather than an exception: a tree arrives + /// from a file, an editor or an agent, and each of them wants every problem + /// listed with where it is — `root.children[2]: no leaf kind "fly"; known: + /// …` — rather than a stack trace about the first. + static BehaviourTreeRead read(Object? json, BehaviourKinds kinds) { + final reader = _Reader(kinds); + final root = reader.node(json, 'root'); + final digest = reader.digestOf(json); + if (root == null || reader.problems.isNotEmpty || digest == null) { + return BehaviourTreeRead._( + null, + List.unmodifiable(reader.problems), + ); + } + return BehaviourTreeRead._( + BehaviourTree._( + root, + reader.nodes, + digest, + json! as Map, + ), + const [], + ); + } + + final _Node _root; + final List<_Node> _nodes; + + /// The document this was read from. + final Map json; + + /// The document's [StateDigest], which is how a board knows whether it was + /// ticked by this tree. + final String digestHex; + + /// How many nodes there are. + int get length => _nodes.length; + + final BehaviourContext _context = BehaviourContext._(); + final List _stack = []; + final List _lastPath = []; + final List _statusOf; + + /// Makes [board] belong to this tree, starting its decision again if it + /// was ticked by another one. + void prepare(Blackboard board) { + if (board.tree == digestHex) return; + board + ..restart() + ..tree = digestHex; + } + + /// Decides: runs the tree from the root, records the path it took on + /// [board], and forgets the memory of every node that is no longer running. + BehaviourStatus tick(Mind mind, Blackboard board) { + prepare(board); + final context = _context + ..mind = mind + ..board = board; + _stack.clear(); + _lastPath.clear(); + _statusOf.fillRange(0, _statusOf.length, null); + + final status = _tick(_root, context); + + board.path + ..clear() + ..addAll(_lastPath); + board.statuses + ..clear() + ..addAll([ + for (final node in _lastPath) _statusOf[node]!, + ]); + if (status == BehaviourStatus.running) { + board.memory.removeWhere((node, _) => !_lastPath.contains(node)); + } else { + board.memory.clear(); + } + return status; + } + + /// Moves: the running leaf's [BehaviourLeaf.act], every step, between the + /// decisions [tick] makes on the steps the system thinks on. + void act(Mind mind, Blackboard board) { + final leaf = _runningLeaf(board); + if (leaf == null) return; + leaf.leaf.act( + _context + ..mind = mind + ..board = board + ..node = leaf.index, + ); + } + + /// The path [board] last took through this tree, root first; empty when it + /// was ticked by another tree or not yet at all. + List pathOf(Blackboard board) => board.tree != digestHex + ? const [] + : [ + for (var i = 0; i < board.path.length; i++) + if (board.path[i] case final int node when node < _nodes.length) + ( + node: node, + kind: _nodes[node].kind, + label: _nodes[node].label, + status: board.statuses[i], + ), + ]; + + /// Where the running leaf is taking the actor, for the overlay; null when + /// nothing is running or the leaf is not going anywhere. + Vector3? goalOf(BehaviourView view) => + _runningLeaf(view.board)?.leaf.goal(view); + + _Leaf? _runningLeaf(Blackboard board) { + if (board.tree != digestHex || !board.running) return null; + return switch (_nodes.elementAtOrNull(board.path.last)) { + final _Leaf leaf => leaf, + _ => null, + }; + } + + BehaviourStatus _tick(_Node node, BehaviourContext context) { + _stack.add(node.index); + final status = switch (node) { + _Leaf() => _leaf(node, context), + _Sequence() => _sequence(node, context), + _Selector() => _selector(node, context), + _Utility() => _utility(node, context), + _Invert() => switch (_tick(node.child, context)) { + BehaviourStatus.success => BehaviourStatus.failure, + BehaviourStatus.failure => BehaviourStatus.success, + BehaviourStatus.running => BehaviourStatus.running, + }, + _AlwaysSucceed() => switch (_tick(node.child, context)) { + BehaviourStatus.running => BehaviourStatus.running, + _ => BehaviourStatus.success, + }, + _Cooldown() => _cooldown(node, context), + }; + _stack.removeLast(); + _statusOf[node.index] = status; + return status; + } + + BehaviourStatus _leaf(_Leaf node, BehaviourContext context) { + // The path is the stack at the last leaf ticked: whatever comes out + // running returns at once, so no leaf is ticked after the running one. + _lastPath + ..clear() + ..addAll(_stack); + context.node = node.index; + return node.leaf.tick(context); + } + + BehaviourStatus _sequence(_Sequence node, BehaviourContext context) { + context.node = node.index; + final from = switch (context.memory) { + final num at => at.toInt(), + _ => 0, + }; + for (var i = from; i < node.children.length; i++) { + final status = _tick(node.children[i], context); + if (status == BehaviourStatus.success) continue; + context.node = node.index; + context.memory = status == BehaviourStatus.running ? i : null; + return status; + } + context.node = node.index; + context.memory = null; + return BehaviourStatus.success; + } + + BehaviourStatus _selector(_Selector node, BehaviourContext context) { + for (final child in node.children) { + final status = _tick(child, context); + if (status != BehaviourStatus.failure) return status; + } + return BehaviourStatus.failure; + } + + BehaviourStatus _utility(_Utility node, BehaviourContext context) { + context.node = node.index; + final last = switch (context.memory) { + final num at => at.toInt(), + _ => -1, + }; + final best = node.choose(context, last); + if (best < 0) { + context.memory = null; + return BehaviourStatus.failure; + } + final status = _tick(node.options[best].child, context); + context.node = node.index; + context.memory = status == BehaviourStatus.running ? best : null; + return status; + } + + BehaviourStatus _cooldown(_Cooldown node, BehaviourContext context) { + final board = context.board; + final readyAt = board.readyAt[node.index]; + if (readyAt != null && board.clock < readyAt) { + return BehaviourStatus.failure; + } + final status = _tick(node.child, context); + if (status != BehaviourStatus.running) { + board.readyAt[node.index] = board.clock + node.seconds; + } + return status; + } +} + +sealed class _Node { + _Node(this.index, this.kind, this.label); + final int index; + final String kind; + final String label; +} + +final class _Leaf extends _Node { + _Leaf(super.index, super.kind, super.label, this.leaf); + final BehaviourLeaf leaf; +} + +final class _Sequence extends _Node { + _Sequence(super.index, super.kind, super.label); + final List<_Node> children = <_Node>[]; +} + +final class _Selector extends _Node { + _Selector(super.index, super.kind, super.label); + final List<_Node> children = <_Node>[]; +} + +final class _Invert extends _Node { + _Invert(super.index, super.kind, super.label); + late final _Node child; +} + +final class _AlwaysSucceed extends _Node { + _AlwaysSucceed(super.index, super.kind, super.label); + late final _Node child; +} + +final class _Cooldown extends _Node { + _Cooldown(super.index, super.kind, super.label, this.seconds); + final double seconds; + late final _Node child; +} + +typedef _Option = ({ + double weight, + List considerations, + _Node child, +}); + +/// Scores every option and runs the best one. +/// +/// **A score is the product of its considerations, times the option's +/// weight**, so any consideration at zero vetoes the option — "only when the +/// focus is in sight" is a consideration that answers nought otherwise — and +/// an option scoring nought is never chosen even when it is the only one. +/// +/// **The option already running gets [inertia] added**, and that is what +/// stops an actor dithering on the line between two options whose scores +/// cross back and forth with every step. Ties go to the earlier option, so +/// the document's order is the tie-break and two runs agree on it. +final class _Utility extends _Node { + _Utility(super.index, super.kind, super.label, this.inertia); + final double inertia; + final List<_Option> options = <_Option>[]; + + int choose(BehaviourContext context, int last) { + var best = -1; + var bestScore = 0.0; + for (var i = 0; i < options.length; i++) { + final option = options[i]; + final score = + option.considerations.fold( + option.weight, + (double product, consider) => product * consider(context), + ) + + (i == last ? inertia : 0.0); + if (score > bestScore) { + best = i; + bestScore = score; + } + } + return best; + } +} + +/// Builds nodes from a document, numbering them as it goes. +final class _Reader { + _Reader(this.kinds); + + final BehaviourKinds kinds; + final List<_Node> nodes = <_Node>[]; + final List problems = []; + + String? digestOf(Object? json) { + try { + return contentDigestHex((json! as Map).cast()); + } on Object { + // Already reported by [node]: a root that is not a JSON object. + return null; + } + } + + _Node? node(Object? json, String where) { + if (json is! Map) { + problems.add('$where: a node is an object with a "kind"'); + return null; + } + final row = json.cast(); + final kind = row['kind']; + if (kind is! String) { + problems.add('$where: a node is an object with a "kind"'); + return null; + } + final label = switch (row['name']) { + final String name => name, + _ => kind, + }; + final index = nodes.length; + switch (kind) { + case 'sequence': + final made = _Sequence(index, kind, label); + nodes.add(made); + made.children.addAll(_children(row, where)); + return made; + case 'selector': + final made = _Selector(index, kind, label); + nodes.add(made); + made.children.addAll(_children(row, where)); + return made; + case 'invert': + final made = _Invert(index, kind, label); + nodes.add(made); + final child = node(row['child'], '$where.child'); + if (child == null) return null; + return made..child = child; + case 'alwaysSucceed': + final made = _AlwaysSucceed(index, kind, label); + nodes.add(made); + final child = node(row['child'], '$where.child'); + if (child == null) return null; + return made..child = child; + case 'cooldown': + final params = BehaviourParams(row, where, problems); + final made = _Cooldown(index, kind, label, params.number('seconds')); + nodes.add(made); + final child = node(row['child'], '$where.child'); + if (child == null) return null; + return made..child = child; + case 'utility': + final params = BehaviourParams(row, where, problems); + final made = _Utility( + index, + kind, + label, + params.number('inertia', 0.0), + ); + nodes.add(made); + made.options.addAll(_options(row, where)); + return made; + } + final build = kinds.leafBuilder(kind); + if (build == null) { + problems.add( + '$where: no leaf kind "$kind"; known: ${kinds.describeKnown()}', + ); + return null; + } + final made = _Leaf( + index, + kind, + label, + build(BehaviourParams(row, where, problems)), + ); + nodes.add(made); + return made; + } + + List<_Node> _children(Map row, String where) { + final children = row['children']; + if (children is! List) { + problems.add('$where: a ${row['kind']} lists its "children"'); + return const <_Node>[]; + } + return <_Node>[ + for (var i = 0; i < children.length; i++) + ?node(children[i], '$where.children[$i]'), + ]; + } + + List<_Option> _options(Map row, String where) { + final options = row['options']; + if (options is! List || options.isEmpty) { + problems.add('$where: a utility lists its "options"'); + return const <_Option>[]; + } + final read = <_Option>[]; + for (var i = 0; i < options.length; i++) { + final at = '$where.options[$i]'; + final option = options[i]; + if (option is! Map) { + problems.add('$at: an option is an object with "do"'); + continue; + } + final params = BehaviourParams(option, at, problems); + final weight = params.number('weight', 1.0); + final considerations = _considerations(option['considerations'], at); + // Named by the option when the child is not, so the overlay says + // "flee" rather than the leaf kind the option happens to run. + final body = option['do']; + final named = + body is Map && body['name'] == null && option['name'] is String + ? {...body, 'name': option['name']} + : body; + final child = node(named, '$at.do'); + if (child == null) continue; + read.add((weight: weight, considerations: considerations, child: child)); + } + return read; + } + + List _considerations(Object? json, String where) { + if (json == null) return const []; + if (json is! List) { + problems.add('$where: "considerations" is a list'); + return const []; + } + final read = []; + for (var i = 0; i < json.length; i++) { + final at = '$where.considerations[$i]'; + final row = json[i]; + final kind = row is Map ? row['kind'] : null; + if (row is! Map || kind is! String) { + problems.add('$at: a consideration is an object with a "kind"'); + continue; + } + final build = kinds.considerationBuilder(kind); + if (build == null) { + problems.add( + '$at: no consideration kind "$kind"; known: ' + '${kinds.describeKnownConsiderations()}', + ); + continue; + } + read.add(build(BehaviourParams(row, at, problems))); + } + return read; + } +} diff --git a/packages/flutter3d_sim/lib/src/actors/blackboard.dart b/packages/flutter3d_sim/lib/src/actors/blackboard.dart new file mode 100644 index 000000000..f2954d291 --- /dev/null +++ b/packages/flutter3d_sim/lib/src/actors/blackboard.dart @@ -0,0 +1,221 @@ +/// What a behaviour tree remembers between one decision and the next. +/// +/// ## A component, and that is the point of it +/// +/// A tree is data and is shared by every actor that runs it; what differs +/// between two of them is where each is in it, what each has noticed and how +/// long each has been waiting. All of that is here, on the actor's entity, so +/// `EcsWorld.save()` writes it with everything else and a snapshot, a rewind +/// or a save file brings a decision back half made. The alternative was state +/// on the brain, written out through `Brain.save` — which works until the +/// first leaf somebody adds keeps a field and forgets to. +/// +/// ## Only what a snapshot may hold +/// +/// Every value is JSON-shaped: `null`, `bool`, `num`, `String`, lists and +/// string-keyed maps of those. [set] refuses anything else, because a +/// `Vector3` put here would be saved as nothing and the restored run would +/// part from the original without anybody being told why. A point is a list +/// of three numbers, and [setPoint] and [point] write and read one. +library; + +import 'package:flutter3d_physics/flutter3d_physics.dart' show readVector; +import 'package:vector_math/vector_math.dart'; + +import '../ecs/ecs_world.dart'; + +/// How a node came out of its last tick. +enum BehaviourStatus { success, failure, running } + +final class Blackboard { + Blackboard({Map? values}) : _values = {} { + values?.forEach(set); + } + + final Map _values; + + /// Seconds this actor has been acting, advanced every step it is alive. + /// + /// **Its own clock rather than the system's step count**, because thinking + /// is throttled: an actor far from the focus decides every fourth step, and + /// a wait measured in decisions would last four times as long out there. + double clock = 0.0; + + /// The digest of the tree this was last ticked by, as eight hex digits. + /// + /// Node indices mean nothing under another tree, so a board restored under a + /// tree that changed starts its decision again rather than resuming at an + /// index that now names something else. See `BehaviourTree.prepare`. + String tree = ''; + + /// Per node, what it needs while it is running: a sequence's cursor, the + /// moment a wait began. Dropped for every node that is not on the running + /// path after a tick, so an abandoned branch starts afresh when it is next + /// chosen. + final Map memory = {}; + + /// Per cooldown node, the clock at which it may run again. Kept off the + /// running path, which is what a cooldown is for. + final Map readyAt = {}; + + /// The nodes ticked on the way to the last leaf, root first. + final List path = []; + + /// How each node on [path] came out, in the same order. + final List statuses = []; + + /// Whether the last tick left something running, which is what `act` asks. + bool get running => + statuses.isNotEmpty && statuses.first == BehaviourStatus.running; + + /// Every key with a value. + Iterable get keys => _values.keys; + + bool has(String key) => _values.containsKey(key); + + Object? operator [](String key) => _values[key]; + + /// Puts [value] under [key], or removes the key for `null`. + /// + /// Throws for a value a snapshot cannot hold — see the note at the top of + /// this file. Thrown rather than skipped: a skipped value is a run that + /// restores differently, found much later. + void set(String key, Object? value) { + if (value == null) { + _values.remove(key); + return; + } + if (!_jsonShaped(value)) { + throw ArgumentError.value( + value, + key, + 'a blackboard holds what a snapshot may hold: null, bool, num, ' + 'String, and lists and string-keyed maps of those', + ); + } + _values[key] = value; + } + + void remove(String key) => _values.remove(key); + + /// The number under [key], or null when there is none. + double? number(String key) => switch (_values[key]) { + final num value => value.toDouble(), + _ => null, + }; + + /// Whether [key] holds anything other than `false`. + bool flag(String key) => switch (_values[key]) { + null || false => false, + _ => true, + }; + + /// Writes [at] as a point, three numbers. + void setPoint(String key, Vector3 at) => + _values[key] = [at.x, at.y, at.z]; + + /// Reads the point under [key] into [into], and says whether there was one. + bool point(String key, Vector3 into) => readVector(_values[key], into); + + /// Forgets where the tree was, keeping what it noticed and the clock. + void restart() { + memory.clear(); + readyAt.clear(); + path.clear(); + statuses.clear(); + } + + Map toJson() => { + 'values': Map.of(_values), + 'clock': clock, + 'tree': tree, + 'memory': { + for (final entry in memory.entries) '${entry.key}': entry.value, + }, + 'readyAt': { + for (final entry in readyAt.entries) '${entry.key}': entry.value, + }, + 'path': List.of(path), + 'statuses': [for (final status in statuses) status.name], + }; + + /// Reads a board back, or null when [data] is not one. + /// + /// Lenient about the parts: a row with the values and nothing else is a + /// board that has not decided anything yet, which is what it reads as. + static Blackboard? fromJson(Object? data) { + if (data is! Map) return null; + final values = data['values']; + final board = Blackboard(); + if (values is Map) { + for (final entry in values.entries) { + final key = entry.key; + if (key is String && _jsonShaped(entry.value)) { + board._values[key] = entry.value; + } + } + } + board + ..clock = switch (data['clock']) { + final num value => value.toDouble(), + _ => 0.0, + } + ..tree = switch (data['tree']) { + final String value => value, + _ => '', + }; + if (data['memory'] case final Map memory) { + for (final entry in memory.entries) { + final index = int.tryParse('${entry.key}'); + if (index != null) board.memory[index] = entry.value; + } + } + if (data['readyAt'] case final Map readyAt) { + for (final entry in readyAt.entries) { + final index = int.tryParse('${entry.key}'); + if (index != null && entry.value is num) { + board.readyAt[index] = (entry.value as num).toDouble(); + } + } + } + final path = data['path']; + final statuses = data['statuses']; + if (path is List && statuses is List && path.length == statuses.length) { + final names = { + for (final status in BehaviourStatus.values) status.name: status, + }; + final read = <(int, BehaviourStatus)>[ + for (var i = 0; i < path.length; i++) + if (path[i] case final num index) + if (names[statuses[i]] case final BehaviourStatus status) + (index.toInt(), status), + ]; + // All or nothing: half a path would act on a leaf the tree never chose. + if (read.length == path.length) { + board.path.addAll(read.map((entry) => entry.$1)); + board.statuses.addAll(read.map((entry) => entry.$2)); + } + } + return board; + } + + static bool _jsonShaped(Object? value) => switch (value) { + null || bool() || num() || String() => true, + final List list => list.every(_jsonShaped), + final Map map => map.entries.every( + (entry) => entry.key is String && _jsonShaped(entry.value), + ), + _ => false, + }; +} + +/// Teaches [entities] to write a [Blackboard] down. +/// +/// A value component, decoded rather than restored in place: a board owns +/// nothing live, so a restore can build a new one, and an entity whose board +/// the save did not have gets a fresh one the next time its brain thinks. +void registerBlackboard(EcsWorld entities) => entities.register( + 'blackboard', + encode: (Blackboard value) => value.toJson(), + decode: Blackboard.fromJson, +); diff --git a/packages/flutter3d_sim/lib/src/camera/photo_camera.dart b/packages/flutter3d_sim/lib/src/camera/photo_camera.dart new file mode 100644 index 000000000..b4288a92b --- /dev/null +++ b/packages/flutter3d_sim/lib/src/camera/photo_camera.dart @@ -0,0 +1,292 @@ +import 'dart:math' as math; + +import 'package:flutter3d_physics/flutter3d_physics.dart'; +import 'package:vector_math/vector_math.dart'; + +import '../physics/layers.dart'; + +/// The camera a paused game hands the player for taking a picture — `N8`. +/// +/// Flown rather than chased: it has a position, a heading, a pitch, a roll and +/// a field of view, and nothing it watches. What makes it more than a debug +/// fly-camera is where it is **not allowed to go**, which is the whole of the +/// design. +/// +/// ## Held inside the level, three ways +/// +/// * **On a tether** of [reach] metres from [anchor] — where the player was +/// standing when photo mode opened. A camera that can fly anywhere shows the +/// back of every wall, the skybox seam and the half-built room past the end +/// of the level, and a player who posts that has posted a bug report. +/// * **Inside [bounds]**, when the game has them — the level's own box, so the +/// tether cannot reach under the floor of a level built on a slab. +/// * **Out of the walls**: every move is a sweep of a small box through the +/// collision world against [wallMask], sliding along what it meets the way +/// the player does. The tether alone would stop at the right distance on the +/// wrong side of a wall. +/// +/// The first two together are a sphere cut by a box, which is convex, and that +/// is what makes the third safe to combine with them: from a point inside the +/// region, a straight line to another point inside it never leaves it. The +/// target of every move is pulled into the region first and then swept to. +/// A slide along one wall only pushes the point back towards the side it came +/// from, which keeps it inside; the skin step off each wall and a corner of +/// three faces are not held by that argument, so a slid point is checked, and +/// one that has left the region is thrown away and the move made again +/// straight, stopping short at the first wall. +/// +/// **Not in the step, and not in `Portable` maths.** The simulation is paused +/// while this flies, and nothing it does reaches a snapshot or a tape. A +/// photo is the one camera in a game that is allowed to be non-deterministic. +final class PhotoCamera { + PhotoCamera({ + required this.world, + this.reach = 12.0, + this.bounds, + this.radius = 0.2, + this.wallMask = CollisionLayers.world, + this.speed = 4.0, + this.minFieldOfView = 0.17, + this.maxFieldOfView = 1.75, + this.maxRoll = math.pi / 4.0, + }) : assert(reach > 0.0, 'a tether is a distance'), + assert(radius > 0.0, 'a camera with no size touches nothing'), + assert( + minFieldOfView > 0.0 && maxFieldOfView > minFieldOfView, + 'a zoom range runs from a narrow view to a wider one', + ); + + /// Where the walls are. + final CollisionWorld world; + + /// How far from [anchor] the camera may go, in metres. + final double reach; + + /// The level's own box, or null where the game has none to give. + final Aabb3? bounds; + + /// Half the side of the box swept through the world. A near plane has to + /// stay this far in front of a wall or it cuts into it. + final double radius; + + /// Which layers stop the camera. + final int wallMask; + + /// Metres a second at full stick. + final double speed; + + /// How far [zoom] may go each way, in radians of vertical field of view — + /// about ten degrees to a hundred. + final double minFieldOfView; + final double maxFieldOfView; + + /// How far the horizon may be tilted either way. Past forty-five degrees a + /// picture stops reading as tilted and starts reading as rotated. + final double maxRoll; + + final Vector3 _anchor = Vector3.zero(); + final Vector3 _eye = Vector3.zero(); + double _yaw = 0.0; + double _pitch = 0.0; + double _roll = 0.0; + double _fieldOfView = 0.9; + + /// Where the player was when this began; the centre of the tether. + Vector3 get anchor => _anchor; + + /// Where the camera is. + Vector3 get eye => _eye; + + /// Heading about +Y, in radians; zero looks down −Z. + double get yaw => _yaw; + + /// Up is positive, held short of straight up or down, where heading stops + /// meaning anything and `lookAt` has no way to tell which way is up. + double get pitch => _pitch; + + /// Tilt of the horizon; positive leans the top of the camera to the right. + double get roll => _roll; + + /// Vertical field of view, in radians. + double get fieldOfView => _fieldOfView; + + /// The unit vector the camera looks along. + Vector3 get forward => Vector3( + -math.sin(_yaw) * math.cos(_pitch), + math.sin(_pitch), + -math.cos(_yaw) * math.cos(_pitch), + ); + + /// A point one metre ahead, for `CameraNode.lookAt`. + Vector3 get target => _eye + forward; + + /// The camera's up, turned by [roll] — the vector `lookAt` wants. + Vector3 get up { + final ahead = forward; + final right = ahead.cross(Vector3(0.0, 1.0, 0.0))..normalize(); + final level = right.cross(ahead)..normalize(); + return level * math.cos(_roll) + right * math.sin(_roll); + } + + /// Takes over from the game's own camera, wherever it was. + /// + /// [anchor] is the tether's centre — the player, not the camera: a chase + /// camera already sits several metres back, and a tether centred on it lets + /// the player see a little less on one side of themselves than the other. + /// + /// **The eye is reached from the anchor, not set.** A chase camera can be + /// further out than [reach], and a third-person camera pulled in by a wall + /// can be half a centimetre from it; either way the starting point is swept + /// to from where the player stands, so it begins inside the region and out of + /// the walls like every point after it. + void begin({ + required Vector3 eye, + required Vector3 target, + required Vector3 anchor, + double? fieldOfView, + }) { + _anchor.setFrom(anchor); + final ahead = target - eye; + if (ahead.length2 > 1e-12) { + ahead.normalize(); + _yaw = math.atan2(-ahead.x, -ahead.z); + _pitch = _clampPitch(math.asin(ahead.y.clamp(-1.0, 1.0))); + } + _roll = 0.0; + if (fieldOfView != null) { + _fieldOfView = fieldOfView.clamp(minFieldOfView, maxFieldOfView); + } + _eye.setFrom(_anchor); + _sweepStraight(_eye, _allowed(eye) - _eye); + } + + /// Turns the camera by [yaw] and [pitch] radians. + void look(double yaw, double pitch) { + _yaw += yaw; + _pitch = _clampPitch(_pitch + pitch); + } + + /// Tilts the horizon by [radians], within [maxRoll]. + void tilt(double radians) => + _roll = (_roll + radians).clamp(-maxRoll, maxRoll); + + /// Narrows the view by [factor] — above one zooms in. + void zoom(double factor) { + if (factor <= 0.0) return; + _fieldOfView = (_fieldOfView / factor).clamp( + minFieldOfView, + maxFieldOfView, + ); + } + + /// Flies for [dt] seconds along [intent], in the camera's own axes: x to the + /// right, y up the world, z forward. A length above one is cut to one. + /// + /// **Up is the world's, not the camera's.** A camera pitched down that rose + /// along its own up would drift backwards as it climbed, and a player + /// lining up a shot reads that as the controls fighting them. + /// + /// Returns whether anything held the camera back — a wall, the tether or the + /// level's box — so a game can say why the camera stopped. + bool fly(Vector3 intent, double dt) { + final length = intent.length; + if (length == 0.0 || dt <= 0.0) return false; + final scale = speed * dt / math.max(1.0, length); + final ahead = Vector3(-math.sin(_yaw), 0.0, -math.cos(_yaw)); + final right = Vector3(math.cos(_yaw), 0.0, -math.sin(_yaw)); + final delta = + (right * intent.x + + Vector3(0.0, intent.y, 0.0) + + (ahead * math.cos(_pitch) + Vector3(0.0, math.sin(_pitch), 0.0)) * + intent.z) + ..scale(scale); + return moveBy(delta); + } + + /// Moves the eye by [delta] in world space, held inside the level. + /// + /// Returns whether anything held it back. + bool moveBy(Vector3 delta) { + final wanted = _eye + delta; + final allowed = _allowed(wanted); + final clipped = allowed.distanceToSquared(wanted) > 1e-12; + + final slid = _eye.clone(); + final blocked = _slide(slid, allowed - _eye); + if (_isAllowed(slid)) { + _eye.setFrom(slid); + return clipped || blocked; + } + // The slide carried the move out of the region. Straight is inside it, by + // convexity; see the class comment. + _sweepStraight(_eye, allowed - _eye); + return true; + } + + double _clampPitch(double pitch) => pitch.clamp(-1.5, 1.5); + + /// [point] pulled into the tether and the box. + Vector3 _allowed(Vector3 point) { + final out = point.clone(); + final box = bounds; + if (box != null) { + out + ..x = out.x.clamp(box.min.x + radius, box.max.x - radius) + ..y = out.y.clamp(box.min.y + radius, box.max.y - radius) + ..z = out.z.clamp(box.min.z + radius, box.max.z - radius); + } + final away = out - _anchor; + final distance = away.length; + if (distance > reach) { + out + ..setFrom(_anchor) + ..addScaled(away, reach / distance); + } + return out; + } + + bool _isAllowed(Vector3 point) => + _allowed(point).distanceToSquared(point) <= _tolerance * _tolerance; + + /// The few millimetres a skin step off a wall may carry the eye past the + /// tether. Below anything that could take it through a wall. + static const double _tolerance = 0.01; + + static const double _skin = 0.001; + + late final CollisionShape _shape = CollisionSphere(radius); + final SweepHit _hit = SweepHit(); + + /// The character controller's slide, three passes for a room's corner. + bool _slide(Vector3 point, Vector3 delta) { + var blocked = false; + for (var pass = 0; pass < 3; pass++) { + if (delta.length2 == 0.0) break; + if (!world.sweep(_shape, point, delta, _hit, mask: wallMask)) { + point.add(delta); + break; + } + blocked = true; + final travel = math.max(0.0, _hit.fraction); + point + ..addScaled(delta, travel) + ..addScaled(_hit.normal, _skin); + delta.scale(1.0 - travel); + final into = delta.dot(_hit.normal); + if (into < 0.0) delta.addScaled(_hit.normal, -into); + } + return blocked; + } + + /// One sweep, no slide: the move stops where it meets something. + void _sweepStraight(Vector3 point, Vector3 delta) { + if (delta.length2 == 0.0) return; + if (!world.sweep(_shape, point, delta, _hit, mask: wallMask)) { + point.add(delta); + return; + } + point + ..addScaled(delta, math.max(0.0, _hit.fraction)) + ..addScaled(_hit.normal, _skin); + } +} diff --git a/packages/flutter3d_sim/lib/src/input/input_state.dart b/packages/flutter3d_sim/lib/src/input/input_state.dart index 0fd928dd0..856a8ca4e 100644 --- a/packages/flutter3d_sim/lib/src/input/input_state.dart +++ b/packages/flutter3d_sim/lib/src/input/input_state.dart @@ -54,6 +54,7 @@ final class InputState { final Vector2 _lookDelta = Vector2.zero(); int? _slotRequest; + final Map _tunes = {}; // MARK: - Reading, from inside a step @@ -137,6 +138,10 @@ final class InputState { /// arriving at the one the player pressed most recently is what they meant. int? get slotRequest => _slotRequest; + /// The tunables set for this step, by name — see [tune]. + Map get tunesThisStep => + Map.unmodifiable(_tunes); + // MARK: - Writing, from a device /// Whether the devices are ignored. @@ -309,6 +314,22 @@ final class InputState { _slotRequest = slot; } + /// Sets the tunable called [name] to [value] from the next step on. + /// + /// **Through the input, because the tape is where a run is reproduced + /// from.** A jump height dragged in an inspector while the game runs + /// changes what the simulation does; set on the simulation directly, it + /// would change the run and leave the tape describing a run with the old + /// height, and every replay of it would disagree with what was played. As + /// an input it is recorded with the step that took it, replayed with that + /// step, and muted with the devices during a replay — so a drag arriving + /// while the timeline replays does not land in the past. `Tunables.take` + /// is the step's side of it. + void tune(String name, double value) { + if (muted) return; + _tunes[name] = value; + } + /// Drops everything, held state included. /// /// For losing focus. A key that was down when the window went away never @@ -324,6 +345,7 @@ final class InputState { _moveAxis.setZero(); _values.clear(); _slotRequest = null; + _tunes.clear(); } // MARK: - Step boundaries @@ -341,6 +363,7 @@ final class InputState { _releasedLatch.clear(); _lookDelta.setZero(); _slotRequest = null; + _tunes.clear(); } void _recomputeMoveAxis() { diff --git a/packages/flutter3d_sim/lib/src/input/input_tape.dart b/packages/flutter3d_sim/lib/src/input/input_tape.dart index 4b57f7ce5..341b84d9d 100644 --- a/packages/flutter3d_sim/lib/src/input/input_tape.dart +++ b/packages/flutter3d_sim/lib/src/input/input_tape.dart @@ -23,6 +23,7 @@ final class InputFrame { this.lookY = 0.0, this.values = const {}, this.slot, + this.tunes = const {}, }); factory InputFrame.fromJson(Map json) => InputFrame( @@ -47,6 +48,13 @@ final class InputFrame { entry.key! as String: (entry.value! as num).toDouble(), }, slot: (json['slot'] as num?)?.toInt(), + tunes: { + for (final entry + in (json['tunes'] as Map? ?? + const {}) + .entries) + entry.key! as String: (entry.value! as num).toDouble(), + }, ); /// Action names rather than the actions themselves. @@ -73,12 +81,16 @@ final class InputFrame { /// release, so a tape of transitions had nowhere to put it. final int? slot; + /// Tunables set by this step, by name — `InputState.tune`. + final Map tunes; + /// Whether this step is worth writing down at all. bool get isIdle => pressed.isEmpty && released.isEmpty && values.isEmpty && slot == null && + tunes.isEmpty && stickX == 0.0 && stickY == 0.0 && lookX == 0.0 && @@ -93,6 +105,7 @@ final class InputFrame { if (lookY != 0.0) 'ly': lookY, if (values.isNotEmpty) 'values': values, if (slot != null) 'slot': slot, + if (tunes.isNotEmpty) 'tunes': tunes, }; } @@ -187,6 +200,7 @@ final class InputTapeRecorder { entry.key.name: entry.value, }, slot: input.slotRequest, + tunes: input.tunesThisStep, ), ); } @@ -242,5 +256,8 @@ final class InputTapePlayback { } final slot = frame.slot; if (slot != null) input.requestSlot(slot); + for (final MapEntry(key: name, :value) in frame.tunes.entries) { + input.tune(name, value); + } } } diff --git a/packages/flutter3d_sim/lib/src/input/tunables.dart b/packages/flutter3d_sim/lib/src/input/tunables.dart new file mode 100644 index 000000000..10357092d --- /dev/null +++ b/packages/flutter3d_sim/lib/src/input/tunables.dart @@ -0,0 +1,66 @@ +import 'input_state.dart'; + +/// The numbers a game lets somebody change while it runs — a jump height, a +/// gravity, an enemy's reaction time — each with the value it starts at. +/// +/// **Changed through the input, read by the step.** Somebody drags a value in +/// an inspector; the editor calls `ext.flutter3d.cvar.set`, which calls +/// `InputState.tune`; the next step calls [take] before it simulates and +/// reads the new value through `[]`. The change is on the tape with that step, +/// so a replay changes it at the same moment, and a run tuned while it was +/// played reproduces like any other. +/// +/// **Part of the state, so part of the snapshot.** A rewind to before a +/// change has to come back with the old value, or the replay from there runs +/// with a value it did not have. Put [toJson] into the game's snapshot and +/// hand it back to [restore]. +/// +/// A name the table does not declare is ignored, not added: a stale +/// inspector naming a tunable an edit has since removed should not grow the +/// state of every run it is sent to. +final class Tunables { + Tunables(Map defaults) + : defaults = Map.unmodifiable(defaults), + _values = Map.of(defaults); + + /// What each tunable starts at. + final Map defaults; + + final Map _values; + + /// The value of [name] now. Throws for a name the table does not declare, + /// which is a typo in the step rather than something to default past. + double operator [](String name) => + _values[name] ?? + (throw ArgumentError.value(name, 'name', 'no such tunable')); + + /// Every tunable and its value now. + Map get values => Map.unmodifiable(_values); + + /// Takes the tunables [input] set this step. Call once per step, before + /// anything reads a value, the same place a step reads the rest of its + /// input. + void take(InputState input) { + for (final MapEntry(key: name, :value) in input.tunesThisStep.entries) { + if (_values.containsKey(name)) _values[name] = value; + } + } + + /// The values that differ from [defaults], for a snapshot: a run nobody + /// tuned adds nothing to its saves. + Map toJson() => { + for (final MapEntry(key: name, :value) in _values.entries) + if (value != defaults[name]) name: value, + }; + + /// Puts back what [toJson] wrote; anything it does not name returns to its + /// default. + void restore(Map json) { + for (final MapEntry(key: name, value: fallback) in defaults.entries) { + _values[name] = switch (json[name]) { + final num saved => saved.toDouble(), + _ => fallback, + }; + } + } +} diff --git a/packages/flutter3d_sim/lib/src/level/breaches.dart b/packages/flutter3d_sim/lib/src/level/breaches.dart index 39d0300e7..6f248d87f 100644 --- a/packages/flutter3d_sim/lib/src/level/breaches.dart +++ b/packages/flutter3d_sim/lib/src/level/breaches.dart @@ -48,6 +48,7 @@ List subtractBox(Brush brush, Aabb3 hole) { shadowCasting: brush.shadowCasting, surface: brush.surface, layer: brush.layer, + drawOrder: brush.drawOrder, ), ); } diff --git a/packages/flutter3d_sim/lib/src/level/brush.dart b/packages/flutter3d_sim/lib/src/level/brush.dart index d8b91d64e..00d409b50 100644 --- a/packages/flutter3d_sim/lib/src/level/brush.dart +++ b/packages/flutter3d_sim/lib/src/level/brush.dart @@ -62,6 +62,7 @@ final class Brush { String? surface, this.layer, this.ramp, + this.drawOrder = 0, Map source = const {}, }) : centre = centre.clone(), size = size.clone(), @@ -110,6 +111,15 @@ final class Brush { /// a brush on a bit of its own, and all of them were unauthorable. final int? layer; + /// Where this brush draws among things in the same bucket — `P7`, the + /// engine's `MeshNode.drawOrder` said in the level document. + /// + /// Nought unless the document says otherwise. Higher draws later: a decal + /// brush laid over a floor, a water surface that must come after what is + /// under it. Brushes with different orders never share a batch, since a + /// batch draws as one. + final int drawOrder; + /// Whether this brush stops anything. /// /// False for decoration — a moulding, a painted alcove, the lip of a step @@ -203,6 +213,7 @@ final class Brush { surface: json.textOrNull('surface'), layer: json.integerOrNull('layer'), ramp: _rampFromName(json.textOrNull('ramp')), + drawOrder: json.integerOrNull('drawOrder') ?? 0, source: json, ); @@ -264,6 +275,7 @@ final class Brush { ramp == null ? null : _rampName(ramp!), whenAbsent: ramp != null, ), + WriteThroughField('drawOrder', drawOrder, whenAbsent: drawOrder != 0), ]); } } diff --git a/packages/flutter3d_sim/lib/src/level/brush_geometry.dart b/packages/flutter3d_sim/lib/src/level/brush_geometry.dart index 42c7f01bc..9cbe1bae1 100644 --- a/packages/flutter3d_sim/lib/src/level/brush_geometry.dart +++ b/packages/flutter3d_sim/lib/src/level/brush_geometry.dart @@ -112,7 +112,8 @@ final class BrushGeometry { }) { final builders = {}; - // Keyed by material and by the shadow answer, because a batch is the + // Keyed by material, by the draw order — `P7` — and by the shadow + // answer, because a batch is the // smallest thing that can be taken out of the shadow pass — and by the // cell, when there is a table to say which cell a face is in. A level // with no fences and no table produces exactly the batches it always did. @@ -120,12 +121,15 @@ final class BrushGeometry { final brush = level.brushes[index]; final slot = visibility == null ? -1 : visibility.slotOf(x, y, z); final own = perBrush ? '|#$index' : ''; - final key = '${brush.material}|${brush.shadowCasting.name}|$slot$own'; + final key = + '${brush.material}|${brush.shadowCasting.name}|$slot$own' + '|${brush.drawOrder}'; return builders.putIfAbsent( key, () => SurfaceBuilder( brush.material, shadowCasting: brush.shadowCasting, + drawOrder: brush.drawOrder, lightmapped: lightmap != null, brush: perBrush ? index : null, ), diff --git a/packages/flutter3d_sim/lib/src/level/brush_surface.dart b/packages/flutter3d_sim/lib/src/level/brush_surface.dart index 83b967500..622127837 100644 --- a/packages/flutter3d_sim/lib/src/level/brush_surface.dart +++ b/packages/flutter3d_sim/lib/src/level/brush_surface.dart @@ -16,6 +16,7 @@ final class BrushSurface { BrushSurface({ required this.material, required this.shadowCasting, + this.drawOrder = 0, required this.positions, required this.normals, required this.texcoords, @@ -27,6 +28,9 @@ final class BrushSurface { final String material; + /// [Brush.drawOrder] of the brushes this surface was built from — `P7`. + final int drawOrder; + /// Which of the level's brushes this surface is, when the level was built /// one surface per brush (`BrushGeometry.build`'s `perBrush`), and null /// when it is a batch of many. diff --git a/packages/flutter3d_sim/lib/src/level/level_diff.dart b/packages/flutter3d_sim/lib/src/level/level_diff.dart new file mode 100644 index 000000000..b1d29b7ae --- /dev/null +++ b/packages/flutter3d_sim/lib/src/level/level_diff.dart @@ -0,0 +1,132 @@ +import '../save/state_digest.dart'; +import 'level.dart'; + +/// What changed between two versions of one level, split by who has to act +/// on it. +/// +/// **The split is the whole point.** An editor saves a level while the game +/// is running it, and the game has two ways to take the change. What only the +/// picture reads — a light, a material, the fog, the music — can be patched +/// into the running scene as it is, and nothing the simulation computed is +/// disturbed. What the simulation reads — brushes it collides with, entities +/// it spawned, the ground — cannot: patched in place, the run would carry on +/// from a state no run of the new level could have reached, and a replay of +/// it would disagree with the run it recorded. That half goes through a +/// timeline branch instead (`RunTimeline.swapLevel`). +/// +/// **Conservative on purpose.** A brush whose only change is its material +/// looks presentational, and usually is — but a brush's surface falls back to +/// its material, and a surface is what footsteps and friction read. A +/// classifier that is sometimes too careful costs a replay; one that is +/// sometimes too bold costs a run that cannot be reproduced. So anything +/// under [simulation] is judged by the whole part, not by a guess at which of +/// its fields the game reads. +final class LevelDiff { + const LevelDiff({ + this.lights = const [], + this.lightCountChanged = false, + this.materials = const [], + this.fog = false, + this.music = false, + this.simulation = const [], + }); + + /// Lights changed in place, by index in the level's list. + final List lights; + + /// Lights were added or removed, so indices past the shorter list mean + /// nothing; a presenter rebuilds the lights rather than patching them. + final bool lightCountChanged; + + /// Materials added, removed or changed, by name. + final List materials; + + /// The fog's colour or density changed. + final bool fog; + + /// The music changed. + final bool music; + + /// Which parts the simulation reads changed: `brushes`, `entities`, + /// `heightfield`, `recipes`, `next`. Empty when the change can be patched + /// into a running scene without touching the run. + final List simulation; + + /// Whether nothing changed at all. + bool get isEmpty => + lights.isEmpty && + !lightCountChanged && + materials.isEmpty && + !fog && + !music && + simulation.isEmpty; + + /// Whether the change can be patched in without a timeline branch. + bool get presentationOnly => simulation.isEmpty; + + Map toJson() => { + 'lights': lights, + 'lightCountChanged': lightCountChanged, + 'materials': materials, + 'fog': fog, + 'music': music, + 'simulation': simulation, + }; +} + +/// Compares [before] and [after], part by part, through their documents. +/// +/// Through `toJson` rather than field by field: the document is what a level +/// *is* to the editor that wrote it, and a field added to a part later is +/// compared the day it is added, with nothing here to remember. +LevelDiff diffLevel(Level before, Level after) { + String digest(Object? json) => contentDigestHex({'v': json}); + bool differs(Object? a, Object? b) => digest(a) != digest(b); + + final oldLights = before.lights; + final newLights = after.lights; + final shared = oldLights.length < newLights.length + ? oldLights.length + : newLights.length; + + final names = {...before.materials.keys, ...after.materials.keys}; + return LevelDiff( + lights: [ + for (var i = 0; i < shared; i++) + if (differs(oldLights[i].toJson(), newLights[i].toJson())) i, + ], + lightCountChanged: oldLights.length != newLights.length, + materials: [ + for (final name in names) + if (differs( + before.materials[name]?.toJson(), + after.materials[name]?.toJson(), + )) + name, + ], + fog: + before.fogColor != after.fogColor || + before.fogDensity != after.fogDensity, + music: before.music != after.music, + simulation: [ + if (differs( + [for (final b in before.brushes) b.toJson()], + [for (final b in after.brushes) b.toJson()], + )) + 'brushes', + if (differs( + [for (final e in before.entities) e.toJson()], + [for (final e in after.entities) e.toJson()], + )) + 'entities', + if (differs(before.heightfield?.toJson(), after.heightfield?.toJson())) + 'heightfield', + if (differs( + [for (final r in before.recipes) r.toJson()], + [for (final r in after.recipes) r.toJson()], + )) + 'recipes', + if (before.next != after.next) 'next', + ], + ); +} diff --git a/packages/flutter3d_sim/lib/src/level/level_patch.dart b/packages/flutter3d_sim/lib/src/level/level_patch.dart new file mode 100644 index 000000000..e91a5a0f8 --- /dev/null +++ b/packages/flutter3d_sim/lib/src/level/level_patch.dart @@ -0,0 +1,416 @@ +import '../save/state_digest.dart'; +import 'level.dart'; +import 'level_diff.dart'; + +/// The lists a level keeps its pieces in, which a [LevelPatch] edits row by +/// row rather than whole. +const List patchedLists = ['brushes', 'lights', 'entities']; + +/// One row of one of [patchedLists] put in, taken out or replaced. +/// +/// **A row is named by where it stands and what it was**, [at] and [was], +/// because the format gives a brush nothing else to be named by. Ids in the +/// document were the alternative and were refused: every level ever saved +/// would grow a key per row on its next save, recipes expand into brushes +/// that have none, and the editor's undo puts back whole documents, so ids +/// would have to be minted and kept stable across all of it. What a reference +/// has to guarantee is narrower than identity — that a patch never edits a +/// row other than the one it was made against — and the row's digest gives +/// exactly that: the receiving end finds the row at [at], digests it, and +/// refuses the patch when it is not the row [was] names. +final class RowEdit { + const RowEdit({required this.list, required this.at, this.was, this.row}); + + factory RowEdit.fromJson(Map json) => RowEdit( + list: json['list']! as String, + at: (json['at']! as num).toInt(), + was: json['was'] as String?, + row: json['row'] as Map?, + ); + + /// One of [patchedLists]. + final String list; + + /// Where the row stands in the list as it is when this edit is applied — + /// after the edits before it in the patch, not in the original. + final int at; + + /// The digest of the row taken out or replaced; null for a row put in. + final String? was; + + /// The row put in or replacing; null for a row taken out. + final Map? row; + + bool get inserts => was == null; + bool get removes => row == null; + + Map toJson() => { + 'list': list, + 'at': at, + 'was': ?was, + 'row': ?row, + }; +} + +/// What changed between two versions of one level, as edits a running game +/// can make to the version it has. +/// +/// **Made against a version, and applied only to that version.** [base] is +/// the digest of the level the patch was made from and [hash] the digest of +/// the level it makes; [applyTo] refuses a level that is not [base] and a +/// result that is not [hash]. A refusal costs one whole document — the sender +/// falls back to it — and a patch applied to the wrong level would cost a +/// level that nobody saved, so nothing between the two is guessed at. +/// +/// Three kinds of change, by how the document holds them: rows of +/// [patchedLists] ([rows]), materials by name ([materials]), and every other +/// top-level key whole ([fields]) — fog, music, the ground, recipes, `next`, +/// and keys this format does not know. A list or the material table that one +/// side has and the other does not goes whole as a field too: the patch then +/// says what the document says rather than inventing an empty one. +final class LevelPatch { + const LevelPatch({ + required this.base, + required this.hash, + this.rows = const [], + this.materials = const {}, + this.fields = const {}, + }); + + factory LevelPatch.fromJson(Map json) => LevelPatch( + base: json['base']! as String, + hash: json['hash']! as String, + rows: [ + for (final row in json['rows'] as List? ?? const []) + RowEdit.fromJson(row! as Map), + ], + materials: + json['materials'] as Map? ?? const {}, + fields: + json['fields'] as Map? ?? const {}, + ); + + /// The edits that make [after] out of [before]. + /// + /// Rows are matched by digest: a common head and tail are trimmed, and what + /// is left between them is aligned by its longest common run, so a brush + /// deleted from the middle of a thousand is one edit rather than the rest of + /// the list shifted by one. Unmatched rows facing each other are paired as + /// replacements, which is what moving or recolouring one thing looks like. + /// A middle too long to align (see [_alignLimit]) is replaced row for row — + /// a bigger patch, never a wrong one. + factory LevelPatch.between(Level before, Level after) { + final old = before.toJson(); + final now = after.toJson(); + final rows = []; + final fields = {}; + for (final key in {...old.keys, ...now.keys}) { + final (a, b) = (old[key], now[key]); + if (_digest(a) == _digest(b)) continue; + if (patchedLists.contains(key) && a is List && b is List) { + rows.addAll(_rowEdits(key, a, b)); + } else if (key == 'materials' && a is Map && b is Map) { + // Handled below, by name. + } else { + fields[key] = b; + } + } + final (oldMaterials, newMaterials) = (old['materials'], now['materials']); + return LevelPatch( + base: before.digestHex, + hash: after.digestHex, + rows: rows, + materials: oldMaterials is Map && newMaterials is Map + ? { + for (final name in { + ...oldMaterials.keys, + ...newMaterials.keys, + }) + if (_digest(oldMaterials[name]) != _digest(newMaterials[name])) + name! as String: newMaterials[name], + } + : const {}, + fields: fields, + ); + } + + /// The service extension error code a game answers a patch with when it + /// does not apply — made against another version, or making one nobody + /// saved. The sender's cue to send the whole document instead; any other + /// error means the level itself was refused, and sending it whole would be + /// refused the same way. + /// + /// Inside the range `dart:developer` leaves to extensions + /// (`ServiceExtensionResponse.extensionErrorMin` to `extensionErrorMax`), + /// spelled here because this package does not import the VM's. + static const int staleCode = -32001; + + /// How many row pairs [LevelPatch.between] will align by longest common + /// run, the cost of which grows as their product. A thousand by a thousand + /// is four megabytes and a few milliseconds; past it, rows are replaced. + static const int _alignLimit = 1000 * 1000; + + /// The digest of the level this patch was made from. + final String base; + + /// The digest of the level it makes. + final String hash; + + /// Row edits, in the order they are applied. + final List rows; + + /// Materials changed or added, by name; null for one taken out. + final Map materials; + + /// Other top-level keys, whole; null for one taken out. + final Map fields; + + /// Whether the patch changes nothing. + bool get isEmpty => rows.isEmpty && materials.isEmpty && fields.isEmpty; + + Map toJson() => { + 'base': base, + 'hash': hash, + if (rows.isNotEmpty) 'rows': [for (final r in rows) r.toJson()], + if (materials.isNotEmpty) 'materials': materials, + if (fields.isNotEmpty) 'fields': fields, + }; + + /// [level] with this patch made to it, and what that changed — or why not. + /// + /// **The diff comes from the patch, not from comparing the two levels.** + /// The edits already say which lamp changed and whether any brush did, so + /// the running game is told that without both documents being digested part + /// by part. The exception is a list or the material table sent whole, where + /// the patch knows that the part changed and not where: that is compared + /// the way [diffLevel] compares documents. + LevelPatchResult applyTo(Level level) { + final had = level.digestHex; + if (had != base) { + return LevelPatchRefused( + 'the patch was made against level $base and the game has $had', + ); + } + final document = level.toJson(); + for (final MapEntry(:key, :value) in fields.entries) { + if (value == null) { + document.remove(key); + } else { + document[key] = value; + } + } + if (materials.isNotEmpty) { + final table = { + ...document['materials'] as Map? ?? + const {}, + }; + for (final MapEntry(:key, :value) in materials.entries) { + if (value == null) { + table.remove(key); + } else { + table[key] = value; + } + } + document['materials'] = table; + } + // Copies, because a list the level never changed is handed back by + // `toJson` as the very list it was read from. + final lists = >{ + for (final list in {for (final edit in rows) edit.list}) + list: [...document[list] as List? ?? const []], + }; + for (final edit in rows) { + final target = lists[edit.list]!; + if (edit.inserts && edit.removes) { + return LevelPatchRefused( + '${edit.list}[${edit.at}] is neither put in nor taken out', + ); + } + final limit = edit.inserts ? target.length : target.length - 1; + if (edit.at < 0 || edit.at > limit) { + return LevelPatchRefused( + '${edit.list}[${edit.at}] is past the end of the game\'s ' + '${target.length}', + ); + } + if (edit.was case final String was) { + final found = _digest(target[edit.at]); + if (found != was) { + return LevelPatchRefused( + '${edit.list}[${edit.at}] is $found in the game, not $was', + ); + } + } + switch ((edit.was, edit.row)) { + case (null, final Map row): + target.insert(edit.at, row); + case (_, null): + target.removeAt(edit.at); + case (_, final Map row): + target[edit.at] = row; + } + } + document.addAll(lists); + + final Level next; + try { + next = Level.fromJson(document); + } on Object catch (error) { + return LevelPatchRefused('the patched document is not a level: $error'); + } + if (next.digestHex != hash) { + return LevelPatchRefused( + 'the patched level digests to ${next.digestHex}, not $hash', + ); + } + final whole = fields.keys.any( + (String key) => patchedLists.contains(key) || key == 'materials', + ); + return LevelPatched(next, whole ? diffLevel(level, next) : _diff()); + } + + /// What the edits change, read off the edits themselves. + LevelDiff _diff() { + Iterable of(String list) => + rows.where((RowEdit edit) => edit.list == list); + final lights = of('lights'); + final lightCountChanged = lights.any( + (RowEdit edit) => edit.inserts || edit.removes, + ); + return LevelDiff( + // With no row put in or taken out, a row's place in the patch is its + // place in both levels. + lights: lightCountChanged + ? const [] + : [for (final edit in lights) edit.at], + lightCountChanged: lightCountChanged, + materials: materials.keys.toList(), + fog: fields.containsKey('fogColor') || fields.containsKey('fogDensity'), + music: fields.containsKey('music'), + simulation: [ + if (of('brushes').isNotEmpty) 'brushes', + if (of('entities').isNotEmpty) 'entities', + for (final key in const ['heightfield', 'recipes', 'next']) + if (fields.containsKey(key)) key, + ], + ); + } + + static String _digest(Object? json) => + contentDigestHex({'v': json}); + + /// Edits that turn the rows [a] of [list] into [b], applied in order. + static List _rowEdits( + String list, + List a, + List b, + ) { + final oldDigests = [for (final row in a) _digest(row)]; + final newDigests = [for (final row in b) _digest(row)]; + final shorter = a.length < b.length ? a.length : b.length; + final head = Iterable.generate( + shorter, + ).takeWhile((int i) => oldDigests[i] == newDigests[i]).length; + final tail = Iterable.generate(shorter - head) + .takeWhile( + (int i) => + oldDigests[a.length - 1 - i] == newDigests[b.length - 1 - i], + ) + .length; + final from = oldDigests.sublist(head, a.length - tail); + final to = newDigests.sublist(head, b.length - tail); + + final edits = []; + var at = head; + var (i, j) = (0, 0); + // Each anchor is a row kept; the sentinel at the end flushes what is left. + for (final (ai, bj) in <(int, int)>[ + ..._anchors(from, to), + (from.length, to.length), + ]) { + final removed = ai - i; + final inserted = bj - j; + final paired = removed < inserted ? removed : inserted; + for (var k = 0; k < paired; k++) { + edits.add( + RowEdit( + list: list, + at: at++, + was: from[i + k], + row: b[head + j + k]! as Map, + ), + ); + } + for (var k = paired; k < removed; k++) { + edits.add(RowEdit(list: list, at: at, was: from[i + k])); + } + for (var k = paired; k < inserted; k++) { + edits.add( + RowEdit( + list: list, + at: at++, + row: b[head + j + k]! as Map, + ), + ); + } + at++; + (i, j) = (ai + 1, bj + 1); + } + return edits; + } + + /// The pairs of positions a longest common run of [a] and [b] keeps, in + /// order. Empty when the two are too long to align (see [_alignLimit]). + static List<(int, int)> _anchors(List a, List b) { + if (a.isEmpty || b.isEmpty || a.length * b.length > _alignLimit) { + return const <(int, int)>[]; + } + final width = b.length + 1; + // `runs[i * width + j]`: the longest common run of a[i..] and b[j..]. + final runs = List.filled((a.length + 1) * width, 0); + for (var i = a.length - 1; i >= 0; i--) { + for (var j = b.length - 1; j >= 0; j--) { + runs[i * width + j] = a[i] == b[j] + ? runs[(i + 1) * width + j + 1] + 1 + : _max(runs[(i + 1) * width + j], runs[i * width + j + 1]); + } + } + final anchors = <(int, int)>[]; + var (i, j) = (0, 0); + while (i < a.length && j < b.length) { + if (a[i] == b[j]) { + anchors.add((i++, j++)); + } else if (runs[(i + 1) * width + j] >= runs[i * width + j + 1]) { + i++; + } else { + j++; + } + } + return anchors; + } + + static int _max(int a, int b) => a > b ? a : b; +} + +/// What [LevelPatch.applyTo] made of a level. +sealed class LevelPatchResult { + const LevelPatchResult(); +} + +/// The patch applied: [level] is the version it makes, and [diff] what that +/// changed for the running game. +final class LevelPatched extends LevelPatchResult { + const LevelPatched(this.level, this.diff); + + final Level level; + final LevelDiff diff; +} + +/// The patch does not apply, and [reason] says where it stopped: a level +/// that is not the one it was made against, a row that is not the one it +/// names, or a result that is not the level it was meant to make. +final class LevelPatchRefused extends LevelPatchResult { + const LevelPatchRefused(this.reason); + + final String reason; +} diff --git a/packages/flutter3d_sim/lib/src/level/surface_builder.dart b/packages/flutter3d_sim/lib/src/level/surface_builder.dart index b3889a10b..87b42f9d5 100644 --- a/packages/flutter3d_sim/lib/src/level/surface_builder.dart +++ b/packages/flutter3d_sim/lib/src/level/surface_builder.dart @@ -12,6 +12,7 @@ final class SurfaceBuilder { SurfaceBuilder( this.material, { required this.shadowCasting, + this.drawOrder = 0, this.lightmapped = false, this.brush, }); @@ -19,6 +20,9 @@ final class SurfaceBuilder { final String material; final ShadowCasting shadowCasting; + /// [Brush.drawOrder] of every brush these triangles came from. + final int drawOrder; + /// The one brush these triangles belong to, when every brush is its own /// surface. See [BrushSurface.brush]. final int? brush; @@ -79,6 +83,7 @@ final class SurfaceBuilder { BrushSurface finish() => BrushSurface( material: material, shadowCasting: shadowCasting, + drawOrder: drawOrder, positions: Float32List.fromList(_positions), normals: Float32List.fromList(_normals), texcoords: Float32List.fromList(_texcoords), diff --git a/packages/flutter3d_sim/lib/src/loop/pause_gate.dart b/packages/flutter3d_sim/lib/src/loop/pause_gate.dart index 4af305fc4..2510c386a 100644 --- a/packages/flutter3d_sim/lib/src/loop/pause_gate.dart +++ b/packages/flutter3d_sim/lib/src/loop/pause_gate.dart @@ -16,12 +16,19 @@ /// So it has a name and a test now. It is a function of four facts and nothing /// else, which is what makes it one: every previous version reached for a device /// and answered a question about the *player's attention* with it. +/// +/// [photoMode] is a fifth, and a separate one rather than a menu that happens +/// to be open: photo mode is the one state in which the player is looking hard +/// at the game and moving every stick — so every device clause below says +/// *run*, and a game that folded it into [menuOpen] would have to remember not +/// to close that "menu" when the pointer is captured again to fly the camera. bool shouldPause({ required bool ready, required bool menuOpen, required bool pointerIsTheGate, required bool pointerHeld, required bool padConnected, + bool photoMode = false, }) { // Nothing to run yet: the level is still loading, or failed to. if (!ready) return true; @@ -30,6 +37,10 @@ bool shouldPause({ // depend on any device. This is the clause that was missing. if (menuOpen) return true; + // The picture is being framed, so the world holds still for it — whatever + // the pointer and the pad say, since both are flying the camera. + if (photoMode) return true; + // Where the pointer can be captured, not having it means the player is // somewhere else — that is what Escape does and what clicking back in undoes. // A controller is a second way of being here, and it is enough on its own. diff --git a/packages/flutter3d_sim/lib/src/math/portable_math.dart b/packages/flutter3d_sim/lib/src/math/portable_math.dart index bd0863aa1..a584b6087 100644 --- a/packages/flutter3d_sim/lib/src/math/portable_math.dart +++ b/packages/flutter3d_sim/lib/src/math/portable_math.dart @@ -1,842 +1,7 @@ -/// The functions a step is allowed to call, computed the same way everywhere. -/// -/// ## Why this exists -/// -/// `parity_test.dart` in this package asked twelve `dart:math` functions for -/// twenty thousand answers apiece, under the VM and under Chrome, and digested -/// each column. **One matched.** `sqrt` is the one the specification pins; -/// every transcendental — `sin`, `cos`, `tan`, `asin`, `acos`, `atan`, `atan2`, -/// `exp`, `log` — gave different bits in the two places, and so did every -/// combination of them. There is nothing portable in `dart:math` to build a -/// substitute out of, because on the VM those are the host's libm and in a -/// browser they are whatever that engine ships, and neither IEEE 754 nor the -/// Dart specification says the two agree on the last bit. -/// -/// `pow` was the tenth, and it was the interesting one: on macOS-arm64 it -/// matched in both places, which read as a second function the specification -/// had pinned. It had not. A third machine, an x86-64 Ubuntu, answered `pow` -/// differently from either — so what the first measurement saw was two runtimes -/// that happened to share one host's libm, and the conclusion drawn from it was -/// the conclusion a sample of two invites. That is why [pow] is here and why -/// `sqrt` is the only thing this file still asks for. -/// -/// `flutter3d_game_racing/test/parity_test.dart` is what that costs: the same -/// tape driving the same car diverged at twenty-three checkpoints of forty, -/// first at step 75. A character controller reached none of the disagreeing -/// arguments and matched all forty — so "a replay is the same run" was a thing -/// that happened to be true for one genre and false for another, which is the -/// worst shape a guarantee can have. -/// -/// ## What is guaranteed here, and what is not -/// -/// **Guaranteed: the same bits on every platform, by construction rather than -/// by luck.** Everything below is built out of `+`, `-`, `*`, `/`, `sqrt`, -/// comparison, and reading the bytes of a double — every one of which Dart -/// pins to IEEE 754 and none of which is a property of the machine. Two -/// platforms running this code cannot disagree, because there is nothing left -/// for them to disagree about. That holds for platforms nobody has measured and -/// for platforms that do not exist yet, which is the part a measurement could -/// never give. -/// -/// **Not guaranteed: the last bit against `dart:math`.** These are minimax -/// polynomials with argument reduction, accurate to a couple of units in the -/// last place — not correctly rounded. `portable_math_test.dart` holds that -/// bound, because a polynomial that is quietly wrong in the fourth digit is a -/// physics bug rather than a rounding one. But a step's answers will differ -/// from the host libm's in the last bit or two, which is exactly the change -/// being made on purpose: the step stops asking the machine. -/// -/// **Not a speed claim either way.** A polynomial `sin` is twenty-odd -/// arithmetic operations; a libm call is a call. Neither is the reason for -/// this. -/// -/// ## Which functions are here -/// -/// Ten: [sin], [cos], [tan], [asin], [acos], [atan], [atan2], [exp], [log] and -/// [pow], plus [sinCos] for the pair a call site usually wants together. That -/// is every name the rule in `tool/structure.dart` refuses, which is the line -/// this list is drawn on — a function the rule forbids and this library does -/// not have is a step with nowhere to go. -/// -/// They arrive in a handful of shapes: an angle turned into a direction -/// ([sinCos]), a direction turned back into an angle ([atan2]), the exponential -/// of a damping term ([exp]), the tangent in the bicycle-model steering -/// ([tan]), the arc sine in the tyre curve ([asin]), and a falloff raised to an -/// exponent ([pow]). -/// -/// ## The rule that keeps it -/// -/// `tool/structure.dart` holds a rule — *nothing a step runs asks the machine -/// for an answer* — that fails on `math.sin` and its neighbours anywhere in the -/// simulation. Presentation is exempt and named: a camera, a light that -/// flickers and the lightmap baker are not part of any run, and holding them to -/// this would be cost with nothing bought. -/// -/// ## Where the numbers come from -/// -/// The polynomial coefficients and the argument reductions are fdlibm's, which -/// is the public-domain reference every libm descends from. What is ours is -/// that they are evaluated here, in Dart, over operations that are the same -/// operations everywhere — rather than in whatever the platform linked. +/// [Portable] lives in `flutter3d_physics` now, the lowest package a fixed +/// step runs through, so that collision, cloth and liquids answer the same +/// questions the same way as everything above them. Re-exported here so the +/// simulation's own imports and its public API stay as they were. library; -import 'dart:math' as math; -import 'dart:typed_data'; - -/// The transcendental functions, computed identically on every platform. -/// -/// See the library doc for why they are not `dart:math`'s. -abstract final class Portable { - /// Sine of [radians]. - /// - /// NaN for an argument that is not finite, as `dart:math` gives. - static double sin(double radians) { - if (!radians.isFinite) return double.nan; - final reduced = _quarterTurns(radians); - return switch (reduced.quadrant) { - 0 => _sin(reduced.r, reduced.tail), - 1 => _cos(reduced.r, reduced.tail), - 2 => -_sin(reduced.r, reduced.tail), - _ => -_cos(reduced.r, reduced.tail), - }; - } - - /// Cosine of [radians]. - static double cos(double radians) { - if (!radians.isFinite) return double.nan; - final reduced = _quarterTurns(radians); - return switch (reduced.quadrant) { - 0 => _cos(reduced.r, reduced.tail), - 1 => -_sin(reduced.r, reduced.tail), - 2 => -_cos(reduced.r, reduced.tail), - _ => _sin(reduced.r, reduced.tail), - }; - } - - /// Both at once, which is the shape most call sites actually want. - /// - /// An angle becoming a direction needs the pair, and asking for them - /// separately reduces the argument twice — the expensive half of the work. - /// It is also one place rather than two for a caller to get the order wrong. - static ({double sin, double cos}) sinCos(double radians) { - if (!radians.isFinite) return (sin: double.nan, cos: double.nan); - final reduced = _quarterTurns(radians); - final s = _sin(reduced.r, reduced.tail); - final c = _cos(reduced.r, reduced.tail); - return switch (reduced.quadrant) { - 0 => (sin: s, cos: c), - 1 => (sin: c, cos: -s), - 2 => (sin: -s, cos: -c), - _ => (sin: -c, cos: s), - }; - } - - /// Tangent of [radians]. - /// - /// **[sin] over [cos] rather than its own polynomial**, and that is a choice - /// with a cost worth naming. A dedicated kernel is a little more accurate - /// near the poles, where `cos` is small and the quotient magnifies its error; - /// what this buys instead is that `tan`, `sin` and `cos` cannot disagree with - /// each other — the same reduction and the same two kernels serve all three. - /// The steering model that calls this works in a range nowhere near a pole. - static double tan(double radians) { - final both = sinCos(radians); - return both.sin / both.cos; - } - - /// Arc tangent of [x], in radians, between -π/2 and π/2. - static double atan(double x) { - if (x.isNaN) return double.nan; - final negative = x.isNegative; - final magnitude = x.abs(); - if (magnitude.isInfinite) return negative ? -_pio2 : _pio2; - - // Which of the four intervals the argument lands in, and the substitution - // that folds it into the one interval the polynomial is fitted over. The - // thresholds are fdlibm's: 7/16, 11/16, 19/16 and 39/16. - final int piece; - final double folded; - if (magnitude < 0.4375) { - piece = -1; - folded = magnitude; - } else if (magnitude < 0.6875) { - piece = 0; - folded = (2.0 * magnitude - 1.0) / (2.0 + magnitude); - } else if (magnitude < 1.1875) { - piece = 1; - folded = (magnitude - 1.0) / (magnitude + 1.0); - } else if (magnitude < 2.4375) { - piece = 2; - folded = (magnitude - 1.5) / (1.0 + 1.5 * magnitude); - } else { - piece = 3; - folded = -1.0 / magnitude; - } - - final z = folded * folded; - final w = z * z; - // Split into even and odd halves so the two chains evaluate independently, - // which is fdlibm's arrangement and is kept because the coefficients are - // fitted to it. - final odd = - z * - (_aT0 + w * (_aT2 + w * (_aT4 + w * (_aT6 + w * (_aT8 + w * _aT10))))); - final even = w * (_aT1 + w * (_aT3 + w * (_aT5 + w * (_aT7 + w * _aT9)))); - - if (piece < 0) { - final result = folded - folded * (odd + even); - return negative ? -result : result; - } - final result = - _atanHi[piece] - ((folded * (odd + even) - _atanLo[piece]) - folded); - return negative ? -result : result; - } - - /// The angle of the vector ([y], [x]), in radians, between -π and π. - /// - /// The quadrant logic is exact arithmetic and comparison, so all of it is - /// portable; only [atan] underneath had to be replaced. - static double atan2(double y, double x) { - if (x.isNaN || y.isNaN) return double.nan; - if (y == 0.0) { - // Sign of zero decides the side, as IEEE says it should: atan2(-0, -1) - // is -π and atan2(+0, -1) is π. - if (x > 0.0 || (x == 0.0 && !x.isNegative)) return y; - return y.isNegative ? -_pi : _pi; - } - if (x == 0.0) return y.isNegative ? -_pio2 : _pio2; - if (x.isInfinite) { - if (y.isInfinite) { - final quarter = x.isNegative ? 3.0 * _pio4 : _pio4; - return y.isNegative ? -quarter : quarter; - } - final flat = x.isNegative ? _pi : 0.0; - return y.isNegative ? -flat : flat; - } - if (y.isInfinite) return y.isNegative ? -_pio2 : _pio2; - - final angle = atan((y / x).abs()); - final folded = x.isNegative ? _pi - angle : angle; - return y.isNegative ? -folded : folded; - } - - /// Arc sine of [x], in radians. NaN outside -1 to 1. - /// - /// **Built on [atan2] rather than given a polynomial of its own.** The - /// identity is `asin(x) = atan2(x, sqrt((1 - x)(1 + x)))`, and the factored - /// form of `1 - x²` is what keeps it accurate as `|x|` approaches one, where - /// the unfactored version loses half its digits to cancellation. At exactly - /// ±1 the square root is zero and `atan2` answers ±π/2 without a special - /// case. It costs a couple of units in the last place against a dedicated - /// approximation, and it is a third of the code to be wrong in. - /// - /// `math.sqrt` is the one call to `dart:math` this file makes, and it is not - /// an oversight: IEEE 754 requires the square root to be correctly rounded, - /// and `parity_test.dart` measured that both platforms comply. It is one of - /// the two functions there was never anything to replace. - static double asin(double x) { - if (x.isNaN || x < -1.0 || x > 1.0) return double.nan; - return atan2(x, math.sqrt((1.0 - x) * (1.0 + x))); - } - - /// Arc cosine of [x], in radians, between 0 and π. NaN outside -1 to 1. - /// - /// The mirror of [asin] and built the same way, with the two arguments of - /// [atan2] swapped: the sine of the answer is the square root and its cosine - /// is `x`, so `atan2(sqrt((1 - x)(1 + x)), x)` is the angle. The factoring of - /// `1 - x²` earns its keep at the same place and for the same reason. - /// - /// **Not `π/2 - asin(x)`**, which is shorter and is a trap: near `x = 1` the - /// arc cosine is nearly zero while `asin` is nearly π/2, so the subtraction - /// cancels away most of the digits the answer was supposed to have. The form - /// here is measured against `dart:math` at two units in the last place across - /// the whole domain; the subtraction is not. - static double acos(double x) { - if (x.isNaN || x < -1.0 || x > 1.0) return double.nan; - return atan2(math.sqrt((1.0 - x) * (1.0 + x)), x); - } - - /// e raised to [x]. - static double exp(double x) { - if (x.isNaN) return x; - if (x > _expOverflow) return double.infinity; - if (x < _expUnderflow) return 0.0; - - // x = k·ln2 + r, with ln2 in two pieces so that k·ln2 is subtracted - // exactly. |r| ends up under half a ln2, where the polynomial is fitted. - final k = (x * _log2e).roundToDouble(); - final hi = x - k * _ln2Hi; - final lo = k * _ln2Lo; - final r = hi - lo; - final t = r * r; - final c = - r - - t * (_expP1 + t * (_expP2 + t * (_expP3 + t * (_expP4 + t * _expP5)))); - - if (k == 0.0) return 1.0 - ((x * c) / (c - 2.0) - x); - final y = 1.0 - ((lo - (r * c) / (2.0 - c)) - hi); - return _scaleByPowerOfTwo(y, k.toInt()); - } - - /// The natural logarithm of [x]. Negative infinity at zero, NaN below it. - /// - /// **The first function here that reads the bits of its argument**, and it has - /// to: the reduction is `x = 2^k · m` with `m` between √2/2 and √2, and `k` is - /// the exponent field. Taking it by repeated halving would be a loop whose - /// length depends on the argument, and taking it through a logarithm would be - /// circular. [_highWord] is how the exponent is read, and `ByteData` is what - /// makes that portable — the same eight bytes in the same order everywhere, - /// which is already the property [_twoTo] and `StateDigest` rest on. - /// - /// The mantissa is normalised into the interval by the `+ 0x95F64` trick: - /// adding that to the mantissa field carries into bit twenty exactly when the - /// mantissa is above √2, and the carry is both the test and the correction to - /// `k`. A subnormal argument is scaled up by 2^54 first and pays for it with - /// `k -= 54`, because its exponent field reads zero and says nothing. - static double log(double x) { - if (x.isNaN) return x; - if (x < 0.0) return double.nan; - if (x == 0.0) return double.negativeInfinity; - if (x.isInfinite) return x; - - var value = x; - var k = 0; - var hx = _highWord(value); - if (hx < 0x00100000) { - k -= 54; - value *= _two54; - hx = _highWord(value); - } - k += (hx >> 20) - 1023; - hx &= 0x000FFFFF; - final carry = (hx + 0x95F64) & 0x100000; - value = _withHighWord(value, hx | (carry ^ 0x3FF00000)); - k += carry >> 20; - - final f = value - 1.0; - final dk = k.toDouble(); - if ((0x000FFFFF & (2 + hx)) < 3) { - // |f| < 2^-20, where `f/(2+f)` would be all rounding error and the plain - // series is both shorter and better. - if (f == 0.0) { - if (k == 0) return 0.0; - return dk * _ln2Hi + dk * _ln2Lo; - } - final r = f * f * (0.5 - 0.33333333333333333 * f); - if (k == 0) return f - r; - return dk * _ln2Hi - ((r - dk * _ln2Lo) - f); - } - - final s = f / (2.0 + f); - final z = s * s; - final w = z * z; - final t1 = w * (_logLg2 + w * (_logLg4 + w * _logLg6)); - final t2 = z * (_logLg1 + w * (_logLg3 + w * (_logLg5 + w * _logLg7))); - final r = t2 + t1; - - // fdlibm writes this bound as the sign of two subtractions ored together; - // it is the mantissa field sitting between √2/2 and √2 rounded to twenty - // bits, and saying so directly costs nothing and cannot be read wrong. - if (hx >= 0x6147A && hx <= 0x6B851) { - final hfsq = 0.5 * f * f; - if (k == 0) return f - (hfsq - s * (hfsq + r)); - return dk * _ln2Hi - ((hfsq - (s * (hfsq + r) + dk * _ln2Lo)) - f); - } - if (k == 0) return f - s * (f - r); - return dk * _ln2Hi - ((s * (f - r) - dk * _ln2Lo) - f); - } - - /// [x] raised to [y]. - /// - /// **The long way round, and the short way was measured first.** `exp(y · - /// log x)` out of the two functions above is four lines and is wrong: the - /// logarithm's last two bits are multiplied by `y` before the exponential - /// magnifies them again, which came out at twenty-six units in the last place - /// at `y = 1.5` and seventy-nine at `y = 5`, against a bound of two. What - /// fixes it is what fdlibm does — carry the logarithm in two pieces, base - /// two, so the product with `y` is formed in something wider than a double - /// before the exponential sees it. That is the whole reason this function is - /// two hundred lines instead of four. - /// - /// The half of it that is not arithmetic is the special values, and there are - /// a lot of them because the answer to `pow` at an edge is a convention rather - /// than a limit: `0^0` is one, `1^NaN` is one, `(-1)^∞` is one, `(-1)^0.5` is - /// not a number, and a negative base is only allowed a whole-number exponent — - /// where the parity of that number decides the sign. Every one of those is a - /// branch below, in the order fdlibm takes them, because the order is - /// load-bearing: `y == 0` answers before NaN is considered, and NaN answers - /// before anything reads a bit. - static double pow(double x, double y) { - // Both before the NaN test, and deliberately. Anything to the nought is - // one, and one to anything is one, including when the anything is a NaN — - // which is what IEEE 754 and C99 say and what `dart:math` answers on the - // VM. It is *not* what fdlibm's kernel answers, nor what a browser's - // `Math.pow` does: both call `1^NaN` and `(-1)^∞` not a number. That is a - // place the two platforms part company like any other, so this file has to - // choose, and it chooses the standard's answer over the older one. - if (y == 0.0) return 1.0; - if (x == 1.0) return 1.0; - if (x.isNaN || y.isNaN) return double.nan; - - // Whether `y` is a whole number and, if it is, whether it is odd — which is - // the only thing that lets a negative base through at all. Past 2^53 the - // gap between doubles is two, so every one of them is even. - final ay = y.abs(); - final int yIsInt; - if (y.isInfinite || y != y.truncateToDouble()) { - yIsInt = 0; - } else if (ay >= _two53) { - yIsInt = 2; - } else { - yIsInt = ay % 2.0 == 1.0 ? 1 : 2; - } - - final ax = x.abs(); - - if (y.isInfinite) { - // (-1)^±∞ is one, for the reason above: `x` is exactly one in magnitude - // and no amount of exponent moves it. - if (ax == 1.0) return 1.0; - if (ax > 1.0) return y.isNegative ? 0.0 : double.infinity; - return y.isNegative ? double.infinity : 0.0; - } - if (y == 1.0) return x; - if (y == -1.0) return 1.0 / x; - if (y == 2.0) return x * x; - // A correctly rounded square root beats anything the general path would - // give. `isNegative` rather than `x >= 0`, so that a negative zero falls - // through to the special-base branch and answers +0 instead of -0. - if (y == 0.5 && !x.isNegative) return math.sqrt(x); - - if (ax == 0.0 || ax == 1.0 || ax.isInfinite) { - // ±0, ±1 and ±∞ are exact at every exponent, and the general path would - // reach them through a logarithm of zero or infinity. - var z = ax; - if (y.isNegative) z = 1.0 / z; - if (x.isNegative) { - if (ax == 1.0 && yIsInt == 0) return double.nan; - if (yIsInt == 1) z = -z; - } - return z; - } - - if (x.isNegative && yIsInt == 0) return double.nan; - // The sign is settled here and multiplied back in at the very end, so - // everything between works on a positive base. - final s = x.isNegative && yIsInt == 1 ? -1.0 : 1.0; - - final ix = _highWord(ax); - - // t1 + t2 is log2(|x|) in two pieces, t1 carrying the top twenty-odd bits - // with a clear tail so that y·t1 is formed without rounding. - final double t1; - final double t2; - - if (ay > 2147483648.0) { - // |y| past 2^31. The result overflows or underflows for any base that is - // not within 2^-20 of one, so the only case left needing real work is a - // base that close — where four terms of the series are the logarithm. - if (ay > 18446744073709551616.0) { - if (ix <= 0x3FEFFFFF) return y.isNegative ? double.infinity : 0.0; - if (ix >= 0x3FF00000) return y.isNegative ? 0.0 : double.infinity; - } - if (ix < 0x3FEFFFFF) { - return y.isNegative ? s * double.infinity : s * 0.0; - } - if (ix > 0x3FF00000) { - return y.isNegative ? s * 0.0 : s * double.infinity; - } - final t = ax - 1.0; - final w = (t * t) * (0.5 - t * (0.3333333333333333333333 - t * 0.25)); - final u = _ivln2Hi * t; - final v = t * _ivln2Lo - w * _ivln2; - t1 = _withLowWordZero(u + v); - t2 = v - (t1 - u); - } else { - var scaled = ax; - var hi = ix; - var n = 0; - if (hi < 0x00100000) { - scaled *= _two53; - n -= 53; - hi = _highWord(scaled); - } - n += (hi >> 20) - 0x3FF; - final mantissa = hi & 0x000FFFFF; - hi = mantissa | 0x3FF00000; - - // Which of the two anchors the mantissa is measured from — one, or one - // and a half. Above √3/2 of the upper anchor the exponent is bumped - // instead, so the ratio below is never far from zero. - final int piece; - if (mantissa <= 0x3988E) { - piece = 0; - } else if (mantissa < 0xBB67A) { - piece = 1; - } else { - piece = 0; - n += 1; - hi -= 0x00100000; - } - scaled = _withHighWord(scaled, hi); - - final u = scaled - _powBp[piece]; - final v = 1.0 / (scaled + _powBp[piece]); - final ss = u * v; - final sHi = _withLowWordZero(ss); - // The high half of `scaled + bp[piece]`, assembled from the exponent - // rather than added, so that the remainder below is exact. - var tHi = _withHighWord( - 0.0, - ((hi >> 1) | 0x20000000) + 0x00080000 + (piece << 18), - ); - final tLo = scaled - (tHi - _powBp[piece]); - final sLo = v * ((u - sHi * tHi) - sHi * tLo); - - var sq = ss * ss; - var r = - sq * - sq * - (_powL1 + - sq * - (_powL2 + - sq * - (_powL3 + - sq * (_powL4 + sq * (_powL5 + sq * _powL6))))); - r += sLo * (sHi + ss); - sq = sHi * sHi; - tHi = _withLowWordZero(3.0 + sq + r); - final tLo2 = r - ((tHi - 3.0) - sq); - - final u2 = sHi * tHi; - final v2 = sLo * tHi + tLo2 * ss; - final pHi = _withLowWordZero(u2 + v2); - final pLo = v2 - (pHi - u2); - final zHi = _powCpHi * pHi; - final zLo = _powCpLo * pHi + pLo * _powCp + _powDpLo[piece]; - - final e = n.toDouble(); - t1 = _withLowWordZero(((zHi + zLo) + _powDpHi[piece]) + e); - t2 = zLo - (((t1 - e) - _powDpHi[piece]) - zHi); - } - - // y·log2(|x|), again in two pieces: y is split so that yHi·t1 is exact. - final yHi = _withLowWordZero(y); - final pLo = (y - yHi) * t1 + y * t2; - var pHi = yHi * t1; - var z = pLo + pHi; - final zHiWord = _highWord(z); - final zLoWord = _lowWord(z); - if (!z.isNegative && zHiWord >= 0x40900000) { - // 2^1024 and above is not a double. The equality case is measured rather - // than assumed: exactly 1024 still rounds down to a finite number unless - // the discarded tail pushes it over. - if (zHiWord != 0x40900000 || zLoWord != 0) return s * double.infinity; - if (pLo + _powOvt > z - pHi) return s * double.infinity; - } else if ((zHiWord & 0x7FFFFFFF) >= 0x4090CC00) { - // Below -1075 not even a subnormal survives. - if (zHiWord != 0xC090CC00 || zLoWord != 0) return s * 0.0; - if (pLo <= z - pHi) return s * 0.0; - } - - // 2^(pHi + pLo): the whole part comes out as an exponent and the fraction, - // now under half, goes through the same polynomial `exp` uses. - final magnitude = zHiWord & 0x7FFFFFFF; - var k = (magnitude >> 20) - 0x3FF; - var whole = 0; - if (magnitude > 0x3FE00000) { - final rounded = zHiWord + (0x00100000 >> (k + 1)); - k = ((rounded & 0x7FFFFFFF) >> 20) - 0x3FF; - // The whole part of z, kept as a double with its fraction masked off. - // Written as a subtraction rather than as a complemented mask, because a - // complement is thirty-two bits wide in one of the two places this runs - // and sixty-four in the other. - final low = 0x000FFFFF >> k; - final t = _withHighWord(0.0, rounded - (rounded & low)); - whole = ((rounded & 0x000FFFFF) | 0x00100000) >> (20 - k); - if (z.isNegative) whole = -whole; - pHi -= t; - } - - var t = _withLowWordZero(pLo + pHi); - final u = t * _lg2Hi; - final v = (pLo - (t - pHi)) * _lg2 + t * _lg2Lo; - z = u + v; - final w = v - (z - u); - t = z * z; - final poly = - z - - t * (_expP1 + t * (_expP2 + t * (_expP3 + t * (_expP4 + t * _expP5)))); - final r = (z * poly) / (poly - 2.0) - (w + z * w); - z = 1.0 - (r - z); - - // Adding the whole part to the exponent field is a multiply by a power of - // two that cannot round. It only fails when the answer is subnormal and - // there is no exponent field left to write into — the exponent would go - // negative and land somewhere it does not mean — which is what the other - // branch is for. - final assembled = _highWord(z) + whole * 0x00100000; - if (assembled < 0x00100000) return s * _scaleByPowerOfTwo(z, whole); - return s * _withHighWord(z, assembled); - } - - // ## Argument reduction - - /// [x] as a quarter turn count and what is left over. - /// - /// `quadrant` is which multiple of π/2 was taken away, modulo four. `r` is - /// the remainder, in -π/4 to π/4, and `tail` is the part of the remainder - /// that did not fit in `r` — carried because the kernels below take it and - /// because without it `cos` loses digits near a multiple of π/2. - /// - /// **π/2 is subtracted in two pieces, and that is the whole trick.** With one - /// double for π/2 the product `n × π/2` is already rounded before the - /// subtraction, so the remainder is wrong in its low bits and the wrongness - /// grows with `n`. [_pio2Hi] has its low thirty-three bits clear, so `n × - /// pio2Hi` is exact for any `n` a simulation reaches, and the correction - /// [_pio2Lo] carries the rest. - /// - /// The quadrant is taken with a remainder on the double rather than by - /// converting to an integer and masking: an integer conversion of a large - /// double is a place the VM and a browser can part company, and `n % 4.0` is - /// exact arithmetic that cannot. - static ({int quadrant, double r, double tail}) _quarterTurns(double x) { - final n = (x * _twoOverPi).roundToDouble(); - final hi = x - n * _pio2Hi; - final lo = n * _pio2Lo; - final r = hi - lo; - return ( - quadrant: (n % 4.0).toInt(), - r: r, - // Exact, because `hi` is far larger than `lo`: what the subtraction - // rounded away is recoverable this way and no other. - tail: (hi - r) - lo, - ); - } - - /// Sine on -π/4 to π/4, given the remainder and its tail. - static double _sin(double x, double tail) { - final z = x * x; - final v = z * x; - final r = _sinS2 + z * (_sinS3 + z * (_sinS4 + z * (_sinS5 + z * _sinS6))); - if (tail == 0.0) return x + v * (_sinS1 + z * r); - return x - ((z * (0.5 * tail - v * r) - tail) - v * _sinS1); - } - - /// Cosine on -π/4 to π/4, given the remainder and its tail. - /// - /// The `w` dance is not decoration: `1 - z/2` throws away the bits that the - /// polynomial is supposed to supply, and `(1 - w) - hz` is what recovers - /// them. Written the obvious way this loses about ten bits near ±π/4. - static double _cos(double x, double tail) { - final z = x * x; - final r = - z * - (_cosC1 + - z * - (_cosC2 + - z * (_cosC3 + z * (_cosC4 + z * (_cosC5 + z * _cosC6))))); - final hz = 0.5 * z; - final w = 1.0 - hz; - return w + (((1.0 - w) - hz) + (z * r - x * tail)); - } - - // ## Bits - - /// [x] × 2^[k], for a [k] the caller has already bounded. - /// - /// A multiply by a power of two is exact, so this changes the exponent and - /// nothing else. Done in two steps when one power of two would be subnormal - /// or infinite on its own — the answer may be either, but the multiplier may - /// not, or the scaling would round twice. - static double _scaleByPowerOfTwo(double x, int k) { - if (k >= -1021 && k <= 1023) return x * _twoTo(k); - if (k > 1023) return x * _twoTo(1023) * _twoTo(k - 1023); - return x * _twoTo(-1021) * _twoTo(k + 1021); - } - - /// 2^[k] as a double, for -1021 ≤ [k] ≤ 1023. - /// - /// Assembled from the exponent field rather than by multiplying, which would - /// be a loop and would round. `ByteData` is the portable way to look at a - /// double's bytes: the same eight bytes in both places, which is the property - /// `StateDigest` leans on as well. - static double _twoTo(int k) { - _scratch.setUint32(0, (k + 1023) << 20); - _scratch.setUint32(4, 0); - return _scratch.getFloat64(0); - } - - /// The top thirty-two bits of [x] — sign, exponent and the high mantissa. - /// - /// **Unsigned, and every caller reads it that way.** fdlibm keeps this word in - /// a signed `int` and tests `hx < 0` to mean a negative argument, which is a - /// thirty-two-bit habit: one of the two platforms here has sixty-four-bit - /// integers and the other has doubles pretending to be integers, and neither - /// agrees with C about what the top bit means. Where fdlibm asks the sign of - /// the word, the ports above ask `x.isNegative` instead, which is the same - /// question asked of the number. - static int _highWord(double x) { - _scratch.setFloat64(0, x); - return _scratch.getUint32(0); - } - - /// The bottom thirty-two bits of [x]. - static int _lowWord(double x) { - _scratch.setFloat64(0, x); - return _scratch.getUint32(4); - } - - /// [x] with its top thirty-two bits replaced by [high]. - static double _withHighWord(double x, int high) { - _scratch.setFloat64(0, x); - _scratch.setUint32(0, high); - return _scratch.getFloat64(0); - } - - /// [x] with its bottom twenty-eight-odd bits of mantissa thrown away. - /// - /// The workhorse of the two-piece arithmetic in [pow]: a number whose low word - /// is zero has at most twenty-one significant bits left, so the product of two - /// of them fits in a double exactly and the tail can be recovered by - /// subtraction. Truncation rather than rounding, because what matters is that - /// the remainder is exactly representable and not that the head is nearest. - static double _withLowWordZero(double x) { - _scratch.setFloat64(0, x); - _scratch.setUint32(4, 0); - return _scratch.getFloat64(0); - } - - static final ByteData _scratch = ByteData(8); - - // ## Constants - // - // fdlibm's, which is where every libm's are from. The two-piece splits are - // the part that matters: each `Hi` has its low bits clear so that a product - // with a small whole number is exact, and each `Lo` carries what that leaves. - - static const double _pi = 3.14159265358979311600; - static const double _pio2 = 1.57079632679489655800; - static const double _pio4 = 0.78539816339744827900; - static const double _twoOverPi = 0.63661977236758134308; - - /// π/2 with the low thirty-three bits of its mantissa clear. - static const double _pio2Hi = 1.57079632673412561417; - - /// What [_pio2Hi] is missing. - static const double _pio2Lo = 6.07710050650619224932e-11; - - static const double _sinS1 = -1.66666666666666324348e-01; - static const double _sinS2 = 8.33333333332248946124e-03; - static const double _sinS3 = -1.98412698298579493134e-04; - static const double _sinS4 = 2.75573137070700676789e-06; - static const double _sinS5 = -2.50507602534068634195e-08; - static const double _sinS6 = 1.58969099521155010221e-10; - - static const double _cosC1 = 4.16666666666666019037e-02; - static const double _cosC2 = -1.38888888888741095749e-03; - static const double _cosC3 = 2.48015872894767294178e-05; - static const double _cosC4 = -2.75573143513906633035e-07; - static const double _cosC5 = 2.08757232129817482790e-09; - static const double _cosC6 = -1.13596475577881948265e-11; - - static const double _aT0 = 3.33333333333329318027e-01; - static const double _aT1 = -1.99999999998764832476e-01; - static const double _aT2 = 1.42857142725034663711e-01; - static const double _aT3 = -1.11111104054623557880e-01; - static const double _aT4 = 9.09088713343650656196e-02; - static const double _aT5 = -7.69187620504482999495e-02; - static const double _aT6 = 6.66107313738753120669e-02; - static const double _aT7 = -5.83357013379057348645e-02; - static const double _aT8 = 4.97687799461593236017e-02; - static const double _aT9 = -3.65315727442169155270e-02; - static const double _aT10 = 1.62858201153657823623e-02; - - /// atan at the middle of each folded interval, in two pieces apiece. - static const List _atanHi = [ - 4.63647609000806093515e-01, // atan(0.5) - 7.85398163397448278999e-01, // atan(1.0) - 9.82793723247329054082e-01, // atan(1.5) - 1.57079632679489655800e+00, // atan(∞) - ]; - static const List _atanLo = [ - 2.26987774529616870924e-17, - 3.06161699786838301793e-17, - 1.39033110312309984516e-17, - 6.12323399573676603587e-17, - ]; - - /// Above this the answer is not a finite double. - static const double _expOverflow = 709.782712893383973096; - - /// Below this it is zero, and no polynomial can say otherwise. - static const double _expUnderflow = -745.133219101941108420; - - static const double _log2e = 1.44269504088896338700; - static const double _ln2Hi = 6.93147180369123816490e-01; - static const double _ln2Lo = 1.90821492927058770002e-10; - - static const double _expP1 = 1.66666666666666019037e-01; - static const double _expP2 = -2.77777777770155933842e-03; - static const double _expP3 = 6.61375632143793436117e-05; - static const double _expP4 = -1.65339022054652515390e-06; - static const double _expP5 = 4.13813679705723846039e-08; - - /// 2^54, the scale that lifts a subnormal into the normal range for [log]. - static const double _two54 = 1.80143985094819840000e+16; - - /// 2^53, where consecutive doubles are two apart and every one of them is - /// even — which is how [pow] knows the parity of a huge exponent. - static const double _two53 = 9007199254740992.0; - - /// The logarithm's series in `s = f/(2 + f)`, fitted to the reduced interval. - static const double _logLg1 = 6.666666666666735130e-01; - static const double _logLg2 = 3.999999999940941908e-01; - static const double _logLg3 = 2.857142874366239149e-01; - static const double _logLg4 = 2.222219843214978396e-01; - static const double _logLg5 = 1.818357216161805012e-01; - static const double _logLg6 = 1.531383769920937332e-01; - static const double _logLg7 = 1.479819860511658591e-01; - - /// The two anchors [pow] measures a mantissa from, and log2 of each in two - /// pieces. `bp[0]` is one, so its logarithm is nothing and both halves are - /// zero; `bp[1]` is one and a half, and its logarithm is where the split - /// starts paying. - static const List _powBp = [1.0, 1.5]; - static const List _powDpHi = [ - 0.0, - 5.84962487220764160156e-01, - ]; - static const List _powDpLo = [ - 0.0, - 1.35003920212974897128e-08, - ]; - - /// (3/2)·(log(x)/log(2) - 1), which is the shape the two anchors leave. - static const double _powL1 = 5.99999999999994648725e-01; - static const double _powL2 = 4.28571428578550184252e-01; - static const double _powL3 = 3.33333329818377432918e-01; - static const double _powL4 = 2.72728123808534006489e-01; - static const double _powL5 = 2.30660745775561754067e-01; - static const double _powL6 = 2.06975017800338417784e-01; - - /// 2/(3·ln2), whole and split, so that the change of base is exact in its - /// high half and the remainder is carried rather than lost. - static const double _powCp = 9.61796693925975554329e-01; - static const double _powCpHi = 9.61796700954437255859e-01; - static const double _powCpLo = -7.02846165095275826516e-09; - - /// ln2, whole and split, for the trip back from base two. - static const double _lg2 = 6.93147180559945286227e-01; - static const double _lg2Hi = 6.93147182464599609375e-01; - static const double _lg2Lo = -1.90465429995776804525e-09; - - /// 1/ln2, whole and split, for the trip out to base two. - static const double _ivln2 = 1.44269504088896338700e+00; - static const double _ivln2Hi = 1.44269502162933349609e+00; - static const double _ivln2Lo = 1.92596299112661746887e-08; - - /// How far above 1024 the exponent has to reach before the answer is not a - /// double: -(1024 - log2(largest double + half an ulp)). - static const double _powOvt = 8.0085662595372944372e-17; -} +export 'package:flutter3d_physics/flutter3d_physics.dart' show Portable; diff --git a/packages/flutter3d_sim/lib/src/nav/nav_grid.dart b/packages/flutter3d_sim/lib/src/nav/nav_grid.dart index fffc0aca0..1a364bec1 100644 --- a/packages/flutter3d_sim/lib/src/nav/nav_grid.dart +++ b/packages/flutter3d_sim/lib/src/nav/nav_grid.dart @@ -1,21 +1,31 @@ /// Where an agent can stand, baked once from the level's brushes. /// -/// ## Why a grid and not a navigation mesh +/// ## A grid, and a navigation mesh beside it /// -/// A navmesh earns its complexity by representing *arbitrary* walkable -/// surfaces. Brushes are not that: a `Brush` is a centre and a size, and -/// `level.dart` says so outright — there are no slopes. A navmesh over -/// axis-aligned boxes is a voxelise → region → contour → triangulate pipeline -/// whose output is the rectangles you could have rasterised directly. The most -/// code, the hardest to test, and it buys a representation the format cannot -/// express. +/// This grid was the whole of navigation for as long as a level was boxes: +/// over axis-aligned brushes a navmesh is a voxelise → region → contour → +/// triangulate pipeline whose output is the rectangles a grid rasterises +/// directly. The level stopped being only boxes. A brush can be a ramp, which +/// this grid reads as the top of its box and so as a riser as tall as the ramp; +/// ground can be a `Heightfield`; and a walkway over a floor is a level this +/// grid can only warn about (see below). `NavMesh` in `navmesh/` is the +/// pipeline, baked from the same brushes on the same lattice, and it is +/// beside this grid rather than instead of it. /// -/// **Ground made of samples arrived later and did not change the answer.** -/// [bakeHeightfield] walks a `Heightfield`, which is terrain and does have -/// slopes — and a height field is already a lattice, so rasterising it into -/// this one is a resample rather than a pipeline. The argument against a -/// navmesh got stronger rather than weaker: the surface it would triangulate -/// is a grid to begin with. +/// **What the mesh is for.** It keeps every floor in a column, so a walkway +/// and the floor under it are both there to walk on. It follows a ramp's +/// surface. And it is a few dozen convex polygons where this is thousands of +/// cells, which is what a single agent's search wants: across a convex polygon +/// the way is a straight line, so a path over the mesh comes out as the few +/// corners it turns at rather than as a staircase of cell centres. +/// +/// **What the grid stays for.** A flow field answers every agent at once from +/// one sweep, which is what a horde wants and a search per agent cannot match, +/// and its clearance and headroom filters let one bake serve every size of +/// body. The two agree where they overlap, and a test holds them to it: the +/// mesh is eroded by this grid's own rule — [clearanceForRadius] over the same +/// eight-neighbour edges — so it covers exactly the cells a field for a body +/// of the same radius accepts. /// /// ## Why it is baked from brushes and not from the collision world /// diff --git a/packages/flutter3d_sim/lib/src/nav/navmesh/contours.dart b/packages/flutter3d_sim/lib/src/nav/navmesh/contours.dart new file mode 100644 index 000000000..13cc16b12 --- /dev/null +++ b/packages/flutter3d_sim/lib/src/nav/navmesh/contours.dart @@ -0,0 +1,247 @@ +/// The outline of each region, traced on the lattice and then simplified. +/// +/// **Every corner is a lattice point and every comparison is on integers**, +/// apart from the one distance the simplifier measures — and that is a ratio +/// of integers, computed by operations IEEE 754 pins, so it is the same number +/// on every machine. +/// +/// ## Why two neighbouring regions agree about their border +/// +/// Two regions share an edge in the finished mesh only if both outlines put +/// the same corners along it. So an edge between two regions is never +/// simplified by measuring: only the corners where the neighbour changes are +/// kept on it, and both sides find the same ones. Outer edges are the ones the +/// simplifier measures, and it walks each in one fixed direction whichever +/// region it is tracing, so even those would agree if they were shared. +library; + +import 'dart:math' as math; +import 'dart:typed_data'; + +import 'open_field.dart'; + +/// One region's outline: corners as lattice `(x, y, z)`, flattened. +final class Contour { + Contour(this.region, this.area, this.vertices); + + final int region; + final int area; + final Int32List vertices; + + int get length => vertices.length ~/ 3; +} + +/// A raw outline corner: where it is, and which region lies beyond the edge +/// that leaves it. +typedef _Corner = ({int x, int y, int z, int beyond, bool areaBorder}); + +/// Traces every region's outline and simplifies it to within [maxError] +/// cells, in the order the regions are first met in a sweep of the field. +List buildContours( + OpenField field, + Int32List region, { + required double maxError, +}) { + final count = field.spanCount; + + // Which sides of each span face something other than its own region. + final sides = Uint8List(count); + for (var s = 0; s < count; s++) { + if (region[s] == 0) continue; + sides[s] = [0, 1, 2, 3].fold(0, (bits, dir) { + final n = field.neighbour(s, dir); + return n >= 0 && region[n] == region[s] ? bits : bits | (1 << dir); + }); + } + + final contours = []; + for (var cz = 0; cz < field.rows; cz++) { + for (var cx = 0; cx < field.columns; cx++) { + final c = cz * field.columns + cx; + for (var s = field.cellStart[c]; s < field.cellStart[c + 1]; s++) { + if (sides[s] == 0 || region[s] == 0) continue; + final raw = _walk(field, region, sides, cx, cz, s); + final simple = _removeDegenerate(_simplify(raw, maxError)); + if (simple.length < 3) continue; + contours.add( + Contour( + region[s], + field.area[s], + Int32List.fromList([ + for (final corner in simple) ...[ + corner.x, + corner.y, + corner.z, + ], + ]), + ), + ); + } + } + } + return contours; +} + +/// Follows the region's border from span [s], keeping the region on one +/// side, until it is back where it started; clears each side it passes. +List<_Corner> _walk( + OpenField field, + Int32List region, + Uint8List sides, + int startX, + int startZ, + int start, +) { + final corners = <_Corner>[]; + var x = startX; + var z = startZ; + var s = start; + var dir = 0; + while ((sides[s] & (1 << dir)) == 0) { + dir++; + } + final startDir = dir; + final area = field.area[start]; + + // Bounded, because a field that broke the invariants this walk relies on + // must stop rather than hang a bake. + for (var guard = 0; guard < 4 * field.spanCount + 16; guard++) { + if ((sides[s] & (1 << dir)) != 0) { + // The corner at the clockwise end of this side. + final px = x + (dir == 1 || dir == 2 ? 1 : 0); + final pz = z + (dir == 0 || dir == 1 ? 1 : 0); + final n = field.neighbour(s, dir); + corners.add(( + x: px, + y: _cornerHeight(field, s, dir), + z: pz, + beyond: n >= 0 ? region[n] : 0, + areaBorder: n >= 0 && field.area[n] != area, + )); + sides[s] &= ~(1 << dir); + dir = (dir + 1) & 3; + } else { + final n = field.neighbour(s, dir); + // A side with no border is a link to the same region by construction. + if (n < 0) break; + x += dirX[dir]; + z += dirZ[dir]; + s = n; + dir = (dir + 3) & 3; + } + if (s == start && dir == startDir) break; + } + return corners; +} + +/// The highest floor among the spans that meet at a corner, so a corner +/// shared by two regions has one height whichever of them is asking. +int _cornerHeight(OpenField field, int s, int dir) { + final turn = (dir + 1) & 3; + final a = field.neighbour(s, dir); + final b = field.neighbour(s, turn); + final viaA = a < 0 ? -1 : field.neighbour(a, turn); + final viaB = b < 0 ? -1 : field.neighbour(b, dir); + return [a, b, viaA, viaB].fold( + field.floor[s], + (high, n) => n < 0 ? high : math.max(high, field.floor[n]), + ); +} + +/// Keeps the corners where the region beyond changes, then adds back the +/// worst-fitting raw corner of any outer edge until every raw corner is +/// within [maxError] cells of the outline. +List<_Corner> _simplify(List<_Corner> raw, double maxError) { + final n = raw.length; + if (n == 0) return raw; + // Indices into [raw], in outline order. + final kept = [ + for (var i = 0; i < n; i++) + if (raw[i].beyond != raw[(i + 1) % n].beyond || + raw[i].areaBorder != raw[(i + 1) % n].areaBorder) + i, + ]; + + if (kept.isEmpty) { + // An island: start from its lower-left and upper-right corners, which + // every trace of the same outline finds whatever corner it began at. + final (lowest, highest) = Iterable.generate(n).fold((0, 0), (pair, i) { + final c = raw[i]; + final lo = raw[pair.$1]; + final hi = raw[pair.$2]; + return ( + c.x < lo.x || (c.x == lo.x && c.z < lo.z) ? i : pair.$1, + c.x > hi.x || (c.x == hi.x && c.z > hi.z) ? i : pair.$2, + ); + }); + kept.addAll([lowest, highest]); + } + + final limit = maxError * maxError; + var i = 0; + while (i < kept.length) { + final a = kept[i]; + final b = kept[(i + 1) % kept.length]; + final ra = raw[a]; + final rb = raw[b]; + // Walk the edge from its lexically smaller end, so the same edge measured + // from either side finds the same worst corner. + final forward = rb.x > ra.x || (rb.x == ra.x && rb.z > ra.z); + final from = forward ? ra : rb; + final to = forward ? rb : ra; + final step = forward ? 1 : n - 1; + final end = forward ? b : a; + var worst = -1; + var worstDistance = 0.0; + var k = forward ? (a + step) % n : (b + step) % n; + // Only an outer edge or an edge between areas is measured; one between + // two regions keeps the corners both sides agreed on. + if (raw[k].beyond == 0 || raw[k].areaBorder) { + while (k != end) { + final d = _distanceSquared(raw[k], from, to); + if (d > worstDistance) { + worstDistance = d; + worst = k; + } + k = (k + step) % n; + } + } + if (worst >= 0 && worstDistance > limit) { + kept.insert(i + 1, worst); + } else { + i++; + } + } + return <_Corner>[for (final k in kept) raw[k]]; +} + +/// The squared distance in cells from [p] to the segment [a]–[b], in the +/// horizontal plane. +double _distanceSquared(_Corner p, _Corner a, _Corner b) { + final ux = b.x - a.x; + final uz = b.z - a.z; + final px = p.x - a.x; + final pz = p.z - a.z; + final length = ux * ux + uz * uz; + final t = length == 0 + ? 0.0 + : ((ux * px + uz * pz) / length).clamp(0.0, 1.0).toDouble(); + final dx = ux * t - px; + final dz = uz * t - pz; + return dx * dx + dz * dz; +} + +/// Drops a corner that sits on the next one in plan. +List<_Corner> _removeDegenerate(List<_Corner> corners) { + final out = <_Corner>[]; + for (var i = 0; i < corners.length; i++) { + final next = corners[(i + 1) % corners.length]; + if (corners[i].x == next.x && + corners[i].z == next.z && + corners.length > 1) { + continue; + } + out.add(corners[i]); + } + return out; +} diff --git a/packages/flutter3d_sim/lib/src/nav/navmesh/distance_field.dart b/packages/flutter3d_sim/lib/src/nav/navmesh/distance_field.dart new file mode 100644 index 000000000..f05dadf7a --- /dev/null +++ b/packages/flutter3d_sim/lib/src/nav/navmesh/distance_field.dart @@ -0,0 +1,131 @@ +/// How far each floor is from the nearest place an agent cannot go, and the +/// two filters that read it. +/// +/// **The metric is `NavGrid`'s on purpose.** A span is at distance one when +/// any of its eight neighbours is missing or a step too far; the rest is a +/// Chebyshev distance by the two-sweep chamfer `NavGrid._clearances` uses. +/// The mesh then keeps exactly the floors whose clearance a flow field for a +/// body of the same radius would accept, and the two navigations of one level +/// disagree about nothing but shape. +library; + +import 'dart:typed_data'; + +import 'open_field.dart'; +import 'span_field.dart'; + +/// The clearance of every span, in cells; zero for a refused span. +Uint16List distanceField(OpenField field, int climb) { + final count = field.spanCount; + const cap = 0xffff; + final dist = Uint16List(count); + + // The diagonal from [s] between [dir] and the next direction round, reached + // along either side of the corner, or −1. + int diagonal(int s, int dir) { + final turn = (dir + 1) & 3; + final a = field.neighbour(s, dir); + final viaA = a < 0 ? -1 : field.neighbour(a, turn); + if (viaA >= 0) return viaA; + final b = field.neighbour(s, turn); + return b < 0 ? -1 : field.neighbour(b, dir); + } + + bool atEdge(int s) { + for (var dir = 0; dir < 4; dir++) { + if (field.neighbour(s, dir) < 0) return true; + final d = diagonal(s, dir); + if (d < 0 || (field.floor[d] - field.floor[s]).abs() > climb) return true; + } + return false; + } + + for (var s = 0; s < count; s++) { + if (field.area[s] == nullArea) continue; + dist[s] = atEdge(s) ? 1 : cap; + } + + // The read of a neighbour's distance through a path of directions; an edge + // span is never relaxed, so an interior one always has all eight. + int through(int s, int first, [int? second]) { + final a = field.neighbour(s, first); + if (a < 0) return 0; + if (second == null) return dist[a]; + final b = field.neighbour(a, second); + return b < 0 ? 0 : dist[b]; + } + + int relaxed(int s, List candidates) => + candidates.fold(dist[s] - 1, (best, d) => d < best ? d : best) + 1; + + for (var cz = 0; cz < field.rows; cz++) { + for (var cx = 0; cx < field.columns; cx++) { + final c = cz * field.columns + cx; + for (var s = field.cellStart[c]; s < field.cellStart[c + 1]; s++) { + if (dist[s] <= 1) continue; + dist[s] = relaxed(s, [ + through(s, 0), + through(s, 0, 3), + through(s, 3), + through(s, 3, 2), + ]); + } + } + } + for (var cz = field.rows - 1; cz >= 0; cz--) { + for (var cx = field.columns - 1; cx >= 0; cx--) { + final c = cz * field.columns + cx; + for (var s = field.cellStart[c + 1] - 1; s >= field.cellStart[c]; s--) { + if (dist[s] <= 1) continue; + dist[s] = relaxed(s, [ + through(s, 2), + through(s, 2, 1), + through(s, 1), + through(s, 1, 0), + ]); + } + } + } + return dist; +} + +/// Refuses every span closer to an edge than [need] cells. +/// +/// All at once, from one distance field, rather than peeling a ring at a +/// time: a ring peeled first would change what the next ring measures from. +void erode(OpenField field, Uint16List dist, int need) { + for (var s = 0; s < field.spanCount; s++) { + if (dist[s] < need) field.area[s] = nullArea; + } +} + +/// Refuses every connected island of fewer than [minCells] spans. +/// +/// Flooded from the lowest-numbered span of each island, neighbours in +/// direction order, so the islands and what survives of them are the same on +/// every run. +void dropIslands(OpenField field, int minCells) { + if (minCells <= 1) return; + final seen = Uint8List(field.spanCount); + final island = []; + for (var start = 0; start < field.spanCount; start++) { + if (seen[start] != 0 || field.area[start] == nullArea) continue; + island + ..clear() + ..add(start); + seen[start] = 1; + for (var k = 0; k < island.length; k++) { + for (var dir = 0; dir < 4; dir++) { + final n = field.neighbour(island[k], dir); + if (n < 0 || seen[n] != 0) continue; + seen[n] = 1; + island.add(n); + } + } + if (island.length < minCells) { + for (final s in island) { + field.area[s] = nullArea; + } + } + } +} diff --git a/packages/flutter3d_sim/lib/src/nav/navmesh/navmesh.dart b/packages/flutter3d_sim/lib/src/nav/navmesh/navmesh.dart new file mode 100644 index 000000000..52ca534e5 --- /dev/null +++ b/packages/flutter3d_sim/lib/src/nav/navmesh/navmesh.dart @@ -0,0 +1,239 @@ +/// Where an agent can stand, as convex polygons rather than cells. +/// +/// ## The bake +/// +/// Brushes and terrain are voxelised into columns of solid spans; the tops of +/// those spans are filtered into floors an agent fits on and can step between; +/// the floors are eroded by the agent's radius, cut into regions, outlined, +/// and the outlines cut into convex polygons that know their neighbours. Each +/// stage is a file in this directory and says why it is shaped the way it is. +/// +/// ## Why it is the same mesh everywhere +/// +/// The voxeliser is the only stage that sees a floating-point number, and it +/// turns each into a whole number of voxels by one division and one rounding. +/// Everything after compares and adds integers, sweeps the lattice in one +/// fixed order, and keeps no map it walks. So [digest] is a fact about the +/// level and the [config] and not about the machine, and CI holds it fixed on +/// every operating system it runs on. +library; + +import 'dart:typed_data'; + +import 'package:vector_math/vector_math.dart'; + +import '../../level/heightfield.dart'; +import '../../level/level.dart'; +import '../../save/state_digest.dart'; +import 'contours.dart'; +import 'distance_field.dart'; +import 'navmesh_config.dart'; +import 'open_field.dart'; +import 'poly_mesh.dart'; +import 'regions.dart'; +import 'span_field.dart'; + +/// The surfaces a polygon can be, by number. +/// +/// A number and not an enum because the set is the game's: mud, a shallow +/// ford, a road a cart prefers. The mesh only carries the number to whatever +/// path search will price it, and keeps two values for itself. +abstract final class NavArea { + /// Not a surface. No polygon has it; a brush given it is not walked on. + static const int none = 0; + + /// Ground, when nothing says otherwise. + static const int ground = 1; +} + +/// Convex polygons over a level's floors, and which of them touch. +final class NavMesh { + NavMesh._({ + required this.config, + required this.originX, + required this.originY, + required this.originZ, + required this._vertices, + required this._polygons, + required this._neighbours, + required this._areas, + }); + + /// What the mesh was baked to. Part of [digest]. + final NavMeshConfig config; + + /// The world position of lattice point `(0, 0, 0)`. + final double originX; + final double originY; + final double originZ; + + final Int32List _vertices; + final Int32List _polygons; + final Int32List _neighbours; + final Uint8List _areas; + + int get maxVerticesPerPolygon => config.maxVerticesPerPolygon; + + int get vertexCount => _vertices.length ~/ 3; + int get polygonCount => _areas.length; + + /// True when there is nowhere to stand: an empty level, or one whose every + /// floor the filters refused. + bool get isEmpty => polygonCount == 0; + + /// A vertex on the lattice, in cells across and voxels up. Whole numbers, + /// which is what a test compares and what [digest] is taken of. + int latticeX(int vertex) => _vertices[vertex * 3]; + int latticeY(int vertex) => _vertices[vertex * 3 + 1]; + int latticeZ(int vertex) => _vertices[vertex * 3 + 2]; + + /// A vertex in the world. + void vertexAt(int vertex, Vector3 out) => out.setValues( + originX + latticeX(vertex) * config.cellSize, + originY + latticeY(vertex) * config.cellHeight, + originZ + latticeZ(vertex) * config.cellSize, + ); + + /// How many corners [polygon] has. + int polygonVertexCount(int polygon) { + final base = polygon * maxVerticesPerPolygon; + var n = 0; + while (n < maxVerticesPerPolygon && _polygons[base + n] >= 0) { + n++; + } + return n; + } + + /// Corner [k] of [polygon]. Corners run so that the polygon turns left at + /// each in the `(x, z)` plane: x across, z up the page. + int polygonVertex(int polygon, int k) => + _polygons[polygon * maxVerticesPerPolygon + k]; + + /// The polygon across edge [k] of [polygon] — the edge from corner `k` to + /// corner `k + 1` — or −1 where the edge is a wall or a drop. + int neighbourAt(int polygon, int k) => + _neighbours[polygon * maxVerticesPerPolygon + k]; + + /// The surface [polygon] is, from the bake's `areaOf`. Never [NavArea.none]. + int areaOf(int polygon) => _areas[polygon]; + + /// Whether the world point `(x, z)` lies on [polygon] in plan, edges + /// included. + bool containsPoint(int polygon, double x, double z) { + final u = (x - originX) / config.cellSize; + final v = (z - originZ) / config.cellSize; + final n = polygonVertexCount(polygon); + for (var k = 0; k < n; k++) { + final a = polygonVertex(polygon, k); + final b = polygonVertex(polygon, (k + 1) % n); + final ax = latticeX(a); + final az = latticeZ(a); + final turn = + (latticeX(b) - ax) * (v - az) - (latticeZ(b) - az) * (u - ax); + if (turn < 0.0) return false; + } + return true; + } + + /// Every polygon over `(x, z)` in plan, lowest index first — one per floor + /// where floors are stacked. + List polygonsAt(double x, double z) => [ + for (var p = 0; p < polygonCount; p++) + if (containsPoint(p, x, z)) p, + ]; + + /// One number for the whole mesh and what it was baked to. + /// + /// Taken of the lattice — whole numbers of cells and voxels — and the + /// config, through [StateDigest], so it is the same bits on every platform + /// a run is replayed on. Two bakes of one level agree on it or one of them + /// is wrong. + int get digest => + (StateDigest() + ..add(config.values) + ..add([originX, originY, originZ]) + ..add(_vertices) + ..add(_polygons) + ..add(_neighbours) + ..add(_areas)) + .value; + + /// [digest] as eight hexadecimal digits, the way a test writes it down. + String get digestHex => digest.toRadixString(16).padLeft(8, '0'); + + /// Bakes the level's architecture and its ground. + /// + /// Recipes are expanded first, as everything that uses a level does — see + /// `expandRecipes`. + static NavMesh bakeLevel( + Level level, { + NavMeshConfig config = const NavMeshConfig(), + int Function(Brush brush)? areaOf, + int groundArea = NavArea.ground, + }) => bake( + expandRecipes(level).brushes, + ground: level.heightfield, + config: config, + areaOf: areaOf, + groundArea: groundArea, + ); + + /// Bakes [brushes] and, when given, [ground] into one mesh. + /// + /// [areaOf] names the surface of each brush's top, [NavArea.ground] unless + /// it says otherwise; [NavArea.none] keeps agents off a brush entirely — + /// lava, a roof the game does not want walked. [groundArea] is the + /// terrain's. + /// + /// **From brushes, not from the collision world**, for `NavGrid`'s reason: + /// the collision world has doors in it, and a mesh baked with a door closed + /// is a mesh with a wall where the door is. + static NavMesh bake( + Iterable brushes, { + Heightfield? ground, + NavMeshConfig config = const NavMeshConfig(), + int Function(Brush brush)? areaOf, + int groundArea = NavArea.ground, + }) { + final solid = + SpanField.rasterise( + brushes, + config: config, + ground: ground, + areaOf: areaOf ?? (_) => NavArea.ground, + groundArea: groundArea, + ) + ..filterLowHangingObstacles(config.walkableClimb) + ..filterLowHeight(config.walkableHeight); + + final open = OpenField.build( + solid, + height: config.walkableHeight, + climb: config.walkableClimb, + ); + erode(open, distanceField(open, config.walkableClimb), config.erosion); + dropIslands(open, config.minIslandCells); + + final regions = monotoneRegions(open); + final contours = buildContours( + open, + regions.region, + maxError: config.maxEdgeError, + ); + final parts = buildPolyMesh( + contours, + maxCorners: config.maxVerticesPerPolygon, + ); + + return NavMesh._( + config: config, + originX: solid.originX, + originY: solid.originY, + originZ: solid.originZ, + vertices: parts.vertices, + polygons: parts.polygons, + neighbours: parts.neighbours, + areas: parts.areas, + ); + } +} diff --git a/packages/flutter3d_sim/lib/src/nav/navmesh/navmesh_config.dart b/packages/flutter3d_sim/lib/src/nav/navmesh/navmesh_config.dart new file mode 100644 index 000000000..71406110e --- /dev/null +++ b/packages/flutter3d_sim/lib/src/nav/navmesh/navmesh_config.dart @@ -0,0 +1,108 @@ +import 'dart:math' as math; + +import '../../math/tolerances.dart'; + +/// The numbers a navigation mesh is baked to, in metres and radians. +/// +/// **Turned into whole voxels once, here, and never again.** Every stage after +/// the voxeliser compares integers: a floor is so many voxels up, a step is so +/// many voxels tall. Two platforms that agree on the conversion therefore agree +/// on everything downstream, and the conversion is one division and one +/// rounding apiece — operations IEEE 754 pins — so they do agree. +final class NavMeshConfig { + const NavMeshConfig({ + this.cellSize = 0.5, + this.cellHeight = 0.1, + this.agentHeight = 1.7, + this.stepHeight = 0.4, + this.agentRadius = 0.3, + this.maxSlope = 0.698, + this.maxEdgeError = 1.3, + this.maxVerticesPerPolygon = 6, + this.minIslandArea = 0.0, + }) : assert(cellSize > 0.0, 'a cell of no width has no place to stand'), + assert( + cellHeight > 0.0, + 'a voxel of no height cannot tell floors apart', + ), + assert( + maxVerticesPerPolygon >= 3, + 'a polygon has three corners at least', + ); + + /// The horizontal lattice, in metres. Half a metre by default, the same as + /// `NavGrid`, so the two bakes of one level stand on the same cells and a + /// test can hold one against the other. + final double cellSize; + + /// The vertical lattice, in metres. A tenth of a metre resolves every step a + /// level is built with and keeps a forty-metre level under four hundred + /// voxels tall. + final double cellHeight; + + /// Room an agent needs above a floor for the floor to count. + final double agentHeight; + + /// The tallest ledge an agent walks up. Matches `CharacterTuning.stepHeight` + /// for the reason `NavGrid.stepHeight` gives: a mesh that promises a step + /// the controller will not take sends agents into walls for ever. + /// + /// On terrain, `cellSize * tan(maxSlope)` has to stay under this, or the + /// rise between two neighbouring cells of a walkable hillside reads as a + /// ledge and the hill is cut into terraces. + final double stepHeight; + + /// How far the mesh keeps away from walls and edges. A body of this radius + /// standing anywhere on the mesh does not overlap anything. + final double agentRadius; + + /// The steepest ground that is still ground, in radians from flat. Forty + /// degrees, as in `NavGrid.bakeHeightfield`. + final double maxSlope; + + /// How far a simplified outline may stray from the voxel edge it replaces, + /// in cells. Corners of anything built on the lattice deviate by a whole + /// cell or more, so they survive; the staircase a diagonal edge makes on the + /// lattice does not. + final double maxEdgeError; + + /// The most corners a polygon may have after triangles are merged. + final int maxVerticesPerPolygon; + + /// An island of walkable ground smaller than this, in square metres, is + /// dropped. Zero keeps everything, which is the default because the top of + /// a crate is a place to stand and only the game knows whether it matters. + final double minIslandArea; + + /// [agentHeight] in voxels, rounded up: a ceiling one voxel short of the + /// agent's head is a ceiling the agent does not fit under. + int get walkableHeight => _up(agentHeight / cellHeight); + + /// [stepHeight] in voxels, rounded down: a step a hair taller than the + /// agent climbs is a wall. + int get walkableClimb => _down(stepHeight / cellHeight); + + /// The clearance in cells an eroded span needs, by `NavGrid`'s own rule — + /// `NavGrid.clearanceForRadius` — so that the mesh covers exactly the cells + /// a flow field for a body of [agentRadius] would accept. + int get erosion => math.max(1, (agentRadius / cellSize + 0.5).ceil()); + + /// [minIslandArea] in cells. + int get minIslandCells => _up(minIslandArea / (cellSize * cellSize)); + + static int _up(double voxels) => (voxels - Tolerance.gridBias).ceil(); + static int _down(double voxels) => (voxels + Tolerance.gridBias).floor(); + + /// The numbers themselves, in a fixed order, for a digest. + List get values => [ + cellSize, + cellHeight, + agentHeight, + stepHeight, + agentRadius, + maxSlope, + maxEdgeError, + maxVerticesPerPolygon.toDouble(), + minIslandArea, + ]; +} diff --git a/packages/flutter3d_sim/lib/src/nav/navmesh/open_field.dart b/packages/flutter3d_sim/lib/src/nav/navmesh/open_field.dart new file mode 100644 index 000000000..40641193a --- /dev/null +++ b/packages/flutter3d_sim/lib/src/nav/navmesh/open_field.dart @@ -0,0 +1,125 @@ +/// The space above every floor, and which floors an agent walks between. +library; + +import 'dart:typed_data'; + +import 'span_field.dart'; + +/// Directions, in the order every stage walks them: −x, +z, +x, −z. Each is +/// the previous one turned a quarter the same way round, so `dir + 1` and +/// `dir + 3` are the two turns the contour walk takes. +const List dirX = [-1, 0, 1, 0]; +const List dirZ = [0, 1, 0, -1]; + +/// One open span per floor: from the top of a solid span up to the bottom of +/// the next one, with links to the floors beside it. +/// +/// Flat arrays rather than objects, indexed by span, because every stage after +/// this one is a sweep over all of them in the same fixed order — column by +/// column, lowest floor first — and that order is the bake's determinism. +final class OpenField { + OpenField._({ + required this.columns, + required this.rows, + required this.cellStart, + required this.floor, + required this.ceiling, + required this.area, + required this.links, + }); + + final int columns; + final int rows; + + /// Spans of column `c` are `cellStart[c]` up to `cellStart[c + 1]`. + final Int32List cellStart; + + /// In voxels. + final Int32List floor; + final Int32List ceiling; + + /// The walking surface, or [nullArea] once a filter has refused the span. + final Uint8List area; + + /// `links[span * 4 + dir]`: the span stepped onto in that direction, or −1. + final Int32List links; + + int get spanCount => floor.length; + + /// The span an agent reaches from [span] in [dir], or −1 when it reaches + /// none — no link, or a link to a span a later filter refused. + int neighbour(int span, int dir) { + final next = links[span * 4 + dir]; + return next < 0 || area[next] == nullArea ? -1 : next; + } + + /// Builds the open spans over every floor of [solid] and links them. + /// + /// Two floors side by side are linked when the step between them is at + /// most [climb] and the gap they share is at least [height] tall — an agent + /// has to fit through the opening, not only under each ceiling apart. Of + /// the floors in the next column the lowest that qualifies wins; for an + /// agent shorter than twice its step there can only be one. + static OpenField build( + SpanField solid, { + required int height, + required int climb, + }) { + final count = solid.columns * solid.rows; + final cellStart = Int32List(count + 1); + final floors = []; + final ceilings = []; + final areas = []; + for (var c = 0; c < count; c++) { + cellStart[c] = floors.length; + final column = solid.spans[c]; + for (var i = 0; i < column.length; i++) { + if (column[i].area == nullArea) continue; + floors.add(column[i].max); + ceilings.add(i + 1 < column.length ? column[i + 1].min : SpanField.sky); + areas.add(column[i].area); + } + } + cellStart[count] = floors.length; + + final floor = Int32List.fromList(floors); + final ceiling = Int32List.fromList(ceilings); + final links = Int32List(floors.length * 4) + ..fillRange(0, floors.length * 4, -1); + + for (var cz = 0; cz < solid.rows; cz++) { + for (var cx = 0; cx < solid.columns; cx++) { + final c = cz * solid.columns + cx; + for (var s = cellStart[c]; s < cellStart[c + 1]; s++) { + for (var dir = 0; dir < 4; dir++) { + final nx = cx + dirX[dir]; + final nz = cz + dirZ[dir]; + if (nx < 0 || nz < 0 || nx >= solid.columns || nz >= solid.rows) { + continue; + } + final n = nz * solid.columns + nx; + for (var t = cellStart[n]; t < cellStart[n + 1]; t++) { + final bottom = floor[s] > floor[t] ? floor[s] : floor[t]; + final top = ceiling[s] < ceiling[t] ? ceiling[s] : ceiling[t]; + if (top - bottom >= height && + (floor[t] - floor[s]).abs() <= climb) { + links[s * 4 + dir] = t; + break; + } + } + } + } + } + } + + return OpenField._( + columns: solid.columns, + rows: solid.rows, + cellStart: cellStart, + floor: floor, + ceiling: ceiling, + area: Uint8List.fromList(areas), + links: links, + ); + } +} diff --git a/packages/flutter3d_sim/lib/src/nav/navmesh/poly_mesh.dart b/packages/flutter3d_sim/lib/src/nav/navmesh/poly_mesh.dart new file mode 100644 index 000000000..04fd2a167 --- /dev/null +++ b/packages/flutter3d_sim/lib/src/nav/navmesh/poly_mesh.dart @@ -0,0 +1,354 @@ +/// Outlines into convex polygons that know their neighbours. +/// +/// Each outline is cut into triangles by ear clipping, the triangles are +/// merged back into convex polygons of at most so many corners, and polygons +/// that share an edge are told about each other. +/// +/// **All of it on integers.** Corners are lattice points, so every turn test +/// is an exact cross product and no tolerance is involved anywhere: a corner +/// is convex, flat or reflex, and two machines cannot see it differently. +library; + +import 'dart:typed_data'; + +import 'contours.dart'; + +/// Vertices as lattice `(x, y, z)`; polygons as [maxCorners] vertex indices +/// apiece padded with −1; the neighbour across each edge, or −1; an area per +/// polygon. +typedef PolyMeshParts = ({ + Int32List vertices, + Int32List polygons, + Int32List neighbours, + Uint8List areas, +}); + +/// Builds the polygon mesh of [contours]. +/// +/// Every polygon turns left at each corner in the `(x, z)` plane — its signed +/// area there is positive — and none has a reflex corner. +PolyMeshParts buildPolyMesh(List contours, {required int maxCorners}) { + final vertices = []; + // Lattice column to the vertices already standing in it. Only ever looked + // up, never walked, so its order is nobody's business. + final byColumn = <(int, int), List>{}; + + // **Corners that differ by a voxel or two are one corner.** Two outlines + // meeting at a corner each take the highest floor they can see from their + // own side of it, and at the edge of a step the two can see different + // floors; welding them is what lets the polygons on each side share it. + int weld(int x, int y, int z) { + final column = byColumn.putIfAbsent((x, z), () => []); + for (final v in column) { + if ((vertices[v * 3 + 1] - y).abs() <= 2) return v; + } + final v = vertices.length ~/ 3; + vertices.addAll([x, y, z]); + column.add(v); + return v; + } + + final polygons = >[]; + final areas = []; + + for (final contour in contours) { + final welded = []; + for (var i = 0; i < contour.length; i++) { + final v = weld( + contour.vertices[i * 3], + contour.vertices[i * 3 + 1], + contour.vertices[i * 3 + 2], + ); + if (welded.isEmpty || welded.last != v) welded.add(v); + } + while (welded.length > 1 && welded.first == welded.last) { + welded.removeLast(); + } + if (welded.length < 3) continue; + + int px(int v) => vertices[v * 3]; + int pz(int v) => vertices[v * 3 + 2]; + final outline = _signedArea(welded, px, pz) < 0 + ? welded.reversed.toList() + : welded; + + final pieces = _merge( + >[ + for (final t in _triangulate(outline, px, pz)) + if (_cross(px, pz, t[0], t[1], t[2]) > 0) t, + ], + maxCorners, + px, + pz, + ); + polygons.addAll(pieces); + areas.addAll(List.filled(pieces.length, contour.area)); + } + + final polyArray = Int32List(polygons.length * maxCorners) + ..fillRange(0, polygons.length * maxCorners, -1); + for (var p = 0; p < polygons.length; p++) { + polyArray.setRange( + p * maxCorners, + p * maxCorners + polygons[p].length, + polygons[p], + ); + } + + return ( + vertices: Int32List.fromList(vertices), + polygons: polyArray, + neighbours: _adjacency(polygons, maxCorners), + areas: Uint8List.fromList(areas), + ); +} + +/// Twice the signed area of the polygon in `(x, z)`. +int _signedArea(List poly, int Function(int) px, int Function(int) pz) { + var sum = 0; + for (var i = 0; i < poly.length; i++) { + final a = poly[i]; + final b = poly[(i + 1) % poly.length]; + sum += px(a) * pz(b) - px(b) * pz(a); + } + return sum; +} + +/// Positive when `a → b → c` turns left in `(x, z)`, zero when it runs +/// straight on. +int _cross(int Function(int) px, int Function(int) pz, int a, int b, int c) => + (px(b) - px(a)) * (pz(c) - pz(a)) - (pz(b) - pz(a)) * (px(c) - px(a)); + +/// Cuts a polygon that turns left into triangles. +/// +/// **The shortest diagonal first.** Clipping the ear whose new edge is the +/// shortest keeps slivers out of the triangles, which keeps them out of the +/// polygons merged from them; and since the choice is the first shortest in +/// outline order, it is the same choice every time. +List> _triangulate( + List outline, + int Function(int) px, + int Function(int) pz, +) { + final ring = List.of(outline); + final triangles = >[]; + + int cross(int a, int b, int c) => _cross(px, pz, a, b, c); + bool left(int a, int b, int c) => cross(a, b, c) > 0; + bool leftOn(int a, int b, int c) => cross(a, b, c) >= 0; + bool collinear(int a, int b, int c) => cross(a, b, c) == 0; + + bool between(int a, int b, int c) { + if (!collinear(a, b, c)) return false; + if (px(a) != px(b)) { + return (px(a) <= px(c) && px(c) <= px(b)) || + (px(a) >= px(c) && px(c) >= px(b)); + } + return (pz(a) <= pz(c) && pz(c) <= pz(b)) || + (pz(a) >= pz(c) && pz(c) >= pz(b)); + } + + bool intersects(int a, int b, int c, int d) { + final proper = + !(collinear(a, b, c) || + collinear(a, b, d) || + collinear(c, d, a) || + collinear(c, d, b)) && + (left(a, b, c) != left(a, b, d)) && + (left(c, d, a) != left(c, d, b)); + return proper || + between(a, b, c) || + between(a, b, d) || + between(c, d, a) || + between(c, d, b); + } + + bool samePlace(int a, int b) => px(a) == px(b) && pz(a) == pz(b); + + // Whether the segment from ring[i] to ring[j] crosses no edge it does not + // end on. + bool clearOfEdges(int i, int j) { + final a = ring[i]; + final b = ring[j]; + for (var k = 0; k < ring.length; k++) { + final k1 = (k + 1) % ring.length; + if (k == i || k1 == i || k == j || k1 == j) continue; + final c = ring[k]; + final d = ring[k1]; + if (samePlace(a, c) || + samePlace(b, c) || + samePlace(a, d) || + samePlace(b, d)) { + continue; + } + if (intersects(a, b, c, d)) return false; + } + return true; + } + + // Whether ring[j] is inside the cone of the corner at ring[i]. + bool inCone(int i, int j) { + final n = ring.length; + final here = ring[i]; + final there = ring[j]; + final next = ring[(i + 1) % n]; + final previous = ring[(i + n - 1) % n]; + if (leftOn(previous, here, next)) { + return left(here, there, previous) && left(there, here, next); + } + return !(leftOn(here, there, next) && leftOn(there, here, previous)); + } + + bool diagonal(int i, int j) => inCone(i, j) && clearOfEdges(i, j); + + // ear[i]: the corner after ring[i] can be clipped. + bool earAfter(int i) => diagonal(i, (i + 2) % ring.length); + final ear = [for (var i = 0; i < ring.length; i++) earAfter(i)]; + + while (ring.length > 3) { + final n = ring.length; + var best = -1; + var bestLength = 0; + for (var i = 0; i < n; i++) { + if (!ear[i]) continue; + final a = ring[i]; + final c = ring[(i + 2) % n]; + final dx = px(c) - px(a); + final dz = pz(c) - pz(a); + final length = dx * dx + dz * dz; + if (best < 0 || length < bestLength) { + best = i; + bestLength = length; + } + } + // No ear: the outline touches itself. Clip the first corner that is not + // reflex, which loses at worst a sliver, rather than give up on the rest. + if (best < 0) { + best = Iterable.generate(n).firstWhere( + (i) => leftOn(ring[i], ring[(i + 1) % n], ring[(i + 2) % n]), + orElse: () => -1, + ); + if (best < 0) return triangles; + } + + final middle = (best + 1) % n; + triangles.add([ring[best], ring[middle], ring[(best + 2) % n]]); + ring.removeAt(middle); + ear.removeAt(middle); + final m = ring.length; + // The two corners either side of the one clipped have new neighbours, and + // so does every ear whose diagonal starts at or crosses one of them. + final before = middle == 0 ? m - 1 : middle - 1; + for (final k in [(before + m - 1) % m, before, (before + 1) % m]) { + ear[k] = earAfter(k); + } + } + triangles.add([ring[0], ring[1], ring[2]]); + return triangles; +} + +/// Merges polygons that share an edge while the result stays convex and +/// within [maxCorners], longest shared edge first. +List> _merge( + List> polygons, + int maxCorners, + int Function(int) px, + int Function(int) pz, +) { + if (maxCorners <= 3) return polygons; + final polys = List>.of(polygons); + + // The shared edge's squared length, with where it sits in each, or null + // when the two cannot be merged. + (int, int, int)? value(List a, List b) { + if (a.length + b.length - 2 > maxCorners) return null; + for (var i = 0; i < a.length; i++) { + final a0 = a[i]; + final a1 = a[(i + 1) % a.length]; + for (var j = 0; j < b.length; j++) { + if (b[j] != a1 || b[(j + 1) % b.length] != a0) continue; + // Convex at both ends of the seam once it is gone. **Straight on is + // allowed**: a corner where the neighbour changes along a straight + // wall has to stay a corner, so both sides of the border have it, + // and refusing to merge across it cut every such rectangle into + // three. + if (_cross( + px, + pz, + a[(i + a.length - 1) % a.length], + a0, + b[(j + 2) % b.length], + ) < + 0) { + return null; + } + if (_cross( + px, + pz, + b[(j + b.length - 1) % b.length], + a1, + a[(i + 2) % a.length], + ) < + 0) { + return null; + } + final dx = px(a1) - px(a0); + final dz = pz(a1) - pz(a0); + return (dx * dx + dz * dz, i, j); + } + } + return null; + } + + while (true) { + var best = 0; + var bestA = -1; + var bestB = -1; + var edgeA = 0; + var edgeB = 0; + for (var p = 0; p < polys.length; p++) { + for (var q = p + 1; q < polys.length; q++) { + final v = value(polys[p], polys[q]); + if (v == null || v.$1 <= best) continue; + (best, bestA, bestB, edgeA, edgeB) = (v.$1, p, q, v.$2, v.$3); + } + } + if (bestA < 0) return polys; + + final a = polys[bestA]; + final b = polys[bestB]; + polys[bestA] = [ + for (var k = 0; k < a.length - 1; k++) a[(edgeA + 1 + k) % a.length], + for (var k = 0; k < b.length - 1; k++) b[(edgeB + 1 + k) % b.length], + ]; + polys[bestB] = polys.last; + polys.removeLast(); + } +} + +/// For each polygon edge, the polygon on the other side of it, or −1. +/// +/// Edge `k` of a polygon runs from its corner `k` to corner `k + 1`. Two +/// polygons are neighbours across an edge when they both have it, in opposite +/// directions; the first two to claim an edge keep it. +Int32List _adjacency(List> polygons, int maxCorners) { + final out = Int32List(polygons.length * maxCorners) + ..fillRange(0, polygons.length * maxCorners, -1); + final open = <(int, int), int>{}; + for (var p = 0; p < polygons.length; p++) { + final poly = polygons[p]; + for (var k = 0; k < poly.length; k++) { + final a = poly[k]; + final b = poly[(k + 1) % poly.length]; + final slot = p * maxCorners + k; + final other = open.remove((b, a)); + if (other != null) { + out[slot] = other ~/ maxCorners; + out[other] = p; + } else { + open[(a, b)] = slot; + } + } + } + return out; +} diff --git a/packages/flutter3d_sim/lib/src/nav/navmesh/regions.dart b/packages/flutter3d_sim/lib/src/nav/navmesh/regions.dart new file mode 100644 index 000000000..343242526 --- /dev/null +++ b/packages/flutter3d_sim/lib/src/nav/navmesh/regions.dart @@ -0,0 +1,97 @@ +/// The floor cut into regions, each of which becomes one outline. +/// +/// ## Monotone, not watershed +/// +/// A watershed grows regions from the middle of open floor outward and gives +/// rounder ones, which make better polygons. It also gives regions with holes +/// — a region that flows round a pillar and meets itself on the far side — +/// and an outline with a hole in it needs a pass that cuts the hole into the +/// outline before anything can triangulate it. That pass, and the merging of +/// small regions that a watershed needs to be usable, are most of the code +/// and most of the ways a bake can disagree with itself. +/// +/// A monotone sweep cannot make a hole. It goes row by row, and a run of floor +/// continues the region of the run before it only when that region continues +/// into nothing else; so every region is one run per row, the runs of +/// consecutive rows overlap, and a shape like that has no inside to go round. +/// What it costs is regions that are longer and thinner than they need be, +/// which the triangles merged into polygons afterwards mostly hide. +library; + +import 'dart:typed_data'; + +import 'open_field.dart'; +import 'span_field.dart'; + +/// Regions numbered from one, and how many there are. Zero is no region. +({Int32List region, int count}) monotoneRegions(OpenField field) { + final region = Int32List(field.spanCount); + // Per sweep in the current row: the region of the row before it continues, + // how many of its spans reached that region, and the id it ends up with. + final continues = []; + final samples = []; + final ids = []; + // How many spans of the current row reached each region of the previous. + final reached = {}; + const several = -1; + var next = 1; + + for (var cz = 0; cz < field.rows; cz++) { + continues.clear(); + samples.clear(); + ids.clear(); + reached.clear(); + + for (var cx = 0; cx < field.columns; cx++) { + final c = cz * field.columns + cx; + for (var s = field.cellStart[c]; s < field.cellStart[c + 1]; s++) { + if (field.area[s] == nullArea) continue; + + // The sweep this span extends along the row, or a new one. + final west = field.neighbour(s, 0); + final sweep = + west >= 0 && field.area[west] == field.area[s] && region[west] > 0 + ? region[west] + : () { + continues.add(0); + samples.add(0); + ids.add(0); + return continues.length; + }(); + + // What the row before says about it. + final south = field.neighbour(s, 3); + if (south >= 0 && field.area[south] == field.area[s]) { + final before = region[south]; + if (before > 0) { + final k = sweep - 1; + if (continues[k] == 0 || continues[k] == before) { + continues[k] = before; + samples[k]++; + reached[before] = (reached[before] ?? 0) + 1; + } else { + continues[k] = several; + } + } + } + region[s] = sweep; + } + } + + // A sweep keeps the region below it when all of that region's spans in + // this row are the sweep's — otherwise the region forks here, and each + // branch is a region of its own. + for (var k = 0; k < continues.length; k++) { + final before = continues[k]; + ids[k] = before > 0 && reached[before] == samples[k] ? before : next++; + } + + for (var cx = 0; cx < field.columns; cx++) { + final c = cz * field.columns + cx; + for (var s = field.cellStart[c]; s < field.cellStart[c + 1]; s++) { + if (region[s] > 0) region[s] = ids[region[s] - 1]; + } + } + } + return (region: region, count: next - 1); +} diff --git a/packages/flutter3d_sim/lib/src/nav/navmesh/span_field.dart b/packages/flutter3d_sim/lib/src/nav/navmesh/span_field.dart new file mode 100644 index 000000000..188174c42 --- /dev/null +++ b/packages/flutter3d_sim/lib/src/nav/navmesh/span_field.dart @@ -0,0 +1,314 @@ +/// The level as columns of solid voxels, and the filters that decide which +/// tops of them are floors. +/// +/// **Sampled at cell centres, the way `NavGrid` samples.** A brush gives a +/// column a floor only when the column's centre is inside it; a brush that +/// only clips the column still fills it, as a span nobody stands on. That is +/// `NavGrid`'s rule — a thin wall crossing a cell blocks it even when no +/// centre lands inside — said in spans, and it is why the two bakes of one +/// level can be held against each other cell by cell. +library; + +import 'dart:math' as math; + +import '../../level/brush.dart'; +import '../../level/heightfield.dart'; +import '../../math/portable_math.dart'; +import '../../math/tolerances.dart'; +import 'navmesh_config.dart'; + +/// The area of a span nobody walks on. +const int nullArea = 0; + +/// One run of solid voxels in a column, `[min, max)` in voxels. +final class SolidSpan { + SolidSpan(this.min, this.max, this.area); + + final int min; + final int max; + + /// What the top of this span is, as a walking surface; [nullArea] when it + /// is not one. The one field the filters change. + int area; +} + +/// Solid voxels, column by column, lowest span first. +final class SpanField { + SpanField._({ + required this.originX, + required this.originY, + required this.originZ, + required this.cellSize, + required this.cellHeight, + required this.columns, + required this.rows, + required this.spans, + }); + + /// The world position of voxel `(0, 0, 0)`'s lowest corner. + final double originX; + final double originY; + final double originZ; + + final double cellSize; + final double cellHeight; + final int columns; + final int rows; + + /// Per column, `cz * columns + cx`, merged and sorted by height. + final List> spans; + + bool get isEmpty => columns == 0 || rows == 0; + + /// Rasterises the solid [brushes] and the [ground] into columns of spans. + /// + /// [areaOf] names a brush's walking surface; [groundArea] is the terrain's. + /// A surface steeper than `config.maxSlope` is solid and not a floor. + /// + /// **The lattice is `NavGrid.bake`'s**: its corner at the least x and z any + /// solid brush reaches, its count the extent over the cell size rounded up. + /// Terrain widens it to cover the field. + static SpanField rasterise( + Iterable brushes, { + required NavMeshConfig config, + Heightfield? ground, + required int Function(Brush brush) areaOf, + required int groundArea, + }) { + final solid = [ + for (final brush in brushes) + if (brush.solid) brush, + ]; + final cs = config.cellSize; + final ch = config.cellHeight; + final samples = ground?.copyOfSamples(); + final groundLow = samples == null || samples.isEmpty + ? null + : samples.reduce(math.min).toDouble(); + + if (solid.isEmpty && ground == null) { + return SpanField._( + originX: 0.0, + originY: 0.0, + originZ: 0.0, + cellSize: cs, + cellHeight: ch, + columns: 0, + rows: 0, + spans: const >[], + ); + } + + final (minX, minY, minZ, maxX, maxZ) = solid.fold( + ground == null + ? ( + double.infinity, + double.infinity, + double.infinity, + double.negativeInfinity, + double.negativeInfinity, + ) + : ( + ground.origin.x, + groundLow!, + ground.origin.z, + ground.origin.x + ground.width, + ground.origin.z + ground.depth, + ), + (box, brush) => ( + math.min(box.$1, brush.min.x), + math.min(box.$2, brush.min.y), + math.min(box.$3, brush.min.z), + math.max(box.$4, brush.max.x), + math.max(box.$5, brush.max.z), + ), + ); + + final columns = math.max(1, ((maxX - minX) / cs).ceil()); + final rows = math.max(1, ((maxZ - minZ) / cs).ceil()); + final raw = List>.generate( + columns * rows, + (_) => [], + ); + + int floorVoxel(double y) => + math.max(0, (((y - minY) / ch) + Tolerance.gridBias).floor()); + int topVoxel(double y) => (((y - minY) / ch) - Tolerance.gridBias).ceil(); + + // Compared as a ratio rather than as an angle, so the only transcendental + // in the whole bake is this one portable tangent. + final steepest = Portable.tan(config.maxSlope); + + for (final brush in solid) { + final lo = brush.min; + final hi = brush.max; + final ramp = brush.ramp; + final rampWalkable = + ramp == null || + brush.size.y <= + steepest * (ramp.x != 0.0 ? brush.size.x : brush.size.z); + final area = areaOf(brush); + + // Every column the brush overlaps by more than a hair, exactly as + // `NavGrid` buckets them. + final x0 = _clamp( + ((lo.x - minX) / cs + Tolerance.gridBias).floor(), + columns, + ); + final x1 = _clamp( + ((hi.x - minX) / cs - Tolerance.gridBias).floor(), + columns, + ); + final z0 = _clamp( + ((lo.z - minZ) / cs + Tolerance.gridBias).floor(), + rows, + ); + final z1 = _clamp( + ((hi.z - minZ) / cs - Tolerance.gridBias).floor(), + rows, + ); + + for (var cz = z0; cz <= z1; cz++) { + final pz = minZ + (cz + 0.5) * cs; + for (var cx = x0; cx <= x1; cx++) { + final px = minX + (cx + 0.5) * cs; + final centred = px >= lo.x && px < hi.x && pz >= lo.z && pz < hi.z; + final top = ramp == null + ? hi.y + : _rampHeight(brush, ramp.x, ramp.z, px, pz); + final bottom = floorVoxel(lo.y); + raw[cz * columns + cx].add( + SolidSpan( + bottom, + math.max(bottom, topVoxel(top)), + centred && rampWalkable ? area : nullArea, + ), + ); + } + } + } + + if (ground != null) { + final bottom = floorVoxel(groundLow!); + for (var cz = 0; cz < rows; cz++) { + final pz = minZ + (cz + 0.5) * cs; + for (var cx = 0; cx < columns; cx++) { + final px = minX + (cx + 0.5) * cs; + // Off the field is not ground, for `bakeHeightfield`'s reason: the + // field answers beyond its edge with the edge's height, and a floor + // laid on that answer is a floor that is not there. + if (!ground.contains(px, pz)) continue; + raw[cz * columns + cx].add( + SolidSpan( + bottom, + math.max(bottom, topVoxel(ground.heightAt(px, pz))), + ground.slopeAt(px, pz) <= config.maxSlope ? groundArea : nullArea, + ), + ); + } + } + } + + return SpanField._( + originX: minX, + originY: minY, + originZ: minZ, + cellSize: cs, + cellHeight: ch, + columns: columns, + rows: rows, + spans: >[for (final column in raw) _merged(column)], + ); + } + + /// Joins spans that overlap or touch. + /// + /// **Sorted before it is merged, so the order brushes are listed in cannot + /// matter.** Merging in arrival order makes the area of a shared top + /// depend on which brush came first; merging in sorted order makes it the + /// largest area among the spans that reach the top, whoever listed them. + static List _merged(List column) { + if (column.length < 2) return column; + column.sort( + (a, b) => a.min != b.min + ? a.min - b.min + : (a.max != b.max ? a.max - b.max : a.area - b.area), + ); + final out = []; + var current = column.first; + for (final next in column.skip(1)) { + if (next.min > current.max) { + out.add(current); + current = next; + } else if (next.max > current.max) { + current = SolidSpan(current.min, next.max, next.area); + } else if (next.max == current.max) { + current = SolidSpan( + current.min, + current.max, + math.max(current.area, next.area), + ); + } + } + out.add(current); + return out; + } + + /// The ramp's surface over `(x, z)`, held inside its footprint so that a + /// column the ramp only clips is filled to the nearest height it has. + static double _rampHeight( + Brush brush, + double uphillX, + double uphillZ, + double x, + double z, + ) { + final lo = brush.min; + final size = brush.size; + final along = uphillX != 0.0 ? (x - lo.x) / size.x : (z - lo.z) / size.z; + final t = (uphillX + uphillZ > 0.0 ? along : 1.0 - along).clamp(0.0, 1.0); + return lo.y + size.y * t; + } + + /// A top that is not a floor becomes one when it is a step up from a floor + /// in the same column. + /// + /// What it is for is a ledge the slope test refused and the agent can still + /// step onto — a kerb on a hillside, the lip of a steep ramp. Decided from + /// what each span was before this pass, so a stack of such tops does not + /// climb itself one step at a time. + void filterLowHangingObstacles(int climb) { + for (final column in spans) { + var belowWalkable = false; + var belowArea = nullArea; + var belowTop = 0; + for (final span in column) { + final walkable = span.area != nullArea; + if (!walkable && + belowWalkable && + (span.max - belowTop).abs() <= climb) { + span.area = belowArea; + } + belowWalkable = walkable; + belowArea = span.area; + belowTop = span.max; + } + } + } + + /// A floor with less room above it than [height] is not a floor. + void filterLowHeight(int height) { + for (final column in spans) { + for (var i = 0; i < column.length; i++) { + final ceiling = i + 1 < column.length ? column[i + 1].min : sky; + if (ceiling - column[i].max < height) column[i].area = nullArea; + } + } + } + + /// The height of open sky, in voxels: more room than any agent asks for. + static const int sky = 0x3fffffff; + + static int _clamp(int value, int count) => + value < 0 ? 0 : (value >= count ? count - 1 : value); +} diff --git a/packages/flutter3d_sim/lib/src/save/demo.dart b/packages/flutter3d_sim/lib/src/save/demo.dart index a2e5f5f55..bad54861a 100644 --- a/packages/flutter3d_sim/lib/src/save/demo.dart +++ b/packages/flutter3d_sim/lib/src/save/demo.dart @@ -1,4 +1,5 @@ import '../input/input_tape.dart'; +import '../level/level.dart'; import 'data_source_trace.dart'; import 'snapshot.dart'; import 'state_digest.dart'; @@ -50,6 +51,13 @@ import 'state_digest.dart'; /// silently degrades to a video. [platform] and [recordedBy] are the /// genuinely optional half: worth keeping when known, meaningless to invent /// when not. +/// +/// ## `HR3`: a level edited under the run +/// +/// [levelSwaps] are the edits the editor sent into the game while this run +/// was recorded, each with the whole document and the step it took effect +/// before. Without them a run with an edit in it parts from its own replay at +/// the edit, and the checkpoints say so without saying why. final class Demo { const Demo({ required this.level, @@ -61,10 +69,18 @@ final class Demo { this.platform, this.recordedBy, this.dataSources, + this.levelSwaps = const [], }); /// Bumped when an existing field changes meaning. - static const int formatVersion = 1; + /// + /// **2 is written only by a run with [levelSwaps] in it.** A build that + /// reads 1 would play such a file through the first level to the end and + /// report a divergence nobody can explain; refusing it with "a newer build" + /// is the honest answer. A run with no edit in it means exactly what it + /// meant before, so it is still written as 1 and older builds still open + /// it. + static const int formatVersion = 2; /// The extension a run is written under — `.f3drun`, wherever it becomes an /// actual file: attached to a bug report, downloaded from the cloud, or @@ -119,11 +135,19 @@ final class Demo { /// with no recorded bindings, not a run misread. final DataSourceTrace? dataSources; + /// The levels put under the run while it was recorded, in step order, no + /// two at the same step. Empty for a run played in one level throughout. + /// + /// [level] and [levelHash] still name the level the run started in: that + /// is the one a replay loads, and these are swapped in as the tape reaches + /// them. + final List levelSwaps; + /// How many fixed steps the run lasted. int get steps => tape.steps; Map toJson() => { - 'version': formatVersion, + 'version': levelSwaps.isEmpty ? 1 : formatVersion, 'level': level, 'levelHash': levelHash, 'run': start.toJson(), @@ -133,6 +157,10 @@ final class Demo { if (platform != null) 'platform': platform, if (recordedBy != null) 'recordedBy': recordedBy, if (dataSources != null) 'dataSources': dataSources!.toJson(), + if (levelSwaps.isNotEmpty) + 'levelSwaps': >[ + for (final swap in levelSwaps) swap.toJson(), + ], }; /// Reads a demo, or throws a [DemoFormatException] that says why not. @@ -206,18 +234,102 @@ final class Demo { throw DemoFormatException('the data sources: ${error.message}'); } } + final readTape = InputTape.fromJson(tape); return Demo( level: level, levelHash: levelHash, start: start, - tape: InputTape.fromJson(tape), + tape: readTape, buildStamp: buildStamp, checkpoints: trace, platform: platform is String ? platform : null, recordedBy: recordedBy is String ? recordedBy : null, dataSources: dataSources, + levelSwaps: _readSwaps(json['levelSwaps'], steps: readTape.steps), ); } + + static List _readSwaps(Object? raw, {required int steps}) { + if (raw == null) return const []; + if (raw is! List) { + throw const DemoFormatException('the level swaps are not a list'); + } + final swaps = [ + for (final entry in raw) DemoLevelSwap._fromJson(entry, steps: steps), + ]; + for (var i = 1; i < swaps.length; i++) { + if (swaps[i].step <= swaps[i - 1].step) { + throw DemoFormatException( + 'the level swap at step ${swaps[i].step} comes after the one at ' + 'step ${swaps[i - 1].step}; swaps are written in step order, one ' + 'per step', + ); + } + } + return List.unmodifiable(swaps); + } +} + +/// One level put under a recorded run: [level] took effect before step +/// [step] of the tape, that is, after [step] of its frames had been played. +/// +/// **The whole document, not only its digest.** The level a game was edited +/// into exists in the editor that sent it and nowhere a replay can look it +/// up — not in the game's assets, which still hold the level as shipped. A +/// digest alone would make the file unplayable everywhere but the machine +/// that recorded it, on the afternoon it was recorded. +final class DemoLevelSwap { + const DemoLevelSwap({required this.step, required this.level}); + + final int step; + final Level level; + + /// [Level.digestHex] of [level], what the editor and the timeline call it. + String get levelHash => level.digestHex; + + Map toJson() => { + 'step': step, + 'levelHash': levelHash, + 'document': level.toJson(), + }; + + /// **The digest is checked against the document**, as + /// `ext.flutter3d.level.apply` checks it: a document that no longer + /// digests to what was written beside it is not the level the run was + /// played in. + factory DemoLevelSwap._fromJson(Object? json, {required int steps}) { + if (json is! Map) { + throw const DemoFormatException('a level swap is not a document'); + } + final step = json['step']; + if (step is! int || step < 0 || step > steps) { + throw DemoFormatException( + 'a level swap names step $step, and the tape has steps 0 to $steps', + ); + } + final hash = json['levelHash']; + final document = json['document']; + if (hash is! String || document is! Map) { + throw DemoFormatException( + 'the level swap at step $step has no level hash or no document', + ); + } + final Level level; + try { + level = Level.fromJson(document); + } on LevelFormatException catch (error) { + throw DemoFormatException( + 'the level swapped in at step $step: ${error.message}', + ); + } + if (level.digestHex != hash) { + throw DemoFormatException( + 'the level swapped in at step $step digests to ${level.digestHex}, ' + 'not $hash; the document changed after it was written', + ); + } + return DemoLevelSwap(step: step, level: level); + } } /// Thrown when a demo cannot be read at all. diff --git a/packages/flutter3d_sim/lib/src/save/entity_tracks.dart b/packages/flutter3d_sim/lib/src/save/entity_tracks.dart new file mode 100644 index 000000000..d19c080b0 --- /dev/null +++ b/packages/flutter3d_sim/lib/src/save/entity_tracks.dart @@ -0,0 +1,245 @@ +import 'first_differing_path.dart'; +import 'snapshot.dart'; + +/// Where in a snapshot the entities are, and what their components are +/// called. +/// +/// **A reading of the save, not a second save.** A snapshot is whatever a +/// genre's `save()` wrote, and the engine does not know an entity from a +/// field; what it can be told is where to look. Two shapes cover what the +/// repository writes: [EntityLayout.ecs] for an `EcsWorld.save()`, which is +/// component-major (`components.Health.7`), and [EntityLayout.rows] for a +/// genre that writes one row per entity (`monsters[3].hp`). Both answer the +/// same thing — entity, then component, then value — so a track and a +/// divergence name an entity the same way whichever wrote it. +final class EntityLayout { + const EntityLayout._(this._read); + + /// An `EcsWorld.save()` found under [at] in the snapshot — the empty list + /// when the snapshot is the world itself. Entities are named by their + /// index, components by the name they were registered under. + factory EntityLayout.ecs([List at = const []]) => + EntityLayout._((data) { + final world = _descend(data, at); + final components = world is Map ? world['components'] : null; + if (components is! Map) return const >{}; + final entities = >{}; + for (final MapEntry(key: component, value: rows) + in components.entries) { + if (rows is! Map) continue; + for (final MapEntry(key: entity, :value) in rows.entries) { + (entities['$entity'] ??= {})['$component'] = value; + } + } + return entities; + }); + + /// One row per entity under [key]: a list, whose entities are named by + /// index, or a map, named by key. Each row's own fields are its + /// components; a row that is not a map is one component called `value`. + factory EntityLayout.rows(String key) => EntityLayout._((data) { + final rows = data[key]; + final named = switch (rows) { + final List list => { + for (var i = 0; i < list.length; i++) '$i': list[i], + }, + final Map map => { + for (final MapEntry(:key, :value) in map.entries) '$key': value, + }, + _ => const {}, + }; + return >{ + for (final MapEntry(key: entity, :value) in named.entries) + entity: value is Map + ? { + for (final MapEntry(:key, value: field) in value.entries) + '$key': field, + } + : {'value': value}, + }; + }); + + final Map> Function(Map data) + _read; + + /// Entity name to component name to value, for one snapshot. + Map> entitiesOf(Snapshot snapshot) => + _read(snapshot.data); + + /// Every (entity, component) that differs between [a] and [b], in entity + /// and then component order — present on one side only counts. + List differences(Snapshot a, Snapshot b) { + final left = entitiesOf(a); + final right = entitiesOf(b); + return [ + for (final entity in _sorted({...left.keys, ...right.keys})) + for (final component in _sorted({ + ...?left[entity]?.keys, + ...?right[entity]?.keys, + })) + if (_differs(left[entity], right[entity], component)) + EntityComponent(entity, component), + ]; + } + + static bool _differs( + Map? a, + Map? b, + String component, + ) => + (a?.containsKey(component) ?? false) != + (b?.containsKey(component) ?? false) || + firstDifferingPath(a?[component], b?[component]) != null; + + static Object? _descend(Object? value, List path) => + path.fold(value, (node, key) => node is Map ? node[key] : null); +} + +/// One component of one entity, named as an [EntityLayout] names them. +final class EntityComponent { + const EntityComponent(this.entity, this.component); + + final String entity; + final String component; + + Map toJson() => { + 'entity': entity, + 'component': component, + }; + + @override + bool operator ==(Object other) => + other is EntityComponent && + other.entity == entity && + other.component == component; + + @override + int get hashCode => Object.hash(entity, component); + + @override + String toString() => '$entity.$component'; +} + +/// A component's value from [step] on, until the next sample says otherwise. +final class TrackSample { + const TrackSample(this.step, this.value, {this.present = true}); + + final int step; + + /// What the component held; meaningless when not [present]. + final Object? value; + + /// False from the step the entity lost the component, or was despawned. + final bool present; + + Map toJson() => { + 'step': step, + if (present) 'value': value else 'absent': true, + }; +} + +/// What every entity's components did over a run, one lane per component. +/// +/// **Changes, not samples.** A lane holds a sample only at the steps its +/// value changed, so a thousand steps of a door that never moved is one +/// entry, and the steps at which something did happen are exactly the marks +/// a scrubber wants to draw. Observing every step costs a snapshot read per +/// step and nothing per entity that stood still. +final class EntityTracks { + EntityTracks(this.layout); + + final EntityLayout layout; + + final Map>> _lanes = + >>{}; + + int? _first; + int? _last; + + /// The first and last step observed, or null before the first. + ({int first, int last})? get span => switch ((_first, _last)) { + (final int first, final int last) => (first: first, last: last), + _ => null, + }; + + /// Reads [snapshot] as the state at [step]. Steps must arrive in order; a + /// step at or before the last one observed is ignored, since a lane holds + /// one history and a second pass over it would write the same marks twice. + void observe(int step, Snapshot snapshot) { + final last = _last; + if (last != null && step <= last) return; + _first ??= step; + _last = step; + final entities = layout.entitiesOf(snapshot); + for (final MapEntry(key: entity, value: components) in entities.entries) { + final lanes = _lanes[entity] ??= >{}; + for (final MapEntry(key: component, :value) in components.entries) { + final lane = lanes[component] ??= []; + final previous = lane.isEmpty ? null : lane.last; + if (previous == null || + !previous.present || + firstDifferingPath(previous.value, value) != null) { + lane.add(TrackSample(step, value)); + } + } + } + // A lane whose component is gone this step closes with an absent sample, + // so a despawn reads as the step it happened rather than as the value + // the component last had, held forever. + for (final MapEntry(key: entity, value: lanes) in _lanes.entries) { + for (final MapEntry(key: component, value: lane) in lanes.entries) { + if (lane.last.present && + !(entities[entity]?.containsKey(component) ?? false)) { + lane.add(TrackSample(step, null, present: false)); + } + } + } + } + + /// Every entity seen, in name order. + List get entities => _sorted(_lanes.keys); + + /// Every component [entity] was seen with, in name order. + List componentsOf(String entity) => + _sorted(_lanes[entity]?.keys ?? const []); + + /// The changes on one lane, oldest first; empty for a lane never seen. + List samples(String entity, String component) => + List.unmodifiable( + _lanes[entity]?[component] ?? const [], + ); + + /// The sample in force at [step]: the last change at or before it, or + /// null when the lane had not started yet. + TrackSample? at(String entity, String component, int step) => samples( + entity, + component, + ).lastWhereOrNull((sample) => sample.step <= step); + + /// Every lane, for a panel across a wire: `{first, last, entities: {name: + /// {component: [{step, value} | {step, absent}]}}}`. + Map toJson() => { + 'first': _first, + 'last': _last, + 'entities': { + for (final entity in entities) + entity: { + for (final component in componentsOf(entity)) + component: >[ + for (final sample in samples(entity, component)) sample.toJson(), + ], + }, + }, + }; +} + +List _sorted(Iterable names) => names.toList()..sort(); + +extension on List { + T? lastWhereOrNull(bool Function(T) test) { + for (var i = length - 1; i >= 0; i--) { + if (test(this[i])) return this[i]; + } + return null; + } +} diff --git a/packages/flutter3d_sim/lib/src/save/first_differing_path.dart b/packages/flutter3d_sim/lib/src/save/first_differing_path.dart new file mode 100644 index 000000000..b2b7ce40f --- /dev/null +++ b/packages/flutter3d_sim/lib/src/save/first_differing_path.dart @@ -0,0 +1,41 @@ +/// A path into JSON-shaped values ([Map], [List], or a primitive) leading +/// to the first leaf where [a] and [b] differ, or null if they are equal. +/// +/// Here rather than in `flutter3d_net`, where it was written for +/// `diffRuns`: the timeline asks the same question after a code reload — +/// where does the run the new code makes first part from the one the old +/// code made — and the timeline does not depend on networking. `flutter3d_net` +/// exports it from here, so nothing that reached it there has moved. +({String path, Object? a, Object? b})? firstDifferingPath( + Object? a, + Object? b, [ + String prefix = '', +]) { + if (a is Map && b is Map) { + final keys = {...a.keys, ...b.keys}.toList() + ..sort((x, y) => x.toString().compareTo(y.toString())); + for (final key in keys) { + final childPrefix = prefix.isEmpty ? '$key' : '$prefix.$key'; + final result = firstDifferingPath(a[key], b[key], childPrefix); + if (result != null) return result; + } + return null; + } + if (a is List && b is List) { + final shorter = a.length < b.length ? a.length : b.length; + for (var i = 0; i < shorter; i++) { + final result = firstDifferingPath(a[i], b[i], '$prefix[$i]'); + if (result != null) return result; + } + if (a.length != b.length) { + return ( + path: prefix.isEmpty ? '' : '$prefix.', + a: a.length, + b: b.length, + ); + } + return null; + } + if (a != b) return (path: prefix, a: a, b: b); + return null; +} diff --git a/packages/flutter3d_sim/lib/src/save/rewind.dart b/packages/flutter3d_sim/lib/src/save/rewind.dart index 36d1f609f..9487ecbe0 100644 --- a/packages/flutter3d_sim/lib/src/save/rewind.dart +++ b/packages/flutter3d_sim/lib/src/save/rewind.dart @@ -124,6 +124,10 @@ final class RewindBuffer { _forget(); } + /// The earliest step a rewind can reach, or null before the first + /// keyframe — the left end of a scrubber, whose right end is [step]. + int? get oldestStep => _keyframes.isEmpty ? null : _keyframes.first.step; + /// How many seconds back a rewind can currently reach. /// /// Less than [history] at the start of a run and until the first keyframe is @@ -180,6 +184,35 @@ final class RewindBuffer { _keyframes.removeWhere((k) => k.step > point.step); } + /// The snapshots held after [step], by the step each was taken before. + /// + /// What the run looked like at those moments — for comparing a replay of + /// the same tape against, after something that should not have changed + /// the outcome (or should have) has changed the code. + Map keyframesAfter(int step) => { + for (final keyframe in _keyframes) + if (keyframe.step > step) keyframe.step: keyframe.snapshot, + }; + + /// Makes [point]'s keyframe the oldest thing held: the keyframes on either + /// side of it and the entries before it are forgotten, the entries after it + /// kept. + /// + /// For a change to the world the snapshots do not carry — a level edited + /// while the run goes on. The run is replayed from [point]'s keyframe under + /// the new world; the keyframes before it describe states the old world + /// led to, and the ones after it states the replay has just replaced, so a + /// rewind through either would put one world's state under the other. + void rebaseAt(RewindPoint point) { + final base = point.step - point.replayed; + _keyframes.removeWhere((k) => k.step != base); + final drop = base - _dropped; + if (drop > 0) { + recorder.tape.frames.removeRange(0, drop); + _dropped = base; + } + } + /// Forgets everything, for a new level. void reset() { recorder.tape.frames.clear(); diff --git a/packages/flutter3d_sim/lib/src/save/save_record.dart b/packages/flutter3d_sim/lib/src/save/save_record.dart new file mode 100644 index 000000000..df0e62eef --- /dev/null +++ b/packages/flutter3d_sim/lib/src/save/save_record.dart @@ -0,0 +1,202 @@ +/// A saved run as a document: which level, the snapshot, and the two numbers +/// that let two copies of it be compared. +/// +/// ## Why the step and the digest travel with it +/// +/// A save that lives in two places — a phone and a tablet, a machine and a +/// server — sooner or later differs between them, and the question is which +/// one the player wants. A wall clock cannot answer it: two devices disagree +/// about the time, and the newer file is not the further run when somebody +/// replayed an old checkpoint. Two things can. The **step** says how far the +/// run has got, in simulation steps across its levels; the **digest** says +/// whether two copies are the same run at all, without comparing them field +/// by field. Equal digests are one save in two places. Different digests are +/// two runs, and the further one is what a player who kept playing wants. +/// +/// The digest is [contentDigestHex] — the same bits-not-text hash a replay +/// checks itself with, so a phone and a browser agree on it. +library; + +import 'save_schema.dart'; +import 'snapshot.dart'; +import 'state_digest.dart' show contentDigestHex; + +final class SaveRecord { + SaveRecord({ + required this.level, + required this.run, + this.step = 0, + this.schema = 0, + }); + + /// The level the snapshot belongs to — see `SaveFile` for why the two are + /// never kept apart. + final String level; + + final Snapshot run; + + /// How far the run has got, in simulation steps across all its levels. + /// + /// Zero for a game that does not count them, which leaves a conflict + /// between two different runs to the player — see [resolveSaves]. + final int step; + + /// The game's [SaveSchema.version] the run's fields are at. + final int schema; + + /// Whether two records are the same run, as eight hexadecimal digits. + /// + /// Of the level and the run and nothing else: the step counts how the run + /// got here, and two copies of one save that disagree about it are still + /// one save. + late final String digest = contentDigestHex({ + 'level': level, + 'run': run.toJson(), + }); + + /// The document. The digest is written down for whoever stores it — a + /// server can show and compare it without understanding a snapshot — and + /// recomputed on the way back in rather than trusted. + Map toJson() => { + 'level': level, + 'schema': schema, + 'step': step, + 'digest': digest, + 'run': run.toJson(), + }; + + /// Reads a document written by [toJson], or by a build from before it + /// carried a schema, and brings the run up to [schema]. + /// + /// **Never throws.** Every way a document can fail to be a save is a + /// [SaveNotRead] that says which. + static SaveRead read(Object? json, SaveSchema schema) { + if (json is! Map) { + return const SaveNotRead('the document is not an object'); + } + final level = json['level']; + final run = json['run']; + if (level is! String || run is! Map) { + return const SaveNotRead('no level and run in it'); + } + // A run with no version in it was written by a build that had the version + // and did not send it — `SaveFile.write` once handed over `run.data`, + // which is the payload without the header `toJson` adds. The shape did not + // change, so it is a version 1 document that failed to say so, and + // refusing it would cost a player a run to fix a bug that was never + // theirs. + final envelope = run[Snapshot.versionKey]; + if (envelope is num && envelope > Snapshot.formatVersion) { + return SaveNotRead( + 'snapshot format version $envelope is newer than this build ' + 'understands (${Snapshot.formatVersion})', + newer: true, + ); + } + final Snapshot snapshot; + try { + snapshot = Snapshot.fromJson( + envelope == null + ? { + Snapshot.versionKey: Snapshot.formatVersion, + ...run, + } + : run, + ); + } on SnapshotFormatException catch (error) { + return SaveNotRead(error.message); + } + final written = switch (json['schema']) { + final num value => value.toInt(), + // A save from before games had a schema: the first one. + _ => 0, + }; + return switch (schema.upgrade(snapshot.data, written)) { + SaveUpgraded(:final run, :final from) => SaveFound( + SaveRecord( + level: level, + run: Snapshot(run), + step: switch (json['step']) { + final num value => value.toInt(), + _ => 0, + }, + schema: schema.version, + ), + upgradedFrom: from, + ), + SaveRefused(:final reason, :final newer) => SaveNotRead( + reason, + newer: newer, + ), + }; + } +} + +/// What [SaveRecord.read] made of a document. +sealed class SaveRead { + const SaveRead(); +} + +final class SaveFound extends SaveRead { + const SaveFound(this.record, {required this.upgradedFrom}); + + final SaveRecord record; + + /// The schema the document was written at. Less than `record.schema` when + /// it was migrated on the way in. + final int upgradedFrom; +} + +final class SaveNotRead extends SaveRead { + const SaveNotRead(this.reason, {this.newer = false}); + + final String reason; + + /// From a newer build: somebody's real progress, to be left alone rather + /// than overwritten. See [SaveRefused.newer]. + final bool newer; +} + +/// Which of two copies of a save to keep. +enum SaveResolution { + /// They are the same run, or there is neither. + inSync, + + /// The local copy is the one to keep, and to send. + keepLocal, + + /// The other copy is the one to keep, and to write here. + takeRemote, + + /// Two different runs equally far along. Only the player can say. + ask, +} + +/// Decides between [local] and [remote], given the digest both sides last +/// agreed on, [base], when there was one. +/// +/// **The base is what makes this more than "the further one wins".** When one +/// copy is still the base, only the other has moved since the two last met, +/// and it wins whatever its step: a player who went back to an earlier +/// checkpoint on the tablet meant to. Only when both moved — two devices +/// played apart — is the step asked, and a tie between two different runs +/// goes to the player, because either answer picked here throws away +/// somebody's evening. +SaveResolution resolveSaves({ + required SaveRecord? local, + required SaveRecord? remote, + String? base, +}) => switch ((local, remote)) { + (null, null) => SaveResolution.inSync, + (_?, null) => SaveResolution.keepLocal, + (null, _?) => SaveResolution.takeRemote, + (final here?, final there?) when here.digest == there.digest => + SaveResolution.inSync, + (final here?, _) when here.digest == base => SaveResolution.takeRemote, + (_, final there?) when there.digest == base => SaveResolution.keepLocal, + (final here?, final there?) when here.step > there.step => + SaveResolution.keepLocal, + (final here?, final there?) when here.step < there.step => + SaveResolution.takeRemote, + _ => SaveResolution.ask, +}; diff --git a/packages/flutter3d_sim/lib/src/save/save_schema.dart b/packages/flutter3d_sim/lib/src/save/save_schema.dart new file mode 100644 index 000000000..bd4cd84b6 --- /dev/null +++ b/packages/flutter3d_sim/lib/src/save/save_schema.dart @@ -0,0 +1,90 @@ +/// What a game's own save data looks like, by version, and how an old one +/// becomes a new one. +/// +/// ## Two versions, and why +/// +/// [Snapshot.formatVersion] is the engine's: it changes when the envelope does, +/// and no game can move it. A game's fields change far more often — a health +/// bar becomes a list of hearts, a key ring gains a colour — and until this +/// existed the only answer was the one `Snapshot` gives a newer document: +/// refuse it. An *older* document was read as if it were current, so a renamed +/// field came back as its default and the player lost what it held without a +/// word. +/// +/// ## The version is the number of migrations +/// +/// **There is no separate number to bump.** A schema with three migrations is +/// version 3, and the only way to make it 4 is to write the fourth. A number +/// kept beside the list could be raised without a migration, and the save from +/// the version in between would then be read as current — the failure this +/// exists to end. A change that needs no rewriting is a migration that returns +/// its argument, written down so the history is complete. +library; + +import 'snapshot.dart' show Snapshot; + +/// Turns a game's save data at one version into the next. +/// +/// Given the run's fields — a copy, so a migration may change it in place and +/// return it — and returns the fields at the next version. +typedef SaveMigration = Map Function(Map run); + +/// A game's save schema: the migrations from version 0 to now. +final class SaveSchema { + const SaveSchema([this.migrations = const []]); + + /// `migrations[n]` takes a version-`n` run to version `n + 1`. + final List migrations; + + /// The version this build writes. + int get version => migrations.length; + + /// Brings [run], written at version [from], up to [version]. + /// + /// A refusal rather than a throw, in both cases that can go wrong: a save + /// from a newer build cannot be read here and must not be overwritten + /// either, and a migration that throws on somebody's real save is a bug + /// whose report should name the step it failed at. + SaveUpgrade upgrade(Map run, int from) { + if (from > version) { + return SaveRefused( + 'save schema $from is newer than this build understands ($version)', + newer: true, + ); + } + if (from < 0) return SaveRefused('save schema $from is not a version'); + try { + final upgraded = migrations.skip(from).fold({ + ...run, + }, (fields, step) => step({...fields})); + return SaveUpgraded(upgraded, from: from); + } catch (error) { + return SaveRefused('a migration from save schema $from failed: $error'); + } + } +} + +/// What [SaveSchema.upgrade] made of a run. +sealed class SaveUpgrade { + const SaveUpgrade(); +} + +/// The run at the current version, and the version it was written at. +final class SaveUpgraded extends SaveUpgrade { + const SaveUpgraded(this.run, {required this.from}); + + final Map run; + final int from; +} + +/// Why the run cannot be read by this build. +final class SaveRefused extends SaveUpgrade { + const SaveRefused(this.reason, {this.newer = false}); + + /// A sentence, in the words a log line or a bug report wants. + final String reason; + + /// Whether the document is from a newer build — and so is somebody's real + /// progress that this build must leave alone rather than replace. + final bool newer; +} diff --git a/packages/flutter3d_sim/lib/src/save/state_digest.dart b/packages/flutter3d_sim/lib/src/save/state_digest.dart index c911642a2..862407041 100644 --- a/packages/flutter3d_sim/lib/src/save/state_digest.dart +++ b/packages/flutter3d_sim/lib/src/save/state_digest.dart @@ -408,6 +408,20 @@ final class DigestTrace { _digests.add(StateDigest.of(state)); } + /// Drops the checkpoints taken after [step]. + /// + /// For a run that was lived again from [step] — a level swapped under it: + /// what was observed after [step] is a run that no longer happened, and a + /// replay checked against it would be checked against the wrong world. The + /// steps between [step] and the next [observe] stay unchecked, which is a + /// gap and not a false answer. + void forgetAfter(int step) { + final keep = _steps.indexWhere((s) => s > step); + if (keep < 0) return; + _steps.removeRange(keep, _steps.length); + _digests.removeRange(keep, _digests.length); + } + /// The first checkpoint at which this trace and [expected] disagree. /// /// Null when they agree the whole way, which is the answer the parity test diff --git a/packages/flutter3d_sim/lib/src/save/tape_bisect.dart b/packages/flutter3d_sim/lib/src/save/tape_bisect.dart new file mode 100644 index 000000000..62c527f41 --- /dev/null +++ b/packages/flutter3d_sim/lib/src/save/tape_bisect.dart @@ -0,0 +1,329 @@ +import '../input/input_state.dart'; +import '../input/input_tape.dart'; +import 'entity_tracks.dart'; +import 'first_differing_path.dart'; +import 'snapshot.dart'; +import 'state_digest.dart'; + +/// One side of a comparison: a simulation, the state it starts from and the +/// tape it plays, able to answer what the state was after any number of +/// steps. +/// +/// **Forward only, with the states it has been asked for kept.** A +/// simulation cannot step backwards, so reaching step 40 after step 90 +/// restores the nearest kept state at or before 40 and plays on from there. +/// Bisection asks for a handful of states, each between two it already has, +/// so the work is a couple of passes over the bracket rather than one per +/// probe. +/// +/// **Each side owns its live objects.** A side remembers where it left them +/// so it can play on rather than restore; two sides stepping one simulation +/// would each find it where the other left it. Comparing two tapes on one +/// build means two instances of the simulation, which is also what keeps +/// the two runs from sharing anything by accident. +final class ReplaySide { + ReplaySide({ + required this.start, + required this.tape, + required this.input, + required this.step, + required this.restore, + required this.capture, + }) : _kept = {0: start}; + + /// The state before the first step. + final Snapshot start; + + /// One entry per step, played into [input] before each. + final InputTape tape; + + final InputState input; + + /// Runs one fixed step of this side's simulation. + final void Function() step; + + /// Puts a snapshot back into this side's live objects. + final void Function(Snapshot snapshot) restore; + + /// Writes this side's live objects down. + final Snapshot Function() capture; + + final Map _kept; + + /// How many steps the live objects have taken since [start], or null when + /// they have not been restored by this side yet. + int? _at; + + /// How many steps this side has run, over every question asked of it — + /// what a test reads to see that bisection is cheaper than walking. + int stepsRun = 0; + + /// The state after [steps] steps of [tape]; `stateAfter(0)` is [start]. + Snapshot stateAfter(int steps) { + assert(steps >= 0 && steps <= tape.steps, 'the tape has ${tape.steps}'); + final kept = _kept[steps]; + if (kept != null) return kept; + final from = _kept.keys.where((k) => k <= steps).reduce(_max); + final at = _at; + final resume = at != null && at >= from && at <= steps ? at : from; + if (resume != at) restore(_kept[resume]!); + final playback = InputTapePlayback( + InputTape(seed: tape.seed, frames: tape.frames.sublist(resume, steps)), + ); + final wasMuted = input.muted; + input.muted = true; + try { + while (!playback.isFinished) { + playback.applyTo(input); + input.beginStep(); + step(); + input.endStep(); + stepsRun++; + } + } finally { + input.muted = wasMuted; + } + _at = steps; + return _kept[steps] = capture(); + } + + static int _max(int a, int b) => a > b ? a : b; +} + +/// What [bisectTapes] found. +sealed class TapeBisection { + const TapeBisection(); + + Map toJson(); +} + +/// The two runs were the same state after every step both tapes have. +final class TapesAgree extends TapeBisection { + const TapesAgree({ + required this.steps, + required this.stepsA, + required this.stepsB, + }); + + /// How many steps were compared: the shorter tape's length. + final int steps; + + final int stepsA; + final int stepsB; + + @override + Map toJson() => { + 'agree': true, + 'steps': steps, + 'stepsA': stepsA, + 'stepsB': stepsB, + }; + + @override + String toString() => stepsA == stepsB + ? 'the runs agree for all $steps steps' + : 'the runs agree for the $steps steps both have; one tape has ' + '${stepsA > stepsB ? stepsA - stepsB : stepsB - stepsA} more'; +} + +/// The first step after which the two runs were different states. +final class TapesDiverge extends TapeBisection { + const TapesDiverge({ + required this.step, + required this.inputsDiffer, + required this.path, + required this.expected, + required this.found, + required this.components, + required this.probes, + }); + + /// After this many steps the states differ, and after one fewer they were + /// the same. Zero when the two runs started from different states; else + /// the tape entry that made the difference is `step - 1`. + final int step; + + /// Whether the two tapes hold different input for that entry. True points + /// at the input — a different recording; false at the code or the + /// platform, since the same state and the same input led somewhere else. + final bool inputsDiffer; + + /// The first differing value, as `firstDifferingPath` spells it: under the + /// entity and component when an [EntityLayout] was given and found one + /// (`7.Health.hp`), into the raw snapshot otherwise (`random`). + final String path; + + final Object? expected; + final Object? found; + + /// Every entity component that differs at [step], in order; empty when no + /// layout was given or the difference is outside the entities. + final List components; + + /// How many steps were compared on the way: the cost of the answer. + final int probes; + + /// The first differing component, or null when [components] is empty. + EntityComponent? get component => + components.isEmpty ? null : components.first; + + @override + Map toJson() => { + 'agree': false, + 'step': step, + 'inputsDiffer': inputsDiffer, + 'path': path, + 'expected': expected, + 'found': found, + 'components': >[ + for (final component in components) component.toJson(), + ], + 'probes': probes, + }; + + @override + String toString() { + final cause = step == 0 + ? 'the runs start from different states' + : 'step ${step - 1} ${inputsDiffer ? 'had different input' : 'had the same input'}'; + return '$cause; first at `$path`: $expected against $found'; + } +} + +/// The first step after which [a] and [b] are different states, found by +/// bisection, and what differs there. +/// +/// **Digests on the way, full snapshots once.** Each probe compares two +/// [StateDigest]s, and only the final step is read field by field. Comparing +/// the states is the part a run pays for per entity, so bisecting a +/// thousand-step bracket costs ten comparisons instead of a thousand, and +/// the stepping stays within twice the bracket (see [ReplaySide]). +/// +/// [agreedAt] and [differsAt] bracket the search when something already +/// narrowed it — two [DigestTrace]s through [bracketFromTraces], say. Both +/// are checked rather than trusted: a bracket that does not hold is widened +/// to the start and to the end of the shorter tape. +/// +/// **What "first" means.** The answer is a step after which the runs differ +/// with the step before it agreeing: a real point where the same state went +/// two ways. Runs that part, meet again and part later are found at one of +/// the partings, the first unless the meeting falls between probes — the +/// search can only be sure of the step it names. +/// +/// [layout] names the entity and component that differ; without one the path +/// is into the raw snapshot. +TapeBisection bisectTapes({ + required ReplaySide a, + required ReplaySide b, + EntityLayout? layout, + int agreedAt = 0, + int? differsAt, +}) { + final limit = a.tape.steps < b.tape.steps ? a.tape.steps : b.tape.steps; + var probes = 0; + bool same(int steps) { + probes++; + return StateDigest.of(a.stateAfter(steps).data) == + StateDigest.of(b.stateAfter(steps).data); + } + + var lo = agreedAt.clamp(0, limit); + if (!same(lo)) { + if (lo == 0 || !same(0)) return _diverge(a, b, 0, layout, probes); + lo = 0; + } + var hi = (differsAt ?? limit).clamp(lo, limit); + if (hi == lo || same(hi)) { + lo = hi; + hi = limit; + if (hi == lo || same(hi)) { + return TapesAgree( + steps: limit, + stepsA: a.tape.steps, + stepsB: b.tape.steps, + ); + } + } + while (hi - lo > 1) { + final mid = lo + (hi - lo) ~/ 2; + if (same(mid)) { + lo = mid; + } else { + hi = mid; + } + } + return _diverge(a, b, hi, layout, probes); +} + +TapesDiverge _diverge( + ReplaySide a, + ReplaySide b, + int step, + EntityLayout? layout, + int probes, +) { + final left = a.stateAfter(step); + final right = b.stateAfter(step); + final components = + layout?.differences(left, right) ?? const []; + final first = components.isEmpty ? null : components.first; + final differs = first == null + ? firstDifferingPath(left.data, right.data) + : _withPrefix( + '${first.entity}.${first.component}', + firstDifferingPath( + layout!.entitiesOf(left)[first.entity]?[first.component], + layout.entitiesOf(right)[first.entity]?[first.component], + ), + ); + final inputsDiffer = + step > 0 && + StateDigest.of(a.tape.frames[step - 1].toJson()) != + StateDigest.of(b.tape.frames[step - 1].toJson()); + return TapesDiverge( + step: step, + inputsDiffer: inputsDiffer, + path: differs?.path ?? '', + expected: differs?.a, + found: differs?.b, + components: components, + probes: probes, + ); +} + +/// A component present on one side only reads as a whole-value difference +/// at the component itself, which `firstDifferingPath` reports with an empty +/// path under it. +({String path, Object? a, Object? b})? _withPrefix( + String prefix, + ({String path, Object? a, Object? b})? inner, +) => inner == null + ? (path: prefix, a: null, b: null) + : ( + path: inner.path.isEmpty + ? prefix + : inner.path.startsWith('[') + ? '$prefix${inner.path}' + : '$prefix.${inner.path}', + a: inner.a, + b: inner.b, + ); + +/// The bracket two digest traces give [bisectTapes], in its own counting. +/// +/// A trace's checkpoint at step `s` is taken after the step numbered `s` has +/// run (`DigestTrace.observe`), so it is the state after `s + 1` steps. The +/// bracket is the last checkpoint both traces agree on and the first they do +/// not; null when they never disagree. +({int agreedAt, int differsAt})? bracketFromTraces( + DigestTrace a, + DigestTrace b, +) { + final divergence = a.divergenceFrom(b.digests); + if (divergence == null) return null; + final before = divergence.step - a.every; + return ( + agreedAt: before < 0 ? 0 : before + 1, + differsAt: divergence.step + 1, + ); +} diff --git a/packages/flutter3d_sim/lib/src/share/run_service.dart b/packages/flutter3d_sim/lib/src/share/run_service.dart new file mode 100644 index 000000000..66a5cc26e --- /dev/null +++ b/packages/flutter3d_sim/lib/src/share/run_service.dart @@ -0,0 +1,245 @@ +import 'dart:convert'; + +import 'share_bundle.dart'; + +/// One HTTP request, as [RunService] makes it. +final class RunRequest { + const RunRequest(this.method, this.uri, {this.body}); + + final String method; + final Uri uri; + + /// JSON, or null for a request with no body. + final String? body; +} + +/// What came back: the status and the body as text. +final class RunResponse { + const RunResponse(this.status, this.body); + + final int status; + final String body; +} + +/// Sends a [RunRequest] and answers with what the server said. +/// +/// **Handed in rather than imported, because this package runs everywhere.** +/// `dart:io`'s client does not exist in a browser and `package:http` would be +/// this package's first dependency for one call; a game passes the client it +/// already has in three lines, and a test passes the server's own handler. +typedef RunTransport = Future Function(RunRequest request); + +/// What a call to a service came to. +sealed class ServiceAnswer { + const ServiceAnswer(); +} + +final class ServiceDone extends ServiceAnswer { + const ServiceDone(this.value); + + final T value; +} + +/// Not done, and why — in the server's words when it answered. +final class ServiceRefused extends ServiceAnswer { + const ServiceRefused(this.reason); + + final String reason; +} + +/// Where a shared bundle stands with the service's moderation. +/// +/// **Constants rather than an enum, because the server decides the set.** A +/// service that grows an `expired` is a service every client written against +/// three values must still understand; [named] answers null for a word it +/// does not know, and a caller treats that as "not open", which is what any +/// status but [published] means to a player. +final class ShareStatus { + const ShareStatus._(this.name); + + /// Anyone with the code can open it. + static const ShareStatus published = ShareStatus._('published'); + + /// Kept, and waiting for a moderator before anyone can open it. + static const ShareStatus pending = ShareStatus._('pending'); + + /// Taken down; the code answers with the reason. + static const ShareStatus removed = ShareStatus._('removed'); + + static const List values = [ + published, + pending, + removed, + ]; + + /// The word the protocol writes it as. + final String name; + + /// The status written as [name], or null for one this build does not know. + static ShareStatus? named(Object? name) { + for (final status in values) { + if (status.name == name) return status; + } + return null; + } + + @override + String toString() => name; +} + +/// A bundle the service has filed: the code to hand out and where it stands. +final class SharedBundle { + const SharedBundle({ + required this.code, + required this.address, + required this.status, + }); + + /// The short code a person types or reads out. + final String code; + + /// The SHA-256 of the bundle's bytes, which is what it is filed under. + final String address; + + final ShareStatus status; +} + +/// The client side of a sharing service — the `v1/shares` routes of +/// `cloud/server` in this repository, or anybody's server that speaks them. +/// +/// **Nothing here shares on its own.** The person pressing Share is the +/// consent; a player's runs sent for telemetry go through +/// `TelemetryUploader`, which asks for its own. +/// +/// Every call answers rather than throws: an unreachable server, a refusal and +/// a reply that is not JSON are all a [ServiceRefused] with a sentence. +final class RunService { + RunService({required this.base, required this.transport}); + + /// Where the service is, e.g. `https://runs.pleion.dev/`. + final Uri base; + + final RunTransport transport; + + /// Files [bundle] and answers with its code. + /// + /// Sending the same bundle twice gives the same code: the service files by + /// the bytes, not by who sent them. + Future> share(ShareBundle bundle) async { + return switch (await _call('POST', 'v1/shares', bundle.toJson())) { + _Answered(:final json) => _shared(json), + _Refusal(:final reason) => ServiceRefused(reason), + }; + } + + /// The bundle behind [code], when it is published. + Future> open(String code) async { + final Map json; + switch (await _call('GET', 'v1/shares/${Uri.encodeComponent(code)}')) { + case _Refusal(:final reason): + return ServiceRefused(reason); + case _Answered(json: final answered): + json = answered; + } + final bundle = json['bundle']; + if (bundle is! Map) { + return ServiceRefused( + json['message'] as String? ?? '"$code" is not open to anyone yet', + ); + } + try { + return ServiceDone(ShareBundle.fromJson(bundle)); + } on ShareFormatException catch (error) { + return ServiceRefused( + '"$code" could not be read: ${error.message}', + ); + } + } + + /// Tells the service's moderators that [code] should not be shared, and why. + Future> report(String code, String reason) async { + final reply = await _call( + 'POST', + 'v1/shares/${Uri.encodeComponent(code)}/reports', + {'reason': reason}, + ); + return _message(reply); + } + + ServiceAnswer _shared(Map json) { + final code = json['code']; + final address = json['address']; + final status = ShareStatus.named(json['status']); + if (code is! String || address is! String || status == null) { + return const ServiceRefused( + 'the service filed the bundle but did not say under what', + ); + } + return ServiceDone( + SharedBundle(code: code, address: address, status: status), + ); + } + + ServiceAnswer _message(_Reply reply) => switch (reply) { + _Refusal(:final reason) => ServiceRefused(reason), + _Answered(:final json) => ServiceDone( + json['message'] as String? ?? 'done', + ), + }; + + Future<_Reply> _call( + String method, + String path, [ + Map? body, + ]) async { + final uri = base.resolve(path); + final RunResponse response; + try { + response = await transport( + RunRequest(method, uri, body: body == null ? null : jsonEncode(body)), + ); + } catch (error) { + return _Refusal('could not reach $base: $error', 0); + } + final Object? decoded; + try { + decoded = response.body.isEmpty ? null : jsonDecode(response.body); + } on FormatException { + return _Refusal( + 'the service answered ${response.status} with something that is ' + 'not JSON', + response.status, + ); + } + final json = decoded is Map ? decoded : null; + if (response.status < 200 || response.status >= 300 || json == null) { + return _Refusal( + json?['message'] as String? ?? + 'the service answered ${response.status}', + response.status, + ); + } + return _Answered(json); + } +} + +/// What [RunService._call] came back with. +sealed class _Reply { + const _Reply(); +} + +/// A success status and a JSON object. +final class _Answered extends _Reply { + const _Answered(this.json); + + final Map json; +} + +/// Anything else, as a sentence — the server's `message` when it sent one — +/// and the status it came with, 0 when the server was not reached. +final class _Refusal extends _Reply { + const _Refusal(this.reason, this.status); + + final String reason; + final int status; +} diff --git a/packages/flutter3d_sim/lib/src/share/share_bundle.dart b/packages/flutter3d_sim/lib/src/share/share_bundle.dart new file mode 100644 index 000000000..10d55af01 --- /dev/null +++ b/packages/flutter3d_sim/lib/src/share/share_bundle.dart @@ -0,0 +1,128 @@ +import '../save/demo.dart'; +import '../save/state_digest.dart'; + +/// A level somebody shares, and optionally a run through it — what a short +/// code stands for. +/// +/// **A level, its hash and a `.f3drun`, and nothing a server could make up.** +/// The level is a document rather than a parsed [Level] so that any genre's +/// own format travels the same way — a level here, a track there — the way +/// [Demo.levelHash] already compares [contentDigestHex] of whatever document +/// recorded it. A reader that knows the format parses [level]; a server that +/// does not can still check the hash and store the bytes. +/// +/// **The run is checked against the level before anything leaves.** A bundle +/// whose run was recorded in another version of the level is a ghost running +/// through walls, and it is refused here and again on the server with the two +/// hashes named, because the person who made it can fix it and the person who +/// opens it cannot. +/// +/// The address a server files it under is not here: a 32-bit [StateDigest] is +/// right for telling two states apart and wrong for naming a document among +/// every document strangers upload, so the server takes a SHA-256 of the bytes +/// [toJson] writes. +final class ShareBundle { + ShareBundle({required this.game, required this.level, this.run, this.title}) + : levelHash = contentDigestHex(level) { + final run = this.run; + if (run != null && run.levelHash != levelHash) { + throw ShareFormatException( + 'the run was recorded in another version of the level ' + '(${run.levelHash}; the level being shared is $levelHash) — record ' + 'it again in this one', + ); + } + } + + /// Bumped when an existing field changes meaning. + static const int formatVersion = 1; + + /// What the game calls itself, so that a viewer knows which game opens it. + final String game; + + /// The level document, as its own `toJson()` wrote it. + final Map level; + + /// [contentDigestHex] of [level]: which version of it this is. + final String levelHash; + + /// A run through [level], or null for a level shared on its own. + final Demo? run; + + /// What the person sharing it called it, or null. + final String? title; + + Map toJson() => { + 'version': formatVersion, + 'game': game, + if (title != null) 'title': title, + 'levelHash': levelHash, + 'level': level, + if (run != null) 'run': run!.toJson(), + }; + + /// Reads a bundle, or throws a [ShareFormatException] that says why not. + /// + /// [levelHash] is read and compared rather than trusted: a bundle edited by + /// hand after it was written names a version of the level it does not hold. + factory ShareBundle.fromJson(Map json) { + final version = json['version']; + if (version is! num) { + throw const ShareFormatException('the bundle has no version in it'); + } + if (version > formatVersion) { + throw ShareFormatException( + 'the bundle was written by a newer build (format $version, this build ' + 'reads $formatVersion) — update flutter3d to open it', + ); + } + final game = json['game']; + if (game is! String || game.isEmpty) { + throw const ShareFormatException('the bundle names no game'); + } + final level = json['level']; + if (level is! Map) { + throw const ShareFormatException('the bundle has no level in it'); + } + final title = json['title']; + if (title != null && title is! String) { + throw const ShareFormatException('the title is not text'); + } + final rawRun = json['run']; + if (rawRun != null && rawRun is! Map) { + throw const ShareFormatException('the run is not a document'); + } + final Demo? run; + try { + run = rawRun == null + ? null + : Demo.fromJson(rawRun as Map); + } on DemoFormatException catch (error) { + throw ShareFormatException('the run: ${error.message}'); + } + final bundle = ShareBundle( + game: game, + level: level, + run: run, + title: title as String?, + ); + final claimed = json['levelHash']; + if (claimed != bundle.levelHash) { + throw ShareFormatException( + 'the bundle says its level is $claimed, and the level in it is ' + '${bundle.levelHash}', + ); + } + return bundle; + } +} + +/// Thrown when a bundle cannot be read, or would not mean what it says. +final class ShareFormatException implements Exception { + const ShareFormatException(this.message); + + final String message; + + @override + String toString() => 'ShareFormatException: $message'; +} diff --git a/packages/flutter3d_sim/lib/src/telemetry/heatmap.dart b/packages/flutter3d_sim/lib/src/telemetry/heatmap.dart new file mode 100644 index 000000000..0b06be0f9 --- /dev/null +++ b/packages/flutter3d_sim/lib/src/telemetry/heatmap.dart @@ -0,0 +1,223 @@ +/// Where many runs of one level went, binned into cells. +/// +/// **One shape for two sources.** `ai-01`'s playtest bins the trails of a +/// random policy, and N7's telemetry bins the trails of real players +/// re-simulated on a server; the editor draws both through the same view. The +/// shape was `Playtest.heatmap`'s map before it was a class here, and the +/// JSON is that map unchanged, so a report written by either reads in the +/// other's reader. +library; + +/// The path of one run across a level, as [Heatmap.bin] takes it. +final class HeatmapTrail { + const HeatmapTrail({ + required this.run, + required this.steps, + required this.outcome, + required this.positions, + this.endedBadly = false, + }); + + /// Which run this was, by the number its source gives runs: a playtest's + /// seed, a telemetry server's run id. + final int run; + + /// The step the run stopped at. + final int steps; + + /// How it ended, in the source's own word — counted under [Heatmap.outcomes]. + final String outcome; + + /// `(x, z)` samples, in order. + final List<(double, double)> positions; + + /// Whether the last of [positions] is a place worth marking: where the run + /// was lost. + final bool endedBadly; +} + +/// One cell of a [Heatmap]. +final class HeatmapCell { + const HeatmapCell({ + required this.x, + required this.z, + required this.samples, + required this.runs, + }); + + /// In cells, not metres: multiply by [Heatmap.cellSize]. + final int x; + final int z; + + /// How many positions landed here. + final int samples; + + /// How many distinct runs those positions came from. A run that stood in + /// one corner for a minute is many samples and one run, and the two numbers + /// answer different questions: where players linger, and where they pass. + final int runs; +} + +/// Where a run was lost. +final class HeatmapEnd { + const HeatmapEnd({ + required this.run, + required this.step, + required this.x, + required this.z, + }); + + final int run; + final int step; + final double x; + final double z; +} + +/// Thrown when a heatmap document cannot be read. +final class HeatmapFormatException implements Exception { + const HeatmapFormatException(this.message); + + final String message; + + @override + String toString() => 'HeatmapFormatException: $message'; +} + +final class Heatmap { + const Heatmap({ + required this.cellSize, + required this.cells, + required this.ends, + required this.outcomes, + }); + + /// Bins every position of every trail into squares [cellSize] metres on a + /// side. + /// + /// [outcomeNames] are counted even when no trail ended that way, so a + /// reader sees `stuck: 0` rather than wondering whether stuck was measured. + factory Heatmap.bin( + Iterable trails, { + double cellSize = 1.0, + Iterable outcomeNames = const [], + }) { + final listed = trails.toList(); + final samples = <(int, int), int>{}; + final runsThrough = <(int, int), Set>{}; + for (final trail in listed) { + for (final (x, z) in trail.positions) { + final cell = ((x / cellSize).floor(), (z / cellSize).floor()); + samples[cell] = (samples[cell] ?? 0) + 1; + (runsThrough[cell] ??= {}).add(trail.run); + } + } + return Heatmap( + cellSize: cellSize, + cells: [ + for (final MapEntry(key: (x, z), value: count) in samples.entries) + HeatmapCell( + x: x, + z: z, + samples: count, + runs: runsThrough[(x, z)]!.length, + ), + ], + ends: [ + for (final trail in listed) + if (trail.endedBadly && trail.positions.isNotEmpty) + HeatmapEnd( + run: trail.run, + step: trail.steps, + x: trail.positions.last.$1, + z: trail.positions.last.$2, + ), + ], + outcomes: { + for (final name in outcomeNames) name: 0, + for (final name in listed.map((trail) => trail.outcome).toSet()) + name: listed.where((trail) => trail.outcome == name).length, + }, + ); + } + + /// Reads what [toJson] wrote, or throws a [HeatmapFormatException] naming + /// the field that is wrong. + factory Heatmap.fromJson(Map json) { + final cellSize = json['cellSize']; + final cells = json['cells']; + final ends = json['deaths']; + final outcomes = json['outcomes']; + if (cellSize is! num || cellSize <= 0) { + throw const HeatmapFormatException('the heatmap has no cell size'); + } + if (cells is! List || ends is! List || outcomes is! Map) { + throw const HeatmapFormatException( + 'a heatmap has cells, deaths and outcomes, and this lacks one', + ); + } + try { + return Heatmap( + cellSize: cellSize.toDouble(), + cells: [ + for (final Map row in cells.cast()) + HeatmapCell( + x: (row['x']! as num).toInt(), + z: (row['z']! as num).toInt(), + samples: (row['samples']! as num).toInt(), + runs: (row['runs']! as num).toInt(), + ), + ], + ends: [ + for (final Map row in ends.cast()) + HeatmapEnd( + run: (row['seed']! as num).toInt(), + step: (row['step']! as num).toInt(), + x: (row['x']! as num).toDouble(), + z: (row['z']! as num).toDouble(), + ), + ], + outcomes: { + for (final MapEntry(:key, :value) in outcomes.entries) + key! as String: (value! as num).toInt(), + }, + ); + } on TypeError catch (error) { + throw HeatmapFormatException('a cell or a death is malformed: $error'); + } + } + + final double cellSize; + final List cells; + final List ends; + final Map outcomes; + + /// How many runs went in. + int get runs => outcomes.values.fold(0, (a, b) => a + b); + + /// **`deaths` and `seed` are the keys, and a run id goes under `seed`.** + /// They are `ai-01`'s, already in reports on disk and in the editor's + /// reader; renaming them for telemetry would split one format into two for + /// the sake of a word. + Map toJson() => { + 'cellSize': cellSize, + 'cells': >[ + for (final cell in cells) + { + 'x': cell.x, + 'z': cell.z, + 'samples': cell.samples, + 'runs': cell.runs, + }, + ], + 'deaths': >[ + for (final end in ends) + { + 'seed': end.run, + 'step': end.step, + 'x': end.x, + 'z': end.z, + }, + ], + 'outcomes': outcomes, + }; +} diff --git a/packages/flutter3d_sim/lib/src/telemetry/resimulation.dart b/packages/flutter3d_sim/lib/src/telemetry/resimulation.dart new file mode 100644 index 000000000..b6e970a88 --- /dev/null +++ b/packages/flutter3d_sim/lib/src/telemetry/resimulation.dart @@ -0,0 +1,138 @@ +/// A recorded run played again, through the same simulation, to read numbers +/// off it the player never sent. +library; + +import 'dart:math' as math; + +import 'package:flutter3d_physics/flutter3d_physics.dart' show CollisionWorld; + +import '../input/input_state.dart'; +import '../input/input_tape.dart'; +import '../level/level.dart'; +import '../level/level_collision.dart'; +import '../loop/headless_run.dart'; +import '../loop/run_outcome.dart'; +import '../save/demo.dart'; +import '../save/state_digest.dart'; + +/// What [resimulate] found. +sealed class Resimulation { + const Resimulation(); +} + +/// The level handed in is not the one the run was recorded in. +final class ResimulationLevelChanged extends Resimulation { + const ResimulationLevelChanged({required this.found, required this.recorded}); + + final String found; + final String recorded; +} + +/// A fresh run of the level does not start where the recording started: the +/// demo began mid-run, or the game's spawning has changed since. +final class ResimulationStartDiffers extends Resimulation { + const ResimulationStartDiffers(); +} + +/// The replay parted from the checkpoints the run was recorded with. +final class ResimulationDiverged extends Resimulation { + const ResimulationDiverged(this.divergence, {required this.every}); + + final Divergence divergence; + + /// How far apart checkpoints were, so the answer can bracket the defect. + final int every; + + /// The last step at which the two runs are known to have agreed. + int get agreedUntil => math.max(0, divergence.step - every); +} + +/// The replay retraced every checkpoint, and here is what it saw on the way. +final class ResimulationRetraced extends Resimulation { + const ResimulationRetraced({ + required this.run, + required this.steps, + required this.checkpoints, + required this.trail, + required this.finalDigest, + }); + + /// The run as the last step left it — [HeadlessRun.reading] and + /// [HeadlessRun.outcome] are the metrics a caller asks of it. + final HeadlessRun run; + + final int steps; + final int checkpoints; + + /// `(x, z)` of [HeadlessRun.position] every so many steps, and once more at + /// the last step, so a run's end is where its trail ends. + final List<(double, double)> trail; + + final int finalDigest; + + RunOutcome get outcome => run.outcome; +} + +/// Plays [demo] into a fresh run of [game] in [level], checks it against the +/// checkpoints it was recorded with, and samples where the player went every +/// [sampleEvery] steps. +/// +/// **Numbers off a replay rather than numbers off the client**, and that is +/// what makes them telemetry worth keeping. A client that reported its own +/// metrics could report anything; one that sends its input can only send +/// input, and the server works out what that input did with the same +/// simulation the player ran — or finds that it did something else and says +/// at which checkpoint. +/// +/// A run that diverges answers no metrics at all: a trail that parted from the +/// player's at step 400 is a trail of somebody who never played. +Resimulation resimulate({ + required HeadlessGame game, + required Level level, + required Demo demo, + int sampleEvery = 10, + double dt = 1.0 / 60.0, +}) { + assert(sampleEvery > 0, 'a sample every no steps is no trail'); + final found = level.digestHex; + if (found != demo.levelHash) { + return ResimulationLevelChanged(found: found, recorded: demo.levelHash); + } + + final world = CollisionWorld(); + level.addTo(world); + final input = InputState(); + final run = game.start(level, world, input); + world.update(); + if (StateDigest.of(run.save().toJson()) != + StateDigest.of(demo.start.toJson())) { + return const ResimulationStartDiffers(); + } + + final playback = InputTapePlayback(demo.tape); + final trace = DigestTrace(every: demo.checkpoints.every); + final trail = <(double, double)>[]; + for (var step = 1; step <= demo.steps; step++) { + playback.applyTo(input); + input.beginStep(); + run.step(dt); + if (step % trace.every == 0) trace.observe(step, run.save().toJson()); + input.endStep(); + if (step % sampleEvery == 0 || step == demo.steps) { + final at = run.position; + trail.add((at.x, at.z)); + } + } + + final divergence = trace.divergenceFrom(demo.checkpoints.digests); + if (divergence != null) { + return ResimulationDiverged(divergence, every: trace.every); + } + return ResimulationRetraced( + run: run, + steps: demo.steps, + checkpoints: trace.steps.length, + trail: trail, + finalDigest: StateDigest.of(run.save().toJson()), + ); +} diff --git a/packages/flutter3d_sim/lib/src/telemetry/telemetry_consent.dart b/packages/flutter3d_sim/lib/src/telemetry/telemetry_consent.dart new file mode 100644 index 000000000..279b341ec --- /dev/null +++ b/packages/flutter3d_sim/lib/src/telemetry/telemetry_consent.dart @@ -0,0 +1,71 @@ +/// Whether a player has said their runs may leave the machine. +library; + +/// A player's answer to "may we send what you did in a level, to see where +/// levels are hard?", kept with when they gave it and to which wording. +/// +/// **Not asked is not yes.** A game that never showed the question sends +/// nothing, and so does one whose question changed since the player answered: +/// [allows] wants a grant to the [policy] the game asks under now. A grant to +/// last year's wording is consent to last year's wording. +/// +/// A game keeps this in its own settings document beside volumes and +/// bindings; [toJson] is the part of that document it owns. +final class TelemetryConsent { + const TelemetryConsent.notAsked() : _yes = null, policy = null, at = null; + + TelemetryConsent.granted({required String this.policy, required this.at}) + : _yes = true; + + TelemetryConsent.declined({required String this.policy, required this.at}) + : _yes = false; + + /// Reads what [toJson] wrote. Anything it cannot read is not asked, never a + /// grant: a settings file damaged in the one field that says yes must not + /// say yes. + factory TelemetryConsent.fromJson(Object? json) { + if (json is! Map) return const TelemetryConsent.notAsked(); + final answer = json['answer']; + final policy = json['policy']; + final at = DateTime.tryParse(json['at'] as String? ?? ''); + if (policy is! String || policy.isEmpty || at == null) { + return const TelemetryConsent.notAsked(); + } + return switch (answer) { + 'granted' => TelemetryConsent.granted(policy: policy, at: at), + 'declined' => TelemetryConsent.declined(policy: policy, at: at), + _ => const TelemetryConsent.notAsked(), + }; + } + + /// Yes, no, or null for never asked. A bool rather than an enum of three: + /// there is no fourth answer to add, and a switch over three names would be + /// a promise there never will be. + final bool? _yes; + + /// Whether the player was asked at all. + bool get asked => _yes != null; + + /// Whether they said yes — to [policy], which may not be today's. + bool get granted => _yes ?? false; + + /// The wording the player answered, by the name the game gives it — + /// `'2026-10'`, `'v2'`. + final String? policy; + + /// When they answered, in UTC. + final DateTime? at; + + /// Whether a run may be sent under [currentPolicy]. + bool allows(String currentPolicy) => granted && policy == currentPolicy; + + Map toJson() => { + 'answer': switch (_yes) { + true => 'granted', + false => 'declined', + null => 'notAsked', + }, + if (policy != null) 'policy': policy, + if (at != null) 'at': at!.toUtc().toIso8601String(), + }; +} diff --git a/packages/flutter3d_sim/lib/src/telemetry/telemetry_upload.dart b/packages/flutter3d_sim/lib/src/telemetry/telemetry_upload.dart new file mode 100644 index 000000000..8185f7deb --- /dev/null +++ b/packages/flutter3d_sim/lib/src/telemetry/telemetry_upload.dart @@ -0,0 +1,269 @@ +/// One run on its way to a telemetry server, and what sends it. +library; + +import 'dart:convert'; + +import '../save/demo.dart'; +import 'telemetry_consent.dart'; + +/// Thrown when an upload cannot be read. +final class TelemetryUploadFormatException implements Exception { + const TelemetryUploadFormatException(this.message); + + final String message; + + @override + String toString() => 'TelemetryUploadFormatException: $message'; +} + +/// What goes over the wire: which game, the run as a [Demo], and the consent +/// it was sent under. +/// +/// **The consent travels with the run**, so the server can refuse a run that +/// arrives without one rather than trusting that every client checked. A +/// client that lies about it can lie; one that forgot is caught. +/// +/// **Anonymous by construction.** [Demo.recordedBy] is dropped on the way in: +/// where a level is hard needs no name, and a field that is never sent is a +/// field nobody has to promise to delete. +final class TelemetryUpload { + TelemetryUpload._({ + required this.game, + required this.demo, + required this.policy, + required this.consentedAt, + }); + + /// Bumped when an existing field changes meaning. + static const int formatVersion = 1; + + /// An upload of [demo], or a sentence saying why there is none. + /// + /// The only way to build one, and it asks [consent] first: an upload that + /// exists is an upload somebody agreed to. + static ({TelemetryUpload? upload, String says}) prepare({ + required String game, + required Demo demo, + required TelemetryConsent consent, + required String policy, + }) { + if (!consent.allows(policy)) { + return ( + upload: null, + says: switch ((consent.asked, consent.granted)) { + (false, _) => + 'not sent: the player has not been asked whether runs may be ' + 'sent — ask, and send once they say yes', + (true, false) => 'not sent: the player said no to sending runs', + (true, true) => + 'not sent: the player agreed to policy "${consent.policy}", ' + 'and runs go out under "$policy" now — ask again', + }, + ); + } + if (demo.steps == 0) { + return (upload: null, says: 'not sent: the run has no steps'); + } + return ( + upload: TelemetryUpload._( + game: game, + demo: Demo( + level: demo.level, + levelHash: demo.levelHash, + start: demo.start, + tape: demo.tape, + buildStamp: demo.buildStamp, + checkpoints: demo.checkpoints, + platform: demo.platform, + dataSources: demo.dataSources, + ), + policy: policy, + consentedAt: consent.at!, + ), + says: 'ready: ${demo.steps} steps of ${demo.level}', + ); + } + + /// Reads an upload, or throws a [TelemetryUploadFormatException] that says + /// why not — a server's side of [toJson]. + factory TelemetryUpload.fromJson(Map json) { + final version = json['version']; + if (version is! num) { + throw const TelemetryUploadFormatException('the upload has no version'); + } + if (version > formatVersion) { + throw TelemetryUploadFormatException( + 'the upload is format $version and this reads $formatVersion', + ); + } + final game = json['game']; + if (game is! String || game.isEmpty) { + throw const TelemetryUploadFormatException('the upload names no game'); + } + final consent = json['consent']; + final policy = consent is Map ? consent['policy'] : null; + final at = consent is Map + ? DateTime.tryParse(consent['at'] as String? ?? '') + : null; + if (policy is! String || policy.isEmpty || at == null) { + throw const TelemetryUploadFormatException( + 'the upload carries no consent, so it was not agreed to', + ); + } + final run = json['run']; + if (run is! Map) { + throw const TelemetryUploadFormatException('the upload has no run'); + } + final Demo demo; + try { + demo = Demo.fromJson(run); + } on DemoFormatException catch (error) { + throw TelemetryUploadFormatException('the run: ${error.message}'); + } + return TelemetryUpload._( + game: game, + demo: demo, + policy: policy, + consentedAt: at, + ); + } + + final String game; + final Demo demo; + final String policy; + final DateTime consentedAt; + + Map toJson() => { + 'version': formatVersion, + 'game': game, + 'consent': { + 'policy': policy, + 'at': consentedAt.toUtc().toIso8601String(), + }, + 'run': demo.toJson(), + }; +} + +/// What a server gave back for an accepted run: its number there, and the key +/// that takes it back out. +final class TelemetryReceipt { + const TelemetryReceipt({required this.run, required this.eraseKey}); + + final int run; + + /// Shown to nobody but the player who sent the run. With it, and only with + /// it, the run is deleted — consent withdrawn is a run that is gone, not a + /// flag on one that stays. + final String eraseKey; +} + +/// The answer to sending one run. +typedef TelemetrySent = ({bool did, String says, TelemetryReceipt? receipt}); + +/// Where uploads go. The HTTP one is [HttpTelemetrySink]; a test hands in its +/// own. +abstract interface class TelemetrySink { + Future deliver(TelemetryUpload upload); +} + +/// One POST, as this package needs it: a body out, a status and a body back. +/// +/// **Handed in rather than imported.** This package runs in a browser and on +/// a server and imports neither `dart:io` nor a client library; whoever has a +/// network wraps theirs in this. +typedef JsonPost = + Future<({int status, String body})> Function(Uri url, String json); + +/// Sends uploads to a server's `POST /api/telemetry/runs`, which the +/// reference server in `cloud/` answers. +final class HttpTelemetrySink implements TelemetrySink { + const HttpTelemetrySink({required this.endpoint, required this.post}); + + /// The server's runs endpoint, in full. + final Uri endpoint; + final JsonPost post; + + @override + Future deliver(TelemetryUpload upload) async { + final ({int status, String body}) answer; + try { + answer = await post(endpoint, jsonEncode(upload.toJson())); + } catch (error) { + return ( + did: false, + says: 'could not reach $endpoint: $error', + receipt: null, + ); + } + final Object? body; + try { + body = jsonDecode(answer.body); + } on FormatException { + return ( + did: false, + says: '$endpoint answered ${answer.status} with no JSON', + receipt: null, + ); + } + final says = body is Map && body['says'] is String + ? body['says'] as String + : '$endpoint answered ${answer.status}'; + if (answer.status != 201 || body is! Map) { + return (did: false, says: says, receipt: null); + } + final run = body['run']; + final key = body['eraseKey']; + if (run is! num || key is! String) { + return ( + did: false, + says: '$endpoint took the run and gave no receipt for it', + receipt: null, + ); + } + return ( + did: true, + says: says, + receipt: TelemetryReceipt(run: run.toInt(), eraseKey: key), + ); + } +} + +/// Sends a finished run, if and only if the player agreed. +/// +/// **The check is here and not in every game**, so a game cannot forget it: +/// [send] reads [consent] at the moment of sending, and the sink is never +/// called without a grant to [policy]. +final class TelemetryUploader { + TelemetryUploader({ + required this.game, + required this.policy, + required this.consent, + required this.sink, + }); + + /// The game's name, as the server knows the games it can re-simulate. + final String game; + + /// The wording consent is asked under now. + final String policy; + + /// The player's answer as it stands, read on every send — a player who + /// withdraws mid-session stops the next run, not the next launch. + final TelemetryConsent Function() consent; + + final TelemetrySink sink; + + Future send(Demo demo) async { + final prepared = TelemetryUpload.prepare( + game: game, + demo: demo, + consent: consent(), + policy: policy, + ); + final upload = prepared.upload; + if (upload == null) { + return (did: false, says: prepared.says, receipt: null); + } + return sink.deliver(upload); + } +} diff --git a/packages/flutter3d_sim/lib/src/world/world_step.dart b/packages/flutter3d_sim/lib/src/world/world_step.dart index 61f990979..27e2bccdf 100644 --- a/packages/flutter3d_sim/lib/src/world/world_step.dart +++ b/packages/flutter3d_sim/lib/src/world/world_step.dart @@ -64,7 +64,7 @@ final class WorldStep { final CollisionWorld collision; final MechanismWorld? mechanisms; - final Dynamics? dynamics; + final RigidDynamics? dynamics; /// Whether a step has run [movers] and not yet reached [publish]. /// diff --git a/packages/flutter3d_sim/test/behaviour_tree_test.dart b/packages/flutter3d_sim/test/behaviour_tree_test.dart new file mode 100644 index 000000000..d7c56d06a --- /dev/null +++ b/packages/flutter3d_sim/test/behaviour_tree_test.dart @@ -0,0 +1,619 @@ +/// Behaviour trees as data: what a document is refused for, what each node +/// does, and that a decision half made comes back from a snapshot. +library; + +import 'dart:convert'; + +import 'package:flutter3d_sim/flutter3d_sim.dart'; +import 'package:test/test.dart'; +import 'package:vector_math/vector_math.dart'; + +const double _dt = 1.0 / 60.0; + +/// Adds one to [key] every tick and succeeds — a leaf a game would register, +/// here to count how often a node was really ticked. +final class _Count extends BehaviourLeaf { + const _Count(this.key); + final String key; + + @override + BehaviourStatus tick(BehaviourContext context) { + context.board.set(key, (context.board.number(key) ?? 0.0) + 1.0); + return BehaviourStatus.success; + } +} + +/// Runs for ever. +final class _Hold extends BehaviourLeaf { + const _Hold(); + + @override + BehaviourStatus tick(BehaviourContext context) => BehaviourStatus.running; +} + +BehaviourKinds _kinds() => BehaviourKinds() + ..leaf('count', (p) => _Count(p.text('key'))) + ..leaf('hold', (p) => const _Hold()); + +BehaviourTree _tree(Map json) { + final read = BehaviourTree.read(json, _kinds()); + expect(read.problems, isEmpty); + return read.tree!; +} + +/// One actor with no body, thinking every step: the focus is at the origin +/// and so is it, which keeps it inside the close range. +final class _Rig { + _Rig(BehaviourTree tree, {Map? board}) + : system = ActorSystem(world: CollisionWorld(), random: GameRandom(1)) { + actor = system.spawn(brain: BehaviourBrain(tree)); + if (board != null) { + system.entities.set(actor.entity, Blackboard(values: board)); + } + } + + final ActorSystem system; + late final Actor actor; + + Blackboard get board => system.entities.get(actor.entity)!; + + List get path => [ + for (final step in BehaviourBrain.pathOf(actor)) + '${step.label}:${step.status.name}', + ]; + + void step([int times = 1]) { + for (var i = 0; i < times; i++) { + system + ..beginStep() + ..step(_dt, focus: Vector3.zero()); + } + } +} + +void main() { + group('reading a document', () { + test('lists every problem with where it is, and the kinds it knows', () { + final read = BehaviourTree.read({ + 'kind': 'selector', + 'children': [ + {'kind': 'fly'}, + {'kind': 'wait'}, + {'kind': 'utility'}, + {'kind': 'sequence'}, + ], + }, BehaviourKinds()); + + // Mutation: stopping at the first problem leaves three of these out. + expect(read.tree, isNull); + expect(read.problems, hasLength(4)); + expect( + read.problems[0], + startsWith('root.children[0]: no leaf kind "fly"'), + ); + expect(read.problems[0], contains('goToFocus')); + expect( + read.problems[1], + 'root.children[1]: wait needs a number "seconds"', + ); + expect( + read.problems[2], + 'root.children[2]: a utility lists its "options"', + ); + expect( + read.problems[3], + 'root.children[3]: a sequence lists its "children"', + ); + }); + + test('an unknown consideration is refused with the ones that exist', () { + final read = BehaviourTree.read({ + 'kind': 'utility', + 'options': [ + { + 'considerations': [ + {'kind': 'mood'}, + ], + 'do': {'kind': 'hold'}, + }, + ], + }, _kinds()); + expect(read.problems, hasLength(1)); + expect( + read.problems.single, + startsWith( + 'root.options[0].considerations[0]: no consideration kind "mood"', + ), + ); + expect(read.problems.single, contains('focusDistance')); + }); + + test('a leaf may not take a composite\'s name', () { + expect( + () => BehaviourKinds().leaf('sequence', (p) => const _Hold()), + throwsArgumentError, + ); + expect( + () => BehaviourKinds().leaf('wait', (p) => const _Hold()), + throwsArgumentError, + ); + // Replacing on purpose is the way a game swaps a standard kind. + BehaviourKinds().leaf('wait', (p) => const _Hold(), replace: true); + }); + }); + + group('nodes', () { + test('a sequence resumes at the child that was running', () { + final rig = _Rig( + _tree({ + 'kind': 'sequence', + 'children': [ + {'kind': 'count', 'key': 'n'}, + {'kind': 'wait', 'seconds': 0.5}, + ], + }), + ); + rig.step(10); + // Mutation: a sequence that starts again from its first child every + // tick counts ten here instead of one. + expect(rig.board.number('n'), 1.0); + expect(rig.path, ['sequence:running', 'wait:running']); + + // Half a second on, the wait ends, the sequence succeeds, and the next + // tick starts it again from the top. + rig.step(30); + expect(rig.board.number('n'), 2.0); + }); + + test('a selector asks its first child again every tick', () { + final rig = _Rig( + _tree({ + 'kind': 'selector', + 'children': [ + { + 'kind': 'sequence', + 'name': 'alarm', + 'children': [ + {'kind': 'check', 'key': 'alarm'}, + {'kind': 'hold'}, + ], + }, + {'kind': 'wait', 'name': 'idle', 'seconds': 100}, + ], + }), + ); + rig.step(5); + expect(rig.path.last, 'idle:running'); + final waitNode = BehaviourBrain.pathOf(rig.actor).last.node; + expect(rig.board.memory, contains(waitNode)); + + rig.board.set('alarm', true); + rig.step(); + // Mutation: a selector that remembers its running child stays idle + // until the hundred-second wait ends. + expect(rig.path, [ + 'selector:running', + 'alarm:running', + 'hold:running', + ]); + // The abandoned wait's start time is forgotten, so it starts afresh. + expect(rig.board.memory, isNot(contains(waitNode))); + }); + + test('a utility runs the best option, and holds on to it', () { + final rig = _Rig( + _tree({ + 'kind': 'utility', + 'inertia': 0.2, + 'options': [ + { + 'name': 'rest', + 'considerations': [ + { + 'kind': 'blackboard', + 'key': 'tired', + 'from': 0, + 'to': 1, + }, + ], + 'do': {'kind': 'hold'}, + }, + { + 'name': 'work', + 'considerations': [ + {'kind': 'constant', 'value': 0.5}, + ], + 'do': {'kind': 'hold'}, + }, + { + 'name': 'never', + 'considerations': [ + {'kind': 'constant', 'value': 0}, + ], + 'weight': 100, + 'do': {'kind': 'hold'}, + }, + ], + }), + board: {'tired': 0.2}, + ); + rig.step(); + // Mutation: a sum rather than a product lets the zero-scored option + // win on its weight. + expect(rig.path.last, 'work:running'); + + // Past work's half, but not by its inertia: the running option stays. + rig.board.set('tired', 0.6); + rig.step(); + // Mutation: without inertia this flips to rest. + expect(rig.path.last, 'work:running'); + + rig.board.set('tired', 0.8); + rig.step(); + expect(rig.path.last, 'rest:running'); + }); + + test('a cooldown refuses its child until the time is up', () { + final rig = _Rig( + _tree({ + 'kind': 'selector', + 'children': [ + { + 'kind': 'cooldown', + 'seconds': 1, + 'child': {'kind': 'count', 'key': 'n'}, + }, + {'kind': 'hold'}, + ], + }), + ); + rig.step(30); + expect(rig.board.number('n'), 1.0); + rig.step(31); + // Mutation: a cooldown kept in memory that is forgotten off the + // running path would count every tick. + expect(rig.board.number('n'), 2.0); + }); + + test('invert and alwaysSucceed turn their child\'s answer', () { + final rig = _Rig( + _tree({ + 'kind': 'sequence', + 'children': [ + { + 'kind': 'invert', + 'child': {'kind': 'check', 'key': 'x'}, + }, + { + 'kind': 'alwaysSucceed', + 'child': {'kind': 'check', 'key': 'x'}, + }, + ], + }), + ); + // Nothing runs, so every decision starts from the top and both + // answers are seen. + rig.step(); + // Mutation: an alwaysSucceed that passed its child's failure through + // fails the sequence here. + expect(rig.path, [ + 'sequence:success', + 'alwaysSucceed:success', + 'check:failure', + ]); + rig.board.set('x', true); + rig.step(); + // Mutation: an invert that passed its child's answer through lets the + // sequence carry on to the second child. + expect(rig.path, [ + 'sequence:failure', + 'invert:failure', + 'check:success', + ]); + }); + }); + + group('the board', () { + test('refuses what a snapshot cannot hold', () { + final board = Blackboard(); + expect(() => board.set('at', Vector3.zero()), throwsArgumentError); + board + ..setPoint('at', Vector3(1.0, 2.0, 3.0)) + ..set('seen', true) + ..set('names', ['a', 1, null]); + final read = Blackboard.fromJson(jsonDecode(jsonEncode(board.toJson())))!; + final at = Vector3.zero(); + expect(read.point('at', at), isTrue); + expect(at, Vector3(1.0, 2.0, 3.0)); + expect(read.flag('seen'), isTrue); + expect(read['names'], ['a', 1, null]); + }); + + test('a tree that changed starts its decision again', () { + final first = _tree({ + 'kind': 'sequence', + 'children': [ + {'kind': 'count', 'key': 'n'}, + {'kind': 'hold'}, + ], + }); + final rig = _Rig(first)..step(3); + expect(rig.board.number('n'), 1.0); + + // Same shape, one more child: node numbers are the same, the digest + // is not. + final second = _tree({ + 'kind': 'sequence', + 'children': [ + {'kind': 'count', 'key': 'n'}, + {'kind': 'hold'}, + {'kind': 'hold'}, + ], + }); + rig.system.entities.get(rig.actor.entity)!.brain = + BehaviourBrain(second); + rig.step(); + // Mutation: a board that trusted its node numbers would resume at the + // hold and never count again. + expect(rig.board.number('n'), 2.0); + expect(rig.board.tree, second.digestHex); + }); + + test('noise and pain are written where the tree can read them', () { + // A body, because hearing is by distance and a bodiless actor is + // nowhere to hear from. + final world = CollisionWorld() + ..addBox(Vector3(0.0, -0.5, 0.0), Vector3(20.0, 1.0, 20.0)) + ..update(); + final system = ActorSystem(world: world, random: GameRandom(1)); + final actor = system.spawn( + body: CharacterController(world: world, position: Vector3(0, 0.9, 0)), + health: Health(10.0), + brain: BehaviourBrain(_tree({'kind': 'hold'})), + ); + for (var i = 0; i < 6; i++) { + system + ..beginStep() + ..step(_dt, focus: Vector3(0.0, 0.9, 5.0)); + } + final board = BehaviourBrain.boardOf(actor); + + system.hear(Vector3(1.0, 0.0, 0.0), radius: 1000.0); + // Mutation: an onNoise left at Brain's default writes nothing here. + expect(board.number(BehaviourBrain.heardAt), closeTo(0.1, 1e-12)); + final at = Vector3.zero(); + expect(board.point(BehaviourBrain.heard, at), isTrue); + expect(at.x, 1.0); + + system.beginStep(); + actor.applyDamage(1.0); + expect(board.number(BehaviourBrain.hurtAt), closeTo(0.1, 1e-12)); + }); + }); + + group('determinism', () { + test('a run of trees has the digests it had', () { + final run = _Arena(); + final trace = DigestTrace(every: 60); + final chosen = {}; + for (var step = 1; step <= 600; step++) { + run.step(step); + trace.observe(step, run.state()); + for (final actor in run.system.actors) { + final path = BehaviourBrain.pathOf(actor); + if (path.length > 1) chosen.add(path[1].label); + } + } + // A trace of a run that never left one option would hold its digests + // while the other two were broken. + expect( + chosen, + containsAll(['close in', 'look into it', 'patrol']), + ); + // Recorded once on the VM. Every number here is `+`, `-`, `*`, `/`, + // `sqrt` and `Portable`, so these are the same on every platform; a + // change of any of them is a change of behaviour, and says at which + // second it started. + expect(trace.hexDigests, _arenaDigests); + }); + + test('a decision half made comes back from a snapshot', () { + // The snapshot is taken on the first step past four seconds at which + // somebody is part-way through a wait: mid-decision, with a sequence's + // cursor and a wait's start on a board, which is the case worth + // checking. Found the same way on every run, so the test is too. + final original = _Arena(); + var at = 0; + while (at < 240 || !original.waiting) { + at++; + original.step(at); + } + expect(at, lessThan(500)); + final saved = jsonDecode(jsonEncode(original.state())) as Map; + + final restored = _Arena()..restore(saved); + final blind = _Arena() + ..restore({ + ...saved.cast(), + 'ecs': _withoutBoards(saved['ecs'] as Map), + }); + final agree = []; + final blindAgrees = []; + for (var step = at + 1; step <= at + 300; step++) { + original.step(step); + restored.step(step); + blind.step(step); + final want = StateDigest.of(original.state()); + agree.add(StateDigest.of(restored.state()) == want); + blindAgrees.add(StateDigest.of(blind.state()) == want); + } + expect(agree, everyElement(isTrue)); + // Mutation: the boards are what carry the decision; a restore without + // them is a different run, and this says the test can tell. + expect(blindAgrees, contains(isFalse)); + }); + }); +} + +Map _withoutBoards(Map ecs) => { + ...ecs.cast(), + 'components': { + ...(ecs['components'] as Map).cast(), + }..remove('blackboard'), +}; + +/// Three actors on a floor with a wall, one tree, a focus that walks a +/// square, and a noise every few seconds. +/// +/// The tree uses every composite and most standard leaves: go for the focus +/// when it is seen and near, look into a noise heard recently, otherwise +/// patrol between two posts with a pause at each. +final class _Arena { + _Arena() { + world + ..addBox(Vector3(0.0, -0.5, 0.0), Vector3(80.0, 1.0, 80.0)) + ..addBox(Vector3(0.0, 1.5, 4.0), Vector3(8.0, 3.0, 0.5)) + ..update(); + for (final (i, x) in <(int, double)>[(0, -6.0), (1, 3.0), (2, 22.0)]) { + final actor = system.spawn( + body: CharacterController( + world: world, + position: Vector3(x, 0.9, -3.0 - i), + ), + health: Health(30.0), + brain: BehaviourBrain(_arenaTree), + facing: Facing(), + name: 'a$i', + ); + system.entities.set( + actor.entity, + Blackboard( + values: { + 'postA': [x - 3.0, 0.9, -8.0], + 'postB': [x + 3.0, 0.9, -8.0], + }, + ), + ); + } + } + + final CollisionWorld world = CollisionWorld(); + late final ActorSystem system = ActorSystem( + world: world, + random: GameRandom(7), + ); + + static final BehaviourTree _arenaTree = BehaviourTree.read( + _arenaJson, + BehaviourKinds(), + ).tree!; + + /// The focus walks a square of side twelve, once every twenty seconds. + Vector3 focusAt(int step) { + final t = (step % 1200) / 1200.0 * 4.0; + final side = t.floor(); + final along = (t - side) * 12.0 - 6.0; + return switch (side) { + 0 => Vector3(along, 0.9, 10.0), + 1 => Vector3(6.0, 0.9, 10.0 - (along + 6.0)), + 2 => Vector3(-along, 0.9, -2.0), + _ => Vector3(-6.0, 0.9, -2.0 + (along + 6.0)), + }; + } + + void step(int step) { + system + ..beginStep() + ..step(_dt, focus: focusAt(step)); + if (step % 170 == 0) { + system.hear( + Vector3(step % 340 == 0 ? 10.0 : -10.0, 0.9, 0.0), + radius: 40, + ); + } + } + + /// Whether any actor is in a wait right now. + bool get waiting => system.actors.any( + (actor) => BehaviourBrain.pathOf(actor).any((s) => s.kind == 'wait'), + ); + + Map state() => { + 'ecs': system.entities.save(), + 'system': system.save(), + }; + + void restore(Map state) { + system.entities.restore((state['ecs']! as Map).cast()); + system + ..restore(state['system']) + ..syncCorpses(); + } +} + +const Map _arenaJson = { + 'kind': 'utility', + 'inertia': 0.1, + 'options': [ + { + 'name': 'close in', + 'considerations': [ + {'kind': 'seesFocus'}, + {'kind': 'focusDistance', 'from': 12, 'to': 4}, + ], + 'do': { + 'kind': 'sequence', + 'children': [ + {'kind': 'markFocus', 'key': 'lastSeen'}, + {'kind': 'goToFocus', 'within': 1.5}, + {'kind': 'wait', 'seconds': 0.5}, + ], + }, + }, + { + 'name': 'look into it', + 'weight': 0.8, + 'considerations': [ + { + 'kind': 'since', + 'key': 'heardAt', + 'from': 6, + 'to': 1, + }, + ], + 'do': { + 'kind': 'cooldown', + 'seconds': 2, + 'child': {'kind': 'goTo', 'key': 'heard', 'within': 1}, + }, + }, + { + 'name': 'patrol', + 'considerations': [ + {'kind': 'constant', 'value': 0.2}, + ], + 'do': { + 'kind': 'sequence', + 'children': [ + {'kind': 'goTo', 'key': 'postA'}, + {'kind': 'wait', 'seconds': 1}, + {'kind': 'goTo', 'key': 'postB'}, + {'kind': 'wait', 'seconds': 1}, + ], + }, + }, + ], +}; + +const List _arenaDigests = [ + '8e79608e', + '147317ff', + '86db2f1d', + '0e366189', + '4bab99a5', + '35708b46', + '79ff0948', + '893cf555', + '257ecce7', + '6bee2463', +]; diff --git a/packages/flutter3d_sim/test/brush_draw_order_test.dart b/packages/flutter3d_sim/test/brush_draw_order_test.dart new file mode 100644 index 000000000..fdb93ab76 --- /dev/null +++ b/packages/flutter3d_sim/test/brush_draw_order_test.dart @@ -0,0 +1,72 @@ +/// A brush's place in the draw order, said in the level document — `P7`. +/// +/// dart test test/brush_draw_order_test.dart +/// +/// The engine has had `MeshNode.drawOrder` since the render order was added, +/// and a level could not ask for it: a water surface that has to come after +/// the floor under it, a decal brush laid over a wall, were reachable from +/// Dart and unauthorable. Pinned here: the number is read, a brush that does +/// not ask says nothing about it, and two brushes of one stone that draw at +/// different places are two batches, because a batch draws as one. +library; + +import 'package:flutter3d_sim/flutter3d_sim.dart'; +import 'package:test/test.dart'; +import 'package:vector_math/vector_math.dart'; + +Brush _wall(double x, {int drawOrder = 0}) => Brush( + centre: Vector3(x, 2.0, 0.0), + size: Vector3(4.0, 4.0, 1.0), + material: 'wall', + drawOrder: drawOrder, +); + +void main() { + test('a document that says nothing draws at nought', () { + final brush = Brush.fromJson(const { + 'at': [0.0, 0.0, 0.0], + 'size': [1.0, 1.0, 1.0], + }); + expect(brush.drawOrder, 0); + expect(brush.toJson().containsKey('drawOrder'), isFalse); + }); + + test('a number is read and written back', () { + // Mutation: drop `drawOrder` from `Brush.toJson` — a level saved from + // the editor forgets its water's order. + final brush = Brush.fromJson(const { + 'at': [0.0, 0.0, 0.0], + 'size': [1.0, 1.0, 1.0], + 'drawOrder': 2, + }); + expect(brush.drawOrder, 2); + // Built in code, so there is no document for the write to pass through + // and the number has to come from the field. + expect(_wall(0.0, drawOrder: 2).toJson()['drawOrder'], 2); + }); + + test('two walls of one stone at different places in the order are two ' + 'batches, each saying its place', () { + // Mutation: leave the order out of `builderFor`'s key — one surface, + // which can say only one order. + final surfaces = const BrushGeometry().build( + Level(brushes: [_wall(0.0), _wall(20.0, drawOrder: 1)]), + ); + expect(surfaces, hasLength(2)); + expect(surfaces.map((BrushSurface it) => it.drawOrder).toSet(), { + 0, + 1, + }); + }); + + test('a breach keeps the order of the brush it cut', () { + // Mutation: drop `drawOrder` from the pieces `subtractBox` builds — the + // wall round a breach falls back to nought. + final cut = subtractBox( + _wall(0.0, drawOrder: 3), + Aabb3.minMax(Vector3(-0.5, 1.5, -1.0), Vector3(0.5, 2.5, 1.0)), + ); + expect(cut, isNotEmpty); + expect(cut.every((Brush it) => it.drawOrder == 3), isTrue); + }); +} diff --git a/packages/flutter3d_sim/test/demo_test.dart b/packages/flutter3d_sim/test/demo_test.dart index 2d15d2fe9..043415c7d 100644 --- a/packages/flutter3d_sim/test/demo_test.dart +++ b/packages/flutter3d_sim/test/demo_test.dart @@ -173,6 +173,113 @@ void main() { }); }); + group('HR3: a level swapped under the run', () { + Demo swapped(List swaps) { + final plain = _demo(steps: 6); + return Demo( + level: plain.level, + levelHash: plain.levelHash, + start: plain.start, + tape: plain.tape, + buildStamp: plain.buildStamp, + checkpoints: plain.checkpoints, + levelSwaps: swaps, + ); + } + + test('carries the document and its step through JSON', () { + final edit = Level(name: 'crypt', fogDensity: 0.02); + final json = + jsonDecode( + jsonEncode( + swapped([ + DemoLevelSwap(step: 4, level: edit), + ]).toJson(), + ), + ) + as Map; + + final read = Demo.fromJson(json); + + expect(read.levelSwaps.single.step, 4); + expect(read.levelSwaps.single.levelHash, edit.digestHex); + expect(read.levelSwaps.single.level.fogDensity, 0.02); + // Mutation: write 1 whatever the run holds — a build that cannot + // replay the swap opens the file and plays through it to a divergence. + expect(json['version'], 2); + }); + + test('a run with no swap is still written as format 1', () { + // Mutation: always write `formatVersion` — every demo this build + // records becomes unreadable to the build before it, for nothing. + expect(_demo().toJson()['version'], 1); + expect(_demo().toJson().containsKey('levelSwaps'), isFalse); + }); + + test('refuses a document that no longer digests to its hash', () { + final json = swapped([ + DemoLevelSwap(step: 2, level: Level(name: 'crypt')), + ]).toJson(); + final swap = (json['levelSwaps']! as List>).single; + (swap['document']! as Map)['fogDensity'] = 0.5; + + // Mutation: trust the hash — the replay swaps in a level nobody played. + expect( + () => + Demo.fromJson(jsonDecode(jsonEncode(json)) as Map), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('digests to'), + ), + ), + ); + }); + + test('refuses swaps out of order, or past the end of the tape', () { + final level = Level(name: 'crypt'); + for (final swaps in >[ + [ + DemoLevelSwap(step: 4, level: level), + DemoLevelSwap(step: 2, level: level), + ], + [ + DemoLevelSwap(step: 3, level: level), + DemoLevelSwap(step: 3, level: level), + ], + [DemoLevelSwap(step: 7, level: level)], + ]) { + // Mutation: drop the order check — a replay walking the list once + // would skip the swap that came second and play the wrong level. + expect( + () => Demo.fromJson( + jsonDecode(jsonEncode(swapped(swaps).toJson())) + as Map, + ), + throwsA(isA()), + reason: 'steps ${swaps.map((s) => s.step).toList()}', + ); + } + }); + + test('a trace forgets what was observed after a step, and keeps the ' + 'rest', () { + final trace = DigestTrace(every: 2); + for (var step = 1; step <= 8; step++) { + trace.observe(step, {'step': step}); + } + + trace.forgetAfter(5); + + // Mutation: forget from the step on — the checkpoint at the swap's own + // step, taken under the old level and still true, goes with it. + expect(trace.steps, [2, 4]); + trace.forgetAfter(4); + expect(trace.steps, [2, 4]); + }); + }); + group('the loop', () { test('records one entry per step, with the step\'s look in it', () { // The moment is the whole point: after the look for the step is added diff --git a/packages/flutter3d_sim/test/entity_tracks_test.dart b/packages/flutter3d_sim/test/entity_tracks_test.dart new file mode 100644 index 000000000..a01d97126 --- /dev/null +++ b/packages/flutter3d_sim/test/entity_tracks_test.dart @@ -0,0 +1,134 @@ +/// `N4`'s per-entity tracks: a run read back as one lane per component of +/// each entity, holding only the steps at which something changed. +/// +/// flutter test test/entity_tracks_test.dart +library; + +import 'package:flutter3d_sim/flutter3d_sim.dart'; +import 'package:test/test.dart'; + +final class _Health { + _Health(this.hp); + int hp; +} + +EcsWorld _world() => EcsWorld() + ..register<_Health>( + 'Health', + encode: (h) => h.hp, + decode: (d) => d is num ? _Health(d.toInt()) : null, + ); + +void main() { + test('reads an EcsWorld save as entity, component, value', () { + // Mutation: key the pivot by component first — the entity is then + // `Health` and the component `0`. + final world = _world(); + final a = world.spawn(); + world.set(a, _Health(5)); + final entities = EntityLayout.ecs(const [ + 'world', + ]).entitiesOf(Snapshot({'world': world.save()})); + + expect(entities, >{ + '${a.index}': {'Health': 5}, + }); + }); + + test('reads rows as entities, by index or by key', () { + // Mutation: name list rows by their contents' `id` — a row with none + // then disappears. + final listed = EntityLayout.rows('monsters').entitiesOf( + const Snapshot({ + 'monsters': [ + {'hp': 3, 'x': 1.0}, + 7, + ], + }), + ); + expect(listed, >{ + '0': {'hp': 3, 'x': 1.0}, + '1': {'value': 7}, + }); + + final keyed = EntityLayout.rows('doors').entitiesOf( + const Snapshot({ + 'doors': { + 'crypt_door': {'open': false}, + }, + }), + ); + expect(keyed.keys, ['crypt_door']); + }); + + test('a lane holds the changes, and a removal closes it', () { + // Mutation: append a sample on every observe. The lane for a value that + // held still is then a hundred entries long. + final world = _world(); + final hero = world.spawn(); + final crate = world.spawn(); + world + ..set(hero, _Health(10)) + ..set(crate, _Health(1)); + final tracks = EntityTracks(EntityLayout.ecs()); + for (var step = 0; step < 100; step++) { + if (step == 40) world.get<_Health>(hero)!.hp = 7; + if (step == 60) world.remove<_Health>(crate); + tracks.observe(step, Snapshot(world.save())); + } + + final heroLane = tracks.samples('${hero.index}', 'Health'); + expect([for (final s in heroLane) (s.step, s.value)], [(0, 10), (40, 7)]); + + final crateLane = tracks.samples('${crate.index}', 'Health'); + expect( + [for (final s in crateLane) (s.step, s.present)], + [(0, true), (60, false)], + ); + expect(tracks.at('${hero.index}', 'Health', 39)!.value, 10); + expect(tracks.at('${hero.index}', 'Health', 40)!.value, 7); + expect(tracks.at('${crate.index}', 'Health', 80)!.present, isFalse); + expect(tracks.span, (first: 0, last: 99)); + }); + + test('a step observed twice is not a second history', () { + // Mutation: drop the guard on `step <= last`. A scrubber that asks for + // the same window again would then double every mark. + final tracks = EntityTracks(EntityLayout.rows('e')); + Snapshot at(int v) => Snapshot({ + 'e': [ + {'v': v}, + ], + }); + tracks + ..observe(0, at(1)) + ..observe(1, at(2)) + ..observe(1, at(3)) + ..observe(0, at(4)); + + expect([for (final s in tracks.samples('0', 'v')) s.value], [1, 2]); + }); + + test('differences names every component that moved, in order', () { + // Mutation: compare presence only. A changed value on both sides is + // then no difference. + final layout = EntityLayout.rows('e'); + final a = const Snapshot({ + 'e': [ + {'hp': 1, 'x': 0}, + {'hp': 1}, + ], + }); + final b = const Snapshot({ + 'e': [ + {'hp': 1, 'x': 2}, + {'hp': 1, 'shield': true}, + ], + }); + + expect(layout.differences(a, b), [ + const EntityComponent('0', 'x'), + const EntityComponent('1', 'shield'), + ]); + }); +} diff --git a/packages/flutter3d_sim/test/f3drun_info_test.dart b/packages/flutter3d_sim/test/f3drun_info_test.dart index bd27b09ee..ac6da372e 100644 --- a/packages/flutter3d_sim/test/f3drun_info_test.dart +++ b/packages/flutter3d_sim/test/f3drun_info_test.dart @@ -60,6 +60,42 @@ void main() { expect(out, contains('buildStamp: test-build-42')); expect(out, contains('platform: macos')); expect(out, contains('recordedBy: dmitrii')); + expect(out, isNot(contains('levelSwap'))); + }); + + test('a level swapped under the run is listed with its step', () { + final dir = Directory.systemTemp.createTempSync('f3drun_info_test'); + addTearDown(() => dir.deleteSync(recursive: true)); + final plain = _demo(); + final edit = Level(name: 'crypt', fogDensity: 0.02); + final file = File('${dir.path}/run${Demo.fileExtension}') + ..writeAsStringSync( + jsonEncode( + Demo( + level: plain.level, + levelHash: plain.levelHash, + start: plain.start, + tape: plain.tape, + buildStamp: plain.buildStamp, + checkpoints: plain.checkpoints, + levelSwaps: [DemoLevelSwap(step: 1, level: edit)], + ).toJson(), + ), + ); + + final result = Process.runSync('dart', [ + 'run', + 'bin/f3drun_info.dart', + file.path, + ], workingDirectory: Directory.current.path); + + // Mutation: leave the swaps out of the listing — a file that replays + // only in the game that recorded it looks like any other run. + expect(result.exitCode, 0, reason: 'stderr was: ${result.stderr}'); + expect( + result.stdout as String, + contains('levelSwap: step 1, ${edit.digestHex}'), + ); }); test('a demo from a newer format is refused with a suggestion, not a ' diff --git a/packages/flutter3d_sim/test/level_diff_test.dart b/packages/flutter3d_sim/test/level_diff_test.dart new file mode 100644 index 000000000..6887a9b4f --- /dev/null +++ b/packages/flutter3d_sim/test/level_diff_test.dart @@ -0,0 +1,150 @@ +/// `diffLevel` splits an edit into what can be patched into a running scene +/// and what has to go through a timeline branch. +/// +/// dart test test/level_diff_test.dart +/// +/// Mutation: drop `'entities'` from the simulation list and the moved crate +/// patches in place; compare lights by count alone and the recoloured lamp +/// is missed. +library; + +import 'package:flutter3d_sim/flutter3d_sim.dart'; +import 'package:test/test.dart'; + +Map _document() => { + 'version': 1, + 'name': 'yard', + 'fogDensity': 0.02, + 'materials': { + 'stone': { + 'color': [0.5, 0.5, 0.5, 1.0], + }, + }, + 'brushes': [ + { + 'at': [0, -0.5, 0], + 'size': [20, 1, 20], + 'material': 'stone', + }, + ], + 'lights': [ + { + 'type': 'point', + 'at': [0, 3, 0], + 'color': [1, 1, 1], + }, + ], + 'entities': [ + { + 'type': 'crate', + 'at': [2, 0, 2], + }, + ], +}; + +/// The level, after [edit] has changed its document. +Level _edited(void Function(Map json) edit) => + Level.fromJson(_document()..let(edit)); + +extension on Map { + void let(void Function(Map) edit) => edit(this); +} + +void main() { + final before = Level.fromJson(_document()); + + test('the same document is no change', () { + final diff = diffLevel(before, Level.fromJson(_document())); + expect(diff.isEmpty, isTrue); + expect(diff.presentationOnly, isTrue); + }); + + test('a recoloured lamp, a material and the fog patch in place', () { + final after = _edited((json) { + ((json['lights']! as List).single! + as Map)['color'] = [ + 1, + 0.5, + 0.2, + ]; + json['materials'] = { + 'stone': { + 'color': [0.3, 0.3, 0.3, 1.0], + }, + 'moss': { + 'color': [0.1, 0.4, 0.1, 1.0], + }, + }; + json['fogDensity'] = 0.05; + }); + + final diff = diffLevel(before, after); + + expect(diff.lights, [0]); + expect(diff.lightCountChanged, isFalse); + expect(diff.materials, unorderedEquals(['stone', 'moss'])); + expect(diff.fog, isTrue); + expect(diff.music, isFalse); + expect(diff.presentationOnly, isTrue); + }); + + test('a lamp added is a count change, not a patch of index one', () { + final after = _edited((json) { + (json['lights']! as List).add({ + 'type': 'point', + 'at': [5, 3, 0], + }); + }); + + final diff = diffLevel(before, after); + + expect(diff.lightCountChanged, isTrue); + expect(diff.lights, isEmpty); + expect(diff.presentationOnly, isTrue); + }); + + test('a moved crate and a wider floor go through the timeline', () { + final after = _edited((json) { + ((json['entities']! as List).single! + as Map)['at'] = [ + 3, + 0, + 2, + ]; + ((json['brushes']! as List).single! + as Map)['size'] = [ + 30, + 1, + 20, + ]; + }); + + final diff = diffLevel(before, after); + + expect(diff.simulation, ['brushes', 'entities']); + expect(diff.presentationOnly, isFalse); + }); + + test('a brush that only changes material is still the simulation\'s', () { + // Its surface falls back to its material, and footsteps read surfaces. + final after = _edited((json) { + ((json['brushes']! as List).single! + as Map)['material'] = + 'moss'; + }); + + expect(diffLevel(before, after).simulation, ['brushes']); + }); + + test('the next level is the run\'s, the music the picture\'s', () { + final after = _edited((json) { + json['next'] = 'cellar'; + json['music'] = 'rain.ogg'; + }); + + final diff = diffLevel(before, after); + + expect(diff.simulation, ['next']); + expect(diff.music, isTrue); + }); +} diff --git a/packages/flutter3d_sim/test/level_patch_test.dart b/packages/flutter3d_sim/test/level_patch_test.dart new file mode 100644 index 000000000..db7e02bdf --- /dev/null +++ b/packages/flutter3d_sim/test/level_patch_test.dart @@ -0,0 +1,246 @@ +/// `LevelPatch` carries an edit to a running game as the rows that changed, +/// and refuses to be applied to any level but the one it was made against. +/// +/// dart test test/level_patch_test.dart +/// +/// Mutation: drop the `was` check in `applyTo` and a patch made against one +/// arrangement of brushes edits whichever brush now stands at that index; +/// align by position instead of by digest and a brush deleted from the +/// middle becomes a replacement of every row after it. +library; + +import 'dart:convert'; +import 'dart:math' as math; + +import 'package:flutter3d_sim/flutter3d_sim.dart'; +import 'package:test/test.dart'; + +Map _brush(double x, {String material = 'stone'}) => + { + 'at': [x, 0, 0], + 'size': [1, 1, 1], + 'material': material, + }; + +Map _document() => { + 'version': 1, + 'name': 'yard', + 'fogDensity': 0.02, + 'materials': { + 'stone': { + 'color': [0.5, 0.5, 0.5, 1.0], + }, + }, + 'brushes': [for (var i = 0; i < 10; i++) _brush(i * 2.0)], + 'lights': [ + { + 'type': 'point', + 'at': [0, 3, 0], + }, + { + 'type': 'point', + 'at': [6, 3, 0], + }, + ], + 'entities': [ + { + 'type': 'crate', + 'at': [2, 0, 2], + }, + ], +}; + +Level _edited(void Function(Map json) edit) { + final json = jsonDecode(jsonEncode(_document())) as Map; + edit(json); + return Level.fromJson(json); +} + +List _list(Map json, String key) => + json[key]! as List; + +/// The level [patch] makes of [level], or a failure naming the refusal. +Level _applied(LevelPatch patch, Level level) => switch (patch.applyTo(level)) { + LevelPatched(:final level) => level, + LevelPatchRefused(:final reason) => fail('refused: $reason'), +}; + +void main() { + final before = Level.fromJson(_document()); + + test('a moved brush is one row, and the run is told it was a brush', () { + final after = _edited((json) => _list(json, 'brushes')[4] = _brush(8.5)); + + final patch = LevelPatch.between(before, after); + + expect(patch.rows, hasLength(1)); + expect(patch.rows.single.at, 4); + expect(patch.materials, isEmpty); + expect(patch.fields, isEmpty); + final result = patch.applyTo(before) as LevelPatched; + expect(result.level.digestHex, after.digestHex); + expect(result.diff.simulation, ['brushes']); + }); + + test('a brush taken from the middle is one edit, not the rest shifted', () { + final after = _edited((json) => _list(json, 'brushes').removeAt(3)); + + final patch = LevelPatch.between(before, after); + + expect(patch.rows, hasLength(1)); + expect(patch.rows.single.removes, isTrue); + expect(_applied(patch, before).digestHex, after.digestHex); + }); + + test('a lamp recoloured is that lamp; one added is a count change', () { + final recoloured = _edited( + (json) => (_list(json, 'lights')[1]! as Map)['color'] = + [1, 0.5, 0.2], + ); + final one = LevelPatch.between(before, recoloured).applyTo(before); + expect((one as LevelPatched).diff.lights, [1]); + expect(one.diff.lightCountChanged, isFalse); + expect(one.diff.presentationOnly, isTrue); + + final added = _edited( + (json) => _list(json, 'lights').insert(1, { + 'type': 'point', + 'at': [3, 3, 0], + }), + ); + final two = LevelPatch.between(before, added).applyTo(before); + expect((two as LevelPatched).diff.lightCountChanged, isTrue); + expect(two.diff.presentationOnly, isTrue); + }); + + test('materials go by name and the fog as a field', () { + final after = _edited((json) { + (json['materials']! as Map)['moss'] = { + 'color': [0.1, 0.4, 0.1, 1.0], + }; + json['fogDensity'] = 0.05; + }); + + final patch = LevelPatch.between(before, after); + + expect(patch.rows, isEmpty); + expect(patch.materials.keys, ['moss']); + expect(patch.fields.keys, ['fogDensity']); + final result = patch.applyTo(before) as LevelPatched; + expect(result.diff.materials, ['moss']); + expect(result.diff.fog, isTrue); + expect(result.diff.presentationOnly, isTrue); + }); + + test('a list one side lacks goes whole, and is compared as a document', () { + final bare = Level.fromJson( + {..._document()}..remove('lights'), + ); + + final patch = LevelPatch.between(bare, before); + + expect(patch.fields.keys, ['lights']); + final result = patch.applyTo(bare) as LevelPatched; + expect(result.level.digestHex, before.digestHex); + expect(result.diff.lightCountChanged, isTrue); + }); + + test('refuses a level it was not made against, naming both', () { + final after = _edited((json) => json['fogDensity'] = 0.05); + final other = _edited((json) => _list(json, 'brushes').removeAt(0)); + + final result = LevelPatch.between(before, after).applyTo(other); + + expect(result, isA()); + expect( + (result as LevelPatchRefused).reason, + allOf(contains(before.digestHex), contains(other.digestHex)), + ); + }); + + test('refuses a row that is not the one it names', () { + final after = _edited((json) => _list(json, 'brushes')[4] = _brush(8.5)); + final patch = LevelPatch.between(before, after); + final pointedElsewhere = LevelPatch( + base: patch.base, + hash: patch.hash, + rows: [ + RowEdit( + list: 'brushes', + at: 5, + was: patch.rows.single.was, + row: patch.rows.single.row, + ), + ], + ); + + final result = pointedElsewhere.applyTo(before); + + expect((result as LevelPatchRefused).reason, contains('brushes[5]')); + }); + + test('crosses the wire as JSON and still applies', () { + final after = _edited((json) { + _list(json, 'brushes') + ..removeAt(7) + ..insert(2, _brush(-3, material: 'moss')); + _list(json, 'entities').add({'type': 'crate'}); + json['music'] = 'rain.ogg'; + }); + + final sent = LevelPatch.fromJson( + jsonDecode(jsonEncode(LevelPatch.between(before, after).toJson())) + as Map, + ); + + final result = sent.applyTo(before) as LevelPatched; + expect(result.level.digestHex, after.digestHex); + expect(result.diff.simulation, ['brushes', 'entities']); + expect(result.diff.music, isTrue); + }); + + test('any shuffle of rows patches to exactly the edited level', () { + // Removals, insertions, moves and duplicates at random, against the + // whole-document digest: the claim is that the alignment never produces + // a patch that makes a different level. + final random = math.Random(7); + for (var round = 0; round < 200; round++) { + final after = _edited((json) { + final brushes = _list(json, 'brushes'); + for (var n = random.nextInt(6); n > 0; n--) { + switch (random.nextInt(4)) { + case 0 when brushes.isNotEmpty: + brushes.removeAt(random.nextInt(brushes.length)); + case 1: + brushes.insert( + random.nextInt(brushes.length + 1), + _brush(random.nextInt(40) / 2), + ); + case 2 when brushes.isNotEmpty: + brushes[random.nextInt(brushes.length)] = _brush(-1.0 - round); + case _ when brushes.isNotEmpty: + brushes.add(brushes[random.nextInt(brushes.length)]); + } + } + }); + final patch = LevelPatch.between(before, after); + expect( + _applied(patch, before).digestHex, + after.digestHex, + reason: 'round $round', + ); + expect( + patch.rows.length, + lessThanOrEqualTo(10 + after.brushes.length), + reason: 'never more edits than rows on both sides', + ); + } + }); + + test('the same level is an empty patch', () { + expect( + LevelPatch.between(before, Level.fromJson(_document())).isEmpty, + isTrue, + ); + }); +} diff --git a/packages/flutter3d_sim/test/navmesh_bake_test.dart b/packages/flutter3d_sim/test/navmesh_bake_test.dart new file mode 100644 index 000000000..ba32a55bc --- /dev/null +++ b/packages/flutter3d_sim/test/navmesh_bake_test.dart @@ -0,0 +1,451 @@ +/// The navigation mesh bake, held against the grid it sits beside. +/// +/// dart test test/navmesh_bake_test.dart +/// +/// Three claims run through every scene here. Every polygon is convex and +/// turns the same way, because the path search that comes next walks across +/// a polygon in a straight line and a reflex corner would put that line +/// through a wall. Adjacency is symmetric, because a search that can go from +/// one polygon to the next and not back finds routes that only work one way. +/// And the mesh covers exactly the cells `NavGrid` says a body of the same +/// radius fits in, because the two are two answers to one question about one +/// level and a disagreement between them is a bug in one or the other. +/// +/// Then the digests: each scene's mesh written down as one number, so that CI +/// on every operating system bakes the same mesh or says it did not. +library; + +import 'dart:typed_data'; + +import 'package:flutter3d_sim/flutter3d_sim.dart'; +import 'package:test/test.dart'; +import 'package:vector_math/vector_math.dart'; + +Brush _box(double x0, double y0, double z0, double x1, double y1, double z1) => + Brush( + centre: Vector3((x0 + x1) / 2, (y0 + y1) / 2, (z0 + z1) / 2), + size: Vector3(x1 - x0, y1 - y0, z1 - z0), + ); + +/// Ten metres square, its top at nought. +List _floor() => [_box(-5, -1, -5, 5, 0, 5)]; + +/// Two rooms either side of a wall half a metre thick, joined by a doorway +/// two metres wide under a lintel. +List _rooms() => [ + _box(-6, -1, -3, 6, 0, 3), + _box(0, 0, -3, 0.5, 3, -1), + _box(0, 0, 1, 0.5, 3, 3), + _box(0, 2.5, -1, 0.5, 3, 1), +]; + +/// A floor and two steps up from it, each taller than an agent climbs. +List _tallSteps() => [ + _box(-2, -1, -4, 2, 0, 0), + _box(-2, -1, 0, 2, 0.6, 2), + _box(-2, -1, 2, 2, 1.2, 4), +]; + +/// The same, each step short enough to walk up. +List _shortSteps() => [ + _box(-2, -1, -4, 2, 0, 0), + _box(-2, -1, 0, 2, 0.3, 2), + _box(-2, -1, 2, 2, 0.6, 4), +]; + +/// A floor, a ramp climbing a metre and a half over six, and the platform at +/// its top. +List _ramp() => [ + _box(-2, -1, -4, 2, 0, 0), + Brush( + centre: Vector3(0, 0.75, 3), + size: Vector3(4, 1.5, 6), + ramp: WedgeUphill.positiveZ, + ), + _box(-2, -1, 6, 2, 1.5, 10), +]; + +/// The floor with a pillar a metre square standing in the middle of it. +List _pillar() => [..._floor(), _box(0, 0, 0, 1, 3, 1)]; + +/// Twenty degrees of slope and the default step: ground steeper than the one +/// and gentler than the other exists, which is the point of [_hillside]. +const NavMeshConfig _hills = NavMeshConfig(maxSlope: 0.35); + +/// Terrain: flat for eight metres, then a hillside rising one in five, then +/// a bank rising three in five, then flat again at the top. +/// +/// **The bank is steep and not tall.** Thirty-one degrees, past the twenty +/// [_hills] allows, but each half-metre cell of it rises 0.3 — under the +/// step. So the slope test is the only thing between the hillside and the +/// top; a cliff would be refused by the step as well, and a test of it would +/// pass with the slope test deleted. +Heightfield _hillside() { + const columns = 21; + const rows = 9; + double height(int x) => switch (x) { + < 8 => 0.0, + < 14 => (x - 8) * 0.2, + < 17 => 1.2 + (x - 14) * 0.6, + _ => 3.0, + }; + return Heightfield( + columns: columns, + rows: rows, + cellSize: 1.0, + heights: Float32List.fromList([ + for (var z = 0; z < rows; z++) + for (var x = 0; x < columns; x++) height(x), + ]), + ); +} + +/// Twice the signed area of corner [k] of [polygon], turning from the corner +/// before it to the one after, in lattice units. +int _turn(NavMesh mesh, int polygon, int k) { + final n = mesh.polygonVertexCount(polygon); + final a = mesh.polygonVertex(polygon, (k + n - 1) % n); + final b = mesh.polygonVertex(polygon, k); + final c = mesh.polygonVertex(polygon, (k + 1) % n); + return (mesh.latticeX(b) - mesh.latticeX(a)) * + (mesh.latticeZ(c) - mesh.latticeZ(a)) - + (mesh.latticeZ(b) - mesh.latticeZ(a)) * + (mesh.latticeX(c) - mesh.latticeX(a)); +} + +/// The claims every mesh in this file has to meet, whatever it was baked +/// from. +void _expectWellFormed(NavMesh mesh) { + for (var p = 0; p < mesh.polygonCount; p++) { + final n = mesh.polygonVertexCount(p); + expect(n, inInclusiveRange(3, mesh.maxVerticesPerPolygon)); + expect(mesh.areaOf(p), isNot(NavArea.none)); + // Mutation: dropping the reversal of outlines that turn right leaves + // whole polygons wound the other way, and every corner of them fails. + for (var k = 0; k < n; k++) { + expect( + _turn(mesh, p, k), + greaterThanOrEqualTo(0), + reason: 'polygon $p has a reflex corner at $k', + ); + } + final area = Iterable.generate(n).fold(0, (sum, k) { + final a = mesh.polygonVertex(p, k); + final b = mesh.polygonVertex(p, (k + 1) % n); + return sum + + mesh.latticeX(a) * mesh.latticeZ(b) - + mesh.latticeX(b) * mesh.latticeZ(a); + }); + expect(area, greaterThan(0), reason: 'polygon $p covers nothing'); + + // Mutation: recording a match on one side only — `out[slot] = …` without + // `out[other] = p` — leaves every second polygon blind to the first. + for (var k = 0; k < n; k++) { + final q = mesh.neighbourAt(p, k); + if (q < 0) continue; + final a = mesh.polygonVertex(p, k); + final b = mesh.polygonVertex(p, (k + 1) % n); + final m = mesh.polygonVertexCount(q); + final back = Iterable.generate(m).where( + (j) => + mesh.neighbourAt(q, j) == p && + mesh.polygonVertex(q, j) == b && + mesh.polygonVertex(q, (j + 1) % m) == a, + ); + expect(back, hasLength(1), reason: '$p sees $q across $k, not back'); + } + } +} + +/// Polygons reachable from [start] across shared edges. +Set _reachable(NavMesh mesh, int start) { + final seen = {start}; + final queue = [start]; + for (var i = 0; i < queue.length; i++) { + final p = queue[i]; + for (var k = 0; k < mesh.polygonVertexCount(p); k++) { + final q = mesh.neighbourAt(p, k); + if (q >= 0 && seen.add(q)) queue.add(q); + } + } + return seen; +} + +/// The lowest and highest corner of [polygon], in metres. +(double, double) _heights(NavMesh mesh, int polygon) { + final v = Vector3.zero(); + return Iterable.generate(mesh.polygonVertexCount(polygon)).fold( + (double.infinity, double.negativeInfinity), + (range, k) { + mesh.vertexAt(mesh.polygonVertex(polygon, k), v); + return (range.$1 < v.y ? range.$1 : v.y, range.$2 > v.y ? range.$2 : v.y); + }, + ); +} + +/// The polygon a body stands on at the centre of grid cell [cell], or −1. +int _standingOn(NavMesh mesh, NavGrid grid, int cell) { + final centre = grid.centreOfCell(cell); + return mesh.polygonsAt(centre.x, centre.z).firstWhere((p) { + final (low, high) = _heights(mesh, p); + return low - grid.stepHeight <= centre.y && + centre.y <= high + grid.stepHeight; + }, orElse: () => -1); +} + +/// Cell by cell: the mesh covers a cell's centre exactly when the grid says a +/// body of [radius] fits there. +void _expectCoverageMatches(NavMesh mesh, NavGrid grid, double radius) { + expect(mesh.originX, grid.originX, reason: 'not the same lattice'); + expect(mesh.originZ, grid.originZ, reason: 'not the same lattice'); + final need = grid.clearanceForRadius(radius); + final wrong = [ + for (var cell = 0; cell < grid.cellCount; cell++) + if ((grid.isWalkable(cell) && grid.clearanceAt(cell) >= need) != + (_standingOn(mesh, grid, cell) >= 0)) + '(${grid.cellX(cell)}, ${grid.cellZ(cell)})', + ]; + expect(wrong, isEmpty, reason: 'the mesh and the grid disagree at $wrong'); +} + +void main() { + const config = NavMeshConfig(); + + group('a flat floor', () { + final mesh = NavMesh.bake(_floor()); + + test('is one quadrilateral, a body\'s radius in from the edge', () { + _expectWellFormed(mesh); + // Mutation: eroding by `agentRadius / cellSize` rounded down, rather + // than by `NavGrid`'s rule, keeps the outer ring and the corners land on + // the brush's own edge at (0, 0) and (20, 20). + expect(mesh.polygonCount, 1); + expect(mesh.polygonVertexCount(0), 4); + final corners = <(int, int)>{ + for (var k = 0; k < 4; k++) + ( + mesh.latticeX(mesh.polygonVertex(0, k)), + mesh.latticeZ(mesh.polygonVertex(0, k)), + ), + }; + expect(corners, <(int, int)>{(1, 1), (19, 1), (19, 19), (1, 19)}); + final v = Vector3.zero(); + mesh.vertexAt(mesh.polygonVertex(0, 0), v); + expect(v.y, closeTo(0.0, 1e-9), reason: 'the floor is at nought'); + }); + + test('covers what the grid covers', () { + _expectCoverageMatches(mesh, NavGrid.bake(_floor()), config.agentRadius); + }); + }); + + group('two rooms and a doorway', () { + test('are one walk for a body that fits the doorway', () { + final mesh = NavMesh.bake(_rooms()); + _expectWellFormed(mesh); + final west = _standingOnPoint(mesh, -4.0, 0.0); + final east = _standingOnPoint(mesh, 4.0, 0.0); + expect(west, isNonNegative); + expect(east, isNonNegative); + // Mutation: matching an edge against itself rather than its reverse — + // `open.remove((a, b))` — finds no neighbour anywhere, and the rooms + // fall apart along with everything else. + expect(_reachable(mesh, west), contains(east)); + _expectCoverageMatches(mesh, NavGrid.bake(_rooms()), config.agentRadius); + }); + + test('and two for a body too wide for it', () { + const wide = NavMeshConfig(agentRadius: 0.8); + final mesh = NavMesh.bake(_rooms(), config: wide); + _expectWellFormed(mesh); + final west = _standingOnPoint(mesh, -4.0, 0.0); + final east = _standingOnPoint(mesh, 4.0, 0.0); + // Mutation: the erosion rule of the flat floor's test, the radius over + // the cell rounded down, keeps a cell of the doorway for this body too. + expect(_reachable(mesh, west), isNot(contains(east))); + _expectCoverageMatches(mesh, NavGrid.bake(_rooms()), wide.agentRadius); + }); + + test('and the wall\'s own top is not a floor anybody fits on', () { + final mesh = NavMesh.bake(_rooms()); + final v = Vector3.zero(); + for (var i = 0; i < mesh.vertexCount; i++) { + mesh.vertexAt(i, v); + expect(v.y, lessThan(0.5), reason: 'a vertex on the wall at $v'); + } + }); + }); + + group('steps', () { + test('taller than a step are floors that do not touch', () { + final mesh = NavMesh.bake(_tallSteps()); + _expectWellFormed(mesh); + final bottom = _standingOnPoint(mesh, 0.0, -2.0); + final top = _standingOnPoint(mesh, 0.0, 3.0); + expect(bottom, isNonNegative); + expect(top, isNonNegative); + expect(_reachable(mesh, bottom), isNot(contains(top))); + _expectCoverageMatches( + mesh, + NavGrid.bake(_tallSteps()), + config.agentRadius, + ); + }); + + test('even for a body with no width, which nothing erodes', () { + // **The radius hides a riser.** Eroded by a body's width, the floor + // either side of a riser is gone before anything links across it, so + // the claim above holds whether or not the link is right. Here there + // is no erosion and the link is all that keeps the floors apart. + const thin = NavMeshConfig(agentRadius: 0.0); + final mesh = NavMesh.bake(_tallSteps(), config: thin); + _expectWellFormed(mesh); + final bottom = _standingOnPoint(mesh, 0.0, -2.0); + final top = _standingOnPoint(mesh, 0.0, 3.0); + expect(_reachable(mesh, bottom), isNot(contains(top))); + // Mutation: comparing the rise without `.abs()` in `OpenField.build` + // links every step down to the floor below it. The first row of each + // step joins the region under it, and the mesh stops covering that + // row where the grid does — rows 8 and 12, measured. + _expectCoverageMatches(mesh, NavGrid.bake(_tallSteps()), 0.0); + }); + + test('short enough to climb are one walk', () { + final mesh = NavMesh.bake(_shortSteps()); + _expectWellFormed(mesh); + final bottom = _standingOnPoint(mesh, 0.0, -2.0); + final top = _standingOnPoint(mesh, 0.0, 3.0); + expect(_reachable(mesh, bottom), contains(top)); + _expectCoverageMatches( + mesh, + NavGrid.bake(_shortSteps()), + config.agentRadius, + ); + }); + }); + + group('a ramp', () { + test('is a walk from the floor to the platform it climbs to', () { + final mesh = NavMesh.bake(_ramp()); + _expectWellFormed(mesh); + final bottom = _standingOnPoint(mesh, 0.0, -2.0); + final top = _standingOnPoint(mesh, 0.0, 8.0); + expect(bottom, isNonNegative); + expect(top, isNonNegative); + // Mutation: rasterising a ramp at the top of its box, as the grid + // does, makes its foot a riser a metre and a half tall. + expect(_reachable(mesh, bottom), contains(top)); + }); + }); + + group('a pillar in the middle of the floor', () { + final mesh = NavMesh.bake(_pillar()); + + test( + 'is a hole the polygons go round, and the floor is still one walk', + () { + _expectWellFormed(mesh); + // Mutation: keeping the lower top when two spans in a column merge — + // the floor's, rather than the pillar's standing on it — lays a floor + // under the pillar that the agent walks through. + expect(mesh.polygonsAt(0.5, 0.5), isEmpty); + expect(_reachable(mesh, 0), hasLength(mesh.polygonCount)); + _expectCoverageMatches( + mesh, + NavGrid.bake(_pillar()), + config.agentRadius, + ); + }, + ); + }); + + group('terrain', () { + final field = _hillside(); + final mesh = NavMesh.bake(const [], ground: field, config: _hills); + + test('walks up a hillside and stops at a bank too steep for it', () { + _expectWellFormed(mesh); + final low = _standingOnPoint(mesh, 3.0, 4.0); + final slope = _standingOnPoint(mesh, 11.0, 4.0); + final top = _standingOnPoint(mesh, 19.0, 4.0); + expect(low, isNonNegative); + expect(slope, isNonNegative); + expect(top, isNonNegative); + // Mutation: rasterising terrain without the slope test makes the bank + // a floor, and at 0.3 a cell it is a walk up to the top. + expect(mesh.polygonsAt(15.5, 4.0), isEmpty); + expect(_reachable(mesh, low), contains(slope)); + expect(_reachable(mesh, low), isNot(contains(top))); + final (_, high) = _heights(mesh, slope); + expect(high, greaterThan(0.5), reason: 'the hillside baked flat'); + }); + + test('covers what the grid covers', () { + _expectCoverageMatches( + mesh, + NavGrid.bakeHeightfield( + field, + stepHeight: _hills.stepHeight, + maxSlope: _hills.maxSlope, + ), + _hills.agentRadius, + ); + }); + }); + + group('an empty level', () { + test('bakes an empty mesh rather than refusing', () { + final mesh = NavMesh.bakeLevel(Level(name: 'empty')); + expect(mesh.isEmpty, isTrue); + expect(mesh.vertexCount, 0); + }); + }); + + group('the same mesh everywhere', () { + test('listing the brushes in another order bakes the same mesh', () { + // Mutation: merging spans in arrival order rather than sorted makes the + // area of a shared top depend on which brush came first. + expect( + NavMesh.bake(_rooms().reversed).digest, + NavMesh.bake(_rooms()).digest, + ); + expect( + NavMesh.bake(_pillar().reversed).digest, + NavMesh.bake(_pillar()).digest, + ); + }); + + // Written down on macOS-arm64 under the VM, matched the same day under + // Chrome, and compared on every other machine CI runs on. A change here + // is a change to the mesh: say why in the commit that makes it, or find + // out what moved. + // + // Mutation: any float the voxeliser rounds differently on another + // platform — a libm `tan` for the ramp test, an `atan2` in the slope — + // moves a span by a voxel and the digest with it, on that platform only. + final golden = <(String, NavMesh Function(), String)>[ + ('empty', () => NavMesh.bakeLevel(Level(name: 'empty')), '39326bec'), + ('floor', () => NavMesh.bake(_floor()), '2634bd3b'), + ('rooms', () => NavMesh.bake(_rooms()), '6fced5bb'), + ('tall steps', () => NavMesh.bake(_tallSteps()), '14aa4be5'), + ('pillar', () => NavMesh.bake(_pillar()), '1ca6d0b9'), + ( + 'hillside', + () => + NavMesh.bake(const [], ground: _hillside(), config: _hills), + '999240fe', + ), + ]; + for (final (name, bake, digest) in golden) { + test('the $name mesh is $digest', () { + expect(bake().digestHex, digest); + }); + } + }); +} + +/// The polygon under world `(x, z)`, or −1. +int _standingOnPoint(NavMesh mesh, double x, double z) { + final found = mesh.polygonsAt(x, z); + return found.isEmpty ? -1 : found.first; +} diff --git a/packages/flutter3d_sim/test/pause_gate_test.dart b/packages/flutter3d_sim/test/pause_gate_test.dart index 8f1c2232c..8928e555d 100644 --- a/packages/flutter3d_sim/test/pause_gate_test.dart +++ b/packages/flutter3d_sim/test/pause_gate_test.dart @@ -76,4 +76,22 @@ void main() { reason: 'the panel never paused a browser or a phone at all', ); }); + + test('photo mode holds the world still while every device flies the camera', () { + // Mutation: drop the `photoMode` clause. The pointer is captured and the + // pad is in hand, because both are moving the camera, so every clause after + // it says run — and the picture is of a level carrying on without the + // player in it. + bool framing({required bool pointerIsTheGate}) => shouldPause( + ready: true, + menuOpen: false, + pointerIsTheGate: pointerIsTheGate, + pointerHeld: true, + padConnected: true, + photoMode: true, + ); + expect(framing(pointerIsTheGate: true), isTrue); + expect(framing(pointerIsTheGate: false), isTrue); + expect(desktop(), isFalse, reason: 'and closing it lets the game go on'); + }); } diff --git a/packages/flutter3d_sim/test/photo_camera_test.dart b/packages/flutter3d_sim/test/photo_camera_test.dart new file mode 100644 index 000000000..5042f1d3e --- /dev/null +++ b/packages/flutter3d_sim/test/photo_camera_test.dart @@ -0,0 +1,204 @@ +/// The photo-mode camera, and the three ways it is held inside the level — +/// `N8`. +/// +/// dart test test/photo_camera_test.dart +/// +/// A free camera is easy; the claims here are about where it cannot go. Each +/// test names the line whose removal would let it through. +library; + +import 'dart:math' as math; + +import 'package:flutter3d_sim/flutter3d_sim.dart'; +import 'package:test/test.dart'; +import 'package:vector_math/vector_math.dart'; + +/// A closed room twenty metres square and six high, walls half a metre thick, +/// with a pillar in the middle of its −Z half. The boxes are kept so a test can +/// ask whether a point is inside one without going through the code under test. +({CollisionWorld world, List solids}) _room() { + final world = CollisionWorld(); + final solids = []; + void box(Vector3 centre, Vector3 size) { + world.addBox(centre, size); + solids.add(Aabb3.centerAndHalfExtents(centre, size / 2.0)); + } + + box(Vector3(0.0, -0.25, 0.0), Vector3(21.0, 0.5, 21.0)); // floor + box(Vector3(0.0, 6.25, 0.0), Vector3(21.0, 0.5, 21.0)); // ceiling + box(Vector3(10.25, 3.0, 0.0), Vector3(0.5, 6.0, 21.0)); + box(Vector3(-10.25, 3.0, 0.0), Vector3(0.5, 6.0, 21.0)); + box(Vector3(0.0, 3.0, 10.25), Vector3(21.0, 6.0, 0.5)); + box(Vector3(0.0, 3.0, -10.25), Vector3(21.0, 6.0, 0.5)); + box(Vector3(0.0, 3.0, -5.0), Vector3(2.0, 6.0, 2.0)); // pillar + world.update(); + return (world: world, solids: solids); +} + +bool _inside(List solids, Vector3 point) => + solids.any((box) => box.containsVector3(point)); + +void main() { + test('begins looking where the game camera looked', () { + final room = _room(); + final camera = PhotoCamera(world: room.world) + ..begin( + eye: Vector3(0.0, 2.0, 6.0), + target: Vector3(3.0, 1.0, 2.0), + anchor: Vector3(0.0, 1.0, 4.0), + ); + final wanted = (Vector3(3.0, 1.0, 2.0) - Vector3(0.0, 2.0, 6.0)) + ..normalize(); + // Mutation: swap the arguments of the `atan2` in `begin`. The camera opens + // ninety degrees off the shot the player paused to take. + expect(camera.forward.distanceTo(wanted), lessThan(1e-6)); + expect(camera.eye.distanceTo(Vector3(0.0, 2.0, 6.0)), lessThan(1e-6)); + }); + + test('a chase camera behind a wall starts on the player\'s side of it', () { + final room = _room(); + // The player stands just in front of the pillar, and the game's camera has + // been left on its far side — where a chase camera ends up for a frame + // when the player turns sharply. + final camera = PhotoCamera(world: room.world) + ..begin( + eye: Vector3(0.0, 2.0, -8.0), + target: Vector3(0.0, 2.0, 0.0), + anchor: Vector3(0.0, 2.0, -2.0), + ); + // Mutation: `_eye.setFrom(eye)` in `begin` instead of sweeping to it from + // the anchor. The first picture is taken from inside the level's far side + // of a wall the player is standing in front of. + expect(camera.eye.z, greaterThan(-4.0 + 0.2 - 1e-3)); + expect(_inside(room.solids, camera.eye), isFalse); + }); + + test('flying into a wall stops in front of it', () { + final room = _room(); + final camera = PhotoCamera(world: room.world, reach: 50.0) + ..begin( + eye: Vector3(0.0, 2.0, 4.0), + target: Vector3(0.0, 2.0, 20.0), + anchor: Vector3(0.0, 2.0, 4.0), + ); + for (var i = 0; i < 300; i++) { + camera.fly(Vector3(0.0, 0.0, 1.0), 1.0 / 60.0); + } + // Mutation: `point.add(delta)` in place of the sweep in `_slide`. Five + // seconds at four metres a second carries the eye through the wall at + // z = 10 and out of the level. + expect(camera.eye.z, lessThanOrEqualTo(10.0 - 0.2 + 1e-2)); + expect(camera.eye.z, greaterThan(9.0), reason: 'it did get there'); + }); + + test('flying along a wall at a slant slides rather than sticks', () { + final room = _room(); + final camera = PhotoCamera(world: room.world, reach: 50.0) + ..begin( + eye: Vector3(-5.0, 2.0, 9.5), + target: Vector3(-5.0, 2.0, 20.0), + anchor: Vector3(-5.0, 2.0, 9.5), + ); + camera.look(math.pi / 4.0, 0.0); + for (var i = 0; i < 60; i++) { + camera.fly(Vector3(0.0, 0.0, 1.0), 1.0 / 60.0); + } + // Mutation: stop at the first hit in `_slide` rather than keeping the part + // of the move that runs along the wall. A camera pressed against a wall at + // forty-five degrees then freezes, and lining up a shot along a corridor + // is impossible. + expect(camera.eye.x, greaterThan(-5.0 + 2.0)); + expect(camera.eye.z, lessThanOrEqualTo(10.0 - 0.2 + 1e-2)); + }); + + test('the tether holds the camera within reach of the player', () { + final room = _room(); + final camera = PhotoCamera(world: room.world, reach: 6.0) + ..begin( + eye: Vector3(5.0, 2.0, 5.0), + target: Vector3(-5.0, 2.0, 5.0), + anchor: Vector3(5.0, 1.0, 5.0), + ); + for (var i = 0; i < 600; i++) { + camera.fly(Vector3(0.3, 0.2, 1.0), 1.0 / 60.0); + } + // Mutation: drop the reach clamp in `_allowed`. Ten seconds of flying + // takes the camera to the room's far wall, eleven metres from the player. + expect(camera.eye.distanceTo(camera.anchor), lessThanOrEqualTo(6.0 + 1e-2)); + expect( + camera.fly(Vector3(0.0, 0.0, 1.0), 1.0 / 60.0), + isTrue, + reason: 'and says it was held, so a game can show the edge', + ); + }); + + test('the level box holds the camera where no wall does', () { + // No ceiling and no floor in this world, only the box — a level on a slab + // with open sky above it. + final world = CollisionWorld()..update(); + final camera = + PhotoCamera( + world: world, + reach: 100.0, + bounds: Aabb3.minMax( + Vector3(-4.0, 0.0, -4.0), + Vector3(4.0, 3.0, 4.0), + ), + )..begin( + eye: Vector3(0.0, 1.0, 0.0), + target: Vector3(0.0, -1.0, 0.0), + anchor: Vector3(0.0, 1.0, 0.0), + ); + for (var i = 0; i < 120; i++) { + camera.fly(Vector3(0.0, -1.0, 0.0), 1.0 / 60.0); + } + // Mutation: drop the box clamp in `_allowed`. The camera goes under the + // level and photographs the underside of the floor. + expect(camera.eye.y, greaterThanOrEqualTo(0.0 + 0.2 - 1e-9)); + }); + + test('five hundred random moves in a furnished room never leave it', () { + // The property `CameraRig` was held to, three hundred random impulses in a + // boxed room; this is the same check for a camera the player steers. + final room = _room(); + final random = math.Random(8); + final camera = PhotoCamera(world: room.world, reach: 9.0) + ..begin( + eye: Vector3(0.0, 2.0, 3.0), + target: Vector3(0.0, 2.0, -3.0), + anchor: Vector3(0.0, 1.0, 3.0), + ); + double signed() => random.nextDouble() * 2.0 - 1.0; + for (var i = 0; i < 500; i++) { + camera + ..look(signed() * 0.8, signed() * 0.6) + ..fly(Vector3(signed(), signed(), signed()) * 3.0, 0.25); + // Mutation: sweep with the camera's own size halved in `_shape`. The + // walk ends a step with the eye a few centimetres into the floor, the + // ceiling or the pillar, which is the near plane cutting into a wall. + expect(_inside(room.solids, camera.eye), isFalse, reason: 'step $i'); + expect( + camera.eye.distanceTo(camera.anchor), + lessThanOrEqualTo(9.0 + 1e-2), + reason: 'step $i', + ); + } + }); + + test('a tilted camera keeps its up at right angles to where it looks', () { + final camera = PhotoCamera(world: CollisionWorld()..update()) + ..begin( + eye: Vector3.zero(), + target: Vector3(0.0, -0.5, -1.0), + anchor: Vector3.zero(), + ) + ..tilt(10.0) + ..zoom(100.0); + // Mutation: drop the `maxRoll` clamp in `tilt`. The horizon turns past + // vertical and the picture is upside down. + expect(camera.roll, closeTo(math.pi / 4.0, 1e-9)); + expect(camera.up.dot(camera.forward).abs(), lessThan(1e-6)); + expect(camera.up.length, closeTo(1.0, 1e-6)); + expect(camera.fieldOfView, camera.minFieldOfView); + }); +} diff --git a/packages/flutter3d_sim/test/run_service_test.dart b/packages/flutter3d_sim/test/run_service_test.dart new file mode 100644 index 000000000..24965990d --- /dev/null +++ b/packages/flutter3d_sim/test/run_service_test.dart @@ -0,0 +1,209 @@ +/// The client side of sharing: the bundle a short code stands for, and +/// `RunService`, which sends it through whatever transport the game hands it. +/// +/// dart test test/run_service_test.dart +/// +/// The end-to-end half — this client against the reference server — is in +/// `cloud/server/test/`, which can depend on both. +/// +/// Mutation: drop the level hash check from `ShareBundle` and a run recorded +/// in another version of the level is shared as if it fitted. +library; + +import 'dart:convert'; + +import 'package:flutter3d_sim/flutter3d_sim.dart'; +import 'package:test/test.dart'; + +Map _level() => Level(name: 'yard').toJson(); + +Demo _run({String? levelHash, String? recordedBy}) => Demo( + level: 'yard.json', + levelHash: levelHash ?? contentDigestHex(_level()), + start: const Snapshot({'x': 0}), + tape: InputTape(seed: 3, frames: [const InputFrame()]), + buildStamp: 'test', + checkpoints: DigestTrace(every: 1)..observe(1, 0), + recordedBy: recordedBy, +); + +/// A transport that writes down what it was asked and answers [status] +/// with [body]. +final class _Wire { + _Wire([this.status = 200, this.body = '{}']); + + final int status; + final String body; + final List sent = []; + + Future call(RunRequest request) async { + sent.add(request); + return RunResponse(status, body); + } +} + +RunService _service(_Wire wire) => + RunService(base: Uri.parse('https://runs.example/'), transport: wire.call); + +void main() { + group('a share bundle', () { + test('reads back what it wrote, hash and run included', () { + final bundle = ShareBundle( + game: 'walk', + level: _level(), + run: _run(), + title: 'the yard', + ); + + final read = ShareBundle.fromJson( + jsonDecode(jsonEncode(bundle.toJson())) as Map, + ); + + expect(read.levelHash, bundle.levelHash); + expect(read.title, 'the yard'); + expect(read.run!.steps, 1); + }); + + test('refuses a run recorded in another version of the level', () { + expect( + () => ShareBundle( + game: 'walk', + level: _level(), + run: _run(levelHash: '00000000'), + ), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('00000000'), + ), + ), + ); + }); + + test('refuses a document whose level is not the one it names', () { + final json = ShareBundle(game: 'walk', level: _level()).toJson(); + (json['level']! as Map)['name'] = 'edited by hand'; + + expect( + () => ShareBundle.fromJson(json), + throwsA(isA()), + ); + }); + + test('refuses a newer format by saying so', () { + final json = ShareBundle(game: 'walk', level: _level()).toJson() + ..['version'] = 99; + + expect( + () => ShareBundle.fromJson(json), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('newer build'), + ), + ), + ); + }); + }); + + group('the service', () { + test('a share posts the bundle and answers with its code', () async { + final wire = _Wire( + 201, + '{"code":"ABCDEFG","address":"ff","status":"pending"}', + ); + + final answer = await _service( + wire, + ).share(ShareBundle(game: 'walk', level: _level(), run: _run())); + + final request = wire.sent.single; + expect(request.method, 'POST'); + expect(request.uri.toString(), 'https://runs.example/v1/shares'); + expect( + answer, + isA>() + .having((d) => d.value.code, 'code', 'ABCDEFG') + .having((d) => d.value.status, 'status', ShareStatus.pending), + ); + }); + + test('a status this build does not know is a refusal', () async { + final answer = await _service( + _Wire(201, '{"code":"ABCDEFG","address":"ff","status":"expired"}'), + ).share(ShareBundle(game: 'walk', level: _level())); + + expect(answer, isA>()); + }); + + test( + 'a code that is not open yet says so in the server\'s words', + () async { + final answer = await _service( + _Wire(200, '{"status":"pending","message":"waiting for review"}'), + ).open('ABCDEFG'); + + expect( + answer, + isA>().having( + (r) => r.reason, + 'reason', + 'waiting for review', + ), + ); + }, + ); + + test('a report carries the reason to the code\'s own route', () async { + final wire = _Wire(200, '{"message":"noted"}'); + + await _service(wire).report('ABC DEF', 'not a level'); + + final request = wire.sent.single; + expect( + request.uri.toString(), + 'https://runs.example/v1/shares/ABC%20DEF/reports', + ); + expect(jsonDecode(request.body!), { + 'reason': 'not a level', + }); + }); + }); + + group('the transport failing', () { + test('an unreachable server is a refusal with a sentence', () async { + final service = RunService( + base: Uri.parse('https://runs.example/'), + transport: (_) async => throw StateError('offline'), + ); + + final answer = await service.open('ABCDEFG'); + + expect( + answer, + isA>().having( + (r) => r.reason, + 'reason', + contains('could not reach'), + ), + ); + }); + + test('a reply that is not JSON is a refusal, not a throw', () async { + final answer = await _service( + _Wire(502, ''), + ).share(ShareBundle(game: 'walk', level: _level())); + + expect( + answer, + isA>().having( + (r) => r.reason, + 'reason', + contains('not JSON'), + ), + ); + }); + }); +} diff --git a/packages/flutter3d_sim/test/save_record_test.dart b/packages/flutter3d_sim/test/save_record_test.dart new file mode 100644 index 000000000..4d718d281 --- /dev/null +++ b/packages/flutter3d_sim/test/save_record_test.dart @@ -0,0 +1,219 @@ +/// A save read by a later build of the game, and two copies of one save that +/// have to become one. Migrations, schema refusals and the conflict rules are +/// what these are about. +library; + +import 'package:flutter3d_sim/flutter3d_sim.dart'; +import 'package:test/test.dart'; + +SaveRecord _record(Map run, {int step = 0, String? level}) => + SaveRecord(level: level ?? 'a.json', run: Snapshot(run), step: step); + +void main() { + group('SaveSchema', () { + // Version 0 kept health as a number; version 1 split it into hearts; + // version 2 renamed `keys` to `keyRing`. + final schema = SaveSchema([ + (run) => run..['hearts'] = ((run.remove('health') as num?) ?? 0) ~/ 10, + (run) => run..['keyRing'] = run.remove('keys'), + ]); + + test('the version is the number of migrations', () { + // Mutation: a version kept beside the list. It can be raised without a + // migration, and the save from the version between is read as current. + expect(schema.version, 2); + expect(const SaveSchema().version, 0); + }); + + test('an old run is brought up through every migration in order', () { + // Mutation: apply only the last migration, or start from 0 whatever the + // run says. The first loses the hearts, the second divides them again. + final upgraded = schema.upgrade({ + 'health': 30, + 'keys': ['red'], + }, 0); + + expect(upgraded, isA()); + final run = (upgraded as SaveUpgraded).run; + expect(run, { + 'hearts': 3, + 'keyRing': ['red'], + }); + + final fromOne = schema.upgrade({ + 'hearts': 2, + 'keys': [], + }, 1); + expect((fromOne as SaveUpgraded).run['hearts'], 2); + }); + + test('a run from a newer build is refused, and said to be newer', () { + // Mutation: read it as current. Its fields mean something this build + // has never heard of, and the next save overwrites the player's + // progress with a misreading of it. + final refused = schema.upgrade({}, 3); + + expect(refused, isA()); + expect((refused as SaveRefused).newer, isTrue); + expect(refused.reason, contains('newer')); + }); + + test('a migration that throws is a refusal naming where it failed', () { + // Mutation: let the throw out. `SaveFile.read` promises never to throw, + // and a launch that crashes on somebody's save is the worst version of + // "the save could not be read". + final broken = SaveSchema([ + (run) => throw StateError('no such field'), + ]); + + final refused = broken.upgrade({}, 0); + expect(refused, isA()); + expect((refused as SaveRefused).newer, isFalse); + expect(refused.reason, contains('from save schema 0')); + }); + + test('the run handed in is left as it was', () { + // Mutation: hand the caller's map to the first migration. A failed + // upgrade then leaves the save it was reading half rewritten. + final run = {'health': 30}; + schema.upgrade(run, 0); + + expect(run, {'health': 30}); + }); + }); + + group('SaveRecord', () { + test('what is written is read back, schema, step and all', () { + final written = SaveRecord( + level: 'b.json', + run: Snapshot({'deaths': 2}), + step: 1200, + schema: 1, + ); + + final read = SaveRecord.read( + written.toJson(), + SaveSchema([(run) => run]), + ); + + expect(read, isA()); + final record = (read as SaveFound).record; + expect(record.level, 'b.json'); + expect(record.step, 1200); + expect(record.run.data, {'deaths': 2}); + expect(record.digest, written.digest); + expect(read.upgradedFrom, 1); + }); + + test('a save from before schemas is version 0 and migrated', () { + // The shape every save on disk had before this: a level and a run, no + // schema, no step. Mutation: treat a missing schema as current, and the + // migration a game wrote for its first change never runs on the saves + // it was written for. + final read = SaveRecord.read( + { + 'level': 'a.json', + 'run': {'version': 1, 'health': 20}, + }, + SaveSchema([ + (run) => run..['hearts'] = (run.remove('health')! as num) ~/ 10, + ]), + ); + + final found = read as SaveFound; + expect(found.upgradedFrom, 0); + expect(found.record.schema, 1); + expect(found.record.run.data, {'hearts': 2}); + }); + + test('a snapshot from a newer engine is refused as newer', () { + final read = SaveRecord.read({ + 'level': 'a.json', + 'run': {'version': Snapshot.formatVersion + 1}, + }, const SaveSchema()); + + expect(read, isA()); + expect((read as SaveNotRead).newer, isTrue); + }); + + test('the digest is of the run, not of how far it got', () { + // Mutation: hash the step too. Two copies of one save that counted + // their steps differently — an older build counted none — would then be + // a conflict for the player to settle, about nothing. + final a = _record({'x': 1}, step: 10); + final b = _record({'x': 1}, step: 99); + final c = _record({'x': 2}, step: 10); + + expect(a.digest, b.digest); + expect(a.digest, isNot(c.digest)); + expect( + _record({'x': 1}, level: 'b.json').digest, + isNot(a.digest), + ); + }); + }); + + group('resolveSaves', () { + final early = _record({'at': 1}, step: 100); + final late = _record({'at': 2}, step: 900); + final sameStep = _record({'at': 3}, step: 900); + + test('one copy, or the same copy twice, is no conflict', () { + expect(resolveSaves(local: null, remote: null), SaveResolution.inSync); + expect( + resolveSaves(local: early, remote: null), + SaveResolution.keepLocal, + ); + expect( + resolveSaves(local: null, remote: early), + SaveResolution.takeRemote, + ); + expect( + resolveSaves( + local: early, + remote: _record({'at': 1}, step: 5), + ), + SaveResolution.inSync, + ); + }); + + test('with no base, the further run wins', () { + // Mutation: compare in the wrong direction, or by digest order. The + // player who played on the tablet comes back to the phone's older run. + expect( + resolveSaves(local: late, remote: early), + SaveResolution.keepLocal, + ); + expect( + resolveSaves(local: early, remote: late), + SaveResolution.takeRemote, + ); + }); + + test('when one side is still the base, the other moved and wins', () { + // A player went back to an earlier checkpoint on the tablet. The phone + // still holds the run both last agreed on, so the tablet's choice is + // the newer one even though it is fewer steps in. + // + // Mutation: ask the step first. The player's deliberate step back is + // undone the next time the phone syncs. + expect( + resolveSaves(local: late, remote: early, base: late.digest), + SaveResolution.takeRemote, + ); + expect( + resolveSaves(local: early, remote: late, base: late.digest), + SaveResolution.keepLocal, + ); + }); + + test('two different runs equally far along go to the player', () { + // Mutation: pick one. Either answer throws away a run somebody played. + expect(resolveSaves(local: late, remote: sameStep), SaveResolution.ask); + expect( + resolveSaves(local: late, remote: sameStep, base: early.digest), + SaveResolution.ask, + ); + }); + }); +} diff --git a/packages/flutter3d_sim/test/tape_bisect_test.dart b/packages/flutter3d_sim/test/tape_bisect_test.dart new file mode 100644 index 000000000..cb3cb9e36 --- /dev/null +++ b/packages/flutter3d_sim/test/tape_bisect_test.dart @@ -0,0 +1,247 @@ +/// `N4`'s divergence bisect: two runs of a tape, and the first step and +/// component at which they stop being the same run. +/// +/// flutter test test/tape_bisect_test.dart +/// +/// The toy is an `EcsWorld` of three entities with a position and a health +/// each, so the answer is checked in the words a person debugging a desync +/// wants — which entity, which component — and a "build" with a defect +/// planted at a known step stands in for a code change or another platform. +library; + +import 'package:flutter3d_sim/flutter3d_sim.dart'; +import 'package:test/test.dart'; + +const GameAction _fire = GameAction('fire'); + +final class _Position { + _Position(this.x); + double x; +} + +final class _Health { + _Health(this.hp); + int hp; +} + +/// One build of the game. [defectAt] makes entity 1 lose a point of health +/// on that step and only that step — a defect that does nothing to the +/// position, so the component named has to be the right one. +final class _Toy { + _Toy({this.defectAt}) { + world + ..register<_Position>( + 'Position', + encode: (p) => p.x, + decode: (d) => d is num ? _Position(d.toDouble()) : null, + ) + ..register<_Health>( + 'Health', + encode: (h) => h.hp, + decode: (d) => d is num ? _Health(d.toInt()) : null, + ); + entities = [for (var i = 0; i < 3; i++) world.spawn()]; + for (final (i, entity) in entities.indexed) { + world + ..set(entity, _Position(i * 10.0)) + ..set(entity, _Health(100)); + } + } + + final int? defectAt; + final EcsWorld world = EcsWorld(); + final GameRandom dice = GameRandom(7); + late final List entities; + int steps = 0; + + void step(InputState input) { + for (final entity in entities) { + world.get<_Position>(entity)!.x += input.moveAxis.x; + } + if (input.pressed(_fire)) { + world.get<_Health>(entities[2])!.hp -= 1 + dice.nextInt(3); + } + if (steps == defectAt) world.get<_Health>(entities[1])!.hp -= 1; + steps++; + } + + Snapshot save() => Snapshot({ + 'world': world.save(), + 'random': dice.state, + 'steps': steps, + }); + + void restore(Snapshot snapshot) { + world.restore(snapshot.data.object('world')!); + dice.state = snapshot.data.integer('random'); + steps = snapshot.data.integer('steps'); + } +} + +/// A tape of [length] steps: the stick swings and fire is pressed now and +/// then. [fireAt] adds one press, for a recording that differs. +InputTape _tape(int length, {int? fireAt}) => InputTape( + seed: 7, + frames: [ + for (var i = 0; i < length; i++) + InputFrame( + pressed: [if (i % 37 == 5 || i == fireAt) _fire.name], + released: [ + if (i % 37 == 6 || (fireAt != null && i == fireAt + 1)) _fire.name, + ], + stickX: i % 4 == 0 ? 1.0 : -0.25, + stickY: 0.0, + ), + ], +); + +ReplaySide _side(_Toy toy, InputTape tape) { + final input = InputState(); + return ReplaySide( + start: toy.save(), + tape: tape, + input: input, + step: () => toy.step(input), + restore: toy.restore, + capture: toy.save, + ); +} + +final _layout = EntityLayout.ecs(const ['world']); + +void main() { + test('names the step a defect was planted on, and the component', () { + // Mutation: return `lo` instead of `hi` from the search — the step named + // is then the last agreement, and `step - 1` misses the defect by one. + final result = bisectTapes( + a: _side(_Toy(), _tape(1000)), + b: _side(_Toy(defectAt: 613), _tape(1000)), + layout: _layout, + ); + + expect(result, isA()); + final diverge = result as TapesDiverge; + expect(diverge.step - 1, 613, reason: 'the tape entry that differed'); + expect(diverge.inputsDiffer, isFalse); + expect(diverge.component, EntityComponent('1', 'Health')); + expect(diverge.components, [ + EntityComponent('1', 'Health'), + ], reason: 'the position and the other healths did not move'); + expect(diverge.path, '1.Health'); + expect((diverge.expected, diverge.found), (100, 99)); + }); + + test('compares a logarithm of the states, not all of them', () { + // Mutation: walk `lo` up one step at a time instead of halving. The + // answer is the same; the probes are six hundred. + final a = _side(_Toy(), _tape(1000)); + final b = _side(_Toy(defectAt: 613), _tape(1000)); + final diverge = bisectTapes(a: a, b: b) as TapesDiverge; + + expect(diverge.probes, lessThanOrEqualTo(12)); + expect( + a.stepsRun, + lessThanOrEqualTo(2000), + reason: + 'the first pass reaches the end, and each probe after it plays on ' + 'from the nearest state already reached: half the bracket, then a ' + 'quarter, and so on', + ); + }); + + test('says when the input is what differs', () { + // Mutation: compare frames at `step` instead of `step - 1`, the entry + // after the one that made the difference. + final diverge = + bisectTapes( + a: _side(_Toy(), _tape(400)), + b: _side(_Toy(), _tape(400, fireAt: 250)), + layout: _layout, + ) + as TapesDiverge; + + expect(diverge.step - 1, 250); + expect(diverge.inputsDiffer, isTrue); + expect(diverge.component, EntityComponent('2', 'Health')); + }); + + test('two identical runs agree, and a longer tape is reported', () { + // Mutation: compare to the longer tape's end; `stateAfter` asserts past + // the shorter one. + final result = bisectTapes( + a: _side(_Toy(), _tape(300)), + b: _side(_Toy(), _tape(320)), + ); + + expect(result, isA()); + expect((result as TapesAgree).steps, 300); + expect(result.toString(), contains('20 more')); + }); + + test('runs that start apart diverge at step zero', () { + // Mutation: start the search without checking the start — bisection + // between two differing ends then names step one. + final shifted = _Toy()..dice.nextInt(5); + final diverge = + bisectTapes( + a: _side(_Toy(), _tape(100)), + b: _side(shifted, _tape(100)), + layout: _layout, + ) + as TapesDiverge; + + expect(diverge.step, 0); + expect(diverge.components, isEmpty, reason: 'only the dice differ'); + expect(diverge.path, 'random'); + }); + + test('a bracket that does not hold is widened rather than trusted', () { + // Mutation: trust `differsAt` — the search between two agreeing ends + // then names a step that never differed. + final diverge = + bisectTapes( + a: _side(_Toy(), _tape(1000)), + b: _side(_Toy(defectAt: 613), _tape(1000)), + agreedAt: 700, + differsAt: 200, + ) + as TapesDiverge; + + expect(diverge.step - 1, 613); + }); + + test('two digest traces bracket the search to one checkpoint interval', () { + // Mutation: drop the `+ 1` that turns a checkpoint step into a count of + // steps — the bracket's top then agrees and is widened to the end. + DigestTrace trace(_Toy toy) { + final input = InputState(); + final playback = InputTapePlayback(_tape(1000)); + final trace = DigestTrace(); + for (var step = 0; !playback.isFinished; step++) { + playback.applyTo(input); + input.beginStep(); + toy.step(input); + input.endStep(); + trace.observe(step, toy.save().data); + } + return trace; + } + + final bracket = bracketFromTraces( + trace(_Toy()), + trace(_Toy(defectAt: 613)), + )!; + expect(bracket, (agreedAt: 601, differsAt: 626)); + + final diverge = + bisectTapes( + a: _side(_Toy(), _tape(1000)), + b: _side(_Toy(defectAt: 613), _tape(1000)), + agreedAt: bracket.agreedAt, + differsAt: bracket.differsAt, + ) + as TapesDiverge; + expect(diverge.step - 1, 613); + expect(diverge.probes, lessThanOrEqualTo(7)); + }); +} diff --git a/packages/flutter3d_sim/test/telemetry_test.dart b/packages/flutter3d_sim/test/telemetry_test.dart new file mode 100644 index 000000000..5d94447e9 --- /dev/null +++ b/packages/flutter3d_sim/test/telemetry_test.dart @@ -0,0 +1,429 @@ +/// N7: a run leaves the machine only with the player's yes, and what the +/// server reads off it comes from playing it again. +/// +/// dart test test/telemetry_test.dart +library; + +import 'dart:convert'; + +import 'package:flutter3d_sim/flutter3d_sim.dart'; +import 'package:test/test.dart'; +import 'package:vector_math/vector_math.dart'; + +const String _policy = '2026-10'; + +/// A body that walks along x as the stick says: past x = 4 the run is won, +/// below x = -2 it is lost. +final class _Walk implements HeadlessRun { + _Walk(this.input, {required this.start}); + + final InputState input; + final double start; + late final Vector3 _at = Vector3(start, 0.0, 0.0); + + @override + RunOutcome outcome = RunOutcome.playing; + + @override + void step(double dt) { + if (outcome.isOver) return; + _at + ..x += input.moveAxis.x * 3.0 * dt + ..z += input.moveAxis.y * 3.0 * dt; + if (_at.x > 4.0) outcome = RunOutcome.won; + if (_at.x < -2.0) outcome = RunOutcome.lost; + } + + @override + Snapshot save() => Snapshot({ + 'x': _at.x, + 'z': _at.z, + 'outcome': outcome.name, + }); + + @override + Vector3 get position => _at; + + @override + void eye(Vector3 out) => out.setFrom(_at); + + @override + void aim(Vector3 out) => out.setValues(1.0, 0.0, 0.0); + + @override + String get summary => 'at ${_at.x}'; + + @override + Map get reading => {'x': _at.x}; +} + +final class _WalkGame implements HeadlessGame { + const _WalkGame({this.from = 0.0}); + + final double from; + + @override + String get name => 'walk'; + + @override + Map get buttons => const {}; + + @override + EntityRegistry registry() => EntityRegistry(const []); + + @override + HeadlessRun start(Level level, CollisionWorld world, InputState input) => + _Walk(input, start: from); +} + +Level _level({double width = 20.0}) => Level( + name: 'strip', + materials: {'stone': LevelMaterial()}, + brushes: [ + Brush( + centre: Vector3(0.0, -0.5, 0.0), + size: Vector3(width, 1.0, 4.0), + material: 'stone', + ), + ], + entities: const [], +); + +/// Plays [level] with the stick at [stick] for [steps] steps, recording as a +/// game does: the tape before the step, the checkpoint after it. +Demo _record(Level level, {required double stick, int steps = 120}) { + final input = InputState(); + final world = CollisionWorld(); + level.addTo(world); + final run = const _WalkGame().start(level, world, input); + final start = run.save(); + final recorder = InputTapeRecorder(seed: 1); + final trace = DigestTrace(every: 10); + for (var step = 1; step <= steps; step++) { + input.setStickAxis(stick, 0.2); + recorder.record(input); + input.beginStep(); + run.step(1.0 / 60.0); + trace.observe(step, run.save().toJson()); + input.endStep(); + } + return Demo( + level: 'levels/strip.json', + levelHash: level.digestHex, + start: start, + tape: recorder.tape, + buildStamp: 'test', + checkpoints: trace, + recordedBy: 'somebody', + ); +} + +TelemetryConsent _granted([String policy = _policy]) => + TelemetryConsent.granted(policy: policy, at: DateTime.utc(2026, 10, 1)); + +final class _Sink implements TelemetrySink { + final List delivered = []; + + @override + Future deliver(TelemetryUpload upload) async { + delivered.add(upload); + return ( + did: true, + says: 'taken', + receipt: const TelemetryReceipt(run: 1, eraseKey: 'k'), + ); + } +} + +void main() { + group('consent', () { + test('nothing is sent until the player is asked and says yes', () async { + // Mutation: `allows` answering true for notAsked sends every run of a + // player who never saw the question. + final sink = _Sink(); + var consent = const TelemetryConsent.notAsked(); + final uploader = TelemetryUploader( + game: 'walk', + policy: _policy, + consent: () => consent, + sink: sink, + ); + final demo = _record(_level(), stick: 1.0); + + final unasked = await uploader.send(demo); + expect(unasked.did, isFalse); + expect(unasked.says, contains('has not been asked')); + + consent = TelemetryConsent.declined( + policy: _policy, + at: DateTime.utc(2026, 10, 1), + ); + final declined = await uploader.send(demo); + expect(declined.says, contains('said no')); + expect(sink.delivered, isEmpty); + + consent = _granted(); + expect((await uploader.send(demo)).did, isTrue); + expect(sink.delivered, hasLength(1)); + }); + + test('a yes to an older wording is not a yes to this one', () { + // Mutation: comparing only the answer and not the policy. + final prepared = TelemetryUpload.prepare( + game: 'walk', + demo: _record(_level(), stick: 1.0), + consent: _granted('2025-01'), + policy: _policy, + ); + expect(prepared.upload, isNull); + expect(prepared.says, contains('"2025-01"')); + expect(prepared.says, contains('ask again')); + }); + + test('a settings file damaged where the yes was reads as not asked', () { + // Mutation: defaulting an unreadable answer to granted. + for (final Object? damaged in [ + null, + 'granted', + {'answer': 'granted'}, + {'answer': 'granted', 'policy': _policy, 'at': 'x'}, + {'answer': 'maybe', 'policy': _policy, 'at': '2026'}, + ]) { + expect( + TelemetryConsent.fromJson(damaged).asked, + isFalse, + reason: '$damaged', + ); + } + final kept = TelemetryConsent.fromJson( + jsonDecode(jsonEncode(_granted().toJson())), + ); + expect(kept.allows(_policy), isTrue); + expect(kept.at, DateTime.utc(2026, 10, 1)); + final no = TelemetryConsent.fromJson( + jsonDecode( + jsonEncode( + TelemetryConsent.declined( + policy: _policy, + at: DateTime.utc(2026, 10, 1), + ).toJson(), + ), + ), + ); + expect((no.asked, no.granted), (true, false)); + }); + }); + + group('the upload', () { + test('drops who recorded the run, and carries the consent', () { + // Mutation: passing the demo through unchanged sends the player's name. + final upload = TelemetryUpload.prepare( + game: 'walk', + demo: _record(_level(), stick: 1.0), + consent: _granted(), + policy: _policy, + ).upload!; + final read = TelemetryUpload.fromJson( + jsonDecode(jsonEncode(upload.toJson())) as Map, + ); + expect(read.demo.recordedBy, isNull); + expect(jsonEncode(upload.toJson()), isNot(contains('somebody'))); + expect(read.game, 'walk'); + expect(read.policy, _policy); + expect(read.demo.steps, 120); + }); + + test('one that arrives without consent is refused by the reader', () { + // Mutation: a server reading uploads that never carried a grant. + final json = TelemetryUpload.prepare( + game: 'walk', + demo: _record(_level(), stick: 1.0), + consent: _granted(), + policy: _policy, + ).upload!.toJson()..remove('consent'); + expect( + () => TelemetryUpload.fromJson(json), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('no consent'), + ), + ), + ); + }); + + test('over HTTP, a receipt comes back from a 201 and the server\'s ' + 'sentence from anything else', () async { + // Mutation: treating any 2xx or any JSON as accepted. + final upload = TelemetryUpload.prepare( + game: 'walk', + demo: _record(_level(), stick: 1.0), + consent: _granted(), + policy: _policy, + ).upload!; + final endpoint = Uri.parse('http://localhost/api/telemetry/runs'); + String? sent; + final taken = await HttpTelemetrySink( + endpoint: endpoint, + post: (url, json) async { + sent = json; + return (status: 201, body: '{"run":7,"eraseKey":"abc","says":"ok"}'); + }, + ).deliver(upload); + expect(taken.did, isTrue); + expect(taken.receipt!.run, 7); + expect(taken.receipt!.eraseKey, 'abc'); + expect(jsonDecode(sent!), containsPair('game', 'walk')); + + final refused = await HttpTelemetrySink( + endpoint: endpoint, + post: (url, json) async => + (status: 422, body: '{"says":"this server plays no walk"}'), + ).deliver(upload); + expect(refused.did, isFalse); + expect(refused.says, 'this server plays no walk'); + + final down = await HttpTelemetrySink( + endpoint: endpoint, + post: (url, json) async => throw StateError('connection refused'), + ).deliver(upload); + expect(down.did, isFalse); + expect(down.says, contains('could not reach')); + }); + }); + + group('re-simulation', () { + test('retraces the run and reads where it went and how it ended', () { + // Mutation: sampling the trail from the recording client rather than + // the replay would make an empty trail here, since a demo has none. + final level = _level(); + final demo = _record(level, stick: 1.0); + + final found = resimulate( + game: const _WalkGame(), + level: level, + demo: demo, + sampleEvery: 20, + ); + + final retraced = found as ResimulationRetraced; + expect(retraced.steps, 120); + expect(retraced.checkpoints, 12); + expect(retraced.outcome, RunOutcome.won); + expect(retraced.trail, hasLength(6)); + // Twenty steps of a stick at (1, 0.2), which the input normalises. + expect(retraced.trail.first.$1, closeTo(0.98, 0.001)); + expect(retraced.trail.last.$1, greaterThan(4.0)); + }); + + test('a level edited since the recording is refused, naming both', () { + // Mutation: skipping the hash check replays a tape into other geometry. + final demo = _record(_level(), stick: 1.0); + final found = resimulate( + game: const _WalkGame(), + level: _level(width: 30.0), + demo: demo, + ); + final changed = found as ResimulationLevelChanged; + expect(changed.recorded, demo.levelHash); + expect(changed.found, _level(width: 30.0).digestHex); + }); + + test('a run that does not start where the recording did is refused', () { + final level = _level(); + final found = resimulate( + game: const _WalkGame(from: 1.0), + level: level, + demo: _record(level, stick: 1.0), + ); + expect(found, isA()); + }); + + test('a tape edited after it was recorded diverges, and says where', () { + // Mutation: trusting the client's checkpoints rather than comparing + // the replay's own against them. + final level = _level(); + final honest = _record(level, stick: 1.0); + final forged = Demo( + level: honest.level, + levelHash: honest.levelHash, + start: honest.start, + tape: InputTape( + seed: honest.tape.seed, + frames: [ + ...honest.tape.frames.take(44), + const InputFrame(stickX: -1.0), + ...honest.tape.frames.skip(45), + ], + ), + buildStamp: honest.buildStamp, + checkpoints: honest.checkpoints, + ); + final found = resimulate( + game: const _WalkGame(), + level: level, + demo: forged, + ); + final diverged = found as ResimulationDiverged; + expect(diverged.divergence.step, 50); + expect(diverged.agreedUntil, 40); + }); + }); + + group('the heatmap', () { + final trails = [ + const HeatmapTrail( + run: 1, + steps: 30, + outcome: 'lost', + positions: <(double, double)>[(0.5, 0.5), (0.6, 0.4), (1.5, 0.5)], + endedBadly: true, + ), + const HeatmapTrail( + run: 2, + steps: 20, + outcome: 'won', + positions: <(double, double)>[(0.2, 0.2), (2.5, -0.5)], + ), + ]; + + test('counts samples and distinct runs per cell, and marks only the ' + 'runs that were lost', () { + // Mutation: counting samples under `runs` would make the first cell + // three runs from two trails. + final map = Heatmap.bin( + trails, + outcomeNames: const ['won', 'lost', 'unfinished'], + ); + final origin = map.cells.singleWhere((c) => c.x == 0 && c.z == 0); + expect(origin.samples, 3); + expect(origin.runs, 2); + expect(map.cells.singleWhere((c) => c.x == 2).z, -1); + expect(map.ends.single.run, 1); + expect(map.ends.single.x, 1.5); + expect(map.outcomes, {'won': 1, 'lost': 1, 'unfinished': 0}); + expect(map.runs, 2); + }); + + test('writes the playtest report\'s keys, and reads them back', () { + // Mutation: renaming `deaths`/`seed` breaks every report already on + // disk and the editor that reads them. + final json = + jsonDecode(jsonEncode(Heatmap.bin(trails, cellSize: 2.0).toJson())) + as Map; + expect(json.keys, containsAll(['cellSize', 'cells', 'deaths'])); + expect((json['deaths']! as List).single, containsPair('seed', 1)); + + final read = Heatmap.fromJson(json); + expect(read.cellSize, 2.0); + expect(read.cells.map((c) => c.samples), [4, 1]); + expect(read.ends.single.step, 30); + + expect( + () => Heatmap.fromJson({'cellSize': 1.0}), + throwsA(isA()), + ); + }); + }); +} diff --git a/packages/flutter3d_sim/test/tunables_test.dart b/packages/flutter3d_sim/test/tunables_test.dart new file mode 100644 index 000000000..1f03db549 --- /dev/null +++ b/packages/flutter3d_sim/test/tunables_test.dart @@ -0,0 +1,106 @@ +/// `HR4`: a number changed while the game runs is an input, so a run tuned as +/// it was played replays like any other. +/// +/// flutter test test/tunables_test.dart +/// +/// Mutation: drop `tunes:` from the recorder and the replay runs at the old +/// speed; skip `tunes` in `InputFrame.toJson` and the saved tape forgets it. +library; + +import 'dart:convert'; + +import 'package:flutter3d_sim/flutter3d_sim.dart'; +import 'package:test/test.dart'; + +/// A walker whose speed is a tunable. +final class _Walker { + final Tunables tunables = Tunables(const {'speed': 1.0}); + double x = 0.0; + + void step(InputState input) { + tunables.take(input); + x += tunables['speed'] * 0.1; + } +} + +/// Plays [steps] steps, tuning the speed to 3 at step 40, and returns the +/// walker and the tape. +({_Walker walker, InputTape tape}) _played(int steps) { + final walker = _Walker(); + final input = InputState(); + final recorder = InputTapeRecorder(seed: 1); + for (var step = 0; step < steps; step++) { + if (step == 40) input.tune('speed', 3.0); + recorder.record(input); + input.beginStep(); + walker.step(input); + input.endStep(); + } + return (walker: walker, tape: recorder.tape); +} + +double _replayed(InputTape tape) { + final walker = _Walker(); + final input = InputState(); + final playback = InputTapePlayback(tape); + while (!playback.isFinished) { + playback.applyTo(input); + input.beginStep(); + walker.step(input); + input.endStep(); + } + return walker.x; +} + +void main() { + test('a tape with a tunable changed half way reproduces the run', () { + final run = _played(100); + // 40 steps at 1, 60 at 3. + expect(run.walker.x, closeTo(4.0 + 18.0, 1e-9)); + expect(_replayed(run.tape), run.walker.x); + }); + + test('and still does after the tape has been saved and read back', () { + final run = _played(100); + final saved = InputTape.fromJson( + jsonDecode(jsonEncode(run.tape.toJson())) as Map, + ); + expect(saved.frames[40].tunes, {'speed': 3.0}); + expect(saved.frames[40].isIdle, isFalse); + expect(_replayed(saved), run.walker.x); + }); + + test('a tune lasts one step on the input and for good on the table', () { + final walker = _Walker(); + final input = InputState()..tune('speed', 2.0); + walker.step(input); + input.endStep(); + expect(input.tunesThisStep, isEmpty); + walker.step(input); + expect(walker.x, closeTo(0.4, 1e-9)); + }); + + test('a muted input takes no tune, as it takes no key', () { + final input = InputState()..muted = true; + input.tune('speed', 9.0); + expect(input.tunesThisStep, isEmpty); + }); + + test('a name the table does not declare is ignored', () { + final tunables = Tunables(const {'speed': 1.0}); + tunables.take(InputState()..tune('gravity', 5.0)); + expect(tunables.values.keys, ['speed']); + expect(() => tunables['gravity'], throwsArgumentError); + }); + + test('a snapshot holds what was tuned, and a restore puts it back', () { + final tunables = Tunables(const {'speed': 1.0, 'jump': 2.0}) + ..take(InputState()..tune('speed', 4.0)); + final saved = tunables.toJson(); + expect(saved, {'speed': 4.0}); + + tunables.take(InputState()..tune('jump', 7.0)); + tunables.restore(saved); + expect(tunables.values, {'speed': 4.0, 'jump': 2.0}); + }); +} diff --git a/packages/flutter3d_sim_mcp/CHANGELOG.md b/packages/flutter3d_sim_mcp/CHANGELOG.md index 4764d0af7..4764f2fd9 100644 --- a/packages/flutter3d_sim_mcp/CHANGELOG.md +++ b/packages/flutter3d_sim_mcp/CHANGELOG.md @@ -1,3 +1,14 @@ +## Unreleased + +- **`verify` refuses a run with the level edited under it**, naming the + step. The swapped level is built by the game that recorded the run, and a + session over a bare simulation would answer with a divergence it did not + cause. +- `Playtest.heatmap` bins through `flutter3d_sim`'s `Heatmap`, and `verify` + replays through `resimulate`, so a playtest report and a telemetry heatmap + are one format and a run this server calls verified is one a telemetry + server would take. Answers and JSON are unchanged. + ## 0.8.0 **An agent's claim about a run comes with the run that proves it.** The diff --git a/packages/flutter3d_sim_mcp/lib/src/playtest.dart b/packages/flutter3d_sim_mcp/lib/src/playtest.dart index 2857b16ed..09b55d441 100644 --- a/packages/flutter3d_sim_mcp/lib/src/playtest.dart +++ b/packages/flutter3d_sim_mcp/lib/src/playtest.dart @@ -123,46 +123,28 @@ final class Playtest { /// metres on a side, counting how many positions from how many distinct /// runs landed in each — the JSON `ai-01`'s own row promises, and the /// same shape `ai-02`'s editor layer draws directly over the level. + /// + /// [Heatmap] does the binning, and N7's telemetry server bins real players' + /// runs through it too, so the two reports are one format. static Map heatmap( List runs, { double cellSize = 1.0, - }) { - final counts = <(int, int), int>{}; - final runsThroughCell = <(int, int), Set>{}; - for (final run in runs) { - for (final (x, z) in run.positions) { - final cell = ((x / cellSize).floor(), (z / cellSize).floor()); - counts[cell] = (counts[cell] ?? 0) + 1; - (runsThroughCell[cell] ??= {}).add(run.seed); - } - } - return { - 'cellSize': cellSize, - 'cells': >[ - for (final entry in counts.entries) - { - 'x': entry.key.$1, - 'z': entry.key.$2, - 'samples': entry.value, - 'runs': runsThroughCell[entry.key]!.length, - }, - ], - 'deaths': >[ - for (final run in runs) - if (run.outcome == PlaytestOutcome.died && run.positions.isNotEmpty) - { - 'seed': run.seed, - 'step': run.steps, - 'x': run.positions.last.$1, - 'z': run.positions.last.$2, - }, - ], - 'outcomes': { - for (final outcome in PlaytestOutcome.values) - outcome.name: runs.where((r) => r.outcome == outcome).length, - }, - }; - } + }) => Heatmap.bin( + [ + for (final run in runs) + HeatmapTrail( + run: run.seed, + steps: run.steps, + outcome: run.outcome.name, + positions: run.positions, + endedBadly: run.outcome == PlaytestOutcome.died, + ), + ], + cellSize: cellSize, + outcomeNames: [ + for (final outcome in PlaytestOutcome.values) outcome.name, + ], + ).toJson(); } /// A random policy's intent for one step — held for a stretch rather than diff --git a/packages/flutter3d_sim_mcp/lib/src/sim_session.dart b/packages/flutter3d_sim_mcp/lib/src/sim_session.dart index 73e6990ad..8b3f969bd 100644 --- a/packages/flutter3d_sim_mcp/lib/src/sim_session.dart +++ b/packages/flutter3d_sim_mcp/lib/src/sim_session.dart @@ -1,6 +1,5 @@ import 'dart:convert'; import 'dart:io'; -import 'dart:math' as math; import 'dart:typed_data'; import 'package:flutter3d_mcp_kit/flutter3d_mcp_kit.dart'; @@ -208,6 +207,16 @@ final class SimSession { } catch (error) { return _refuse('could not read a run from "$path": $error'); } + // A swapped level goes in through the game's own build, which a session + // over a bare simulation does not have; playing through the swap would + // answer with a divergence the simulation did not cause. + if (demo.levelSwaps case [final first, ...]) { + return _refuse( + '"$path" has the level edited under it at step ${first.step}, and ' + 'this replays a run in one level — play it in the game it was ' + 'recorded in', + ); + } final ReadingPredicate? claim; try { claim = predicate == null ? null : ReadingPredicate.fromJson(predicate); @@ -221,52 +230,37 @@ final class SimSession { } catch (error) { return _refuse('could not read the run\'s level "${demo.level}": $error'); } - if (level.digestHex != demo.levelHash) { - return _refuse( - 'the level at "${demo.level}" has changed since the run was recorded ' - '(hash ${level.digestHex}, the run says ${demo.levelHash}) — a tape ' - 'played into different geometry proves nothing', - ); - } - - final world = CollisionWorld(); - level.addTo(world); - final input = InputState(); - final run = game.start(level, world, input); - world.update(); - // A run that does not start where the recording started diverges before - // its first step, and saying so is more use than the checkpoint after. - if (StateDigest.of(run.save().toJson()) != - StateDigest.of(demo.start.toJson())) { - return _refuse( - 'diverges before the first step: a fresh ${game.name} run of ' - '"${demo.level}" does not start where "$path" started', - ); - } - - final playback = InputTapePlayback(demo.tape); - final trace = DigestTrace(every: demo.checkpoints.every); - for (var step = 1; step <= demo.steps; step++) { - playback.applyTo(input); - input.beginStep(); - run.step(_dt); - if (step % trace.every == 0) trace.observe(step, run.save().toJson()); - input.endStep(); - } - - final divergence = trace.divergenceFrom(demo.checkpoints.digests); - if (divergence != null) { - return _refuse( - 'diverges — $divergence; the difference arose after step ' - '${math.max(0, divergence.step - trace.every)}', - ); + // The same replay N7's telemetry server reads metrics off, so a run this + // tool calls verified is a run that server would take. + final ResimulationRetraced retraced; + switch (resimulate(game: game, level: level, demo: demo, dt: _dt)) { + case ResimulationLevelChanged(:final found, :final recorded): + return _refuse( + 'the level at "${demo.level}" has changed since the run was ' + 'recorded (hash $found, the run says $recorded) — a tape played ' + 'into different geometry proves nothing', + ); + // A run that does not start where the recording started diverges + // before its first step, and saying so is more use than the + // checkpoint after. + case ResimulationStartDiffers(): + return _refuse( + 'diverges before the first step: a fresh ${game.name} run of ' + '"${demo.level}" does not start where "$path" started', + ); + case final ResimulationDiverged diverged: + return _refuse( + 'diverges — ${diverged.divergence}; the difference arose after ' + 'step ${diverged.agreedUntil}', + ); + case final ResimulationRetraced found: + retraced = found; } - final hex = StateDigest.of( - run.save().toJson(), - ).toRadixString(16).padLeft(8, '0'); + final run = retraced.run; + final hex = retraced.finalDigest.toRadixString(16).padLeft(8, '0'); final agrees = 'replayed ${demo.steps} steps of "$path": agrees at all ' - '${trace.steps.length} checkpoints, ends at step ${demo.steps}, ' + '${retraced.checkpoints} checkpoints, ends at step ${demo.steps}, ' 'digest $hex'; if (claim == null) return _ok('$agrees. ${run.summary}'); try { diff --git a/packages/flutter3d_sim_mcp/test/claims_test.dart b/packages/flutter3d_sim_mcp/test/claims_test.dart index dafda1606..65070fe82 100644 --- a/packages/flutter3d_sim_mcp/test/claims_test.dart +++ b/packages/flutter3d_sim_mcp/test/claims_test.dart @@ -284,6 +284,39 @@ void main() { expect(replayed.says, contains('has changed since the run was recorded')); }); + test('a run with the level edited under it is refused, naming the ' + 'step', () { + final session = _session()..open(_crypt); + final path = '${workspace.path}/edited.f3drun'; + session + ..step(steps: 10, moveY: 1.0) + ..writeRun(path); + final demo = Demo.fromJson( + jsonDecode(File(path).readAsStringSync()) as Map, + ); + File(path).writeAsStringSync( + jsonEncode( + Demo( + level: demo.level, + levelHash: demo.levelHash, + start: demo.start, + tape: demo.tape, + buildStamp: demo.buildStamp, + checkpoints: demo.checkpoints, + levelSwaps: [ + DemoLevelSwap(step: 4, level: Level(name: 'crypt')), + ], + ).toJson(), + ), + ); + + // Mutation: drop the refusal — the session plays through the edit in + // the shipped crypt and reports the run as diverging. + final replayed = session.verify(path); + expect(replayed.did, isFalse); + expect(replayed.says, contains('edited under it at step 4')); + }); + test('verify leaves the session\'s own run where it was', () { final session = _session()..open(_crypt); final path = '${workspace.path}/aside.f3drun'; diff --git a/packages/flutter3d_testing/CHANGELOG.md b/packages/flutter3d_testing/CHANGELOG.md index 67cc11fae..0a01a626a 100644 --- a/packages/flutter3d_testing/CHANGELOG.md +++ b/packages/flutter3d_testing/CHANGELOG.md @@ -1,3 +1,23 @@ +## Unreleased + +- **`MaterialProgramStage` moved to `flutter3d_app`**, which a game can + depend on; this package carries `flutter_test`. + +* **`testReplay` turns a recorded run into a test that needs no GPU.** + `testReplay('test/tapes/level.f3drun', start: …, digestAt: …, goldensAt: …)` + registers a test that plays the tape into the game on the software + backend. At each step in `digestAt` (every checkpoint the tape holds, by + default) the digest of the game's snapshot must equal the recorded one; a + divergence names its step and the last checkpoint that agreed. At each + step in `goldensAt` the frame is compared with + `test/goldens/-.png`. The game implements `ReplaySubject` + (`levelHash`, `restore`, `step`, `save`, `frame`) and gets a `ReplayStart` + with the tape, the input to read and the device to upload to. A step past + the end of the tape, a digest where the tape has no checkpoint, a test + that checks nothing and a level changed since the recording all fail + before the first step. `expectReplayMatches` does the same for a `Demo` + in memory, and `readTape` reads a `.f3drun` or says why it cannot. + ## 0.8.0 * **Two pictures can be compared the way an eye would.** `flip` takes two diff --git a/packages/flutter3d_testing/README.md b/packages/flutter3d_testing/README.md index 38ae72f47..cf45b5c50 100644 --- a/packages/flutter3d_testing/README.md +++ b/packages/flutter3d_testing/README.md @@ -22,6 +22,28 @@ test('the crypt still looks like the crypt', () async { The first run records the reference and says so. Later runs fail when the picture changes, and the message says by how much and how to re-record. +## Replaying a recorded run + +A `.f3drun` your game recorded is a test too: + +```dart +void main() { + testReplay( + 'test/tapes/ascent.f3drun', + start: Ascent.open, // builds a ReplaySubject on the device it is given + goldensAt: [120, 600], + ); +} +``` + +The replay runs on the software backend. At every checkpoint the tape holds +(or only those you name in `digestAt`) the simulation's digest has to match the +recorded one, and the frames at `goldensAt` are compared with +`test/goldens/ascent-120.png` and `ascent-600.png`. If the level changed since +the tape was recorded, the test says that before stepping, because a +divergence at step 25 would send you looking in the wrong place. +`apps/flutter3d_demo_platformer/test/replay_test.dart` is a complete example. + ## Why this can exist here and nowhere else `flutter3d_cpu` is a full backend: a software rasteriser that passes the same diff --git a/packages/flutter3d_testing/lib/flutter3d_testing.dart b/packages/flutter3d_testing/lib/flutter3d_testing.dart index 04bfbde9e..f50dca772 100644 --- a/packages/flutter3d_testing/lib/flutter3d_testing.dart +++ b/packages/flutter3d_testing/lib/flutter3d_testing.dart @@ -40,8 +40,8 @@ library; export 'src/flip.dart'; export 'src/golden.dart'; -export 'src/material_stage.dart'; export 'src/quality_table_builder.dart'; export 'src/render_frame.dart'; export 'src/replay_golden.dart'; export 'src/replay_pacing.dart'; +export 'src/test_replay.dart'; diff --git a/packages/flutter3d_testing/lib/src/draw.dart b/packages/flutter3d_testing/lib/src/draw.dart new file mode 100644 index 000000000..221dcc966 --- /dev/null +++ b/packages/flutter3d_testing/lib/src/draw.dart @@ -0,0 +1,48 @@ +import 'package:flutter3d/flutter3d.dart'; +import 'package:vector_math/vector_math.dart'; + +import 'render_frame.dart'; + +/// Draws [subject] once through [renderer] and reads the frame back. +/// +/// **Not exported: the two callers are [renderFrame], which builds a device per +/// frame, and `testReplay`, which keeps one for a whole run.** A replay cannot +/// take a fresh device per golden — the level it stepped was uploaded to the +/// first one — so the half that is the same in both lives here rather than +/// twice. +Future drawOnce({ + required GraphicsDevice device, + required Renderer renderer, + required FrameSubject subject, + required int width, + required int height, + RenderSettings settings = const RenderSettings(), + Vector4? clearColor, +}) async { + final result = renderer.render( + width: width, + height: height, + scene: subject.scene, + views: [ + RenderView( + camera: subject.camera, + clearColor: clearColor ?? Vector4(0.0, 0.0, 0.0, 1.0), + ), + ], + settings: settings, + ); + + final pixels = await device.readPixels(result.frame); + if (pixels == null) { + throw StateError( + 'the frame could not be read back from the software device, which has ' + 'nothing to be busy with and no driver to blame', + ); + } + return ( + pixels: pixels.buffer.asUint8List(), + width: width, + height: height, + drawCalls: result.drawCalls, + ); +} diff --git a/packages/flutter3d_testing/lib/src/material_stage.dart b/packages/flutter3d_testing/lib/src/material_stage.dart deleted file mode 100644 index ca10a0455..000000000 --- a/packages/flutter3d_testing/lib/src/material_stage.dart +++ /dev/null @@ -1,99 +0,0 @@ -/// The software backend's half of the material language — `gfx-84n`. -/// -/// **Twenty lines, and here rather than in `flutter3d_cpu`.** That package has -/// `flutter3d_core` as a dev dependency with "nothing in `lib/` reaches it" -/// written beside it: a backend that imported the engine's formats library -/// would invert the direction an application assembles the two in. This -/// package already depends on both, which is what it is for. -/// -/// Everything that decides what a material *means* is in -/// `material_eval.dart`, beside the emitter that writes the GLSL — so what is -/// left here is filling in the surface a fragment is being shaded at, and -/// handing back what `WriteSurface(rgb, a)` takes. -/// -/// final program = specialiseMaterial( -/// parseMaterial(source), -/// const MaterialVariant('RimLight'), -/// ); -/// final device = CpuDevice( -/// shaders: CpuShaderLibrary({ -/// ...builtinCpuShaders(), -/// 'RimLight': CpuStage.fragment(MaterialProgramStage(program)), -/// }), -/// ); -library; - -import 'dart:typed_data'; - -import 'package:flutter3d_core/formats.dart'; -import 'package:flutter3d_cpu/flutter3d_cpu.dart'; -import 'package:vector_math/vector_math.dart'; - -/// One compiled material, as a stage the software backend can be given. -/// -/// The program must already be through `specialiseMaterial`: a parameter has -/// no value until a variant gives it one, and a backend picking a default here -/// would be a backend disagreeing with the compiled shader. -final class MaterialProgramStage implements CpuFragmentShader { - const MaterialProgramStage(this.program); - - final MaterialProgram program; - - @override - Vector4? run(Float32List v, ShaderBindings bindings, FragmentContext c) { - final s = readSurface(v, bindings, c); - // Null is the GLSL's `discard`, which `ReadSurface` performs for a masked - // material under its cutoff — the emitted shader gets it from the same - // header, so both sides drop the same fragments. - if (s == null) return null; - - final footprint = uvFootprint(c); - final result = evaluateMaterial( - program, - MaterialSurfaceValues( - inputs: >{ - 'albedo': [s.albedo.x, s.albedo.y, s.albedo.z], - 'alpha': [s.alpha], - 'normal': [s.normal.x, s.normal.y, s.normal.z], - 'view': [s.view.x, s.view.y, s.view.z], - 'nDotV': [s.nDotV], - 'metallic': [s.metallic], - 'roughness': [s.roughness], - 'occlusion': [s.occlusion], - 'emissive': [s.emissive.x, s.emissive.y, s.emissive.z], - 'ambient': [s.ambient.x, s.ambient.y, s.ambient.z], - 'uv': [v[kVUv], v[kVUv + 1]], - 'world': [s.world.x, s.world.y, s.world.z], - }, - sample: (slot, u, w) { - final texture = bindings.textures[slot.bindingName]; - // White is what `surface.glsl` falls back to for an unbound base - // colour, so a material sampling a slot this draw did not fill - // looks the same on both sides. - if (texture == null) return const [1, 1, 1, 1]; - final texel = texture.sample( - u, - w, - du: footprint.du, - dv: footprint.dv, - ); - return [texel.x, texel.y, texel.z, texel.w]; - }, - ), - ); - - // `WriteSurface(rgb, a)` in `color.glsl`, which is the one-argument form: - // a surface that cannot say how polished it is is written fully rough, so - // nothing reflects off it. The same call `unlit.frag` makes and the same - // transcription `UnlitShader` makes of it. - return writeLit( - c, - v, - bindings, - colour: Vector3(result[0], result[1], result[2]), - alpha: result[3], - normal: s.normal, - roughness: 1, - ); - } -} diff --git a/packages/flutter3d_testing/lib/src/render_frame.dart b/packages/flutter3d_testing/lib/src/render_frame.dart index 961b0bfd8..423e35ad6 100644 --- a/packages/flutter3d_testing/lib/src/render_frame.dart +++ b/packages/flutter3d_testing/lib/src/render_frame.dart @@ -4,6 +4,8 @@ import 'package:flutter3d/flutter3d.dart'; import 'package:flutter3d_cpu/testing.dart'; import 'package:vector_math/vector_math.dart'; +import 'draw.dart'; + /// What a test hands back: the scene to draw and the camera to draw it from. typedef FrameSubject = ({Scene scene, CameraNode camera}); @@ -76,30 +78,13 @@ Future renderFrame({ fallbackNormal: kit.normal, ); - final result = renderer.render( + return drawOnce( + device: kit.device, + renderer: renderer, + subject: subject, width: width, height: height, - scene: subject.scene, - views: [ - RenderView( - camera: subject.camera, - clearColor: clearColor ?? Vector4(0.0, 0.0, 0.0, 1.0), - ), - ], settings: settings, - ); - - final pixels = await kit.device.readPixels(result.frame); - if (pixels == null) { - throw StateError( - 'the frame could not be read back from the software device, which has ' - 'nothing to be busy with and no driver to blame', - ); - } - return ( - pixels: pixels.buffer.asUint8List(), - width: width, - height: height, - drawCalls: result.drawCalls, + clearColor: clearColor, ); } diff --git a/packages/flutter3d_testing/lib/src/test_replay.dart b/packages/flutter3d_testing/lib/src/test_replay.dart new file mode 100644 index 000000000..0c057d19c --- /dev/null +++ b/packages/flutter3d_testing/lib/src/test_replay.dart @@ -0,0 +1,313 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter3d/flutter3d.dart'; +import 'package:flutter3d_cpu/testing.dart'; +import 'package:flutter3d_sim/flutter3d_sim.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'draw.dart'; +import 'golden.dart'; +import 'render_frame.dart'; + +/// What a game is handed to build the run a tape is replayed into. +final class ReplayStart { + const ReplayStart({ + required this.demo, + required this.input, + required this.device, + }); + + /// The tape being replayed, for its level path and anything else the game + /// wants to know before it loads. + final Demo demo; + + /// The input the tape is played into, a step at a time. + /// + /// **The game has to read this one.** A simulation built with an + /// [InputState] of its own steps against nobody's hands, stands still for + /// the whole tape, and diverges at the first checkpoint for a reason that + /// is about the test and not the game. + final InputState input; + + /// Where the level's meshes and textures go: the software device every + /// golden of this replay is drawn on. + final GraphicsDevice device; +} + +/// One run of a game, as a replay test drives it. +/// +/// **An interface the game implements in its test, not one this package +/// implements for every genre.** A platformer, a racer and a strategy game +/// step, save and draw in their own ways, and the four calls below are the +/// whole of what a replay needs from any of them. +abstract interface class ReplaySubject { + /// The `levelHash` of the level the run was built from — [Level.digestHex], + /// or [contentDigestHex] of a genre's own document — or null when the game + /// cannot say. + /// + /// Asked so that a level edited since the tape was recorded fails as that, + /// rather than as a divergence at the first checkpoint, which reads as a + /// bug in the simulation and sends whoever reads it looking there. + String? get levelHash; + + /// Puts the run in the state the tape starts from. + void restore(Snapshot start); + + /// Advances the run one fixed step of [dt] seconds, reading the input it + /// was handed in [ReplayStart.input]. + void step(double dt); + + /// The whole state, the same snapshot the tape's checkpoints were taken of. + Snapshot save(); + + /// The scene and camera to draw at [step], once the run has reached it. + /// + /// May be asynchronous so that a game can wait for what it loads in the + /// background — models a level names, which arrive a few frames after the + /// level does — before the golden is drawn. A frame drawn before they + /// arrived differs from one drawn after by how busy the machine was. + FutureOr frame(int step); +} + +/// Builds the run a tape is replayed into. +typedef ReplayStarter = FutureOr Function(ReplayStart start); + +/// Registers a test that replays the tape at [tapePath] and checks it. +/// +/// void main() { +/// testReplay( +/// 'test/tapes/ascent.f3drun', +/// start: Ascent.open, +/// goldensAt: [120, 600], +/// ); +/// } +/// +/// A tape is a `.f3drun` — a [Demo] written with `jsonEncode(demo.toJson())` +/// — and the game's own recorder writes one whenever a level is played. The +/// replay runs on the software backend, so a game's CI needs no GPU, no +/// display and no driver to keep two promises: +/// +/// * **[digestAt] says the simulation still does what it did.** At each step +/// named, the digest of [ReplaySubject.save] must equal the one the tape +/// recorded there. Null, the default, means every checkpoint the tape holds; +/// an empty list means none. +/// * **[goldensAt] says the picture at those steps still looks the same.** +/// Each is compared against `goldenDirectory/-.png`, recorded on +/// the first local run the way [expectMatchesGolden] records any golden. +/// +/// The two answer different questions, which is why one test asks both: a +/// step can be numerically identical and drawn by a renderer that changed, +/// and a step can look the same by eye while the state underneath diverged. +/// +/// Everything else is [expectReplayMatches], which does the work. +void testReplay( + String tapePath, { + required ReplayStarter start, + Iterable? digestAt, + Iterable goldensAt = const [], + String goldenDirectory = 'test/goldens', + int width = 320, + int height = 180, + double dt = 1.0 / 60.0, + RenderSettings settings = const RenderSettings(), + double tolerance = 0.0, + bool? recordMissing, + String? description, + Object? skip, +}) { + // A level is read through `rootBundle`, which needs the binding up; a game + // developer should not have to know that to write one line. + TestWidgetsFlutterBinding.ensureInitialized(); + test(description ?? 'the replay of $tapePath still checks out', () async { + await expectReplayMatches( + readTape(tapePath), + start: start, + digestAt: digestAt, + goldensAt: goldensAt, + goldenDirectory: goldenDirectory, + goldenName: _stem(tapePath), + width: width, + height: height, + dt: dt, + settings: settings, + tolerance: tolerance, + recordMissing: recordMissing, + label: tapePath, + ); + }, skip: skip); +} + +/// The tape at [path], or a test failure that says why it could not be read. +/// +/// A failure rather than a [DemoFormatException], because it is read in a +/// test report: the path and the reason in one sentence, and what to do. +Demo readTape(String path) { + final file = File(path); + if (!file.existsSync()) { + fail( + 'no tape at $path. A tape is a .f3drun, the JSON of a Demo; record one ' + 'by playing the level, or write one with jsonEncode(demo.toJson()).', + ); + } + final Object? json; + try { + json = jsonDecode(file.readAsStringSync()); + } on FormatException catch (error) { + fail('$path is not JSON: ${error.message}'); + } + if (json is! Map) { + fail('$path is not a tape: a .f3drun is a JSON object'); + } + try { + return Demo.fromJson(json); + } on DemoFormatException catch (error) { + fail('$path is not a tape: ${error.message}'); + } +} + +/// Replays [demo] into the run [start] builds and checks it at the steps +/// asked for; see [testReplay] for what [digestAt] and [goldensAt] mean. +/// +/// [tolerance] and [recordMissing] are [expectMatchesGolden]'s, for every +/// golden of the replay. +/// +/// For a game that has its [Demo] in memory rather than in a file — one it +/// has just recorded, say. The golden for step `s` is +/// `goldenDirectory/goldenName-s.png`, and [label] names the tape in every +/// failure. +/// +/// **Refuses before it steps** a request it cannot answer: a step past the +/// end of the tape, a digest at a step the tape has no checkpoint for, a +/// test that asks for nothing, and a level that changed since the recording. +/// Each is a mistake in the test or in the tape, and failing on it after a +/// thousand steps of replay would only make the report longer. +Future expectReplayMatches( + Demo demo, { + required ReplayStarter start, + Iterable? digestAt, + Iterable goldensAt = const [], + String goldenDirectory = 'test/goldens', + String goldenName = 'replay', + int width = 320, + int height = 180, + double dt = 1.0 / 60.0, + RenderSettings settings = const RenderSettings(), + double tolerance = 0.0, + bool? recordMissing, + String label = 'the tape', +}) async { + final recorded = { + for (final (i, step) in demo.checkpoints.steps.indexed) + step: demo.checkpoints.digests[i], + }; + final digests = digestAt?.toSet() ?? recorded.keys.toSet(); + final goldens = goldensAt.toSet(); + + final unrecorded = digests.where((step) => !recorded.containsKey(step)); + if (unrecorded.isNotEmpty) { + fail( + '$label has no checkpoint at step ${unrecorded.join(', ')}: it took ' + 'one every ${demo.checkpoints.every} steps' + '${recorded.isEmpty ? ' and holds none' : ', ${_some(recorded.keys)}'}. ' + 'Ask for one of those, or leave digestAt out to check them all.', + ); + } + final outside = goldens.where((step) => step < 1 || step > demo.steps); + if (outside.isNotEmpty) { + fail( + '$label lasts ${demo.steps} steps, so there is no step ' + '${outside.join(', ')} to draw. Ask for a step from 1 to ${demo.steps}, ' + 'or record a longer run.', + ); + } + if (digests.isEmpty && goldens.isEmpty) { + fail( + 'this replay of $label checks nothing: ' + '${recorded.isEmpty ? 'the tape holds no checkpoints' : 'digestAt is empty'} ' + 'and goldensAt names no step. A replay that checks nothing passes ' + 'whatever the game does.', + ); + } + + final kit = cpuTestDevice(width: width, height: height); + final renderer = Renderer.create( + device: kit.device, + fallbackAlbedo: kit.albedo, + fallbackNormal: kit.normal, + ); + final input = InputState(); + final run = await start( + ReplayStart(demo: demo, input: input, device: kit.device), + ); + + final levelHash = run.levelHash; + if (levelHash != null && levelHash != demo.levelHash) { + fail( + 'the level ${demo.level} has changed since $label was recorded: the ' + 'tape was played on ${demo.levelHash}, the level is $levelHash now. A ' + 'tape played into ' + 'edited geometry diverges for a reason that is not a bug; record the ' + 'tape again.', + ); + } + + run.restore(demo.start); + final playback = InputTapePlayback(demo.tape); + final last = {...digests, ...goldens}.reduce((a, b) => a > b ? a : b); + int? agreed; + for (var step = 1; step <= last; step++) { + playback.applyTo(input); + run.step(dt); + // Before `endStep`, because that is where the recorder took it: the + // checkpoint is the state the step left, with the step's input still on. + if (digests.contains(step)) { + final found = StateDigest.of(run.save().toJson()); + final expected = recorded[step]!; + if (found != expected) { + fail( + '$label diverged at step $step: the tape recorded ${_hex(expected)} ' + 'and this build reached ${_hex(found)}. ' + '${agreed == null ? 'No earlier checkpoint was checked' : 'Step $agreed still agreed'}, ' + 'so the change is in the steps between. If it is meant, record the ' + 'tape again.', + ); + } + agreed = step; + } + input.endStep(); + + if (goldens.contains(step)) { + final frame = await drawOnce( + device: kit.device, + renderer: renderer, + subject: await run.frame(step), + width: width, + height: height, + settings: settings, + ); + await expectMatchesGolden( + frame, + '$goldenDirectory/$goldenName-$step.png', + tolerance: tolerance, + recordMissing: recordMissing, + reason: 'step $step of $label', + ); + } + } +} + +String _hex(int digest) => digest.toRadixString(16).padLeft(8, '0'); + +/// The first few of [steps], for a sentence rather than a column. +String _some(Iterable steps) => steps.length <= 6 + ? 'at ${steps.join(', ')}' + : 'at ${steps.take(5).join(', ')} and ${steps.length - 5} more'; + +/// `test/tapes/ascent.f3drun` → `ascent`. +String _stem(String path) { + final name = path.split(RegExp(r'[/\\]')).last; + final dot = name.lastIndexOf('.'); + return dot > 0 ? name.substring(0, dot) : name; +} diff --git a/packages/flutter3d_testing/pubspec.yaml b/packages/flutter3d_testing/pubspec.yaml index 03fa338ac..b2fa8ebf4 100644 --- a/packages/flutter3d_testing/pubspec.yaml +++ b/packages/flutter3d_testing/pubspec.yaml @@ -48,6 +48,11 @@ dependencies: dev_dependencies: flutter_lints: ^6.0.0 + # The material language's software stage and compiler, for the two tests + # that draw a material written in it — `P8` moved them to the package a + # game depends on, which this one is not. + flutter3d_app: ^0.8.0 + # The Khronos sample models the document decoders read — for the one test # that checks a *rendered* round trip rather than a byte one. Not a real # dependency: nothing this package ships builds a `ModelDocument`. diff --git a/packages/flutter3d_testing/test/bundled_material_test.dart b/packages/flutter3d_testing/test/bundled_material_test.dart new file mode 100644 index 000000000..f9d84bac6 --- /dev/null +++ b/packages/flutter3d_testing/test/bundled_material_test.dart @@ -0,0 +1,365 @@ +/// A bundle built from a `.f3dmat`, loaded with nothing registered by hand — +/// `P8`. +/// +/// flutter test test/bundled_material_test.dart +/// +/// The build keeps each material's source in the bundle beside its compiled +/// sections. These claims are about what that buys: the software backend +/// compiles the source itself, a runtime reads the material's lighting model +/// out of it, and a reload of the bundle reaches a draw already holding the +/// stage. +library; + +import 'dart:typed_data'; + +import 'package:flutter3d/flutter3d.dart'; +import 'package:flutter3d_app/flutter3d_app.dart' show materialLanguageCompiler; +import 'package:flutter3d_cpu/flutter3d_cpu.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:vector_math/vector_math.dart'; + +const int _size = 32; + +/// A material that paints its surface one flat [colour]. +String _flat(String name, String colour) => + ''' +material $name { + fragment { + return vec4($colour, 1.0); + } +} +'''; + +/// A bundle as the build writes one for [sources]: their stages, and their +/// text in the material section. The compiled sections are left out: the +/// software backend reads none of them. +ByteData _bundle(Map sources) => ShaderBundle( + name: 'materials', + sdk: '', + stages: [ + for (final name in sources.keys) ShaderBundleStage(name, fragment: true), + ], + sections: { + ShaderBundle.materialSection: encodeMaterialSection(sources), + }, +).encode(); + +CpuDevice _device({CpuMaterialCompiler? compiler}) => CpuDevice( + width: _size, + height: _size, + shaders: CpuShaderLibrary(builtinCpuShaders()), + materialCompiler: compiler, +); + +/// The middle pixel of a frame of one cube drawn with [lighting], through a +/// renderer whose materials are [library]. +Future _centre( + CpuDevice device, + LoadedShaderLibrary? library, + LightingModel lighting, { + Map parameters = const {}, + Renderer? renderer, +}) async { + final camera = CameraNode()..setPosition(0.0, 0.0, 3.0); + final scene = Scene() + ..add(camera) + ..add( + MeshNode( + DeviceMesh.upload(device, CuboidShape().build()), + Material(lighting: lighting)..parameters.addAll(parameters), + ), + ); + final drawing = + renderer ?? Renderer.create(device: device, materials: library); + final result = drawing.render( + width: _size, + height: _size, + scene: scene, + views: [RenderView(camera: camera)], + settings: const RenderSettings( + bloom: BloomSettings(enabled: false), + tonemap: false, + look: LookSettings(dither: 0.0), + ), + ); + final pixels = (await device.readPixels(result.frame))!; + final at = ((_size ~/ 2) * _size + _size ~/ 2) * 4; + return Vector3( + pixels.getUint8(at) / 255.0, + pixels.getUint8(at + 1) / 255.0, + pixels.getUint8(at + 2) / 255.0, + ); +} + +void main() { + test('the material section reads back what was written', () { + final bundle = ShaderBundle.decode( + _bundle({'Red': _flat('Red', 'vec3(1.0, 0.0, 0.0)')}), + ); + expect(decodeMaterialSection(bundle), { + 'Red': _flat('Red', 'vec3(1.0, 0.0, 0.0)'), + }); + final none = ShaderBundle( + name: 'hand-written', + sdk: '', + stages: const [], + ); + expect(decodeMaterialSection(none), isEmpty); + }); + + test( + 'the software backend draws a built material with nothing registered', + () async { + // Mutation: leave the material section out of `compileMaterial` in + // `flutter3d_build`, or the compile out of `CpuLoadedShaderLibrary`, and + // the bundle is refused here, as it was before the section existed. + final device = _device(compiler: materialLanguageCompiler); + final bytes = _bundle({ + 'Red': _flat('Red', 'vec3(1.0, 0.0, 0.0)'), + }); + final library = await device.loadShaders(bytes); + final lighting = BundledMaterials.read(bytes)['Red']; + + final centre = await _centre(device, library, lighting); + expect(centre.x, closeTo(1.0, 0.01)); + expect(centre.y, closeTo(0.0, 0.01)); + }, + ); + + test('without a compiler the bundle is refused by name', () { + expect( + () => _device().loadShaders( + _bundle({'Red': _flat('Red', 'vec3(1.0, 0.0, 0.0)')}), + ), + throwsA( + isA().having( + (ShaderBundleRefused r) => r.reason, + 'reason', + contains('materialCompiler'), + ), + ), + ); + }); + + test('a reload reaches a draw already holding the stage', () async { + // Mutation: build a new stage on a reload instead of pointing the one + // handed out at the new source, and the renderer's pipeline keeps the + // old colour. + final device = _device(compiler: materialLanguageCompiler); + final library = await device.loadShaders( + _bundle({'Paint': _flat('Paint', 'vec3(1.0, 0.0, 0.0)')}), + ); + final lighting = BundledMaterials.read( + _bundle({'Paint': _flat('Paint', 'vec3(1.0, 0.0, 0.0)')}), + )['Paint']; + final handle = library['Paint']; + + expect((await _centre(device, library, lighting)).x, closeTo(1.0, 0.01)); + library.refresh( + _bundle({'Paint': _flat('Paint', 'vec3(0.0, 0.0, 1.0)')}), + ); + expect(identical(library['Paint'], handle), isTrue); + final after = await _centre(device, library, lighting); + expect(after.x, closeTo(0.0, 0.01)); + expect(after.z, closeTo(1.0, 0.01)); + }); + + test('a source that does not compile leaves the library as it was', () async { + final device = _device(compiler: materialLanguageCompiler); + final library = await device.loadShaders( + _bundle({'Paint': _flat('Paint', 'vec3(1.0, 0.0, 0.0)')}), + ); + expect( + () => library.refresh( + _bundle({'Paint': 'material Paint { fragment { } '}), + ), + throwsA(isA()), + ); + final lighting = BundledMaterials.read( + _bundle({'Paint': _flat('Paint', 'vec3(1.0, 0.0, 0.0)')}), + )['Paint']; + expect((await _centre(device, library, lighting)).x, closeTo(1.0, 0.01)); + }); + + test('a lighting model is the program\'s own answer about its maps', () { + // A material that samples a map is bound with it, and the light list it + // never declares is not: a hand-built model that defaulted the list to + // its maps is the bind failure `MaterialBindings` exists to prevent. + final textured = BundledMaterials.read( + _bundle({ + 'Mapped': ''' +material Mapped { + texture tex = base_color_texture; + fragment { + return vec4(sample(tex, uv).rgb, 1.0); + } +} +''', + }), + )['Mapped']; + expect(textured.usesAlbedoTexture, isTrue); + expect(textured.usesLightList, isFalse); + expect( + () => BundledMaterials.read( + _bundle({'Mapped': _flat('Mapped', 'vec3(0.5)')}), + )['Other'], + throwsArgumentError, + ); + }); + + test('a uniform takes the value set on the material', () async { + // `P8`: the value a game sets in `Material.parameters`, bound as + // `MaterialParams`, reaches the software stage as it reaches the GPU's. + const source = ''' +material Tinted { + uniform vec3 tint = vec3(1.0, 0.0, 0.0); + fragment { + return vec4(tint, 1.0); + } +} +'''; + final device = _device(compiler: materialLanguageCompiler); + final bytes = _bundle({'Tinted': source}); + final library = await device.loadShaders(bytes); + final materials = BundledMaterials.read(bytes); + final lighting = materials['Tinted']; + expect(lighting.usesMaterialParameters, isTrue); + + final defaults = materials.parameters('Tinted'); + expect(defaults['tint'], [1.0, 0.0, 0.0]); + final centre = await _centre( + device, + library, + lighting, + parameters: { + 'tint': Float32List.fromList([0.0, 1.0, 0.0]), + }, + ); + expect(centre.x, closeTo(0.0, 0.01)); + expect(centre.y, closeTo(1.0, 0.01)); + }); + + group('more than one bundle — P8', () { + test('two bundles added to one renderer both draw', () async { + // Each `.f3dmat` the hook compiles is a bundle of its own. + // + // Mutation: make `Renderer.addMaterials` keep only the latest library + // — the first bundle's material is a missing stage. + final device = _device(compiler: materialLanguageCompiler); + final red = _bundle({ + 'Red': _flat('Red', 'vec3(1.0, 0.0, 0.0)'), + }); + final blue = _bundle({ + 'Blue': _flat('Blue', 'vec3(0.0, 0.0, 1.0)'), + }); + final renderer = Renderer.create(device: device) + ..addMaterials(await device.loadShaders(red)) + ..addMaterials(await device.loadShaders(blue)); + + final first = await _centre( + device, + null, + BundledMaterials.read(red)['Red'], + renderer: renderer, + ); + final second = await _centre( + device, + null, + BundledMaterials.read(blue)['Blue'], + renderer: renderer, + ); + expect(first.x, closeTo(1.0, 0.01)); + expect(second.z, closeTo(1.0, 0.01)); + }); + + test('the one added later wins a name, and taking it out gives the name ' + 'back', () async { + // Mutation: keep `_fragmentShaders` across `addMaterials` — the stage + // the renderer resolved first goes on drawing after the name changed + // hands. + final device = _device(compiler: materialLanguageCompiler); + final red = await device.loadShaders( + _bundle({ + 'Paint': _flat('Paint', 'vec3(1.0, 0.0, 0.0)'), + }), + ); + final green = await device.loadShaders( + _bundle({ + 'Paint': _flat('Paint', 'vec3(0.0, 1.0, 0.0)'), + }), + ); + final lighting = BundledMaterials.read( + _bundle({ + 'Paint': _flat('Paint', 'vec3(1.0, 0.0, 0.0)'), + }), + )['Paint']; + final renderer = Renderer.create(device: device, materials: red); + + Future paint() => + _centre(device, null, lighting, renderer: renderer); + expect((await paint()).x, closeTo(1.0, 0.01)); + renderer.addMaterials(green); + expect((await paint()).y, closeTo(1.0, 0.01)); + expect(renderer.removeMaterials(green), isTrue); + expect((await paint()).x, closeTo(1.0, 0.01)); + expect(renderer.removeMaterials(green), isFalse); + }); + }); + + test('each copy of a batch reads its own numbers as instance — P8', () async { + // Two instances of one cube, the left given red and the right green as + // their own four numbers; the material paints what it reads. + // + // Mutation: write nought for `kVInstance` in the software backend's + // instanced vertex stage — both copies draw black. + const source = ''' +material Painted { + fragment { + return vec4(instance.rgb, 1.0); + } +} +'''; + final device = _device(compiler: materialLanguageCompiler); + final bytes = _bundle({'Painted': source}); + final library = await device.loadShaders(bytes); + final batch = + InstancedMeshNode( + DeviceMesh.upload(device, CuboidShape().build()), + Material(lighting: BundledMaterials.read(bytes)['Painted']), + capacity: 2, + ) + ..addInstance( + Matrix4.translationValues(-0.8, 0.0, 0.0) + ..scaleByDouble(0.6, 0.6, 0.6, 1.0), + data: Vector4(1.0, 0.0, 0.0, 0.0), + ) + ..addInstance( + Matrix4.translationValues(0.8, 0.0, 0.0) + ..scaleByDouble(0.6, 0.6, 0.6, 1.0), + data: Vector4(0.0, 1.0, 0.0, 0.0), + ); + final camera = CameraNode()..setPosition(0.0, 0.0, 3.0); + final scene = Scene() + ..add(camera) + ..add(batch); + final result = Renderer.create(device: device, materials: library).render( + width: _size, + height: _size, + scene: scene, + views: [RenderView(camera: camera)], + settings: const RenderSettings( + bloom: BloomSettings(enabled: false), + tonemap: false, + look: LookSettings(dither: 0.0), + ), + ); + final pixels = (await device.readPixels(result.frame))!; + List at(int x) { + final i = ((_size ~/ 2) * _size + x) * 4; + return [pixels.getUint8(i), pixels.getUint8(i + 1)]; + } + + expect(at(_size ~/ 2 - 8), [255, 0]); + expect(at(_size ~/ 2 + 8), [0, 255]); + }); +} diff --git a/packages/flutter3d_testing/test/material_language_frame_test.dart b/packages/flutter3d_testing/test/material_language_frame_test.dart index ac18b231b..775c4d414 100644 --- a/packages/flutter3d_testing/test/material_language_frame_test.dart +++ b/packages/flutter3d_testing/test/material_language_frame_test.dart @@ -19,9 +19,9 @@ import 'dart:io'; import 'dart:typed_data'; import 'package:flutter3d/flutter3d.dart'; +import 'package:flutter3d_app/flutter3d_app.dart' show MaterialProgramStage; import 'package:flutter3d_cpu/flutter3d_cpu.dart'; import 'package:flutter3d_cpu/testing.dart'; -import 'package:flutter3d_testing/flutter3d_testing.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:vector_math/vector_math.dart'; @@ -55,7 +55,11 @@ material Rim { '''; /// The cube every case below draws, with its material pointed at [model]. -Future _render(LightingModel model, CpuStage? stage) async { +Future _render( + LightingModel model, + CpuStage? stage, { + bool sun = false, +}) async { final kit = cpuTestDevice(width: _width, height: _height); final device = kit.device; @@ -86,6 +90,24 @@ Future _render(LightingModel model, CpuStage? stage) async { final asset = await ModelAsset.fromDocument(document, device: device); final scene = Scene(); asset.instantiate(scene); + if (sun) { + // A sun with a shadow, and a slab under the box in the same material to + // catch it, so the shadow is drawn through the stage under test too. + scene + ..add( + LightNode(intensity: 2.0, castsShadow: true) + ..setLocalForward(Vector3(0.5, -1.0, 0.6).normalized()), + ) + ..add( + MeshNode( + DeviceMesh.upload( + device, + CuboidShape(size: Vector3(4.0, 0.1, 4.0)).build(), + ), + Material(baseColor: Vector4(0.8, 0.4, 0.2, 1.0), lighting: model), + )..setPosition(0.0, -0.55, 0.0), + ); + } final renderer = Renderer.create( device: device, @@ -104,9 +126,17 @@ Future _render(LightingModel model, CpuStage? stage) async { scene: scene, views: [ RenderView( - camera: CameraNode( - projection: const PerspectiveProjection(fovYRadians: 0.9), - )..setPosition(1.6, 1.2, 2.4), + camera: sun + // Aimed, so the box and the shadow it throws on the slab are + // both in the frame. + ? (CameraNode( + projection: const PerspectiveProjection(fovYRadians: 0.9), + ) + ..setPosition(2.4, 2.0, 3.2) + ..lookAt(Vector3(0.0, -0.3, 0.0))) + : (CameraNode( + projection: const PerspectiveProjection(fovYRadians: 0.9), + )..setPosition(1.6, 1.2, 2.4)), clearColor: Vector4(0, 0, 0, 1), ), ], @@ -218,4 +248,42 @@ void main() { ); expect(_differing(plain, dark), 0); }); + + test( + 'Lambert written as a lighting hook is the hand-written Lambert — P8', + () async { + // A `light` block returning the albedo is Lambert's ShadeLight, and a + // fragment body returning `lit` is Lambert's main: the lights through the + // block by radiance, n·l and shadow, the ambient under the occlusion, the + // emissive. Under a sun with a shadow, every pixel must agree. + // + // Mutation: gather without the shadow in `MaterialProgramStage` + // (`shadowed: false`), or leave the ambient out of `lit`. The occlusion + // is not held here: the box carries no occlusion map, so it is one. + const source = ''' +material LangLambert { + light { + return albedo; + } + fragment { + return vec4(lit, alpha); + } +} +'''; + final program = _program(source, const MaterialVariant('LangLambert')); + final builtIn = await _render(LightingModel.lambert, null, sun: true); + final written = await _render( + describeMaterial( + program, + ).lightingModel(label: 'Written Lambert', shaderName: 'LangLambert'), + CpuStage.fragment(MaterialProgramStage(program)), + sun: true, + ); + expect( + _differing(builtIn, written), + 0, + reason: 'the lighting hook and the transcribed Lambert disagree', + ); + }, + ); } diff --git a/packages/flutter3d_testing/test/test_replay_test.dart b/packages/flutter3d_testing/test/test_replay_test.dart new file mode 100644 index 000000000..59430e654 --- /dev/null +++ b/packages/flutter3d_testing/test/test_replay_test.dart @@ -0,0 +1,284 @@ +/// `N5`: a tape replayed on the software backend, checked by digest and by +/// golden. +/// +/// flutter test test/test_replay_test.dart +/// +/// A walker on a line rather than a shipped level, for the reason +/// `replay_golden_test.dart` gives: what is under test is the replay — which +/// steps are checked, what a divergence says, what is refused before a step +/// is taken — and a counter makes every one of those visible. A real game's +/// replay test is `apps/flutter3d_demo_platformer/test/replay_test.dart`. +library; + +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter3d/flutter3d.dart'; +import 'package:flutter3d_sim/flutter3d_sim.dart'; +import 'package:flutter3d_testing/flutter3d_testing.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:vector_math/vector_math.dart'; + +const double _dt = 1.0 / 60.0; +const String _levelHash = 'c0ffee00'; + +/// Walks forward while the button is held, and counts every step. +final class _Walker implements ReplaySubject { + _Walker(this.input, {this.strayFrom, this.hash}); + + final InputState input; + + /// The step from which this walker goes twice as far — a change to the + /// simulation that only shows after it. + final int? strayFrom; + + final String? hash; + + double x = 0.0; + int steps = 0; + final List framesAt = []; + + @override + String? get levelHash => hash; + + @override + void restore(Snapshot start) { + x = start.data.number('x'); + steps = start.data.integer('steps'); + } + + @override + void step(double dt) { + steps++; + final stray = strayFrom != null && steps >= strayFrom!; + if (input.held(GameAction.moveForward)) x += stray ? 2.0 : 1.0; + } + + @override + Snapshot save() => Snapshot({'x': x, 'steps': steps}); + + @override + FrameSubject frame(int step) { + framesAt.add(step); + final scene = Scene(); + final camera = CameraNode( + projection: const PerspectiveProjection( + fovYRadians: 1.2, + near: 0.05, + far: 200.0, + ), + )..setPositionFrom(Vector3(0.0, 0.0, 5.0)); + camera.lookAt(Vector3.zero()); + scene.add(camera); + return (scene: scene, camera: camera); + } +} + +/// Twenty steps of the walker, held forward on two steps out of three, with +/// a checkpoint every five — the way a game's own recorder writes a run. +Demo _record({int steps = 20}) { + final input = InputState(); + final walker = _Walker(input); + final start = walker.save(); + final recorder = InputTapeRecorder(seed: 1); + final checkpoints = DigestTrace(every: 5); + for (var i = 0; i < steps; i++) { + i % 3 == 2 + ? input.release(GameAction.moveForward) + : input.press(GameAction.moveForward); + recorder.record(input); + walker.step(_dt); + checkpoints.observe(recorder.tape.steps, walker.save().toJson()); + input.endStep(); + } + return Demo( + level: 'line.json', + levelHash: _levelHash, + start: start, + tape: recorder.tape, + buildStamp: 'test-build', + checkpoints: checkpoints, + ); +} + +Matcher _failsWith(String text) => throwsA( + isA().having((e) => e.message, 'message', contains(text)), +); + +void main() { + late Directory dir; + setUp(() => dir = Directory.systemTemp.createTempSync('test_replay_test')); + tearDown(() => dir.deleteSync(recursive: true)); + + test('the same simulation replays its own tape and every checkpoint ' + 'agrees', () async { + // Mutation: drop `playback.applyTo(input)` from `expectReplayMatches`, + // and the walker stands still through the replay and diverges at step 5. + late _Walker walker; + await expectReplayMatches( + _record(), + start: (start) => walker = _Walker(start.input), + width: 16, + height: 16, + ); + // Mutation: stop at the first checkpoint rather than the last, and only + // five steps are taken. + expect(walker.steps, 20, reason: 'the last checkpoint is at step 20'); + }); + + test('a changed simulation fails at the first checkpoint it changed ' + 'before, and names the one that still agreed', () async { + // Mutation: compare the digest after `endStep` against a recorder that + // took it before, or compare nothing, and a walker that goes twice as + // far from step 8 passes. + await expectLater( + expectReplayMatches( + _record(), + start: (start) => _Walker(start.input, strayFrom: 8), + width: 16, + height: 16, + label: 'line.f3drun', + ), + _failsWith('line.f3drun diverged at step 10'), + ); + await expectLater( + expectReplayMatches( + _record(), + start: (start) => _Walker(start.input, strayFrom: 8), + width: 16, + height: 16, + ), + _failsWith('Step 5 still agreed'), + ); + }); + + test('digestAt checks only the steps it names', () async { + // Mutation: ignore `digestAt` and check every checkpoint, and the stray + // from step 8 is caught at step 10, which this test did not ask about. + late _Walker walker; + await expectReplayMatches( + _record(), + start: (start) => walker = _Walker(start.input, strayFrom: 8), + digestAt: [5], + width: 16, + height: 16, + ); + expect(walker.steps, 5, reason: 'nothing past the last step asked for'); + }); + + test('goldensAt draws at the steps it names, into goldenDirectory, and a ' + 'second replay matches what the first recorded', () async { + // Mutation: draw only at the last step asked for, and `framesAt` is + // [10]; name the file after its place in the list rather than its step, + // and `line-3.png` is not where it is looked for. + late _Walker walker; + Future once() => expectReplayMatches( + _record(), + start: (start) => walker = _Walker(start.input), + digestAt: const [], + goldensAt: [3, 10], + goldenDirectory: dir.path, + goldenName: 'line', + width: 16, + height: 16, + // Recording is what the first call is for, even on a CI runner. + recordMissing: true, + ); + await once(); + expect(walker.framesAt, [3, 10]); + expect(File('${dir.path}/line-3.png').existsSync(), isTrue); + expect(File('${dir.path}/line-10.png').existsSync(), isTrue); + await once(); + }); + + group('refuses before it steps', () { + Future<_Walker> refused( + Matcher failure, { + Iterable? digestAt, + Iterable goldensAt = const [], + Demo? demo, + String? hash, + }) async { + _Walker? walker; + await expectLater( + expectReplayMatches( + demo ?? _record(), + start: (start) => walker = _Walker(start.input, hash: hash), + digestAt: digestAt, + goldensAt: goldensAt, + goldenDirectory: dir.path, + width: 16, + height: 16, + ), + failure, + ); + return walker ?? _Walker(InputState()); + } + + test('a digest at a step the tape holds no checkpoint for', () async { + // Mutation: skip the check, and `recorded[7]!` throws a null check + // error that names neither the step nor the checkpoints there are. + await refused( + _failsWith('no checkpoint at step 7: it took one every 5 steps'), + digestAt: [7], + ); + }); + + test('a golden past the end of the tape', () async { + // Mutation: skip the check, and the replay steps an empty tape past + // its end, draws a frame of a walker who stopped, and records it. + await refused( + _failsWith('lasts 20 steps, so there is no step 21'), + goldensAt: [21], + ); + }); + + test('a replay that checks nothing', () async { + // Mutation: skip the check, and `reduce` throws on an empty set — or, + // written with a default, the test passes whatever the game does. + await refused(_failsWith('checks nothing'), digestAt: const []); + }); + + test('a level that changed since the recording', () async { + // Mutation: skip the check, and the failure is a divergence at step 5, + // which sends the reader into the simulation. + final walker = await refused( + _failsWith('the level line.json has changed'), + hash: 'baadf00d', + ); + expect(walker.steps, 0, reason: 'refused before the first step'); + }); + }); + + group('readTape', () { + test('names the path when there is no file there', () { + // Mutation: let `File.readAsStringSync` throw, and the report is a + // PathNotFoundException rather than what to do about it. + expect( + () => readTape('${dir.path}/missing.f3drun'), + _failsWith('no tape at ${dir.path}/missing.f3drun'), + ); + }); + + test("says why a file is not a tape, in the format's own words", () { + // Mutation: let the DemoFormatException through, and the path is lost. + final file = File('${dir.path}/half.f3drun') + ..writeAsStringSync(jsonEncode({'version': 1})); + expect( + () => readTape(file.path), + _failsWith('${file.path} is not a tape: the demo names no level'), + ); + }); + + test('reads back what a recorder wrote', () { + // Mutation: read the tape but drop its checkpoints, and a replay of it + // has nothing to compare against. + final demo = _record(); + final file = File('${dir.path}/line.f3drun') + ..writeAsStringSync(jsonEncode(demo.toJson())); + final read = readTape(file.path); + expect(read.steps, 20); + expect(read.checkpoints.hexDigests, demo.checkpoints.hexDigests); + }); + }); +} diff --git a/packages/flutter3d_webgl/CHANGELOG.md b/packages/flutter3d_webgl/CHANGELOG.md index 3072daaa4..228918340 100644 --- a/packages/flutter3d_webgl/CHANGELOG.md +++ b/packages/flutter3d_webgl/CHANGELOG.md @@ -1,3 +1,37 @@ +## Unreleased + +- The shader table regenerated for the caustic stages. +- The shader table regenerated for the painted, unclamped transmittance. +- The shader table regenerated for `ShadowTransmittance` and the coloured + sun shadow (`ShadowSettings.translucentCasters`). +- **Alpha to coverage**: `SAMPLE_ALPHA_TO_COVERAGE`, off at every pass's + start; `supportsAlphaToCoverage` is true where the context multisamples. + +- **The generated tables carry the orthographic camera's paths**, and + `orthographic-metal` is in the browser reference set. + +- **The generated tables carry the debug views**, and `debug-view-split` + is in the browser reference set. + +- **`LensFlare` translated, and the composite's distortion**; + `lens-flare` and `lens-distortion` are in the browser reference set. +- **SMAA 1x's three stages translated**, from the same sources as every + other stage; `smaa-teapot` is in the browser reference set. + +**The generated tables carry the decal stage** of `flutter3d_shaders`. + +**The generated tables carry `PlanarReflection` and +`RenderTextureEncode`**, and the `planar-mirror` and `render-texture` +goldens are in this set. + +**The generated tables carry `SkyPhysical`, `SkyPhysicalVertex` and the +height fog in `ApplyFog`.** + +**The GLSL ES translator and the section codec moved to +`flutter3d_shaders`**, so a build hook with no Flutter SDK can use them. +`encodeWebGlSection`, `decodeWebGlSection` and `WebGlSectionSources` are +still exported from this package's barrel under the same names. + ## 0.8.2+1 **Resolves on Flutter 3.44 and Dart 3.12.0.** The constraints asked for Dart diff --git a/packages/flutter3d_webgl/lib/engine_shaders.dart b/packages/flutter3d_webgl/lib/engine_shaders.dart index 7c1464f7e..7ad9c67d8 100644 --- a/packages/flutter3d_webgl/lib/engine_shaders.dart +++ b/packages/flutter3d_webgl/lib/engine_shaders.dart @@ -199,6 +199,9 @@ out vec2 v_texcoord; out vec4 v_tangent; out vec4 v_color; out vec2 v_lightmap_uv; +/// `P8`: an instance's own four numbers, a material's `instance` — from +/// slot 1 in the instanced stage, nought in every other. +out vec4 v_instance; void main() { // Morphed first and in the mesh's own space, which is the order glTF @@ -230,6 +233,7 @@ void main() { mirrored ? -morphed_tangent.w : morphed_tangent.w); v_color = color; v_lightmap_uv = vec2(0.0); + v_instance = vec4(0.0); gl_Position = frame_info.mvp * vec4(morphed_position, 1.0); } @@ -483,6 +487,9 @@ out vec2 v_texcoord; out vec4 v_tangent; out vec4 v_color; out vec2 v_lightmap_uv; +/// `P8`: an instance's own four numbers, a material's `instance` — from +/// slot 1 in the instanced stage, nought in every other. +out vec4 v_instance; /// [joint] as an index into `joint_matrices`, kept inside the array. /// @@ -547,6 +554,7 @@ void main() { v_texcoord = texcoord; v_color = color; v_lightmap_uv = vec2(0.0); + v_instance = vec4(0.0); gl_Position = frame_info.mvp * (skin * vec4(morphed_position, 1.0)); } @@ -823,6 +831,8 @@ in vec4 i_row1; in vec4 i_row2; /// Multiplied into the vertex colour, so a batch of one mesh can vary its tint. in vec4 i_color; +/// The instance's own four numbers — `P8`, `InstancedMeshNode.setInstanceData`. +in vec4 i_data; layout(std140) uniform FrameInfo { mat4 mvp; @@ -837,6 +847,9 @@ out vec2 v_texcoord; out vec4 v_tangent; out vec4 v_color; out vec2 v_lightmap_uv; +/// `P8`: an instance's own four numbers, a material's `instance` — from +/// slot 1 in the instanced stage, nought in every other. +out vec4 v_instance; void main() { // Columns from rows: GLSL matrices are column-major, so the constructor is @@ -877,6 +890,7 @@ void main() { mirrored ? -morphed_tangent.w : morphed_tangent.w); v_color = color * i_color; v_lightmap_uv = vec2(0.0); + v_instance = i_data; gl_Position = frame_info.mvp * local; } @@ -1059,6 +1073,9 @@ out vec2 v_texcoord; out vec4 v_tangent; out vec4 v_color; out vec2 v_lightmap_uv; +/// `P8`: an instance's own four numbers, a material's `instance` — from +/// slot 1 in the instanced stage, nought in every other. +out vec4 v_instance; void main() { vec3 morphed_position = position; @@ -1076,6 +1093,7 @@ void main() { mirrored ? -morphed_tangent.w : morphed_tangent.w); v_color = vec4(1.0); v_lightmap_uv = color.xy; + v_instance = vec4(0.0); gl_Position = frame_info.mvp * vec4(morphed_position, 1.0); } @@ -1996,6 +2014,9 @@ out vec2 v_texcoord; out vec4 v_tangent; out vec4 v_color; out vec2 v_lightmap_uv; +/// `P8`: an instance's own four numbers, a material's `instance` — from +/// slot 1 in the instanced stage, nought in every other. +out vec4 v_instance; // The w below which a point is treated as at the eye. Dividing by a w near zero // sends a neighbour to infinity, and dividing by a negative one mirrors it @@ -2067,6 +2088,7 @@ void main() { v_tangent = vec4(1.0, 0.0, 0.0, 1.0); v_color = color; v_lightmap_uv = vec2(0.0); + v_instance = vec4(0.0); } ''', @@ -2162,6 +2184,221 @@ void main() { gl_Position = particle_mesh_info.view_projection * vec4(world, 1.0); } +''', + 'CausticPhotonVertex': r'''#version 300 es + +// One photon of sunlight through a refracting caster — `ShadowSettings. +// caustics`. +// +// **Where the light goes, worked out per photon, and splatted where it +// lands.** The caster has been drawn into two small maps as the sun sees it: +// its near faces and its far faces, each a normal and a depth +// (`caustic_surface.frag`). One photon per texel of that map comes in along +// the light, is bent into the caster by Snell's law at the near face, crosses +// it to the far face — as far as the two depths say, along the bent ray — and +// is bent out again there; it loses what Fresnel reflects at each face and +// what the volume absorbs over the length it travelled, and is lost entirely +// where it meets the far face too steeply to leave. Then it is followed to +// whatever receives it, by stepping along its new direction against the +// atlas's own depth (copied, since the atlas is what this draws into). +// +// **How big its splat is comes from where its neighbours land** — ray +// differentials. The photon one texel over and the one one row down are +// followed too, and the two offsets to where they land span the parallelogram +// this photon's share of the light was spread over: wide and faint where the +// caster spread the beam, small and bright where it gathered it. Its energy +// is divided by that area, so light is neither made nor lost by the splat, +// and a quad is never smaller than a couple of texels, or it could fall +// between texel centres and add nothing. +// +// One instance per photon: the instance index is the texel. A photon that +// starts on no surface, is totally reflected, leaves almost sideways or finds +// no receiver is drawn off the tile. +// +// `textureLod` at texel centres throughout, never `texelFetch`, which the +// Impeller shader compiler does not accept in a vertex stage — see +// `lib/morph.glsl`. + +precision highp float; + +/// One corner of the photon's quad, from −1 to 1 on each axis. +in vec2 corner; + +/// What the photon carries, already divided by the area it covers, in rgb. +/// The particles' names for the particles' pair, which is what a photon is: +/// the varyings are numbered once across every stage, and two new names +/// would be two more for every family to keep apart. +out vec4 v_color; + +/// Where in its quad this vertex is, for the falloff. +out vec2 v_uv; + +layout(std140) uniform CausticInfo { + /// The caster's map back to the world, and the world into it: the + /// cascade's matrix cropped to the caster's footprint, in the convention + /// the atlas is sampled in (window depth, rows from the top). + mat4 map_to_world; + mat4 world_to_map; + + /// The world into the cascade's tile, sampled the same way, and the world + /// into the tile's clip space, drawn the backend's way. + mat4 world_to_tile; + mat4 world_to_clip; + + /// x: photons along each side of the map. y: the least a quad reaches + /// either way, in clip units. z, w: the spacing of photons across and + /// down, in clip units. + vec4 grid; + + /// xyz: the way the light travels, in the world. w unused. + vec4 light; + + /// x: the caster's index of refraction. y: its reflectance head-on. + /// z: its attenuation distance, nought for none. w unused. + vec4 optics; + + /// rgb: its transmission times its base colour. w unused. + vec4 tint; + + /// rgb: its attenuation colour. w unused. + vec4 attenuation; +} +caustic_info; + +uniform sampler2D caustic_front; +uniform sampler2D caustic_back; +uniform sampler2D caustic_depth; + +/// A point's place in a map whose matrix produced [p], rows from the top — +/// the same reading `shadow.glsl` makes of the atlas. +vec2 MapUv(vec4 p) { return vec2(p.x * 0.5 + 0.5, 0.5 - p.y * 0.5); } + +float Schlick(float f0, float cosine) { + float c = clamp(1.0 - cosine, 0.0, 1.0); + return f0 + (1.0 - f0) * c * c * c * c * c; +} + +/// The photon starting at [uv] of the maps, followed to where it lands: +/// xy where, in clip space, and w one if it lands at all. [energy] is what +/// it carries. +vec4 Follow(vec2 uv, out vec3 energy) { + energy = vec3(0.0); + vec4 front = textureLod(caustic_front, uv, 0.0); + if (front.a >= 1.0 || dot(front.xyz, front.xyz) < 0.25) return vec4(0.0); + + vec2 ndc = vec2(uv.x * 2.0 - 1.0, (0.5 - uv.y) * 2.0); + vec3 entry = (caustic_info.map_to_world * vec4(ndc, front.a, 1.0)).xyz; + // Metres along the light for one unit of stored depth. + float range = length((caustic_info.map_to_world * vec4(0.0, 0.0, 1.0, 0.0)).xyz); + + vec3 l = normalize(caustic_info.light.xyz); + vec3 n1 = normalize(front.xyz); + if (dot(n1, l) > 0.0) n1 = -n1; + float index = max(caustic_info.optics.x, 1.0); + vec3 inside = refract(l, n1, 1.0 / index); + + // A far face behind this texel, or the photon started on the caster's + // rim, where the two maps disagree about whether there is anything. + vec4 back = textureLod(caustic_back, uv, 0.0); + if (back.a <= front.a) return vec4(0.0); + float across = (back.a - front.a) * range; + vec3 exit = entry + inside * (across / max(dot(inside, l), 0.2)); + vec4 there = textureLod( + caustic_back, clamp(MapUv(caustic_info.world_to_map * vec4(exit, 1.0)), 0.0, 1.0), 0.0); + vec3 n2 = dot(there.xyz, there.xyz) > 0.25 ? normalize(there.xyz) : normalize(back.xyz); + if (dot(n2, inside) < 0.0) n2 = -n2; + vec3 out_ray = refract(inside, -n2, index); + if (dot(out_ray, out_ray) < 1e-6) return vec4(0.0); + out_ray = normalize(out_ray); + // Leaving almost sideways it lands far off and faint, and following it + // there against a depth map is where the search goes wrong. + if (dot(out_ray, l) < 0.3) return vec4(0.0); + + float f0 = caustic_info.optics.y; + energy = caustic_info.tint.rgb * + (1.0 - Schlick(f0, abs(dot(l, n1)))) * + (1.0 - Schlick(f0, abs(dot(out_ray, n2)))); + float fading = caustic_info.optics.z; + if (fading > 0.0) { + energy *= pow(max(caustic_info.attenuation.rgb, vec3(1e-4)), + vec3(length(exit - entry) / fading)); + } + + // Out to whatever receives it: step along the ray by what the atlas says + // is left between here and the first opaque surface below. + vec3 p = exit; + float down = max(dot(out_ray, l), 0.05); + for (int k = 0; k < 4; k++) { + vec4 q = caustic_info.world_to_tile * vec4(p, 1.0); + float receiver = textureLod(caustic_depth, clamp(MapUv(q), 0.0, 1.0), 0.0).r; + float advance = (receiver - q.z) * range / down; + if (k == 0) advance = max(advance, 0.0); + p += out_ray * advance; + } + // Only where the search settled on a surface. + vec4 landed = caustic_info.world_to_tile * vec4(p, 1.0); + float under = textureLod(caustic_depth, clamp(MapUv(landed), 0.0, 1.0), 0.0).r; + if (abs(under - landed.z) * range > 0.02) return vec4(0.0); + + vec4 clip = caustic_info.world_to_clip * vec4(p, 1.0); + return vec4(clip.xy / clip.w, 0.0, 1.0); +} + +/// [a], made at least [least] long, in its own direction or [fallback]'s. +vec2 AtLeast(vec2 a, vec2 fallback, float least) { + float size = length(a); + if (size < 1e-9) return normalize(fallback) * least; + return size < least ? a * (least / size) : a; +} + +void main() { + v_uv = corner; + v_color = vec4(0.0); + // Off the tile until it is known to land somewhere. + gl_Position = vec4(4.0, 4.0, 0.5, 1.0); + + float n = caustic_info.grid.x; + float id = float(gl_InstanceID); + float column = mod(id, n); + float row = floor(id / n); + vec2 uv = vec2((column + 0.5) / n, (row + 0.5) / n); + float texel = 1.0 / n; + + vec3 energy; + vec4 here = Follow(uv, energy); + if (here.w < 0.5) return; + + // Where the neighbours land, one texel across and one down — or the + // other way, where this photon is on the edge — or, failing both, where + // they would have landed with nothing in the way. + vec2 spacing_x = vec2(caustic_info.grid.z, 0.0); + vec2 spacing_y = vec2(0.0, caustic_info.grid.w); + vec3 unused; + vec4 next_x = Follow(uv + vec2(texel, 0.0), unused); + vec2 a = next_x.w > 0.5 ? next_x.xy - here.xy : spacing_x; + if (next_x.w < 0.5) { + vec4 prev_x = Follow(uv - vec2(texel, 0.0), unused); + if (prev_x.w > 0.5) a = here.xy - prev_x.xy; + } + vec4 next_y = Follow(uv + vec2(0.0, texel), unused); + vec2 b = next_y.w > 0.5 ? next_y.xy - here.xy : spacing_y; + if (next_y.w < 0.5) { + vec4 prev_y = Follow(uv - vec2(0.0, texel), unused); + if (prev_y.w > 0.5) b = here.xy - prev_y.xy; + } + // Half again, so neighbouring quads overlap, and never under the least. + float least = caustic_info.grid.y; + a = AtLeast(1.5 * a, spacing_x, least); + b = AtLeast(1.5 * b, spacing_y, least); + float area = max(abs(a.x * b.y - a.y * b.x), least * least); + + gl_Position = vec4(here.xy + corner.x * a + corner.y * b, 0.5, 1.0); + // The photon's own share of the light, over what the falloff covers: + // (1 − r²)² integrates to π/3 of the area the quad's disc maps to. + float share = caustic_info.grid.z * caustic_info.grid.w; + v_color = vec4(energy * share / (1.0471976 * area), 0.0); +} + ''', 'ShadowTileResetVertex': r'''#version 300 es @@ -2307,6 +2544,71 @@ void main() { gl_Position = vec4(position, 0.999999, 1.0); } +''', + 'SkyPhysicalVertex': r'''#version 300 es + +// Vertex stage for the physical sky: the gradient sky's triangle, carrying the +// air instead of three colours. +// +// **Its own stage rather than `sky.vert` read under other names.** The data +// travels the same way and for the same reason — `sky.vert` sets out what was +// measured: on Impeller a uniform block never reaches the sky's pipeline and a +// vertex attribute does — and it is the same size, six vec4s after the ray. But +// a varying is matched by name between the stages, and a fragment stage reading +// `v_zenith` as a scattering coefficient is one nobody could maintain. +// +// The depth is `sky.vert`'s 0.999999, for `sky.vert`'s reason: the far plane +// less a hair, so the pass's own `less` passes against a cleared buffer and +// fails against anything already drawn. +// +// Lengths are in kilometres here, not metres. The planet's radius is 6360 of +// them, and in metres its square is 4·10¹³, which single precision holds to +// within a few units — enough to put the horizon a pixel off where the ray +// grazes it. The renderer converts; see `Renderer._skyVertexBytes`. +precision highp float; + +layout(location = 0) in vec2 position; + +// The world-space view ray at this corner. +layout(location = 1) in vec3 corner_ray; + +/// rgb: Rayleigh scattering at the ground, per km. a: its scale height, km. +layout(location = 2) in vec4 rayleigh; +/// x: Mie scattering at the ground, per km. y: Mie extinction, per km. +/// z: its scale height, km. w: Henyey–Greenstein's g. +layout(location = 3) in vec4 mie; +/// xyz: unit vector pointing at the sun. w: the sunlight entering the air. +layout(location = 4) in vec4 sun; +/// x: the planet's radius. y: the top of the air's. z: the eye's, all km. +/// w: the ground's albedo. +layout(location = 5) in vec4 planet; +/// x: how bright the stars are. y: the share of cells holding one. +/// z: cells across a face of the cube they are scattered on. w: unused. +layout(location = 6) in vec4 stars; +/// x: cosine of the disc's angular radius. y: how much softer its edge is, +/// as a difference of cosines. z: how bright the disc is. w: unused. +layout(location = 7) in vec4 disc; + +out vec3 v_ray; +out vec4 v_rayleigh; +out vec4 v_mie; +out vec4 v_sun; +out vec4 v_planet; +out vec4 v_stars; +out vec4 v_disc; + +void main() { + v_ray = corner_ray; + v_rayleigh = rayleigh; + v_mie = mie; + v_sun = sun; + v_planet = planet; + v_stars = stars; + v_disc = disc; + + gl_Position = vec4(position, 0.999999, 1.0); +} + ''', 'VertexTextureProbeVertex': r'''#version 300 es @@ -2459,6 +2761,9 @@ out vec2 v_texcoord; out vec4 v_tangent; out vec4 v_color; out vec2 v_lightmap_uv; +/// `P8`: an instance's own four numbers, a material's `instance` — from +/// slot 1 in the instanced stage, nought in every other. +out vec4 v_instance; vec4 MvpRow(int r) { return vec4(frame_info.mvp[0][r], frame_info.mvp[1][r], @@ -2495,6 +2800,7 @@ void main() { v_texcoord = texcoord; v_color = vec4(d, color.a); v_lightmap_uv = vec2(0.0); + v_instance = vec4(0.0); gl_Position = frame_info.mvp * vec4(corner, 1.0); } @@ -2643,6 +2949,13 @@ bool g_debug_surface_on = false; /// A global for the reason [g_debug_surface] is one. bool g_premultiply = false; +/// How much of what is already behind a blended surface comes through it — +/// a thin pane of glass, `M3`: what the blend multiplies the target by is +/// one minus the alpha, so the alpha written is the coverage less this share +/// of it, while the colour stays weighted by the coverage alone. Nought for +/// everything else, which writes what it always wrote. +float g_pass_through = 0.0; + // **A stage that needs none of this must be able to declare none of it.** On // Vulkan both stages' descriptors are merged into one set layout, and two // bindings with the same number in it is not a layout the specification @@ -2659,11 +2972,13 @@ bool g_premultiply = false; /// way to move offsets nobody expected to move. Three vec4s is a cheap price /// for not touching any of that. layout(std140) uniform FogInfo { - /// rgb: linear fog colour. w: density per metre, zero for no fog. + /// rgb: linear fog colour. w: density per metre at the eye, zero for no + /// fog. vec4 fog; /// xyz: camera position in world space. Duplicated from FragInfo so this - /// block stands alone; a vec3 is cheaper than a coupling. + /// block stands alone; a vec3 is cheaper than a coupling. w: how fast the + /// fog thins upwards, per metre — `P5`, see [ApplyFog]; nought is flat fog. vec4 eye; /// xyz: the direction the camera looks, as a unit vector in world space. @@ -2674,15 +2989,24 @@ layout(std140) uniform FogInfo { /// question [eye] does — where the camera is and which way it faces — and /// this is the block `color.glsl` can see. vec4 forward; + + /// x: one when the camera's projection is orthographic, nought when it is + /// perspective — `P7`, see [Orthographic]. y, z, w unused. Appended, so + /// every offset above stays where four backends agree on it. + vec4 projection; } fog_info; -/// How far this fragment is from the eye, in world metres. +/// Whether the camera is orthographic — `P7`. /// -/// What the fog fades by. Distance rather than depth, because fog is a -/// property of the air between two points and does not care which way the -/// camera happens to face. -float EyeDistance() { return distance(v_world_position, fog_info.eye.xyz); } +/// **What every eye-relative quantity has to ask first.** Through a +/// perspective lens the rays meet at the eye, so the way to the eye and the +/// distance to it are the fragment's own; through an orthographic one the +/// rays are parallel and the eye is only where the camera was put along its +/// axis, which moves nothing in the picture. Reading the eye's position as if +/// it were a point the light travels to slides highlights across the frame +/// as the camera pans and lays fog in rings round a point nobody sees. +bool Orthographic() { return fog_info.projection.x > 0.5; } /// How far this fragment is *along the view axis*, in world metres. /// @@ -2697,6 +3021,27 @@ float ViewDepth() { return dot(v_world_position - fog_info.eye.xyz, fog_info.forward.xyz); } +/// How far this fragment is from the eye, in world metres. +/// +/// What the fog fades by. Distance rather than depth, because fog is a +/// property of the air between two points and does not care which way the +/// camera happens to face — through a perspective lens. Through an +/// orthographic one every ray starts on the eye's plane, so the air a ray +/// crosses is its depth from that plane, never less than nought. +float EyeDistance() { + return Orthographic() ? max(ViewDepth(), 0.0) + : distance(v_world_position, fog_info.eye.xyz); +} + +/// The unit direction from this fragment back along the ray that reached +/// it: to the eye through a perspective lens, against the view axis through +/// an orthographic one — `P7`. What a highlight, a Fresnel term and a +/// reflection are measured from. +vec3 TowardsEye() { + return Orthographic() ? -fog_info.forward.xyz + : normalize(fog_info.eye.xyz - v_world_position); +} + #else // F3D_NO_FOG // The same two questions, answered without the block: a stage that declares no @@ -2789,6 +3134,16 @@ void WriteSurfaceGeometry(float roughness) { /// /// Exponential rather than linear, because linear fog has a visible plane /// where it starts and a dungeon corridor is exactly where that shows. +/// +/// **Height fog — `P5`.** `fog_info.fog.w` is the density at the eye and +/// `fog_info.eye.w` how fast it falls off upwards, per metre. The density +/// along the ray is then `density · e^(−falloff · Δy · t)` for t from nought +/// to one, and its mean over the ray is `(1 − e^(−k)) / k` with +/// `k = falloff · Δy`: an exact integral, so a fog lying on the ground costs +/// one exponential here and no march. Below a thousandth `k` is answered by +/// the first two terms of the same series, because the quotient there is +/// two nearly equal numbers divided by a small one. A falloff of nought skips +/// all of it, which keeps a flat fog the same to the bit. vec3 ApplyFog(vec3 color) { #ifdef F3D_NO_FOG return color; @@ -2796,6 +3151,11 @@ vec3 ApplyFog(vec3 color) { float density = fog_info.fog.w; if (density <= 0.0) return color; float d = EyeDistance(); + float falloff = fog_info.eye.w; + if (falloff > 0.0) { + float k = falloff * (v_world_position.y - fog_info.eye.y); + density *= abs(k) > 1e-3 ? (1.0 - exp(-k)) / k : 1.0 - 0.5 * k; + } return mix(fog_info.fog.rgb, color, clamp(exp(-density * d), 0.0, 1.0)); #endif } @@ -2850,7 +3210,8 @@ void WriteWeightedBlended() { /// which is exact, so an opaque draw writes what it always wrote. void WriteSurface(vec3 linearColor, float alpha, float roughness) { float weight = g_premultiply ? alpha : 1.0; - frag_color = vec4(ApplyFog(linearColor) * weight, alpha); + frag_color = vec4(ApplyFog(linearColor) * weight, + alpha * (1.0 - g_pass_through)); WriteSurfaceGeometry(roughness); WriteWeightedBlended(); } @@ -3118,8 +3479,9 @@ layout(std140) uniform FragInfo { vec4 material; /// x: alpha cutoff (negative when the material is not masked: -1 opaque, - /// -0.5 blended, -2 hashed), y: normal scale, z: occlusion strength, - /// w: emissive strength. + /// -0.5 blended, -2 hashed; above one the cutoff plus one, drawn as + /// coverage — `P7`), y: normal scale, z: occlusion strength, w: emissive + /// strength. vec4 material2; /// x: exposure, y: active light count, z: index of the shadow-casting light. @@ -3174,7 +3536,9 @@ layout(std140) uniform FragInfo { vec4 ambient_ground; /// x, y, z: the depth bias of each cascade, in that cascade's own normalized - /// depth. w unused. + /// depth. w: one when the atlas carries what see-through casters let + /// through and this draw is to be shaded by it, nought otherwise — + /// `ShadowSettings.translucentCasters`; see `ShadowFactor`. /// /// `ShadowSettings.bias` is one number and a cascade's depth range is not: /// a near cascade is stretched towards the light when a caster stands @@ -3195,6 +3559,15 @@ layout(std140) uniform FragInfo { /// while a temporal resolve runs, minus one otherwise — `S3`, which steps /// the soft shadow's rotation by it. vec4 target_origin; + + /// x: which debug view replaces the light — `P6`, `DebugView.code`, nought + /// for none. y: where it starts, as a share of the target's width from the + /// left; nought is the whole frame. z: the target's width in pixels, which + /// turns the share into a column. w unused. + /// + /// Appended for the reason `ambient_sky` was: every offset above stays + /// where the four backends already agree on it. + vec4 debug_view; } frag_info; @@ -3285,8 +3658,25 @@ Surface ReadSurface() { // number in a block six shaders share, and -1 already meant "not masked"; // anything more negative was free. See [MaterialAlphaMode.hashed]. float cutoff = frag_info.material2.x; - if (cutoff >= 0.0) { + if (cutoff > 1.0) { + // **Coverage instead of a cut — `P7`.** One above the cutoff says the + // pass multisamples and turns this fragment's alpha into the share of + // samples it covers, so nothing is discarded: the alpha is sharpened to + // run from nought to one across about a pixel either side of the cutoff, + // and the resolve smooths the edge as it smooths a triangle's. Unsharpened, + // a texture's soft alpha would cover half the samples of every pixel it + // fades across and draw a screen door. Branched on a uniform, so the + // derivative is taken in uniform control flow, as WGSL requires. + float edge = cutoff - 1.0; + s.alpha = clamp((s.alpha - edge) / max(fwidth(s.alpha), 1e-4) + 0.5, + 0.0, 1.0); + } else if (cutoff >= 0.0) { if (s.alpha < cutoff) discard; + // What survives the cut is a surface, and opaque: the texture's alpha has + // done its work. Written as it was, it went into the frame's alpha, and + // whatever read the frame as premultiplied — a golden's capture — divided + // the colour by it and lit the inside of every leaf towards its rim. + s.alpha = 1.0; } else if (cutoff < -1.5) { // **Stochastic instead of a threshold.** A leaf texture at 40% opacity is // either entirely there or entirely gone under a fixed cutoff, so a fern @@ -3323,7 +3713,15 @@ Surface ReadSurface() { // double-sided material ever draws a back face, since everything else has // them culled. if (!gl_FrontFacing) s.n = -s.n; +#ifdef F3D_NO_FOG + // The stages without the fog block — shadows and the id pass — light + // nothing, and keep the eye's point. s.v = normalize(frag_info.camera_position.xyz - v_world_position); +#else + // `P7`: against the view axis through an orthographic lens, where the + // eye's point is only where the camera was put. + s.v = TowardsEye(); +#endif // Clamped away from zero: a grazing view direction otherwise divides by zero // in the specular visibility term. s.n_dot_v = max(dot(s.n, s.v), 1e-4); @@ -3804,6 +4202,15 @@ LightSample SampleLight(int index, Surface s) { /// returns `ShadowFactor(...)`; an unlit one returns 1. float LightVisibility(Surface s, LightSample light, int index); +/// What the see-through casters between the sun and this fragment let +/// through, per channel — `ShadowSettings.translucentCasters`. Set by +/// `ShadowFactor` for the light it shadows and reset to one before every +/// light, so a model that samples no shadow map, and every light but the +/// sun, leaves it white. A colour beside the visibility rather than folded +/// into it, because visibility is one number in every model and coloured +/// light is not. +vec3 light_transmittance = vec3(1.0); + /// A model's per-light term, defined by each fragment shader. /// /// A prototype here and the definition in the model is what lets the loop below @@ -4222,6 +4629,7 @@ vec3 AccumulateLights(Surface s) { if (i >= count) break; LightSample light = SampleLight(i, s); if (light.n_dot_l <= 0.0) continue; + light_transmittance = vec3(1.0); // A light from the list has no shadow row to read — see `LightHasShadow`. // A branch rather than something folded into the two calls, because both // index tables eight entries wide and the ninth light would read past them @@ -4231,12 +4639,73 @@ vec3 AccumulateLights(Surface s) { PointShadowFactor(v_world_position, s.n, i) : 1.0; if (visibility <= 0.0) continue; - total += ShadeLight(s, light) * light.radiance * light.n_dot_l * visibility; + total += ShadeLight(s, light) * light.radiance * light.n_dot_l * visibility * + light_transmittance; } return total; } +/// Whether any channel of [c] is NaN or infinite. +/// +/// **Comparisons, not `isnan` and not the bits.** WGSL has no `isNan`, and +/// reading the exponent through `floatBitsToUint` takes `impellerc` down in +/// its GLSL ES output, which has no bit casts. A NaN is the one value unequal +/// to itself, and an infinity the one above every finite float. +bool NonFinite(vec3 c) { + return any(notEqual(c, c)) || any(greaterThan(abs(c), vec3(3.0e38))); +} + +/// `P6`: the material channel `FragInfo.debug_view` asks for, written in +/// place of [lit]. False, and nothing written, when no view is on or the +/// fragment sits left of the split; the caller then writes the light. +/// +/// **Display values, through the same exits the light takes.** The channel +/// is what an artist would read off the texture — an albedo as its sRGB +/// colour, a roughness as a grey — converted to linear so the composite's +/// encode hands it back unchanged; the composite leaves this side of the +/// split out of the exposure and the tone curve (`CompositeInfo.lens.y`). +/// The surface buffer and the weighted-blended targets are written as +/// [WriteSurface] writes them, so a debug view changes what the frame shows +/// and nothing the passes after it read. No fog: a channel seen through fog +/// is not the channel. +/// +/// [lit] is read by one view only, [DebugView.nonFinite], which shows a NaN +/// or an infinity as magenta over the light's own luminance in grey. +bool WriteDebugView(Surface s, vec3 lit) { + float view = frag_info.debug_view.x; + if (view < 0.5) return false; + if (gl_FragCoord.x < frag_info.debug_view.y * frag_info.debug_view.z) { + return false; + } + int code = int(view + 0.5); + vec3 shown = vec3(0.0); + if (code == 1) { + shown = LinearToSrgb(clamp(s.albedo, vec3(0.0), vec3(1.0))); + } else if (code == 2) { + shown = s.n * 0.5 + vec3(0.5); + } else if (code == 3) { + shown = vec3(clamp(s.roughness, 0.0, 1.0)); + } else if (code == 4) { + shown = vec3(clamp(s.metallic, 0.0, 1.0)); + } else if (code == 5) { + shown = vec3(clamp(s.occlusion, 0.0, 1.0)); + } else if (code == 6) { + shown = LinearToSrgb(clamp(s.emissive, vec3(0.0), vec3(1.0))); + } else if (code == 7) { + shown = vec3(fract(MapUv(kMapBaseColor)), 0.0); + } else if (code == 8) { + float grey = dot(LinearToSrgb(clamp(lit, vec3(0.0), vec3(1.0))), + vec3(0.2126, 0.7152, 0.0722)); + shown = NonFinite(lit) ? vec3(1.0, 0.0, 1.0) : vec3(grey * 0.5); + } + float weight = g_premultiply ? s.alpha : 1.0; + frag_color = vec4(SrgbToLinear(shown) * weight, s.alpha); + WriteSurfaceGeometry(s.roughness); + WriteWeightedBlended(); + return true; +} + #endif // SURFACE_GLSL_ @@ -4262,6 +4731,7 @@ void main() { // The albedo is already linear, and an unlit surface is best // understood as emitting exactly it, so it goes into the HDR // target as light like everything else. + if (WriteDebugView(s, s.albedo)) return; WriteSurface(s.albedo, s.alpha); } @@ -4426,6 +4896,13 @@ bool g_debug_surface_on = false; /// A global for the reason [g_debug_surface] is one. bool g_premultiply = false; +/// How much of what is already behind a blended surface comes through it — +/// a thin pane of glass, `M3`: what the blend multiplies the target by is +/// one minus the alpha, so the alpha written is the coverage less this share +/// of it, while the colour stays weighted by the coverage alone. Nought for +/// everything else, which writes what it always wrote. +float g_pass_through = 0.0; + // **A stage that needs none of this must be able to declare none of it.** On // Vulkan both stages' descriptors are merged into one set layout, and two // bindings with the same number in it is not a layout the specification @@ -4442,11 +4919,13 @@ bool g_premultiply = false; /// way to move offsets nobody expected to move. Three vec4s is a cheap price /// for not touching any of that. layout(std140) uniform FogInfo { - /// rgb: linear fog colour. w: density per metre, zero for no fog. + /// rgb: linear fog colour. w: density per metre at the eye, zero for no + /// fog. vec4 fog; /// xyz: camera position in world space. Duplicated from FragInfo so this - /// block stands alone; a vec3 is cheaper than a coupling. + /// block stands alone; a vec3 is cheaper than a coupling. w: how fast the + /// fog thins upwards, per metre — `P5`, see [ApplyFog]; nought is flat fog. vec4 eye; /// xyz: the direction the camera looks, as a unit vector in world space. @@ -4457,15 +4936,24 @@ layout(std140) uniform FogInfo { /// question [eye] does — where the camera is and which way it faces — and /// this is the block `color.glsl` can see. vec4 forward; + + /// x: one when the camera's projection is orthographic, nought when it is + /// perspective — `P7`, see [Orthographic]. y, z, w unused. Appended, so + /// every offset above stays where four backends agree on it. + vec4 projection; } fog_info; -/// How far this fragment is from the eye, in world metres. +/// Whether the camera is orthographic — `P7`. /// -/// What the fog fades by. Distance rather than depth, because fog is a -/// property of the air between two points and does not care which way the -/// camera happens to face. -float EyeDistance() { return distance(v_world_position, fog_info.eye.xyz); } +/// **What every eye-relative quantity has to ask first.** Through a +/// perspective lens the rays meet at the eye, so the way to the eye and the +/// distance to it are the fragment's own; through an orthographic one the +/// rays are parallel and the eye is only where the camera was put along its +/// axis, which moves nothing in the picture. Reading the eye's position as if +/// it were a point the light travels to slides highlights across the frame +/// as the camera pans and lays fog in rings round a point nobody sees. +bool Orthographic() { return fog_info.projection.x > 0.5; } /// How far this fragment is *along the view axis*, in world metres. /// @@ -4480,6 +4968,27 @@ float ViewDepth() { return dot(v_world_position - fog_info.eye.xyz, fog_info.forward.xyz); } +/// How far this fragment is from the eye, in world metres. +/// +/// What the fog fades by. Distance rather than depth, because fog is a +/// property of the air between two points and does not care which way the +/// camera happens to face — through a perspective lens. Through an +/// orthographic one every ray starts on the eye's plane, so the air a ray +/// crosses is its depth from that plane, never less than nought. +float EyeDistance() { + return Orthographic() ? max(ViewDepth(), 0.0) + : distance(v_world_position, fog_info.eye.xyz); +} + +/// The unit direction from this fragment back along the ray that reached +/// it: to the eye through a perspective lens, against the view axis through +/// an orthographic one — `P7`. What a highlight, a Fresnel term and a +/// reflection are measured from. +vec3 TowardsEye() { + return Orthographic() ? -fog_info.forward.xyz + : normalize(fog_info.eye.xyz - v_world_position); +} + #else // F3D_NO_FOG // The same two questions, answered without the block: a stage that declares no @@ -4572,6 +5081,16 @@ void WriteSurfaceGeometry(float roughness) { /// /// Exponential rather than linear, because linear fog has a visible plane /// where it starts and a dungeon corridor is exactly where that shows. +/// +/// **Height fog — `P5`.** `fog_info.fog.w` is the density at the eye and +/// `fog_info.eye.w` how fast it falls off upwards, per metre. The density +/// along the ray is then `density · e^(−falloff · Δy · t)` for t from nought +/// to one, and its mean over the ray is `(1 − e^(−k)) / k` with +/// `k = falloff · Δy`: an exact integral, so a fog lying on the ground costs +/// one exponential here and no march. Below a thousandth `k` is answered by +/// the first two terms of the same series, because the quotient there is +/// two nearly equal numbers divided by a small one. A falloff of nought skips +/// all of it, which keeps a flat fog the same to the bit. vec3 ApplyFog(vec3 color) { #ifdef F3D_NO_FOG return color; @@ -4579,6 +5098,11 @@ vec3 ApplyFog(vec3 color) { float density = fog_info.fog.w; if (density <= 0.0) return color; float d = EyeDistance(); + float falloff = fog_info.eye.w; + if (falloff > 0.0) { + float k = falloff * (v_world_position.y - fog_info.eye.y); + density *= abs(k) > 1e-3 ? (1.0 - exp(-k)) / k : 1.0 - 0.5 * k; + } return mix(fog_info.fog.rgb, color, clamp(exp(-density * d), 0.0, 1.0)); #endif } @@ -4633,7 +5157,8 @@ void WriteWeightedBlended() { /// which is exact, so an opaque draw writes what it always wrote. void WriteSurface(vec3 linearColor, float alpha, float roughness) { float weight = g_premultiply ? alpha : 1.0; - frag_color = vec4(ApplyFog(linearColor) * weight, alpha); + frag_color = vec4(ApplyFog(linearColor) * weight, + alpha * (1.0 - g_pass_through)); WriteSurfaceGeometry(roughness); WriteWeightedBlended(); } @@ -4901,8 +5426,9 @@ layout(std140) uniform FragInfo { vec4 material; /// x: alpha cutoff (negative when the material is not masked: -1 opaque, - /// -0.5 blended, -2 hashed), y: normal scale, z: occlusion strength, - /// w: emissive strength. + /// -0.5 blended, -2 hashed; above one the cutoff plus one, drawn as + /// coverage — `P7`), y: normal scale, z: occlusion strength, w: emissive + /// strength. vec4 material2; /// x: exposure, y: active light count, z: index of the shadow-casting light. @@ -4957,7 +5483,9 @@ layout(std140) uniform FragInfo { vec4 ambient_ground; /// x, y, z: the depth bias of each cascade, in that cascade's own normalized - /// depth. w unused. + /// depth. w: one when the atlas carries what see-through casters let + /// through and this draw is to be shaded by it, nought otherwise — + /// `ShadowSettings.translucentCasters`; see `ShadowFactor`. /// /// `ShadowSettings.bias` is one number and a cascade's depth range is not: /// a near cascade is stretched towards the light when a caster stands @@ -4978,6 +5506,15 @@ layout(std140) uniform FragInfo { /// while a temporal resolve runs, minus one otherwise — `S3`, which steps /// the soft shadow's rotation by it. vec4 target_origin; + + /// x: which debug view replaces the light — `P6`, `DebugView.code`, nought + /// for none. y: where it starts, as a share of the target's width from the + /// left; nought is the whole frame. z: the target's width in pixels, which + /// turns the share into a column. w unused. + /// + /// Appended for the reason `ambient_sky` was: every offset above stays + /// where the four backends already agree on it. + vec4 debug_view; } frag_info; @@ -5068,8 +5605,25 @@ Surface ReadSurface() { // number in a block six shaders share, and -1 already meant "not masked"; // anything more negative was free. See [MaterialAlphaMode.hashed]. float cutoff = frag_info.material2.x; - if (cutoff >= 0.0) { + if (cutoff > 1.0) { + // **Coverage instead of a cut — `P7`.** One above the cutoff says the + // pass multisamples and turns this fragment's alpha into the share of + // samples it covers, so nothing is discarded: the alpha is sharpened to + // run from nought to one across about a pixel either side of the cutoff, + // and the resolve smooths the edge as it smooths a triangle's. Unsharpened, + // a texture's soft alpha would cover half the samples of every pixel it + // fades across and draw a screen door. Branched on a uniform, so the + // derivative is taken in uniform control flow, as WGSL requires. + float edge = cutoff - 1.0; + s.alpha = clamp((s.alpha - edge) / max(fwidth(s.alpha), 1e-4) + 0.5, + 0.0, 1.0); + } else if (cutoff >= 0.0) { if (s.alpha < cutoff) discard; + // What survives the cut is a surface, and opaque: the texture's alpha has + // done its work. Written as it was, it went into the frame's alpha, and + // whatever read the frame as premultiplied — a golden's capture — divided + // the colour by it and lit the inside of every leaf towards its rim. + s.alpha = 1.0; } else if (cutoff < -1.5) { // **Stochastic instead of a threshold.** A leaf texture at 40% opacity is // either entirely there or entirely gone under a fixed cutoff, so a fern @@ -5106,7 +5660,15 @@ Surface ReadSurface() { // double-sided material ever draws a back face, since everything else has // them culled. if (!gl_FrontFacing) s.n = -s.n; +#ifdef F3D_NO_FOG + // The stages without the fog block — shadows and the id pass — light + // nothing, and keep the eye's point. s.v = normalize(frag_info.camera_position.xyz - v_world_position); +#else + // `P7`: against the view axis through an orthographic lens, where the + // eye's point is only where the camera was put. + s.v = TowardsEye(); +#endif // Clamped away from zero: a grazing view direction otherwise divides by zero // in the specular visibility term. s.n_dot_v = max(dot(s.n, s.v), 1e-4); @@ -5587,6 +6149,15 @@ LightSample SampleLight(int index, Surface s) { /// returns `ShadowFactor(...)`; an unlit one returns 1. float LightVisibility(Surface s, LightSample light, int index); +/// What the see-through casters between the sun and this fragment let +/// through, per channel — `ShadowSettings.translucentCasters`. Set by +/// `ShadowFactor` for the light it shadows and reset to one before every +/// light, so a model that samples no shadow map, and every light but the +/// sun, leaves it white. A colour beside the visibility rather than folded +/// into it, because visibility is one number in every model and coloured +/// light is not. +vec3 light_transmittance = vec3(1.0); + /// A model's per-light term, defined by each fragment shader. /// /// A prototype here and the definition in the model is what lets the loop below @@ -6005,6 +6576,7 @@ vec3 AccumulateLights(Surface s) { if (i >= count) break; LightSample light = SampleLight(i, s); if (light.n_dot_l <= 0.0) continue; + light_transmittance = vec3(1.0); // A light from the list has no shadow row to read — see `LightHasShadow`. // A branch rather than something folded into the two calls, because both // index tables eight entries wide and the ninth light would read past them @@ -6014,12 +6586,73 @@ vec3 AccumulateLights(Surface s) { PointShadowFactor(v_world_position, s.n, i) : 1.0; if (visibility <= 0.0) continue; - total += ShadeLight(s, light) * light.radiance * light.n_dot_l * visibility; + total += ShadeLight(s, light) * light.radiance * light.n_dot_l * visibility * + light_transmittance; } return total; } +/// Whether any channel of [c] is NaN or infinite. +/// +/// **Comparisons, not `isnan` and not the bits.** WGSL has no `isNan`, and +/// reading the exponent through `floatBitsToUint` takes `impellerc` down in +/// its GLSL ES output, which has no bit casts. A NaN is the one value unequal +/// to itself, and an infinity the one above every finite float. +bool NonFinite(vec3 c) { + return any(notEqual(c, c)) || any(greaterThan(abs(c), vec3(3.0e38))); +} + +/// `P6`: the material channel `FragInfo.debug_view` asks for, written in +/// place of [lit]. False, and nothing written, when no view is on or the +/// fragment sits left of the split; the caller then writes the light. +/// +/// **Display values, through the same exits the light takes.** The channel +/// is what an artist would read off the texture — an albedo as its sRGB +/// colour, a roughness as a grey — converted to linear so the composite's +/// encode hands it back unchanged; the composite leaves this side of the +/// split out of the exposure and the tone curve (`CompositeInfo.lens.y`). +/// The surface buffer and the weighted-blended targets are written as +/// [WriteSurface] writes them, so a debug view changes what the frame shows +/// and nothing the passes after it read. No fog: a channel seen through fog +/// is not the channel. +/// +/// [lit] is read by one view only, [DebugView.nonFinite], which shows a NaN +/// or an infinity as magenta over the light's own luminance in grey. +bool WriteDebugView(Surface s, vec3 lit) { + float view = frag_info.debug_view.x; + if (view < 0.5) return false; + if (gl_FragCoord.x < frag_info.debug_view.y * frag_info.debug_view.z) { + return false; + } + int code = int(view + 0.5); + vec3 shown = vec3(0.0); + if (code == 1) { + shown = LinearToSrgb(clamp(s.albedo, vec3(0.0), vec3(1.0))); + } else if (code == 2) { + shown = s.n * 0.5 + vec3(0.5); + } else if (code == 3) { + shown = vec3(clamp(s.roughness, 0.0, 1.0)); + } else if (code == 4) { + shown = vec3(clamp(s.metallic, 0.0, 1.0)); + } else if (code == 5) { + shown = vec3(clamp(s.occlusion, 0.0, 1.0)); + } else if (code == 6) { + shown = LinearToSrgb(clamp(s.emissive, vec3(0.0), vec3(1.0))); + } else if (code == 7) { + shown = vec3(fract(MapUv(kMapBaseColor)), 0.0); + } else if (code == 8) { + float grey = dot(LinearToSrgb(clamp(lit, vec3(0.0), vec3(1.0))), + vec3(0.2126, 0.7152, 0.0722)); + shown = NonFinite(lit) ? vec3(1.0, 0.0, 1.0) : vec3(grey * 0.5); + } + float weight = g_premultiply ? s.alpha : 1.0; + frag_color = vec4(SrgbToLinear(shown) * weight, s.alpha); + WriteSurfaceGeometry(s.roughness); + WriteWeightedBlended(); + return true; +} + #endif // SURFACE_GLSL_ @@ -6041,6 +6674,515 @@ void main() { WriteSurface(s.albedo, s.alpha); } +''', + 'PlanarReflection': r'''#version 300 es +precision highp float; +precision highp int; +precision highp sampler2D; +precision highp samplerCube; + +// `P4`: a planar reflector's surface, drawn a second time over itself with +// the picture a mirrored camera took of the world above it. +// +// **Laid over the surface rather than inside its lighting.** The reflection +// is read by the pixel's place on screen, not by anything the material +// knows, so the stage that reads it needs nothing a lit model has: no maps, +// no lights, no shadows. Putting it inside the lit models would have been a +// sampler and a block added to six stages that every other draw pays to +// declare, and a seventh header permutation to keep byte-identical. As a +// draw of its own it costs only the surfaces that reflect, and a frame +// without a reflector compiles and binds exactly what it did. +// +// Drawn with the depth test `lessEqual` and no depth write, straight after +// the opaque half, so it lands on exactly the pixels the surface itself won +// and nothing in front of the surface is painted. Blended source-over with +// the colour premultiplied, as every blended draw here is. +// +// **No second output**, for the reason `xray.frag` gives at length: a blend +// protects attachment zero only, and only on the backends whose `setBlend` +// honours an attachment index. The surface buffer keeps describing the +// surface underneath, which is the truth about it. +// +// **And not `lib/surface.glsl`**, which `xray.frag` does include: that header +// declares the base colour sampler for every stage that takes it, and a stage +// that never reads it has it dropped from the Metal function while reflection +// still reports it, which is the slot `metal_bindings_test.dart` exists to +// catch. The two things wanted from `FragInfo` come another way: the eye is +// `FogInfo`'s, and the target's rows ride in this stage's own block. +#define F3D_NO_SURFACE_BUFFER +// --- lib/color.glsl --- +// Colour space helpers and the fragment output interface. +// +// Split out of surface.glsl so a shader that needs no material inputs — the +// normals debug view — can avoid DECLARING the FragInfo uniform block at all. +// That matters more than it looks: reflection metadata reports a block as +// present merely because it was declared, even when the compiled shader binds +// no such buffer, so a declared-but-unused block is indistinguishable from a +// used one until Metal crashes on the bind. + +#ifndef COLOR_GLSL_ +#define COLOR_GLSL_ + +precision highp float; + +const float kPi = 3.14159265359; + +// One varying set shared by every fragment shader, matching mesh.vert. +// +// All five are declared here, including the two the debug models never read: a +// fragment shader whose `in` block disagrees with the vertex shader's `out` +// block fails to link, and there is no partial-match rule to lean on. +in vec3 v_world_position; +in vec3 v_normal; +in vec2 v_texcoord; +in vec4 v_tangent; +in vec4 v_color; + +/// Where this fragment is in the level's lightmap. Zero from every vertex +/// stage but `mesh_lightmapped.vert`, and read only by the lit models, which +/// sample a one-texel black there when a material has no map. +in vec2 v_lightmap_uv; + +layout(location = 0) out vec4 frag_color; + +// The second attachment: what a screen-space effect needs to know about the +// surface it is looking at. World-space normal in rgb, and in a the depth along +// the view axis in world metres — not a window depth; `WriteSurfaceGeometry` +// says at length why not. +// +// Depth travels here rather than in a depth texture because flutter_gpu cannot +// sample one — the same reason the shadow pass writes its depth into a colour +// target. See ARCHITECTURE.md §2. +// +// Guarded, because not every stage that includes this header draws into a +// two-attachment target. The shadow pass draws into one, and a pipeline +// declaring an output its target has no slot for is a mismatch worth avoiding +// rather than discovering. +#ifndef F3D_NO_SURFACE_BUFFER +layout(location = 1) out vec4 frag_surface; + +/// The surface's own colour, sRGB-encoded, alpha one where a surface was +/// drawn — `L5`. The third attachment, present only when a pass reads it (the +/// indirect light does) and the device opens three; like the surface buffer, +/// written unconditionally and discarded when absent. Stored in the surface +/// buffer's format rather than eight bits a channel, and `Renderer` says why. +layout(location = 2) out vec4 frag_albedo; +#endif + +/// What [frag_albedo] carries: the lit models set it in `ReadSurface`, and a +/// stage that reflects nothing — unlit, the debug views — leaves it black, +/// which is what light bounced onto it would come to. +vec3 g_albedo = vec3(0.0); + +/// Octahedral encoding: a unit vector in two channels instead of three. +/// +/// Worth the arithmetic because the fourth channel is already spent on depth, +/// and without a free channel there is nowhere to put roughness — which is the +/// difference between a reflection that knows stone from a mirror and one that +/// does not. The error is well under a degree, far below anything a reflection +/// off rough stone would show. +vec2 EncodeOctahedral(vec3 n) { + n /= abs(n.x) + abs(n.y) + abs(n.z); + vec2 e = n.xy; + if (n.z < 0.0) { + e = (1.0 - abs(n.yx)) * vec2(n.x >= 0.0 ? 1.0 : -1.0, + n.y >= 0.0 ? 1.0 : -1.0); + } + return e * 0.5 + 0.5; +} + +/// Where a debug pass leaves the picture it wants shown instead of the normal. +/// +/// Declared here, in the header every lit shader includes **first**, and +/// written from surface.glsl, which is included after. The alternative was a +/// new member on a shared uniform block; a global costs nothing and moves no +/// offsets. It is read at the moment the surface buffer is written, which +/// happens after the lighting loop has run, so the value is there by then. +vec3 g_debug_surface = vec3(0.0); +bool g_debug_surface_on = false; + +/// Whether [WriteSurface] weights the colour by its alpha: set by +/// `ReadSurface` for a material that blends, and false for everything else. +/// +/// **The blend takes its source as premultiplied**, so a blended surface has +/// to hand it the colour times the alpha — a pane at a fifth of opaque adds a +/// fifth of its light, not all of it. glTF's blend mode is Porter and Duff's +/// over on straight colour, and this is the one place that turns the lit +/// radiance into what that means. An opaque or masked surface keeps its +/// colour whole: its alpha is not a coverage, and nothing blends it. +/// A global for the reason [g_debug_surface] is one. +bool g_premultiply = false; + +/// How much of what is already behind a blended surface comes through it — +/// a thin pane of glass, `M3`: what the blend multiplies the target by is +/// one minus the alpha, so the alpha written is the coverage less this share +/// of it, while the colour stays weighted by the coverage alone. Nought for +/// everything else, which writes what it always wrote. +float g_pass_through = 0.0; + +// **A stage that needs none of this must be able to declare none of it.** On +// Vulkan both stages' descriptors are merged into one set layout, and two +// bindings with the same number in it is not a layout the specification +// allows. A driver may accept it anyway; a Galaxy A55's refuses the pipeline +// with `ErrorUnknown` and no other word, which is how the shadow pass came to +// build everywhere except there — its only uniform block was this one, and it +// landed on the same binding as the vertex stage's first. +#ifndef F3D_NO_FOG + +/// Distance fog, in its own block rather than folded into FragInfo. +/// +/// Its own because color.glsl is included before FragInfo is declared, and +/// because appending to a block that half a dozen shaders already share is a +/// way to move offsets nobody expected to move. Three vec4s is a cheap price +/// for not touching any of that. +layout(std140) uniform FogInfo { + /// rgb: linear fog colour. w: density per metre at the eye, zero for no + /// fog. + vec4 fog; + + /// xyz: camera position in world space. Duplicated from FragInfo so this + /// block stands alone; a vec3 is cheaper than a coupling. w: how fast the + /// fog thins upwards, per metre — `P5`, see [ApplyFog]; nought is flat fog. + vec4 eye; + + /// xyz: the direction the camera looks, as a unit vector in world space. + /// w: what a transparent draw writes under weighted blended transparency — + /// `R8`, see `WriteWeightedBlended`. Zero for every other draw. + /// + /// Here rather than in a block of its own because it answers the same + /// question [eye] does — where the camera is and which way it faces — and + /// this is the block `color.glsl` can see. + vec4 forward; + + /// x: one when the camera's projection is orthographic, nought when it is + /// perspective — `P7`, see [Orthographic]. y, z, w unused. Appended, so + /// every offset above stays where four backends agree on it. + vec4 projection; +} +fog_info; + +/// Whether the camera is orthographic — `P7`. +/// +/// **What every eye-relative quantity has to ask first.** Through a +/// perspective lens the rays meet at the eye, so the way to the eye and the +/// distance to it are the fragment's own; through an orthographic one the +/// rays are parallel and the eye is only where the camera was put along its +/// axis, which moves nothing in the picture. Reading the eye's position as if +/// it were a point the light travels to slides highlights across the frame +/// as the camera pans and lays fog in rings round a point nobody sees. +bool Orthographic() { return fog_info.projection.x > 0.5; } + +/// How far this fragment is *along the view axis*, in world metres. +/// +/// What the surface buffer's alpha holds. Depth rather than distance, and the +/// difference only shows on an orthographic camera — where the rays through +/// the pixels are parallel instead of meeting at the eye, so a distance from +/// the eye names a sphere that the pixel's ray crosses somewhere the reader +/// cannot solve for. A depth along the axis names a plane, which every ray +/// crosses exactly once. See `WorldAtDepth` in `post/ssao.frag` for the +/// reconstruction both projections share. +float ViewDepth() { + return dot(v_world_position - fog_info.eye.xyz, fog_info.forward.xyz); +} + +/// How far this fragment is from the eye, in world metres. +/// +/// What the fog fades by. Distance rather than depth, because fog is a +/// property of the air between two points and does not care which way the +/// camera happens to face — through a perspective lens. Through an +/// orthographic one every ray starts on the eye's plane, so the air a ray +/// crosses is its depth from that plane, never less than nought. +float EyeDistance() { + return Orthographic() ? max(ViewDepth(), 0.0) + : distance(v_world_position, fog_info.eye.xyz); +} + +/// The unit direction from this fragment back along the ray that reached +/// it: to the eye through a perspective lens, against the view axis through +/// an orthographic one — `P7`. What a highlight, a Fresnel term and a +/// reflection are measured from. +vec3 TowardsEye() { + return Orthographic() ? -fog_info.forward.xyz + : normalize(fog_info.eye.xyz - v_world_position); +} + +#else // F3D_NO_FOG + +// The same two questions, answered without the block: a stage that declares no +// fog has no eye position to measure from either. Stubs rather than a guard at +// every call site, so that what includes this file reads the same whichever +// way it was compiled. +float EyeDistance() { return 0.0; } +float ViewDepth() { return 0.0; } + +#endif // F3D_NO_FOG + +/// sRGB to linear. Textures are authored in sRGB, but lighting is only correct +/// in linear space; skipping this is what makes naive renderers look muddy. +vec3 SrgbToLinear(vec3 srgb) { + return mix( + srgb / 12.92, + pow((srgb + vec3(0.055)) / 1.055, vec3(2.4)), + step(vec3(0.04045), srgb)); +} + +/// Linear to sRGB. The render target is a plain UNorm format rather than an +/// sRGB one, so the encode has to happen here. +vec3 LinearToSrgb(vec3 linear) { + return mix( + linear * 12.92, + 1.055 * pow(linear, vec3(1.0 / 2.4)) - vec3(0.055), + step(vec3(0.0031308), linear)); +} + +/// Writes scene-referred linear light into the HDR target. +/// +/// No tone map and no sRGB encode: those moved into the composite pass, which +/// is the entire point of rendering into `r16g16b16a16Float` first. Applying +/// them here meant every model wrote display-referred colour into an 8-bit +/// buffer, so anything above display white was gone before post-processing +/// could see it — and bloom is a function of exactly that. +/// +/// Exposure moved with them, for the same reason: it belongs on the same side +/// of the display transform as the tone map. +/// Records the geometry of this fragment for whatever runs after the scene. +/// +/// Called from the same place that writes colour, so a surface cannot be lit +/// into the frame without also describing itself — which is the failure that +/// leaves a screen-space effect reflecting whatever was in the buffer before. +/// +/// rg: octahedral normal. b: perceptual roughness. a: **depth along the view +/// axis, in world metres** — see [ViewDepth]. +/// +/// **Not `gl_FragCoord.z`, and that is a defect this channel carried until it +/// was looked at.** Window depth crowds every distant surface into the top of +/// its range — with a near plane of a tenth of a metre, everything past twenty +/// metres lives in the last half a hundredth of `[0, 1]` — and this attachment +/// is a half float, whose steps up there are about five ten-thousandths. So two +/// surfaces half a metre apart at twenty metres stored the *same* number, and +/// every screen-space pass that compares against this channel decided whole +/// bands of pixels by rounding. The occlusion pass drew them: vertical stripes +/// along the lines of constant depth on any wall receding from the camera, on +/// both GPU backends. The software rasteriser kept the channel at full +/// precision and drew the effect correctly, so it was the one that looked +/// wrong against the other two. +/// +/// A depth in metres has none of that: the exponent carries the range and the +/// mantissa carries the same relative precision everywhere, which at twenty +/// metres is a centimetre. Both numbers are measured in +/// `flutter3d/test/surface_depth_test.dart`. +/// +/// Zero still means nothing was drawn. The attachment is cleared to zero and +/// nothing is drawn in front of the near plane. +void WriteSurfaceGeometry(float roughness) { +#ifndef F3D_NO_SURFACE_BUFFER + // `L5`: the surface's colour, whatever the surface buffer ends up holding. + frag_albedo = vec4(LinearToSrgb(clamp(g_albedo, vec3(0.0), vec3(1.0))), 1.0); + // A debug pass takes the buffer over rather than getting one of its own. + // The surface buffer already has an attachment, a viewer and a golden; a + // second one would need all three built before it could answer anything. + if (g_debug_surface_on) { + frag_surface = vec4(g_debug_surface, ViewDepth()); + return; + } + // Reversed on a back face, as the lit normal is, so the occlusion and + // reflection passes see the side of a double-sided surface that faces them. + vec3 geometric = normalize(v_normal); + if (!gl_FrontFacing) geometric = -geometric; + frag_surface = vec4(EncodeOctahedral(geometric), + clamp(roughness, 0.0, 1.0), ViewDepth()); +#endif +} + +/// Fades [color] toward the fog with distance from the eye. +/// +/// Exponential rather than linear, because linear fog has a visible plane +/// where it starts and a dungeon corridor is exactly where that shows. +/// +/// **Height fog — `P5`.** `fog_info.fog.w` is the density at the eye and +/// `fog_info.eye.w` how fast it falls off upwards, per metre. The density +/// along the ray is then `density · e^(−falloff · Δy · t)` for t from nought +/// to one, and its mean over the ray is `(1 − e^(−k)) / k` with +/// `k = falloff · Δy`: an exact integral, so a fog lying on the ground costs +/// one exponential here and no march. Below a thousandth `k` is answered by +/// the first two terms of the same series, because the quotient there is +/// two nearly equal numbers divided by a small one. A falloff of nought skips +/// all of it, which keeps a flat fog the same to the bit. +vec3 ApplyFog(vec3 color) { +#ifdef F3D_NO_FOG + return color; +#else + float density = fog_info.fog.w; + if (density <= 0.0) return color; + float d = EyeDistance(); + float falloff = fog_info.eye.w; + if (falloff > 0.0) { + float k = falloff * (v_world_position.y - fog_info.eye.y); + density *= abs(k) > 1e-3 ? (1.0 - exp(-k)) / k : 1.0 - 0.5 * k; + } + return mix(fog_info.fog.rgb, color, clamp(exp(-density * d), 0.0, 1.0)); +#endif +} + +/// How much a transparent fragment counts for against the others over its +/// pixel — `R8`. McGuire and Bavoil's depth weight (their equation 9): a near +/// layer outweighs a far one, which is all the ordering a weighted average +/// can keep. [alpha] multiplies it, as theirs does, so a faint layer counts +/// faintly. Depth along the view axis, in metres, the surface buffer's. +float WeightedBlendedWeight(float alpha) { + float z = abs(ViewDepth()); + float near = z / 5.0; + float far = z / 200.0; + float far3 = far * far * far; + return alpha * + clamp(10.0 / (1e-5 + near * near + far3 * far3), 1e-2, 3e3); +} + +/// What a transparent draw writes when the frame composites transparency +/// order-independently — `R8`. `fog_info.forward.w` says which: +/// +/// - 0: [frag_color] as it stands, the sorted blend's source. Every opaque +/// draw, and every draw in a frame that sorts. +/// - 1: the accumulation target's share — the colour, which the engine keeps +/// premultiplied, and the alpha, both times the weight. Added. +/// - 2: the revealage target's — the alpha alone, in every channel, which the +/// blend multiplies the target by one minus of. +/// - 3: both at once, the second into attachment one, where the surface +/// buffer would be; the pass that asks has no surface buffer attached. +/// +/// Selects rather than returns, because a phi of constants is what +/// SPIRV-Cross refuses. At nought the branch is not taken and [frag_color] +/// is untouched, which is what keeps a sorting frame byte-identical. +void WriteWeightedBlended() { +#ifndef F3D_NO_FOG + float mode = fog_info.forward.w; + if (mode > 0.5) { + float alpha = frag_color.a; + float weight = WeightedBlendedWeight(alpha); + vec4 accumulate = vec4(frag_color.rgb * weight, alpha * weight); + bool revealage = mode > 1.5 && mode < 2.5; + frag_color = revealage ? vec4(alpha) : accumulate; +#ifndef F3D_NO_SURFACE_BUFFER + if (mode > 2.5) frag_surface = vec4(alpha); +#endif + } +#endif +} + +/// The fog is mixed in before the weight, so a thin distant pane adds a thin +/// share of the fog too rather than all of it. Times one when nothing blends, +/// which is exact, so an opaque draw writes what it always wrote. +void WriteSurface(vec3 linearColor, float alpha, float roughness) { + float weight = g_premultiply ? alpha : 1.0; + frag_color = vec4(ApplyFog(linearColor) * weight, + alpha * (1.0 - g_pass_through)); + WriteSurfaceGeometry(roughness); + WriteWeightedBlended(); +} + +/// For a stage with no material to speak of. +/// +/// Fully rough, which is the honest default: a surface that cannot say how +/// polished it is should not be reflected off. +void WriteSurface(vec3 linearColor, float alpha) { + WriteSurface(linearColor, alpha, 1.0); +} + +/// Writes a value that is already display-referred. +/// +/// For debug output, where the colour is not a light value at all: a normal +/// encoded as RGB means nothing after a tone curve. Converting to linear here +/// means the composite pass's sRGB encode hands the original back unchanged, +/// provided the view also turns tone mapping and exposure off — which is what +/// `RenderSettings.tonemap` is for. +void WriteDisplayColor(vec3 displayColor, float alpha) { + frag_color = vec4(SrgbToLinear(displayColor), alpha); + WriteSurfaceGeometry(1.0); +} + +#endif // COLOR_GLSL_ + +// --- lib/frag_coord.glsl --- +// Where a fragment sits, counted from the top of its target on every backend. + +#ifndef FRAG_COORD_GLSL_ +#define FRAG_COORD_GLSL_ + +/// `gl_FragCoord.xy` with row zero at the top of the picture. +/// +/// [rows] is the target's height where the backend's row zero is the bottom +/// of the picture, and zero where it is the top. WebGL2 is the first kind: +/// window coordinates start at the lower left, and the engine draws the +/// picture upright there rather than mirroring every projection. Metal, +/// WebGPU and the software rasteriser are the second. +/// +/// **Why a pattern cares and a picture does not.** Every screen-space pattern +/// in the engine — the Bayer dither, the grain, the jitter a ray march starts +/// from, the rotation of a shadow kernel — is a function of the pixel's row. +/// Read from the bottom, the same frame gets the pattern turned upside down, +/// and a four-row Bayer cell lands on different rows unless the height is a +/// multiple of four. The picture underneath is identical; the pattern on top +/// of it is not, and a comparison across backends counts every pixel it +/// moved. +vec2 FragCoordFromTop(float rows) { + return rows > 0.0 ? vec2(gl_FragCoord.x, rows - gl_FragCoord.y) + : gl_FragCoord.xy; +} + +#endif // FRAG_COORD_GLSL_ + + +/// What the mirrored camera saw, in linear light, the size of the view it +/// was taken for or a fraction of it. +uniform sampler2D reflection_texture; + +layout(std140) uniform PlanarReflectionInfo { + /// xy: the view's top-left corner in pixels of the target, counted from + /// the top. zw: the view's size in pixels. + vec4 view; + + /// x: the reflectance straight on, Schlick's F0 — one for a mirror, about + /// 0.02 for water. y: the strength the reflection is laid on with, which + /// scales the whole Fresnel term. z: the target's rows where its first row + /// is the bottom of the picture, nought where it is the top — see + /// `FragCoordFromTop`. w unused. + vec4 params; + + /// rgb: a linear tint the reflected light is multiplied by. w unused. + vec4 tint; +} +planar_info; + +void main() { + // The same pixel the mirrored camera drew, by place on screen: its + // projection is the view's own, so the texel under this fragment is the + // reflected point behind it. Counted from the top on every backend, and + // turned back where the texture's first row is the bottom of the picture. + float rows = planar_info.params.z; + vec2 pixel = FragCoordFromTop(rows); + vec2 uv = (pixel - planar_info.view.xy) / planar_info.view.zw; + if (rows > 0.0) uv.y = 1.0 - uv.y; + vec3 reflected = + textureLod(reflection_texture, uv, 0.0).rgb * planar_info.tint.rgb; + + // Schlick's Fresnel on the geometric normal, facing the eye: water reflects + // a little looking down into it and nearly everything at a grazing angle, + // and a mirror's F0 of one makes the term one everywhere. + vec3 n = normalize(v_normal); + if (!gl_FrontFacing) n = -n; + vec3 v = TowardsEye(); + float cosine = clamp(dot(n, v), 0.0, 1.0); + float f0 = planar_info.params.x; + float grazing = 1.0 - cosine; + float grazing2 = grazing * grazing; + float fresnel = f0 + (1.0 - f0) * grazing2 * grazing2 * grazing; + float alpha = clamp(fresnel * planar_info.params.y, 0.0, 1.0); + + // Fogged as the surface is: the reflection is light leaving the surface + // and crosses the same air to the eye. Premultiplied for the blend. + frag_color = vec4(ApplyFog(reflected) * alpha, alpha); +} + ''', 'Lambert': r'''#version 300 es precision highp float; @@ -6195,6 +7337,13 @@ bool g_debug_surface_on = false; /// A global for the reason [g_debug_surface] is one. bool g_premultiply = false; +/// How much of what is already behind a blended surface comes through it — +/// a thin pane of glass, `M3`: what the blend multiplies the target by is +/// one minus the alpha, so the alpha written is the coverage less this share +/// of it, while the colour stays weighted by the coverage alone. Nought for +/// everything else, which writes what it always wrote. +float g_pass_through = 0.0; + // **A stage that needs none of this must be able to declare none of it.** On // Vulkan both stages' descriptors are merged into one set layout, and two // bindings with the same number in it is not a layout the specification @@ -6211,11 +7360,13 @@ bool g_premultiply = false; /// way to move offsets nobody expected to move. Three vec4s is a cheap price /// for not touching any of that. layout(std140) uniform FogInfo { - /// rgb: linear fog colour. w: density per metre, zero for no fog. + /// rgb: linear fog colour. w: density per metre at the eye, zero for no + /// fog. vec4 fog; /// xyz: camera position in world space. Duplicated from FragInfo so this - /// block stands alone; a vec3 is cheaper than a coupling. + /// block stands alone; a vec3 is cheaper than a coupling. w: how fast the + /// fog thins upwards, per metre — `P5`, see [ApplyFog]; nought is flat fog. vec4 eye; /// xyz: the direction the camera looks, as a unit vector in world space. @@ -6226,15 +7377,24 @@ layout(std140) uniform FogInfo { /// question [eye] does — where the camera is and which way it faces — and /// this is the block `color.glsl` can see. vec4 forward; + + /// x: one when the camera's projection is orthographic, nought when it is + /// perspective — `P7`, see [Orthographic]. y, z, w unused. Appended, so + /// every offset above stays where four backends agree on it. + vec4 projection; } fog_info; -/// How far this fragment is from the eye, in world metres. +/// Whether the camera is orthographic — `P7`. /// -/// What the fog fades by. Distance rather than depth, because fog is a -/// property of the air between two points and does not care which way the -/// camera happens to face. -float EyeDistance() { return distance(v_world_position, fog_info.eye.xyz); } +/// **What every eye-relative quantity has to ask first.** Through a +/// perspective lens the rays meet at the eye, so the way to the eye and the +/// distance to it are the fragment's own; through an orthographic one the +/// rays are parallel and the eye is only where the camera was put along its +/// axis, which moves nothing in the picture. Reading the eye's position as if +/// it were a point the light travels to slides highlights across the frame +/// as the camera pans and lays fog in rings round a point nobody sees. +bool Orthographic() { return fog_info.projection.x > 0.5; } /// How far this fragment is *along the view axis*, in world metres. /// @@ -6249,6 +7409,27 @@ float ViewDepth() { return dot(v_world_position - fog_info.eye.xyz, fog_info.forward.xyz); } +/// How far this fragment is from the eye, in world metres. +/// +/// What the fog fades by. Distance rather than depth, because fog is a +/// property of the air between two points and does not care which way the +/// camera happens to face — through a perspective lens. Through an +/// orthographic one every ray starts on the eye's plane, so the air a ray +/// crosses is its depth from that plane, never less than nought. +float EyeDistance() { + return Orthographic() ? max(ViewDepth(), 0.0) + : distance(v_world_position, fog_info.eye.xyz); +} + +/// The unit direction from this fragment back along the ray that reached +/// it: to the eye through a perspective lens, against the view axis through +/// an orthographic one — `P7`. What a highlight, a Fresnel term and a +/// reflection are measured from. +vec3 TowardsEye() { + return Orthographic() ? -fog_info.forward.xyz + : normalize(fog_info.eye.xyz - v_world_position); +} + #else // F3D_NO_FOG // The same two questions, answered without the block: a stage that declares no @@ -6341,6 +7522,16 @@ void WriteSurfaceGeometry(float roughness) { /// /// Exponential rather than linear, because linear fog has a visible plane /// where it starts and a dungeon corridor is exactly where that shows. +/// +/// **Height fog — `P5`.** `fog_info.fog.w` is the density at the eye and +/// `fog_info.eye.w` how fast it falls off upwards, per metre. The density +/// along the ray is then `density · e^(−falloff · Δy · t)` for t from nought +/// to one, and its mean over the ray is `(1 − e^(−k)) / k` with +/// `k = falloff · Δy`: an exact integral, so a fog lying on the ground costs +/// one exponential here and no march. Below a thousandth `k` is answered by +/// the first two terms of the same series, because the quotient there is +/// two nearly equal numbers divided by a small one. A falloff of nought skips +/// all of it, which keeps a flat fog the same to the bit. vec3 ApplyFog(vec3 color) { #ifdef F3D_NO_FOG return color; @@ -6348,6 +7539,11 @@ vec3 ApplyFog(vec3 color) { float density = fog_info.fog.w; if (density <= 0.0) return color; float d = EyeDistance(); + float falloff = fog_info.eye.w; + if (falloff > 0.0) { + float k = falloff * (v_world_position.y - fog_info.eye.y); + density *= abs(k) > 1e-3 ? (1.0 - exp(-k)) / k : 1.0 - 0.5 * k; + } return mix(fog_info.fog.rgb, color, clamp(exp(-density * d), 0.0, 1.0)); #endif } @@ -6402,7 +7598,8 @@ void WriteWeightedBlended() { /// which is exact, so an opaque draw writes what it always wrote. void WriteSurface(vec3 linearColor, float alpha, float roughness) { float weight = g_premultiply ? alpha : 1.0; - frag_color = vec4(ApplyFog(linearColor) * weight, alpha); + frag_color = vec4(ApplyFog(linearColor) * weight, + alpha * (1.0 - g_pass_through)); WriteSurfaceGeometry(roughness); WriteWeightedBlended(); } @@ -6670,8 +7867,9 @@ layout(std140) uniform FragInfo { vec4 material; /// x: alpha cutoff (negative when the material is not masked: -1 opaque, - /// -0.5 blended, -2 hashed), y: normal scale, z: occlusion strength, - /// w: emissive strength. + /// -0.5 blended, -2 hashed; above one the cutoff plus one, drawn as + /// coverage — `P7`), y: normal scale, z: occlusion strength, w: emissive + /// strength. vec4 material2; /// x: exposure, y: active light count, z: index of the shadow-casting light. @@ -6726,7 +7924,9 @@ layout(std140) uniform FragInfo { vec4 ambient_ground; /// x, y, z: the depth bias of each cascade, in that cascade's own normalized - /// depth. w unused. + /// depth. w: one when the atlas carries what see-through casters let + /// through and this draw is to be shaded by it, nought otherwise — + /// `ShadowSettings.translucentCasters`; see `ShadowFactor`. /// /// `ShadowSettings.bias` is one number and a cascade's depth range is not: /// a near cascade is stretched towards the light when a caster stands @@ -6747,6 +7947,15 @@ layout(std140) uniform FragInfo { /// while a temporal resolve runs, minus one otherwise — `S3`, which steps /// the soft shadow's rotation by it. vec4 target_origin; + + /// x: which debug view replaces the light — `P6`, `DebugView.code`, nought + /// for none. y: where it starts, as a share of the target's width from the + /// left; nought is the whole frame. z: the target's width in pixels, which + /// turns the share into a column. w unused. + /// + /// Appended for the reason `ambient_sky` was: every offset above stays + /// where the four backends already agree on it. + vec4 debug_view; } frag_info; @@ -6837,8 +8046,25 @@ Surface ReadSurface() { // number in a block six shaders share, and -1 already meant "not masked"; // anything more negative was free. See [MaterialAlphaMode.hashed]. float cutoff = frag_info.material2.x; - if (cutoff >= 0.0) { + if (cutoff > 1.0) { + // **Coverage instead of a cut — `P7`.** One above the cutoff says the + // pass multisamples and turns this fragment's alpha into the share of + // samples it covers, so nothing is discarded: the alpha is sharpened to + // run from nought to one across about a pixel either side of the cutoff, + // and the resolve smooths the edge as it smooths a triangle's. Unsharpened, + // a texture's soft alpha would cover half the samples of every pixel it + // fades across and draw a screen door. Branched on a uniform, so the + // derivative is taken in uniform control flow, as WGSL requires. + float edge = cutoff - 1.0; + s.alpha = clamp((s.alpha - edge) / max(fwidth(s.alpha), 1e-4) + 0.5, + 0.0, 1.0); + } else if (cutoff >= 0.0) { if (s.alpha < cutoff) discard; + // What survives the cut is a surface, and opaque: the texture's alpha has + // done its work. Written as it was, it went into the frame's alpha, and + // whatever read the frame as premultiplied — a golden's capture — divided + // the colour by it and lit the inside of every leaf towards its rim. + s.alpha = 1.0; } else if (cutoff < -1.5) { // **Stochastic instead of a threshold.** A leaf texture at 40% opacity is // either entirely there or entirely gone under a fixed cutoff, so a fern @@ -6875,7 +8101,15 @@ Surface ReadSurface() { // double-sided material ever draws a back face, since everything else has // them culled. if (!gl_FrontFacing) s.n = -s.n; +#ifdef F3D_NO_FOG + // The stages without the fog block — shadows and the id pass — light + // nothing, and keep the eye's point. s.v = normalize(frag_info.camera_position.xyz - v_world_position); +#else + // `P7`: against the view axis through an orthographic lens, where the + // eye's point is only where the camera was put. + s.v = TowardsEye(); +#endif // Clamped away from zero: a grazing view direction otherwise divides by zero // in the specular visibility term. s.n_dot_v = max(dot(s.n, s.v), 1e-4); @@ -7356,6 +8590,15 @@ LightSample SampleLight(int index, Surface s) { /// returns `ShadowFactor(...)`; an unlit one returns 1. float LightVisibility(Surface s, LightSample light, int index); +/// What the see-through casters between the sun and this fragment let +/// through, per channel — `ShadowSettings.translucentCasters`. Set by +/// `ShadowFactor` for the light it shadows and reset to one before every +/// light, so a model that samples no shadow map, and every light but the +/// sun, leaves it white. A colour beside the visibility rather than folded +/// into it, because visibility is one number in every model and coloured +/// light is not. +vec3 light_transmittance = vec3(1.0); + /// A model's per-light term, defined by each fragment shader. /// /// A prototype here and the definition in the model is what lets the loop below @@ -7774,6 +9017,7 @@ vec3 AccumulateLights(Surface s) { if (i >= count) break; LightSample light = SampleLight(i, s); if (light.n_dot_l <= 0.0) continue; + light_transmittance = vec3(1.0); // A light from the list has no shadow row to read — see `LightHasShadow`. // A branch rather than something folded into the two calls, because both // index tables eight entries wide and the ninth light would read past them @@ -7783,12 +9027,73 @@ vec3 AccumulateLights(Surface s) { PointShadowFactor(v_world_position, s.n, i) : 1.0; if (visibility <= 0.0) continue; - total += ShadeLight(s, light) * light.radiance * light.n_dot_l * visibility; + total += ShadeLight(s, light) * light.radiance * light.n_dot_l * visibility * + light_transmittance; } return total; } +/// Whether any channel of [c] is NaN or infinite. +/// +/// **Comparisons, not `isnan` and not the bits.** WGSL has no `isNan`, and +/// reading the exponent through `floatBitsToUint` takes `impellerc` down in +/// its GLSL ES output, which has no bit casts. A NaN is the one value unequal +/// to itself, and an infinity the one above every finite float. +bool NonFinite(vec3 c) { + return any(notEqual(c, c)) || any(greaterThan(abs(c), vec3(3.0e38))); +} + +/// `P6`: the material channel `FragInfo.debug_view` asks for, written in +/// place of [lit]. False, and nothing written, when no view is on or the +/// fragment sits left of the split; the caller then writes the light. +/// +/// **Display values, through the same exits the light takes.** The channel +/// is what an artist would read off the texture — an albedo as its sRGB +/// colour, a roughness as a grey — converted to linear so the composite's +/// encode hands it back unchanged; the composite leaves this side of the +/// split out of the exposure and the tone curve (`CompositeInfo.lens.y`). +/// The surface buffer and the weighted-blended targets are written as +/// [WriteSurface] writes them, so a debug view changes what the frame shows +/// and nothing the passes after it read. No fog: a channel seen through fog +/// is not the channel. +/// +/// [lit] is read by one view only, [DebugView.nonFinite], which shows a NaN +/// or an infinity as magenta over the light's own luminance in grey. +bool WriteDebugView(Surface s, vec3 lit) { + float view = frag_info.debug_view.x; + if (view < 0.5) return false; + if (gl_FragCoord.x < frag_info.debug_view.y * frag_info.debug_view.z) { + return false; + } + int code = int(view + 0.5); + vec3 shown = vec3(0.0); + if (code == 1) { + shown = LinearToSrgb(clamp(s.albedo, vec3(0.0), vec3(1.0))); + } else if (code == 2) { + shown = s.n * 0.5 + vec3(0.5); + } else if (code == 3) { + shown = vec3(clamp(s.roughness, 0.0, 1.0)); + } else if (code == 4) { + shown = vec3(clamp(s.metallic, 0.0, 1.0)); + } else if (code == 5) { + shown = vec3(clamp(s.occlusion, 0.0, 1.0)); + } else if (code == 6) { + shown = LinearToSrgb(clamp(s.emissive, vec3(0.0), vec3(1.0))); + } else if (code == 7) { + shown = vec3(fract(MapUv(kMapBaseColor)), 0.0); + } else if (code == 8) { + float grey = dot(LinearToSrgb(clamp(lit, vec3(0.0), vec3(1.0))), + vec3(0.2126, 0.7152, 0.0722)); + shown = NonFinite(lit) ? vec3(1.0, 0.0, 1.0) : vec3(grey * 0.5); + } + float weight = g_premultiply ? s.alpha : 1.0; + frag_color = vec4(SrgbToLinear(shown) * weight, s.alpha); + WriteSurfaceGeometry(s.roughness); + WriteWeightedBlended(); + return true; +} + #endif // SURFACE_GLSL_ // --- lib/irradiance.glsl --- @@ -8253,7 +9558,16 @@ float ShadowFactor(Surface s, LightSample light, int lightIndex) { // whole class of missing-shadow bug, and the last cascade is fitted to the // entire scene, so the fall-through always terminates somewhere real. int cascadeCount = int(frag_info.shadow_cascades.z + 0.5); + // `P7`: by depth along the view axis through an orthographic lens, whose + // cascades are slabs of the view's box rather than spheres about the eye. +#ifdef F3D_NO_FOG float viewDistance = length(v_world_position - frag_info.camera_position.xyz); +#else + float viewDistance = + Orthographic() + ? ViewDepth() + : length(v_world_position - frag_info.camera_position.xyz); +#endif int cascade = 0; if (cascadeCount > 1 && viewDistance > frag_info.shadow_cascades.x) cascade = 1; if (cascadeCount > 2 && viewDistance > frag_info.shadow_cascades.y) cascade = 2; @@ -8394,6 +9708,19 @@ float ShadowFactor(Surface s, LightSample light, int lightIndex) { // kernel, and how far under minus one the value sits is the light-bleeding // cut. A sign rather than another uniform, for the reason the softness // itself rides here. + // **What the see-through casters let through** — before the filter, whose + // soft path leaves early where it finds no blocker. One bilinear tap: a + // translucent caster's shadow is light shaded rather than light stopped, + // and its edge is softened by the filtering the tap already gets. Green + // and blue hold what was taken from red and green, alpha what was left of + // blue — the layout `shadow_transmittance.frag` explains. + if (frag_info.shadow_bias.w > 0.5) { + vec4 stored = textureLod(shadow_texture, clamp(uv, tileLo, tileHi), 0.0); + // Up to four: light a caster gathered, not only light it stopped. + vec3 through = clamp(vec3(1.0 - stored.g, 1.0 - stored.b, stored.a), 0.0, 4.0); + light_transmittance = mix(vec3(1.0), through, clamp(strength, 0.0, 1.0)); + } + float softness = frag_info.ambient_ground.w; float lit = 0.0; if (softness < 0.0) { @@ -8512,10 +9839,9 @@ void main() { // roughness, so sampling it would leave a slot the compiler then drops. ApplyCommonMaps(s); vec3 ambient = s.albedo * (s.ambient + SampleLightmap()) * s.occlusion; - WriteSurface( - AccumulateLights(s) * s.occlusion + ambient + s.emissive, - s.alpha, - s.roughness); + vec3 lit = AccumulateLights(s) * s.occlusion + ambient + s.emissive; + if (WriteDebugView(s, lit)) return; + WriteSurface(lit, s.alpha, s.roughness); } ''', @@ -8674,6 +10000,13 @@ bool g_debug_surface_on = false; /// A global for the reason [g_debug_surface] is one. bool g_premultiply = false; +/// How much of what is already behind a blended surface comes through it — +/// a thin pane of glass, `M3`: what the blend multiplies the target by is +/// one minus the alpha, so the alpha written is the coverage less this share +/// of it, while the colour stays weighted by the coverage alone. Nought for +/// everything else, which writes what it always wrote. +float g_pass_through = 0.0; + // **A stage that needs none of this must be able to declare none of it.** On // Vulkan both stages' descriptors are merged into one set layout, and two // bindings with the same number in it is not a layout the specification @@ -8690,11 +10023,13 @@ bool g_premultiply = false; /// way to move offsets nobody expected to move. Three vec4s is a cheap price /// for not touching any of that. layout(std140) uniform FogInfo { - /// rgb: linear fog colour. w: density per metre, zero for no fog. + /// rgb: linear fog colour. w: density per metre at the eye, zero for no + /// fog. vec4 fog; /// xyz: camera position in world space. Duplicated from FragInfo so this - /// block stands alone; a vec3 is cheaper than a coupling. + /// block stands alone; a vec3 is cheaper than a coupling. w: how fast the + /// fog thins upwards, per metre — `P5`, see [ApplyFog]; nought is flat fog. vec4 eye; /// xyz: the direction the camera looks, as a unit vector in world space. @@ -8705,15 +10040,24 @@ layout(std140) uniform FogInfo { /// question [eye] does — where the camera is and which way it faces — and /// this is the block `color.glsl` can see. vec4 forward; + + /// x: one when the camera's projection is orthographic, nought when it is + /// perspective — `P7`, see [Orthographic]. y, z, w unused. Appended, so + /// every offset above stays where four backends agree on it. + vec4 projection; } fog_info; -/// How far this fragment is from the eye, in world metres. +/// Whether the camera is orthographic — `P7`. /// -/// What the fog fades by. Distance rather than depth, because fog is a -/// property of the air between two points and does not care which way the -/// camera happens to face. -float EyeDistance() { return distance(v_world_position, fog_info.eye.xyz); } +/// **What every eye-relative quantity has to ask first.** Through a +/// perspective lens the rays meet at the eye, so the way to the eye and the +/// distance to it are the fragment's own; through an orthographic one the +/// rays are parallel and the eye is only where the camera was put along its +/// axis, which moves nothing in the picture. Reading the eye's position as if +/// it were a point the light travels to slides highlights across the frame +/// as the camera pans and lays fog in rings round a point nobody sees. +bool Orthographic() { return fog_info.projection.x > 0.5; } /// How far this fragment is *along the view axis*, in world metres. /// @@ -8728,6 +10072,27 @@ float ViewDepth() { return dot(v_world_position - fog_info.eye.xyz, fog_info.forward.xyz); } +/// How far this fragment is from the eye, in world metres. +/// +/// What the fog fades by. Distance rather than depth, because fog is a +/// property of the air between two points and does not care which way the +/// camera happens to face — through a perspective lens. Through an +/// orthographic one every ray starts on the eye's plane, so the air a ray +/// crosses is its depth from that plane, never less than nought. +float EyeDistance() { + return Orthographic() ? max(ViewDepth(), 0.0) + : distance(v_world_position, fog_info.eye.xyz); +} + +/// The unit direction from this fragment back along the ray that reached +/// it: to the eye through a perspective lens, against the view axis through +/// an orthographic one — `P7`. What a highlight, a Fresnel term and a +/// reflection are measured from. +vec3 TowardsEye() { + return Orthographic() ? -fog_info.forward.xyz + : normalize(fog_info.eye.xyz - v_world_position); +} + #else // F3D_NO_FOG // The same two questions, answered without the block: a stage that declares no @@ -8820,6 +10185,16 @@ void WriteSurfaceGeometry(float roughness) { /// /// Exponential rather than linear, because linear fog has a visible plane /// where it starts and a dungeon corridor is exactly where that shows. +/// +/// **Height fog — `P5`.** `fog_info.fog.w` is the density at the eye and +/// `fog_info.eye.w` how fast it falls off upwards, per metre. The density +/// along the ray is then `density · e^(−falloff · Δy · t)` for t from nought +/// to one, and its mean over the ray is `(1 − e^(−k)) / k` with +/// `k = falloff · Δy`: an exact integral, so a fog lying on the ground costs +/// one exponential here and no march. Below a thousandth `k` is answered by +/// the first two terms of the same series, because the quotient there is +/// two nearly equal numbers divided by a small one. A falloff of nought skips +/// all of it, which keeps a flat fog the same to the bit. vec3 ApplyFog(vec3 color) { #ifdef F3D_NO_FOG return color; @@ -8827,6 +10202,11 @@ vec3 ApplyFog(vec3 color) { float density = fog_info.fog.w; if (density <= 0.0) return color; float d = EyeDistance(); + float falloff = fog_info.eye.w; + if (falloff > 0.0) { + float k = falloff * (v_world_position.y - fog_info.eye.y); + density *= abs(k) > 1e-3 ? (1.0 - exp(-k)) / k : 1.0 - 0.5 * k; + } return mix(fog_info.fog.rgb, color, clamp(exp(-density * d), 0.0, 1.0)); #endif } @@ -8881,7 +10261,8 @@ void WriteWeightedBlended() { /// which is exact, so an opaque draw writes what it always wrote. void WriteSurface(vec3 linearColor, float alpha, float roughness) { float weight = g_premultiply ? alpha : 1.0; - frag_color = vec4(ApplyFog(linearColor) * weight, alpha); + frag_color = vec4(ApplyFog(linearColor) * weight, + alpha * (1.0 - g_pass_through)); WriteSurfaceGeometry(roughness); WriteWeightedBlended(); } @@ -9149,8 +10530,9 @@ layout(std140) uniform FragInfo { vec4 material; /// x: alpha cutoff (negative when the material is not masked: -1 opaque, - /// -0.5 blended, -2 hashed), y: normal scale, z: occlusion strength, - /// w: emissive strength. + /// -0.5 blended, -2 hashed; above one the cutoff plus one, drawn as + /// coverage — `P7`), y: normal scale, z: occlusion strength, w: emissive + /// strength. vec4 material2; /// x: exposure, y: active light count, z: index of the shadow-casting light. @@ -9205,7 +10587,9 @@ layout(std140) uniform FragInfo { vec4 ambient_ground; /// x, y, z: the depth bias of each cascade, in that cascade's own normalized - /// depth. w unused. + /// depth. w: one when the atlas carries what see-through casters let + /// through and this draw is to be shaded by it, nought otherwise — + /// `ShadowSettings.translucentCasters`; see `ShadowFactor`. /// /// `ShadowSettings.bias` is one number and a cascade's depth range is not: /// a near cascade is stretched towards the light when a caster stands @@ -9226,6 +10610,15 @@ layout(std140) uniform FragInfo { /// while a temporal resolve runs, minus one otherwise — `S3`, which steps /// the soft shadow's rotation by it. vec4 target_origin; + + /// x: which debug view replaces the light — `P6`, `DebugView.code`, nought + /// for none. y: where it starts, as a share of the target's width from the + /// left; nought is the whole frame. z: the target's width in pixels, which + /// turns the share into a column. w unused. + /// + /// Appended for the reason `ambient_sky` was: every offset above stays + /// where the four backends already agree on it. + vec4 debug_view; } frag_info; @@ -9316,8 +10709,25 @@ Surface ReadSurface() { // number in a block six shaders share, and -1 already meant "not masked"; // anything more negative was free. See [MaterialAlphaMode.hashed]. float cutoff = frag_info.material2.x; - if (cutoff >= 0.0) { + if (cutoff > 1.0) { + // **Coverage instead of a cut — `P7`.** One above the cutoff says the + // pass multisamples and turns this fragment's alpha into the share of + // samples it covers, so nothing is discarded: the alpha is sharpened to + // run from nought to one across about a pixel either side of the cutoff, + // and the resolve smooths the edge as it smooths a triangle's. Unsharpened, + // a texture's soft alpha would cover half the samples of every pixel it + // fades across and draw a screen door. Branched on a uniform, so the + // derivative is taken in uniform control flow, as WGSL requires. + float edge = cutoff - 1.0; + s.alpha = clamp((s.alpha - edge) / max(fwidth(s.alpha), 1e-4) + 0.5, + 0.0, 1.0); + } else if (cutoff >= 0.0) { if (s.alpha < cutoff) discard; + // What survives the cut is a surface, and opaque: the texture's alpha has + // done its work. Written as it was, it went into the frame's alpha, and + // whatever read the frame as premultiplied — a golden's capture — divided + // the colour by it and lit the inside of every leaf towards its rim. + s.alpha = 1.0; } else if (cutoff < -1.5) { // **Stochastic instead of a threshold.** A leaf texture at 40% opacity is // either entirely there or entirely gone under a fixed cutoff, so a fern @@ -9354,7 +10764,15 @@ Surface ReadSurface() { // double-sided material ever draws a back face, since everything else has // them culled. if (!gl_FrontFacing) s.n = -s.n; +#ifdef F3D_NO_FOG + // The stages without the fog block — shadows and the id pass — light + // nothing, and keep the eye's point. s.v = normalize(frag_info.camera_position.xyz - v_world_position); +#else + // `P7`: against the view axis through an orthographic lens, where the + // eye's point is only where the camera was put. + s.v = TowardsEye(); +#endif // Clamped away from zero: a grazing view direction otherwise divides by zero // in the specular visibility term. s.n_dot_v = max(dot(s.n, s.v), 1e-4); @@ -9835,6 +11253,15 @@ LightSample SampleLight(int index, Surface s) { /// returns `ShadowFactor(...)`; an unlit one returns 1. float LightVisibility(Surface s, LightSample light, int index); +/// What the see-through casters between the sun and this fragment let +/// through, per channel — `ShadowSettings.translucentCasters`. Set by +/// `ShadowFactor` for the light it shadows and reset to one before every +/// light, so a model that samples no shadow map, and every light but the +/// sun, leaves it white. A colour beside the visibility rather than folded +/// into it, because visibility is one number in every model and coloured +/// light is not. +vec3 light_transmittance = vec3(1.0); + /// A model's per-light term, defined by each fragment shader. /// /// A prototype here and the definition in the model is what lets the loop below @@ -10253,6 +11680,7 @@ vec3 AccumulateLights(Surface s) { if (i >= count) break; LightSample light = SampleLight(i, s); if (light.n_dot_l <= 0.0) continue; + light_transmittance = vec3(1.0); // A light from the list has no shadow row to read — see `LightHasShadow`. // A branch rather than something folded into the two calls, because both // index tables eight entries wide and the ninth light would read past them @@ -10262,12 +11690,73 @@ vec3 AccumulateLights(Surface s) { PointShadowFactor(v_world_position, s.n, i) : 1.0; if (visibility <= 0.0) continue; - total += ShadeLight(s, light) * light.radiance * light.n_dot_l * visibility; + total += ShadeLight(s, light) * light.radiance * light.n_dot_l * visibility * + light_transmittance; } return total; } +/// Whether any channel of [c] is NaN or infinite. +/// +/// **Comparisons, not `isnan` and not the bits.** WGSL has no `isNan`, and +/// reading the exponent through `floatBitsToUint` takes `impellerc` down in +/// its GLSL ES output, which has no bit casts. A NaN is the one value unequal +/// to itself, and an infinity the one above every finite float. +bool NonFinite(vec3 c) { + return any(notEqual(c, c)) || any(greaterThan(abs(c), vec3(3.0e38))); +} + +/// `P6`: the material channel `FragInfo.debug_view` asks for, written in +/// place of [lit]. False, and nothing written, when no view is on or the +/// fragment sits left of the split; the caller then writes the light. +/// +/// **Display values, through the same exits the light takes.** The channel +/// is what an artist would read off the texture — an albedo as its sRGB +/// colour, a roughness as a grey — converted to linear so the composite's +/// encode hands it back unchanged; the composite leaves this side of the +/// split out of the exposure and the tone curve (`CompositeInfo.lens.y`). +/// The surface buffer and the weighted-blended targets are written as +/// [WriteSurface] writes them, so a debug view changes what the frame shows +/// and nothing the passes after it read. No fog: a channel seen through fog +/// is not the channel. +/// +/// [lit] is read by one view only, [DebugView.nonFinite], which shows a NaN +/// or an infinity as magenta over the light's own luminance in grey. +bool WriteDebugView(Surface s, vec3 lit) { + float view = frag_info.debug_view.x; + if (view < 0.5) return false; + if (gl_FragCoord.x < frag_info.debug_view.y * frag_info.debug_view.z) { + return false; + } + int code = int(view + 0.5); + vec3 shown = vec3(0.0); + if (code == 1) { + shown = LinearToSrgb(clamp(s.albedo, vec3(0.0), vec3(1.0))); + } else if (code == 2) { + shown = s.n * 0.5 + vec3(0.5); + } else if (code == 3) { + shown = vec3(clamp(s.roughness, 0.0, 1.0)); + } else if (code == 4) { + shown = vec3(clamp(s.metallic, 0.0, 1.0)); + } else if (code == 5) { + shown = vec3(clamp(s.occlusion, 0.0, 1.0)); + } else if (code == 6) { + shown = LinearToSrgb(clamp(s.emissive, vec3(0.0), vec3(1.0))); + } else if (code == 7) { + shown = vec3(fract(MapUv(kMapBaseColor)), 0.0); + } else if (code == 8) { + float grey = dot(LinearToSrgb(clamp(lit, vec3(0.0), vec3(1.0))), + vec3(0.2126, 0.7152, 0.0722)); + shown = NonFinite(lit) ? vec3(1.0, 0.0, 1.0) : vec3(grey * 0.5); + } + float weight = g_premultiply ? s.alpha : 1.0; + frag_color = vec4(SrgbToLinear(shown) * weight, s.alpha); + WriteSurfaceGeometry(s.roughness); + WriteWeightedBlended(); + return true; +} + #endif // SURFACE_GLSL_ // --- lib/irradiance.glsl --- @@ -10732,7 +12221,16 @@ float ShadowFactor(Surface s, LightSample light, int lightIndex) { // whole class of missing-shadow bug, and the last cascade is fitted to the // entire scene, so the fall-through always terminates somewhere real. int cascadeCount = int(frag_info.shadow_cascades.z + 0.5); + // `P7`: by depth along the view axis through an orthographic lens, whose + // cascades are slabs of the view's box rather than spheres about the eye. +#ifdef F3D_NO_FOG float viewDistance = length(v_world_position - frag_info.camera_position.xyz); +#else + float viewDistance = + Orthographic() + ? ViewDepth() + : length(v_world_position - frag_info.camera_position.xyz); +#endif int cascade = 0; if (cascadeCount > 1 && viewDistance > frag_info.shadow_cascades.x) cascade = 1; if (cascadeCount > 2 && viewDistance > frag_info.shadow_cascades.y) cascade = 2; @@ -10873,6 +12371,19 @@ float ShadowFactor(Surface s, LightSample light, int lightIndex) { // kernel, and how far under minus one the value sits is the light-bleeding // cut. A sign rather than another uniform, for the reason the softness // itself rides here. + // **What the see-through casters let through** — before the filter, whose + // soft path leaves early where it finds no blocker. One bilinear tap: a + // translucent caster's shadow is light shaded rather than light stopped, + // and its edge is softened by the filtering the tap already gets. Green + // and blue hold what was taken from red and green, alpha what was left of + // blue — the layout `shadow_transmittance.frag` explains. + if (frag_info.shadow_bias.w > 0.5) { + vec4 stored = textureLod(shadow_texture, clamp(uv, tileLo, tileHi), 0.0); + // Up to four: light a caster gathered, not only light it stopped. + vec3 through = clamp(vec3(1.0 - stored.g, 1.0 - stored.b, stored.a), 0.0, 4.0); + light_transmittance = mix(vec3(1.0), through, clamp(strength, 0.0, 1.0)); + } + float softness = frag_info.ambient_ground.w; float lit = 0.0; if (softness < 0.0) { @@ -10997,10 +12508,9 @@ void main() { // output here. ApplyMetallicRoughnessMap(s); vec3 ambient = s.albedo * (s.ambient + SampleLightmap()) * s.occlusion; - WriteSurface( - AccumulateLights(s) * s.occlusion + ambient + s.emissive, - s.alpha, - s.roughness); + vec3 lit = AccumulateLights(s) * s.occlusion + ambient + s.emissive; + if (WriteDebugView(s, lit)) return; + WriteSurface(lit, s.alpha, s.roughness); } ''', @@ -11188,6 +12698,13 @@ bool g_debug_surface_on = false; /// A global for the reason [g_debug_surface] is one. bool g_premultiply = false; +/// How much of what is already behind a blended surface comes through it — +/// a thin pane of glass, `M3`: what the blend multiplies the target by is +/// one minus the alpha, so the alpha written is the coverage less this share +/// of it, while the colour stays weighted by the coverage alone. Nought for +/// everything else, which writes what it always wrote. +float g_pass_through = 0.0; + // **A stage that needs none of this must be able to declare none of it.** On // Vulkan both stages' descriptors are merged into one set layout, and two // bindings with the same number in it is not a layout the specification @@ -11204,11 +12721,13 @@ bool g_premultiply = false; /// way to move offsets nobody expected to move. Three vec4s is a cheap price /// for not touching any of that. layout(std140) uniform FogInfo { - /// rgb: linear fog colour. w: density per metre, zero for no fog. + /// rgb: linear fog colour. w: density per metre at the eye, zero for no + /// fog. vec4 fog; /// xyz: camera position in world space. Duplicated from FragInfo so this - /// block stands alone; a vec3 is cheaper than a coupling. + /// block stands alone; a vec3 is cheaper than a coupling. w: how fast the + /// fog thins upwards, per metre — `P5`, see [ApplyFog]; nought is flat fog. vec4 eye; /// xyz: the direction the camera looks, as a unit vector in world space. @@ -11219,15 +12738,24 @@ layout(std140) uniform FogInfo { /// question [eye] does — where the camera is and which way it faces — and /// this is the block `color.glsl` can see. vec4 forward; + + /// x: one when the camera's projection is orthographic, nought when it is + /// perspective — `P7`, see [Orthographic]. y, z, w unused. Appended, so + /// every offset above stays where four backends agree on it. + vec4 projection; } fog_info; -/// How far this fragment is from the eye, in world metres. +/// Whether the camera is orthographic — `P7`. /// -/// What the fog fades by. Distance rather than depth, because fog is a -/// property of the air between two points and does not care which way the -/// camera happens to face. -float EyeDistance() { return distance(v_world_position, fog_info.eye.xyz); } +/// **What every eye-relative quantity has to ask first.** Through a +/// perspective lens the rays meet at the eye, so the way to the eye and the +/// distance to it are the fragment's own; through an orthographic one the +/// rays are parallel and the eye is only where the camera was put along its +/// axis, which moves nothing in the picture. Reading the eye's position as if +/// it were a point the light travels to slides highlights across the frame +/// as the camera pans and lays fog in rings round a point nobody sees. +bool Orthographic() { return fog_info.projection.x > 0.5; } /// How far this fragment is *along the view axis*, in world metres. /// @@ -11242,6 +12770,27 @@ float ViewDepth() { return dot(v_world_position - fog_info.eye.xyz, fog_info.forward.xyz); } +/// How far this fragment is from the eye, in world metres. +/// +/// What the fog fades by. Distance rather than depth, because fog is a +/// property of the air between two points and does not care which way the +/// camera happens to face — through a perspective lens. Through an +/// orthographic one every ray starts on the eye's plane, so the air a ray +/// crosses is its depth from that plane, never less than nought. +float EyeDistance() { + return Orthographic() ? max(ViewDepth(), 0.0) + : distance(v_world_position, fog_info.eye.xyz); +} + +/// The unit direction from this fragment back along the ray that reached +/// it: to the eye through a perspective lens, against the view axis through +/// an orthographic one — `P7`. What a highlight, a Fresnel term and a +/// reflection are measured from. +vec3 TowardsEye() { + return Orthographic() ? -fog_info.forward.xyz + : normalize(fog_info.eye.xyz - v_world_position); +} + #else // F3D_NO_FOG // The same two questions, answered without the block: a stage that declares no @@ -11334,6 +12883,16 @@ void WriteSurfaceGeometry(float roughness) { /// /// Exponential rather than linear, because linear fog has a visible plane /// where it starts and a dungeon corridor is exactly where that shows. +/// +/// **Height fog — `P5`.** `fog_info.fog.w` is the density at the eye and +/// `fog_info.eye.w` how fast it falls off upwards, per metre. The density +/// along the ray is then `density · e^(−falloff · Δy · t)` for t from nought +/// to one, and its mean over the ray is `(1 − e^(−k)) / k` with +/// `k = falloff · Δy`: an exact integral, so a fog lying on the ground costs +/// one exponential here and no march. Below a thousandth `k` is answered by +/// the first two terms of the same series, because the quotient there is +/// two nearly equal numbers divided by a small one. A falloff of nought skips +/// all of it, which keeps a flat fog the same to the bit. vec3 ApplyFog(vec3 color) { #ifdef F3D_NO_FOG return color; @@ -11341,6 +12900,11 @@ vec3 ApplyFog(vec3 color) { float density = fog_info.fog.w; if (density <= 0.0) return color; float d = EyeDistance(); + float falloff = fog_info.eye.w; + if (falloff > 0.0) { + float k = falloff * (v_world_position.y - fog_info.eye.y); + density *= abs(k) > 1e-3 ? (1.0 - exp(-k)) / k : 1.0 - 0.5 * k; + } return mix(fog_info.fog.rgb, color, clamp(exp(-density * d), 0.0, 1.0)); #endif } @@ -11395,7 +12959,8 @@ void WriteWeightedBlended() { /// which is exact, so an opaque draw writes what it always wrote. void WriteSurface(vec3 linearColor, float alpha, float roughness) { float weight = g_premultiply ? alpha : 1.0; - frag_color = vec4(ApplyFog(linearColor) * weight, alpha); + frag_color = vec4(ApplyFog(linearColor) * weight, + alpha * (1.0 - g_pass_through)); WriteSurfaceGeometry(roughness); WriteWeightedBlended(); } @@ -11663,8 +13228,9 @@ layout(std140) uniform FragInfo { vec4 material; /// x: alpha cutoff (negative when the material is not masked: -1 opaque, - /// -0.5 blended, -2 hashed), y: normal scale, z: occlusion strength, - /// w: emissive strength. + /// -0.5 blended, -2 hashed; above one the cutoff plus one, drawn as + /// coverage — `P7`), y: normal scale, z: occlusion strength, w: emissive + /// strength. vec4 material2; /// x: exposure, y: active light count, z: index of the shadow-casting light. @@ -11719,7 +13285,9 @@ layout(std140) uniform FragInfo { vec4 ambient_ground; /// x, y, z: the depth bias of each cascade, in that cascade's own normalized - /// depth. w unused. + /// depth. w: one when the atlas carries what see-through casters let + /// through and this draw is to be shaded by it, nought otherwise — + /// `ShadowSettings.translucentCasters`; see `ShadowFactor`. /// /// `ShadowSettings.bias` is one number and a cascade's depth range is not: /// a near cascade is stretched towards the light when a caster stands @@ -11740,6 +13308,15 @@ layout(std140) uniform FragInfo { /// while a temporal resolve runs, minus one otherwise — `S3`, which steps /// the soft shadow's rotation by it. vec4 target_origin; + + /// x: which debug view replaces the light — `P6`, `DebugView.code`, nought + /// for none. y: where it starts, as a share of the target's width from the + /// left; nought is the whole frame. z: the target's width in pixels, which + /// turns the share into a column. w unused. + /// + /// Appended for the reason `ambient_sky` was: every offset above stays + /// where the four backends already agree on it. + vec4 debug_view; } frag_info; @@ -11830,8 +13407,25 @@ Surface ReadSurface() { // number in a block six shaders share, and -1 already meant "not masked"; // anything more negative was free. See [MaterialAlphaMode.hashed]. float cutoff = frag_info.material2.x; - if (cutoff >= 0.0) { + if (cutoff > 1.0) { + // **Coverage instead of a cut — `P7`.** One above the cutoff says the + // pass multisamples and turns this fragment's alpha into the share of + // samples it covers, so nothing is discarded: the alpha is sharpened to + // run from nought to one across about a pixel either side of the cutoff, + // and the resolve smooths the edge as it smooths a triangle's. Unsharpened, + // a texture's soft alpha would cover half the samples of every pixel it + // fades across and draw a screen door. Branched on a uniform, so the + // derivative is taken in uniform control flow, as WGSL requires. + float edge = cutoff - 1.0; + s.alpha = clamp((s.alpha - edge) / max(fwidth(s.alpha), 1e-4) + 0.5, + 0.0, 1.0); + } else if (cutoff >= 0.0) { if (s.alpha < cutoff) discard; + // What survives the cut is a surface, and opaque: the texture's alpha has + // done its work. Written as it was, it went into the frame's alpha, and + // whatever read the frame as premultiplied — a golden's capture — divided + // the colour by it and lit the inside of every leaf towards its rim. + s.alpha = 1.0; } else if (cutoff < -1.5) { // **Stochastic instead of a threshold.** A leaf texture at 40% opacity is // either entirely there or entirely gone under a fixed cutoff, so a fern @@ -11868,7 +13462,15 @@ Surface ReadSurface() { // double-sided material ever draws a back face, since everything else has // them culled. if (!gl_FrontFacing) s.n = -s.n; +#ifdef F3D_NO_FOG + // The stages without the fog block — shadows and the id pass — light + // nothing, and keep the eye's point. s.v = normalize(frag_info.camera_position.xyz - v_world_position); +#else + // `P7`: against the view axis through an orthographic lens, where the + // eye's point is only where the camera was put. + s.v = TowardsEye(); +#endif // Clamped away from zero: a grazing view direction otherwise divides by zero // in the specular visibility term. s.n_dot_v = max(dot(s.n, s.v), 1e-4); @@ -12349,6 +13951,15 @@ LightSample SampleLight(int index, Surface s) { /// returns `ShadowFactor(...)`; an unlit one returns 1. float LightVisibility(Surface s, LightSample light, int index); +/// What the see-through casters between the sun and this fragment let +/// through, per channel — `ShadowSettings.translucentCasters`. Set by +/// `ShadowFactor` for the light it shadows and reset to one before every +/// light, so a model that samples no shadow map, and every light but the +/// sun, leaves it white. A colour beside the visibility rather than folded +/// into it, because visibility is one number in every model and coloured +/// light is not. +vec3 light_transmittance = vec3(1.0); + /// A model's per-light term, defined by each fragment shader. /// /// A prototype here and the definition in the model is what lets the loop below @@ -12767,6 +14378,7 @@ vec3 AccumulateLights(Surface s) { if (i >= count) break; LightSample light = SampleLight(i, s); if (light.n_dot_l <= 0.0) continue; + light_transmittance = vec3(1.0); // A light from the list has no shadow row to read — see `LightHasShadow`. // A branch rather than something folded into the two calls, because both // index tables eight entries wide and the ninth light would read past them @@ -12776,12 +14388,73 @@ vec3 AccumulateLights(Surface s) { PointShadowFactor(v_world_position, s.n, i) : 1.0; if (visibility <= 0.0) continue; - total += ShadeLight(s, light) * light.radiance * light.n_dot_l * visibility; + total += ShadeLight(s, light) * light.radiance * light.n_dot_l * visibility * + light_transmittance; } return total; } +/// Whether any channel of [c] is NaN or infinite. +/// +/// **Comparisons, not `isnan` and not the bits.** WGSL has no `isNan`, and +/// reading the exponent through `floatBitsToUint` takes `impellerc` down in +/// its GLSL ES output, which has no bit casts. A NaN is the one value unequal +/// to itself, and an infinity the one above every finite float. +bool NonFinite(vec3 c) { + return any(notEqual(c, c)) || any(greaterThan(abs(c), vec3(3.0e38))); +} + +/// `P6`: the material channel `FragInfo.debug_view` asks for, written in +/// place of [lit]. False, and nothing written, when no view is on or the +/// fragment sits left of the split; the caller then writes the light. +/// +/// **Display values, through the same exits the light takes.** The channel +/// is what an artist would read off the texture — an albedo as its sRGB +/// colour, a roughness as a grey — converted to linear so the composite's +/// encode hands it back unchanged; the composite leaves this side of the +/// split out of the exposure and the tone curve (`CompositeInfo.lens.y`). +/// The surface buffer and the weighted-blended targets are written as +/// [WriteSurface] writes them, so a debug view changes what the frame shows +/// and nothing the passes after it read. No fog: a channel seen through fog +/// is not the channel. +/// +/// [lit] is read by one view only, [DebugView.nonFinite], which shows a NaN +/// or an infinity as magenta over the light's own luminance in grey. +bool WriteDebugView(Surface s, vec3 lit) { + float view = frag_info.debug_view.x; + if (view < 0.5) return false; + if (gl_FragCoord.x < frag_info.debug_view.y * frag_info.debug_view.z) { + return false; + } + int code = int(view + 0.5); + vec3 shown = vec3(0.0); + if (code == 1) { + shown = LinearToSrgb(clamp(s.albedo, vec3(0.0), vec3(1.0))); + } else if (code == 2) { + shown = s.n * 0.5 + vec3(0.5); + } else if (code == 3) { + shown = vec3(clamp(s.roughness, 0.0, 1.0)); + } else if (code == 4) { + shown = vec3(clamp(s.metallic, 0.0, 1.0)); + } else if (code == 5) { + shown = vec3(clamp(s.occlusion, 0.0, 1.0)); + } else if (code == 6) { + shown = LinearToSrgb(clamp(s.emissive, vec3(0.0), vec3(1.0))); + } else if (code == 7) { + shown = vec3(fract(MapUv(kMapBaseColor)), 0.0); + } else if (code == 8) { + float grey = dot(LinearToSrgb(clamp(lit, vec3(0.0), vec3(1.0))), + vec3(0.2126, 0.7152, 0.0722)); + shown = NonFinite(lit) ? vec3(1.0, 0.0, 1.0) : vec3(grey * 0.5); + } + float weight = g_premultiply ? s.alpha : 1.0; + frag_color = vec4(SrgbToLinear(shown) * weight, s.alpha); + WriteSurfaceGeometry(s.roughness); + WriteWeightedBlended(); + return true; +} + #endif // SURFACE_GLSL_ // --- lib/irradiance.glsl --- @@ -13246,7 +14919,16 @@ float ShadowFactor(Surface s, LightSample light, int lightIndex) { // whole class of missing-shadow bug, and the last cascade is fitted to the // entire scene, so the fall-through always terminates somewhere real. int cascadeCount = int(frag_info.shadow_cascades.z + 0.5); + // `P7`: by depth along the view axis through an orthographic lens, whose + // cascades are slabs of the view's box rather than spheres about the eye. +#ifdef F3D_NO_FOG float viewDistance = length(v_world_position - frag_info.camera_position.xyz); +#else + float viewDistance = + Orthographic() + ? ViewDepth() + : length(v_world_position - frag_info.camera_position.xyz); +#endif int cascade = 0; if (cascadeCount > 1 && viewDistance > frag_info.shadow_cascades.x) cascade = 1; if (cascadeCount > 2 && viewDistance > frag_info.shadow_cascades.y) cascade = 2; @@ -13387,6 +15069,19 @@ float ShadowFactor(Surface s, LightSample light, int lightIndex) { // kernel, and how far under minus one the value sits is the light-bleeding // cut. A sign rather than another uniform, for the reason the softness // itself rides here. + // **What the see-through casters let through** — before the filter, whose + // soft path leaves early where it finds no blocker. One bilinear tap: a + // translucent caster's shadow is light shaded rather than light stopped, + // and its edge is softened by the filtering the tap already gets. Green + // and blue hold what was taken from red and green, alpha what was left of + // blue — the layout `shadow_transmittance.frag` explains. + if (frag_info.shadow_bias.w > 0.5) { + vec4 stored = textureLod(shadow_texture, clamp(uv, tileLo, tileHi), 0.0); + // Up to four: light a caster gathered, not only light it stopped. + vec3 through = clamp(vec3(1.0 - stored.g, 1.0 - stored.b, stored.a), 0.0, 4.0); + light_transmittance = mix(vec3(1.0), through, clamp(strength, 0.0, 1.0)); + } + float softness = frag_info.ambient_ground.w; float lit = 0.0; if (softness < 0.0) { @@ -13513,7 +15208,8 @@ layout(std140) uniform LayerInfo { /// `KHR_materials_dispersion` — `M3`. vec4 transmission; - /// rgb: the volume's attenuation colour, linear. w: unused. + /// rgb: the volume's attenuation colour, linear. w: one when the volume + /// is a convex body — `MaterialExtensions.convexVolume`. vec4 attenuation; /// x: `KHR_materials_iridescence`, y: the film's index of refraction, z: @@ -13611,6 +15307,16 @@ vec3 g_transmittance = vec3(1.0); float g_iridescence = 0.0; vec3 g_irid_fresnel = vec3(0.04); +/// Whether this is a thin pane over whatever is behind it — `M3`: a blended +/// surface that transmits and has no volume. Light crosses a thin wall +/// without bending, so what is behind it is exactly what the target already +/// holds where it is drawn, and the blend lets that through +/// ([g_pass_through]) rather than the shader reading it from the copy. The +/// copy is taken before any transmissive draw, so read from it, the liquid +/// in a glass tube vanished behind the tube's own wall, which showed the +/// table where the liquid stood. +bool g_pane = false; + /// Whether the index is `KHR_materials_ior`'s nought: the value its /// specular-glossiness migration writes, which means an index of infinity — /// a Fresnel of one at every angle, and no dispersion. @@ -13657,6 +15363,15 @@ void ReadLayers(Surface s) { // `M3`: the coat map's other two lanes. g_transmission = clamp(layer_info.transmission.x * coatTexel.b, 0.0, 1.0); g_thickness = max(layer_info.transmission.y * coatTexel.a, 0.0); + // `MaterialExtensions.convexVolume`: the thickness is the body's depth + // through its middle, and a ray crosses as much of it as squarely as the + // bent ray meets the surface — Filament's solid sphere. Kept above nought, + // where nought means a thin wall. + if (layer_info.attenuation.w > 0.5 && g_thickness > 0.0) { + vec3 bent = refract(-s.v, s.n, 1.0 / RefractionIor()); + g_thickness = max(g_thickness * max(-dot(s.n, bent), 0.0), + 1e-4 * g_thickness); + } // Beer's law over the thickness: what is left of each colour after the // attenuation distance is the attenuation colour. float distance = layer_info.transmission.z; @@ -14189,7 +15904,9 @@ void main() { // `M3`: without an environment the light passing through is the flat // ambient too, less what the medium takes — unless the scene behind is // there to be read, when that share is the scene instead (below). - ambient *= mix(vec3(1.0), SceneColourBound() ? vec3(0.0) : g_transmittance, + g_pane = g_premultiply && g_transmission > 0.0 && g_thickness <= 0.0; + ambient *= mix(vec3(1.0), + SceneColourBound() || g_pane ? vec3(0.0) : g_transmittance, g_transmission); #endif @@ -14271,7 +15988,7 @@ void main() { // and the scene's added below. vec3 reflects = mix(g_f0_dielectric, g_irid_fresnel, g_iridescence) * ab.x + g_f90 * ab.y; - vec3 through = SceneColourBound() + vec3 through = SceneColourBound() || g_pane ? vec3(0.0) : TransmittedRadiance(s, levels) * g_transmittance * (vec3(1.0) - min(reflects, vec3(1.0))); @@ -14296,13 +16013,18 @@ void main() { // copy of it — less what the dielectric reflects and what the medium // takes, tinted by the base colour. Light already, so neither the ambient // strength nor the occlusion scales it. - if (SceneColourBound()) { - vec2 sceneAb = EnvBrdf(s.roughness, s.n_dot_v); - vec3 sceneReflects = - mix(g_f0_dielectric, g_irid_fresnel, g_iridescence) * sceneAb.x + - g_f90 * sceneAb.y; - ambient += diffuseColor * SceneBehind(s) * g_transmittance * - (vec3(1.0) - min(sceneReflects, vec3(1.0))) * g_transmission; + vec2 sceneAb = EnvBrdf(s.roughness, s.n_dot_v); + vec3 sceneReflects = + mix(g_f0_dielectric, g_irid_fresnel, g_iridescence) * sceneAb.x + + g_f90 * sceneAb.y; + vec3 passes = diffuseColor * g_transmittance * + (vec3(1.0) - min(sceneReflects, vec3(1.0))) * g_transmission; + if (g_pane) { + // One alpha for the three colours: a pane's tint is kept as its mean, + // which is what clear and faintly tinted glass needs. + g_pass_through = clamp((passes.r + passes.g + passes.b) / 3.0, 0.0, 1.0); + } else if (SceneColourBound()) { + ambient += SceneBehind(s) * passes; } #endif // The light the level's walls throw on each other, baked: diffuse only, @@ -14326,19 +16048,15 @@ void main() { // emission included — glTF's own layering. The direct light was scaled in // `ShadeLight`. vec3 sheenAmbient = g_sheen * g_sheen_albedo * sheenIncoming * s.occlusion; - WriteSurface( - AccumulateLights(s) * s.occlusion + - (ambient * g_sheen_scale + sheenAmbient + s.emissive) * - g_coat_through + - coatAmbient, - s.alpha, - s.roughness); + vec3 lit = AccumulateLights(s) * s.occlusion + + (ambient * g_sheen_scale + sheenAmbient + s.emissive) * + g_coat_through + + coatAmbient; #else - WriteSurface( - AccumulateLights(s) * s.occlusion + ambient + s.emissive, - s.alpha, - s.roughness); + vec3 lit = AccumulateLights(s) * s.occlusion + ambient + s.emissive; #endif + if (WriteDebugView(s, lit)) return; + WriteSurface(lit, s.alpha, s.roughness); } #endif // PBR_GLSL_ @@ -14534,6 +16252,13 @@ bool g_debug_surface_on = false; /// A global for the reason [g_debug_surface] is one. bool g_premultiply = false; +/// How much of what is already behind a blended surface comes through it — +/// a thin pane of glass, `M3`: what the blend multiplies the target by is +/// one minus the alpha, so the alpha written is the coverage less this share +/// of it, while the colour stays weighted by the coverage alone. Nought for +/// everything else, which writes what it always wrote. +float g_pass_through = 0.0; + // **A stage that needs none of this must be able to declare none of it.** On // Vulkan both stages' descriptors are merged into one set layout, and two // bindings with the same number in it is not a layout the specification @@ -14550,11 +16275,13 @@ bool g_premultiply = false; /// way to move offsets nobody expected to move. Three vec4s is a cheap price /// for not touching any of that. layout(std140) uniform FogInfo { - /// rgb: linear fog colour. w: density per metre, zero for no fog. + /// rgb: linear fog colour. w: density per metre at the eye, zero for no + /// fog. vec4 fog; /// xyz: camera position in world space. Duplicated from FragInfo so this - /// block stands alone; a vec3 is cheaper than a coupling. + /// block stands alone; a vec3 is cheaper than a coupling. w: how fast the + /// fog thins upwards, per metre — `P5`, see [ApplyFog]; nought is flat fog. vec4 eye; /// xyz: the direction the camera looks, as a unit vector in world space. @@ -14565,15 +16292,24 @@ layout(std140) uniform FogInfo { /// question [eye] does — where the camera is and which way it faces — and /// this is the block `color.glsl` can see. vec4 forward; + + /// x: one when the camera's projection is orthographic, nought when it is + /// perspective — `P7`, see [Orthographic]. y, z, w unused. Appended, so + /// every offset above stays where four backends agree on it. + vec4 projection; } fog_info; -/// How far this fragment is from the eye, in world metres. +/// Whether the camera is orthographic — `P7`. /// -/// What the fog fades by. Distance rather than depth, because fog is a -/// property of the air between two points and does not care which way the -/// camera happens to face. -float EyeDistance() { return distance(v_world_position, fog_info.eye.xyz); } +/// **What every eye-relative quantity has to ask first.** Through a +/// perspective lens the rays meet at the eye, so the way to the eye and the +/// distance to it are the fragment's own; through an orthographic one the +/// rays are parallel and the eye is only where the camera was put along its +/// axis, which moves nothing in the picture. Reading the eye's position as if +/// it were a point the light travels to slides highlights across the frame +/// as the camera pans and lays fog in rings round a point nobody sees. +bool Orthographic() { return fog_info.projection.x > 0.5; } /// How far this fragment is *along the view axis*, in world metres. /// @@ -14588,6 +16324,27 @@ float ViewDepth() { return dot(v_world_position - fog_info.eye.xyz, fog_info.forward.xyz); } +/// How far this fragment is from the eye, in world metres. +/// +/// What the fog fades by. Distance rather than depth, because fog is a +/// property of the air between two points and does not care which way the +/// camera happens to face — through a perspective lens. Through an +/// orthographic one every ray starts on the eye's plane, so the air a ray +/// crosses is its depth from that plane, never less than nought. +float EyeDistance() { + return Orthographic() ? max(ViewDepth(), 0.0) + : distance(v_world_position, fog_info.eye.xyz); +} + +/// The unit direction from this fragment back along the ray that reached +/// it: to the eye through a perspective lens, against the view axis through +/// an orthographic one — `P7`. What a highlight, a Fresnel term and a +/// reflection are measured from. +vec3 TowardsEye() { + return Orthographic() ? -fog_info.forward.xyz + : normalize(fog_info.eye.xyz - v_world_position); +} + #else // F3D_NO_FOG // The same two questions, answered without the block: a stage that declares no @@ -14680,6 +16437,16 @@ void WriteSurfaceGeometry(float roughness) { /// /// Exponential rather than linear, because linear fog has a visible plane /// where it starts and a dungeon corridor is exactly where that shows. +/// +/// **Height fog — `P5`.** `fog_info.fog.w` is the density at the eye and +/// `fog_info.eye.w` how fast it falls off upwards, per metre. The density +/// along the ray is then `density · e^(−falloff · Δy · t)` for t from nought +/// to one, and its mean over the ray is `(1 − e^(−k)) / k` with +/// `k = falloff · Δy`: an exact integral, so a fog lying on the ground costs +/// one exponential here and no march. Below a thousandth `k` is answered by +/// the first two terms of the same series, because the quotient there is +/// two nearly equal numbers divided by a small one. A falloff of nought skips +/// all of it, which keeps a flat fog the same to the bit. vec3 ApplyFog(vec3 color) { #ifdef F3D_NO_FOG return color; @@ -14687,6 +16454,11 @@ vec3 ApplyFog(vec3 color) { float density = fog_info.fog.w; if (density <= 0.0) return color; float d = EyeDistance(); + float falloff = fog_info.eye.w; + if (falloff > 0.0) { + float k = falloff * (v_world_position.y - fog_info.eye.y); + density *= abs(k) > 1e-3 ? (1.0 - exp(-k)) / k : 1.0 - 0.5 * k; + } return mix(fog_info.fog.rgb, color, clamp(exp(-density * d), 0.0, 1.0)); #endif } @@ -14741,7 +16513,8 @@ void WriteWeightedBlended() { /// which is exact, so an opaque draw writes what it always wrote. void WriteSurface(vec3 linearColor, float alpha, float roughness) { float weight = g_premultiply ? alpha : 1.0; - frag_color = vec4(ApplyFog(linearColor) * weight, alpha); + frag_color = vec4(ApplyFog(linearColor) * weight, + alpha * (1.0 - g_pass_through)); WriteSurfaceGeometry(roughness); WriteWeightedBlended(); } @@ -15009,8 +16782,9 @@ layout(std140) uniform FragInfo { vec4 material; /// x: alpha cutoff (negative when the material is not masked: -1 opaque, - /// -0.5 blended, -2 hashed), y: normal scale, z: occlusion strength, - /// w: emissive strength. + /// -0.5 blended, -2 hashed; above one the cutoff plus one, drawn as + /// coverage — `P7`), y: normal scale, z: occlusion strength, w: emissive + /// strength. vec4 material2; /// x: exposure, y: active light count, z: index of the shadow-casting light. @@ -15065,7 +16839,9 @@ layout(std140) uniform FragInfo { vec4 ambient_ground; /// x, y, z: the depth bias of each cascade, in that cascade's own normalized - /// depth. w unused. + /// depth. w: one when the atlas carries what see-through casters let + /// through and this draw is to be shaded by it, nought otherwise — + /// `ShadowSettings.translucentCasters`; see `ShadowFactor`. /// /// `ShadowSettings.bias` is one number and a cascade's depth range is not: /// a near cascade is stretched towards the light when a caster stands @@ -15086,6 +16862,15 @@ layout(std140) uniform FragInfo { /// while a temporal resolve runs, minus one otherwise — `S3`, which steps /// the soft shadow's rotation by it. vec4 target_origin; + + /// x: which debug view replaces the light — `P6`, `DebugView.code`, nought + /// for none. y: where it starts, as a share of the target's width from the + /// left; nought is the whole frame. z: the target's width in pixels, which + /// turns the share into a column. w unused. + /// + /// Appended for the reason `ambient_sky` was: every offset above stays + /// where the four backends already agree on it. + vec4 debug_view; } frag_info; @@ -15176,8 +16961,25 @@ Surface ReadSurface() { // number in a block six shaders share, and -1 already meant "not masked"; // anything more negative was free. See [MaterialAlphaMode.hashed]. float cutoff = frag_info.material2.x; - if (cutoff >= 0.0) { + if (cutoff > 1.0) { + // **Coverage instead of a cut — `P7`.** One above the cutoff says the + // pass multisamples and turns this fragment's alpha into the share of + // samples it covers, so nothing is discarded: the alpha is sharpened to + // run from nought to one across about a pixel either side of the cutoff, + // and the resolve smooths the edge as it smooths a triangle's. Unsharpened, + // a texture's soft alpha would cover half the samples of every pixel it + // fades across and draw a screen door. Branched on a uniform, so the + // derivative is taken in uniform control flow, as WGSL requires. + float edge = cutoff - 1.0; + s.alpha = clamp((s.alpha - edge) / max(fwidth(s.alpha), 1e-4) + 0.5, + 0.0, 1.0); + } else if (cutoff >= 0.0) { if (s.alpha < cutoff) discard; + // What survives the cut is a surface, and opaque: the texture's alpha has + // done its work. Written as it was, it went into the frame's alpha, and + // whatever read the frame as premultiplied — a golden's capture — divided + // the colour by it and lit the inside of every leaf towards its rim. + s.alpha = 1.0; } else if (cutoff < -1.5) { // **Stochastic instead of a threshold.** A leaf texture at 40% opacity is // either entirely there or entirely gone under a fixed cutoff, so a fern @@ -15214,7 +17016,15 @@ Surface ReadSurface() { // double-sided material ever draws a back face, since everything else has // them culled. if (!gl_FrontFacing) s.n = -s.n; +#ifdef F3D_NO_FOG + // The stages without the fog block — shadows and the id pass — light + // nothing, and keep the eye's point. s.v = normalize(frag_info.camera_position.xyz - v_world_position); +#else + // `P7`: against the view axis through an orthographic lens, where the + // eye's point is only where the camera was put. + s.v = TowardsEye(); +#endif // Clamped away from zero: a grazing view direction otherwise divides by zero // in the specular visibility term. s.n_dot_v = max(dot(s.n, s.v), 1e-4); @@ -15695,6 +17505,15 @@ LightSample SampleLight(int index, Surface s) { /// returns `ShadowFactor(...)`; an unlit one returns 1. float LightVisibility(Surface s, LightSample light, int index); +/// What the see-through casters between the sun and this fragment let +/// through, per channel — `ShadowSettings.translucentCasters`. Set by +/// `ShadowFactor` for the light it shadows and reset to one before every +/// light, so a model that samples no shadow map, and every light but the +/// sun, leaves it white. A colour beside the visibility rather than folded +/// into it, because visibility is one number in every model and coloured +/// light is not. +vec3 light_transmittance = vec3(1.0); + /// A model's per-light term, defined by each fragment shader. /// /// A prototype here and the definition in the model is what lets the loop below @@ -16113,6 +17932,7 @@ vec3 AccumulateLights(Surface s) { if (i >= count) break; LightSample light = SampleLight(i, s); if (light.n_dot_l <= 0.0) continue; + light_transmittance = vec3(1.0); // A light from the list has no shadow row to read — see `LightHasShadow`. // A branch rather than something folded into the two calls, because both // index tables eight entries wide and the ninth light would read past them @@ -16122,12 +17942,73 @@ vec3 AccumulateLights(Surface s) { PointShadowFactor(v_world_position, s.n, i) : 1.0; if (visibility <= 0.0) continue; - total += ShadeLight(s, light) * light.radiance * light.n_dot_l * visibility; + total += ShadeLight(s, light) * light.radiance * light.n_dot_l * visibility * + light_transmittance; } return total; } +/// Whether any channel of [c] is NaN or infinite. +/// +/// **Comparisons, not `isnan` and not the bits.** WGSL has no `isNan`, and +/// reading the exponent through `floatBitsToUint` takes `impellerc` down in +/// its GLSL ES output, which has no bit casts. A NaN is the one value unequal +/// to itself, and an infinity the one above every finite float. +bool NonFinite(vec3 c) { + return any(notEqual(c, c)) || any(greaterThan(abs(c), vec3(3.0e38))); +} + +/// `P6`: the material channel `FragInfo.debug_view` asks for, written in +/// place of [lit]. False, and nothing written, when no view is on or the +/// fragment sits left of the split; the caller then writes the light. +/// +/// **Display values, through the same exits the light takes.** The channel +/// is what an artist would read off the texture — an albedo as its sRGB +/// colour, a roughness as a grey — converted to linear so the composite's +/// encode hands it back unchanged; the composite leaves this side of the +/// split out of the exposure and the tone curve (`CompositeInfo.lens.y`). +/// The surface buffer and the weighted-blended targets are written as +/// [WriteSurface] writes them, so a debug view changes what the frame shows +/// and nothing the passes after it read. No fog: a channel seen through fog +/// is not the channel. +/// +/// [lit] is read by one view only, [DebugView.nonFinite], which shows a NaN +/// or an infinity as magenta over the light's own luminance in grey. +bool WriteDebugView(Surface s, vec3 lit) { + float view = frag_info.debug_view.x; + if (view < 0.5) return false; + if (gl_FragCoord.x < frag_info.debug_view.y * frag_info.debug_view.z) { + return false; + } + int code = int(view + 0.5); + vec3 shown = vec3(0.0); + if (code == 1) { + shown = LinearToSrgb(clamp(s.albedo, vec3(0.0), vec3(1.0))); + } else if (code == 2) { + shown = s.n * 0.5 + vec3(0.5); + } else if (code == 3) { + shown = vec3(clamp(s.roughness, 0.0, 1.0)); + } else if (code == 4) { + shown = vec3(clamp(s.metallic, 0.0, 1.0)); + } else if (code == 5) { + shown = vec3(clamp(s.occlusion, 0.0, 1.0)); + } else if (code == 6) { + shown = LinearToSrgb(clamp(s.emissive, vec3(0.0), vec3(1.0))); + } else if (code == 7) { + shown = vec3(fract(MapUv(kMapBaseColor)), 0.0); + } else if (code == 8) { + float grey = dot(LinearToSrgb(clamp(lit, vec3(0.0), vec3(1.0))), + vec3(0.2126, 0.7152, 0.0722)); + shown = NonFinite(lit) ? vec3(1.0, 0.0, 1.0) : vec3(grey * 0.5); + } + float weight = g_premultiply ? s.alpha : 1.0; + frag_color = vec4(SrgbToLinear(shown) * weight, s.alpha); + WriteSurfaceGeometry(s.roughness); + WriteWeightedBlended(); + return true; +} + #endif // SURFACE_GLSL_ // --- lib/irradiance.glsl --- @@ -16592,7 +18473,16 @@ float ShadowFactor(Surface s, LightSample light, int lightIndex) { // whole class of missing-shadow bug, and the last cascade is fitted to the // entire scene, so the fall-through always terminates somewhere real. int cascadeCount = int(frag_info.shadow_cascades.z + 0.5); + // `P7`: by depth along the view axis through an orthographic lens, whose + // cascades are slabs of the view's box rather than spheres about the eye. +#ifdef F3D_NO_FOG float viewDistance = length(v_world_position - frag_info.camera_position.xyz); +#else + float viewDistance = + Orthographic() + ? ViewDepth() + : length(v_world_position - frag_info.camera_position.xyz); +#endif int cascade = 0; if (cascadeCount > 1 && viewDistance > frag_info.shadow_cascades.x) cascade = 1; if (cascadeCount > 2 && viewDistance > frag_info.shadow_cascades.y) cascade = 2; @@ -16733,6 +18623,19 @@ float ShadowFactor(Surface s, LightSample light, int lightIndex) { // kernel, and how far under minus one the value sits is the light-bleeding // cut. A sign rather than another uniform, for the reason the softness // itself rides here. + // **What the see-through casters let through** — before the filter, whose + // soft path leaves early where it finds no blocker. One bilinear tap: a + // translucent caster's shadow is light shaded rather than light stopped, + // and its edge is softened by the filtering the tap already gets. Green + // and blue hold what was taken from red and green, alpha what was left of + // blue — the layout `shadow_transmittance.frag` explains. + if (frag_info.shadow_bias.w > 0.5) { + vec4 stored = textureLod(shadow_texture, clamp(uv, tileLo, tileHi), 0.0); + // Up to four: light a caster gathered, not only light it stopped. + vec3 through = clamp(vec3(1.0 - stored.g, 1.0 - stored.b, stored.a), 0.0, 4.0); + light_transmittance = mix(vec3(1.0), through, clamp(strength, 0.0, 1.0)); + } + float softness = frag_info.ambient_ground.w; float lit = 0.0; if (softness < 0.0) { @@ -16859,7 +18762,8 @@ layout(std140) uniform LayerInfo { /// `KHR_materials_dispersion` — `M3`. vec4 transmission; - /// rgb: the volume's attenuation colour, linear. w: unused. + /// rgb: the volume's attenuation colour, linear. w: one when the volume + /// is a convex body — `MaterialExtensions.convexVolume`. vec4 attenuation; /// x: `KHR_materials_iridescence`, y: the film's index of refraction, z: @@ -16957,6 +18861,16 @@ vec3 g_transmittance = vec3(1.0); float g_iridescence = 0.0; vec3 g_irid_fresnel = vec3(0.04); +/// Whether this is a thin pane over whatever is behind it — `M3`: a blended +/// surface that transmits and has no volume. Light crosses a thin wall +/// without bending, so what is behind it is exactly what the target already +/// holds where it is drawn, and the blend lets that through +/// ([g_pass_through]) rather than the shader reading it from the copy. The +/// copy is taken before any transmissive draw, so read from it, the liquid +/// in a glass tube vanished behind the tube's own wall, which showed the +/// table where the liquid stood. +bool g_pane = false; + /// Whether the index is `KHR_materials_ior`'s nought: the value its /// specular-glossiness migration writes, which means an index of infinity — /// a Fresnel of one at every angle, and no dispersion. @@ -17003,6 +18917,15 @@ void ReadLayers(Surface s) { // `M3`: the coat map's other two lanes. g_transmission = clamp(layer_info.transmission.x * coatTexel.b, 0.0, 1.0); g_thickness = max(layer_info.transmission.y * coatTexel.a, 0.0); + // `MaterialExtensions.convexVolume`: the thickness is the body's depth + // through its middle, and a ray crosses as much of it as squarely as the + // bent ray meets the surface — Filament's solid sphere. Kept above nought, + // where nought means a thin wall. + if (layer_info.attenuation.w > 0.5 && g_thickness > 0.0) { + vec3 bent = refract(-s.v, s.n, 1.0 / RefractionIor()); + g_thickness = max(g_thickness * max(-dot(s.n, bent), 0.0), + 1e-4 * g_thickness); + } // Beer's law over the thickness: what is left of each colour after the // attenuation distance is the attenuation colour. float distance = layer_info.transmission.z; @@ -17535,7 +19458,9 @@ void main() { // `M3`: without an environment the light passing through is the flat // ambient too, less what the medium takes — unless the scene behind is // there to be read, when that share is the scene instead (below). - ambient *= mix(vec3(1.0), SceneColourBound() ? vec3(0.0) : g_transmittance, + g_pane = g_premultiply && g_transmission > 0.0 && g_thickness <= 0.0; + ambient *= mix(vec3(1.0), + SceneColourBound() || g_pane ? vec3(0.0) : g_transmittance, g_transmission); #endif @@ -17617,7 +19542,7 @@ void main() { // and the scene's added below. vec3 reflects = mix(g_f0_dielectric, g_irid_fresnel, g_iridescence) * ab.x + g_f90 * ab.y; - vec3 through = SceneColourBound() + vec3 through = SceneColourBound() || g_pane ? vec3(0.0) : TransmittedRadiance(s, levels) * g_transmittance * (vec3(1.0) - min(reflects, vec3(1.0))); @@ -17642,13 +19567,18 @@ void main() { // copy of it — less what the dielectric reflects and what the medium // takes, tinted by the base colour. Light already, so neither the ambient // strength nor the occlusion scales it. - if (SceneColourBound()) { - vec2 sceneAb = EnvBrdf(s.roughness, s.n_dot_v); - vec3 sceneReflects = - mix(g_f0_dielectric, g_irid_fresnel, g_iridescence) * sceneAb.x + - g_f90 * sceneAb.y; - ambient += diffuseColor * SceneBehind(s) * g_transmittance * - (vec3(1.0) - min(sceneReflects, vec3(1.0))) * g_transmission; + vec2 sceneAb = EnvBrdf(s.roughness, s.n_dot_v); + vec3 sceneReflects = + mix(g_f0_dielectric, g_irid_fresnel, g_iridescence) * sceneAb.x + + g_f90 * sceneAb.y; + vec3 passes = diffuseColor * g_transmittance * + (vec3(1.0) - min(sceneReflects, vec3(1.0))) * g_transmission; + if (g_pane) { + // One alpha for the three colours: a pane's tint is kept as its mean, + // which is what clear and faintly tinted glass needs. + g_pass_through = clamp((passes.r + passes.g + passes.b) / 3.0, 0.0, 1.0); + } else if (SceneColourBound()) { + ambient += SceneBehind(s) * passes; } #endif // The light the level's walls throw on each other, baked: diffuse only, @@ -17672,19 +19602,15 @@ void main() { // emission included — glTF's own layering. The direct light was scaled in // `ShadeLight`. vec3 sheenAmbient = g_sheen * g_sheen_albedo * sheenIncoming * s.occlusion; - WriteSurface( - AccumulateLights(s) * s.occlusion + - (ambient * g_sheen_scale + sheenAmbient + s.emissive) * - g_coat_through + - coatAmbient, - s.alpha, - s.roughness); + vec3 lit = AccumulateLights(s) * s.occlusion + + (ambient * g_sheen_scale + sheenAmbient + s.emissive) * + g_coat_through + + coatAmbient; #else - WriteSurface( - AccumulateLights(s) * s.occlusion + ambient + s.emissive, - s.alpha, - s.roughness); + vec3 lit = AccumulateLights(s) * s.occlusion + ambient + s.emissive; #endif + if (WriteDebugView(s, lit)) return; + WriteSurface(lit, s.alpha, s.roughness); } #endif // PBR_GLSL_ @@ -17848,6 +19774,13 @@ bool g_debug_surface_on = false; /// A global for the reason [g_debug_surface] is one. bool g_premultiply = false; +/// How much of what is already behind a blended surface comes through it — +/// a thin pane of glass, `M3`: what the blend multiplies the target by is +/// one minus the alpha, so the alpha written is the coverage less this share +/// of it, while the colour stays weighted by the coverage alone. Nought for +/// everything else, which writes what it always wrote. +float g_pass_through = 0.0; + // **A stage that needs none of this must be able to declare none of it.** On // Vulkan both stages' descriptors are merged into one set layout, and two // bindings with the same number in it is not a layout the specification @@ -17864,11 +19797,13 @@ bool g_premultiply = false; /// way to move offsets nobody expected to move. Three vec4s is a cheap price /// for not touching any of that. layout(std140) uniform FogInfo { - /// rgb: linear fog colour. w: density per metre, zero for no fog. + /// rgb: linear fog colour. w: density per metre at the eye, zero for no + /// fog. vec4 fog; /// xyz: camera position in world space. Duplicated from FragInfo so this - /// block stands alone; a vec3 is cheaper than a coupling. + /// block stands alone; a vec3 is cheaper than a coupling. w: how fast the + /// fog thins upwards, per metre — `P5`, see [ApplyFog]; nought is flat fog. vec4 eye; /// xyz: the direction the camera looks, as a unit vector in world space. @@ -17879,15 +19814,24 @@ layout(std140) uniform FogInfo { /// question [eye] does — where the camera is and which way it faces — and /// this is the block `color.glsl` can see. vec4 forward; + + /// x: one when the camera's projection is orthographic, nought when it is + /// perspective — `P7`, see [Orthographic]. y, z, w unused. Appended, so + /// every offset above stays where four backends agree on it. + vec4 projection; } fog_info; -/// How far this fragment is from the eye, in world metres. +/// Whether the camera is orthographic — `P7`. /// -/// What the fog fades by. Distance rather than depth, because fog is a -/// property of the air between two points and does not care which way the -/// camera happens to face. -float EyeDistance() { return distance(v_world_position, fog_info.eye.xyz); } +/// **What every eye-relative quantity has to ask first.** Through a +/// perspective lens the rays meet at the eye, so the way to the eye and the +/// distance to it are the fragment's own; through an orthographic one the +/// rays are parallel and the eye is only where the camera was put along its +/// axis, which moves nothing in the picture. Reading the eye's position as if +/// it were a point the light travels to slides highlights across the frame +/// as the camera pans and lays fog in rings round a point nobody sees. +bool Orthographic() { return fog_info.projection.x > 0.5; } /// How far this fragment is *along the view axis*, in world metres. /// @@ -17902,6 +19846,27 @@ float ViewDepth() { return dot(v_world_position - fog_info.eye.xyz, fog_info.forward.xyz); } +/// How far this fragment is from the eye, in world metres. +/// +/// What the fog fades by. Distance rather than depth, because fog is a +/// property of the air between two points and does not care which way the +/// camera happens to face — through a perspective lens. Through an +/// orthographic one every ray starts on the eye's plane, so the air a ray +/// crosses is its depth from that plane, never less than nought. +float EyeDistance() { + return Orthographic() ? max(ViewDepth(), 0.0) + : distance(v_world_position, fog_info.eye.xyz); +} + +/// The unit direction from this fragment back along the ray that reached +/// it: to the eye through a perspective lens, against the view axis through +/// an orthographic one — `P7`. What a highlight, a Fresnel term and a +/// reflection are measured from. +vec3 TowardsEye() { + return Orthographic() ? -fog_info.forward.xyz + : normalize(fog_info.eye.xyz - v_world_position); +} + #else // F3D_NO_FOG // The same two questions, answered without the block: a stage that declares no @@ -17994,6 +19959,16 @@ void WriteSurfaceGeometry(float roughness) { /// /// Exponential rather than linear, because linear fog has a visible plane /// where it starts and a dungeon corridor is exactly where that shows. +/// +/// **Height fog — `P5`.** `fog_info.fog.w` is the density at the eye and +/// `fog_info.eye.w` how fast it falls off upwards, per metre. The density +/// along the ray is then `density · e^(−falloff · Δy · t)` for t from nought +/// to one, and its mean over the ray is `(1 − e^(−k)) / k` with +/// `k = falloff · Δy`: an exact integral, so a fog lying on the ground costs +/// one exponential here and no march. Below a thousandth `k` is answered by +/// the first two terms of the same series, because the quotient there is +/// two nearly equal numbers divided by a small one. A falloff of nought skips +/// all of it, which keeps a flat fog the same to the bit. vec3 ApplyFog(vec3 color) { #ifdef F3D_NO_FOG return color; @@ -18001,6 +19976,11 @@ vec3 ApplyFog(vec3 color) { float density = fog_info.fog.w; if (density <= 0.0) return color; float d = EyeDistance(); + float falloff = fog_info.eye.w; + if (falloff > 0.0) { + float k = falloff * (v_world_position.y - fog_info.eye.y); + density *= abs(k) > 1e-3 ? (1.0 - exp(-k)) / k : 1.0 - 0.5 * k; + } return mix(fog_info.fog.rgb, color, clamp(exp(-density * d), 0.0, 1.0)); #endif } @@ -18055,7 +20035,8 @@ void WriteWeightedBlended() { /// which is exact, so an opaque draw writes what it always wrote. void WriteSurface(vec3 linearColor, float alpha, float roughness) { float weight = g_premultiply ? alpha : 1.0; - frag_color = vec4(ApplyFog(linearColor) * weight, alpha); + frag_color = vec4(ApplyFog(linearColor) * weight, + alpha * (1.0 - g_pass_through)); WriteSurfaceGeometry(roughness); WriteWeightedBlended(); } @@ -18323,8 +20304,9 @@ layout(std140) uniform FragInfo { vec4 material; /// x: alpha cutoff (negative when the material is not masked: -1 opaque, - /// -0.5 blended, -2 hashed), y: normal scale, z: occlusion strength, - /// w: emissive strength. + /// -0.5 blended, -2 hashed; above one the cutoff plus one, drawn as + /// coverage — `P7`), y: normal scale, z: occlusion strength, w: emissive + /// strength. vec4 material2; /// x: exposure, y: active light count, z: index of the shadow-casting light. @@ -18379,7 +20361,9 @@ layout(std140) uniform FragInfo { vec4 ambient_ground; /// x, y, z: the depth bias of each cascade, in that cascade's own normalized - /// depth. w unused. + /// depth. w: one when the atlas carries what see-through casters let + /// through and this draw is to be shaded by it, nought otherwise — + /// `ShadowSettings.translucentCasters`; see `ShadowFactor`. /// /// `ShadowSettings.bias` is one number and a cascade's depth range is not: /// a near cascade is stretched towards the light when a caster stands @@ -18400,6 +20384,15 @@ layout(std140) uniform FragInfo { /// while a temporal resolve runs, minus one otherwise — `S3`, which steps /// the soft shadow's rotation by it. vec4 target_origin; + + /// x: which debug view replaces the light — `P6`, `DebugView.code`, nought + /// for none. y: where it starts, as a share of the target's width from the + /// left; nought is the whole frame. z: the target's width in pixels, which + /// turns the share into a column. w unused. + /// + /// Appended for the reason `ambient_sky` was: every offset above stays + /// where the four backends already agree on it. + vec4 debug_view; } frag_info; @@ -18490,8 +20483,25 @@ Surface ReadSurface() { // number in a block six shaders share, and -1 already meant "not masked"; // anything more negative was free. See [MaterialAlphaMode.hashed]. float cutoff = frag_info.material2.x; - if (cutoff >= 0.0) { + if (cutoff > 1.0) { + // **Coverage instead of a cut — `P7`.** One above the cutoff says the + // pass multisamples and turns this fragment's alpha into the share of + // samples it covers, so nothing is discarded: the alpha is sharpened to + // run from nought to one across about a pixel either side of the cutoff, + // and the resolve smooths the edge as it smooths a triangle's. Unsharpened, + // a texture's soft alpha would cover half the samples of every pixel it + // fades across and draw a screen door. Branched on a uniform, so the + // derivative is taken in uniform control flow, as WGSL requires. + float edge = cutoff - 1.0; + s.alpha = clamp((s.alpha - edge) / max(fwidth(s.alpha), 1e-4) + 0.5, + 0.0, 1.0); + } else if (cutoff >= 0.0) { if (s.alpha < cutoff) discard; + // What survives the cut is a surface, and opaque: the texture's alpha has + // done its work. Written as it was, it went into the frame's alpha, and + // whatever read the frame as premultiplied — a golden's capture — divided + // the colour by it and lit the inside of every leaf towards its rim. + s.alpha = 1.0; } else if (cutoff < -1.5) { // **Stochastic instead of a threshold.** A leaf texture at 40% opacity is // either entirely there or entirely gone under a fixed cutoff, so a fern @@ -18528,7 +20538,15 @@ Surface ReadSurface() { // double-sided material ever draws a back face, since everything else has // them culled. if (!gl_FrontFacing) s.n = -s.n; +#ifdef F3D_NO_FOG + // The stages without the fog block — shadows and the id pass — light + // nothing, and keep the eye's point. s.v = normalize(frag_info.camera_position.xyz - v_world_position); +#else + // `P7`: against the view axis through an orthographic lens, where the + // eye's point is only where the camera was put. + s.v = TowardsEye(); +#endif // Clamped away from zero: a grazing view direction otherwise divides by zero // in the specular visibility term. s.n_dot_v = max(dot(s.n, s.v), 1e-4); @@ -19009,6 +21027,15 @@ LightSample SampleLight(int index, Surface s) { /// returns `ShadowFactor(...)`; an unlit one returns 1. float LightVisibility(Surface s, LightSample light, int index); +/// What the see-through casters between the sun and this fragment let +/// through, per channel — `ShadowSettings.translucentCasters`. Set by +/// `ShadowFactor` for the light it shadows and reset to one before every +/// light, so a model that samples no shadow map, and every light but the +/// sun, leaves it white. A colour beside the visibility rather than folded +/// into it, because visibility is one number in every model and coloured +/// light is not. +vec3 light_transmittance = vec3(1.0); + /// A model's per-light term, defined by each fragment shader. /// /// A prototype here and the definition in the model is what lets the loop below @@ -19427,6 +21454,7 @@ vec3 AccumulateLights(Surface s) { if (i >= count) break; LightSample light = SampleLight(i, s); if (light.n_dot_l <= 0.0) continue; + light_transmittance = vec3(1.0); // A light from the list has no shadow row to read — see `LightHasShadow`. // A branch rather than something folded into the two calls, because both // index tables eight entries wide and the ninth light would read past them @@ -19436,12 +21464,73 @@ vec3 AccumulateLights(Surface s) { PointShadowFactor(v_world_position, s.n, i) : 1.0; if (visibility <= 0.0) continue; - total += ShadeLight(s, light) * light.radiance * light.n_dot_l * visibility; + total += ShadeLight(s, light) * light.radiance * light.n_dot_l * visibility * + light_transmittance; } return total; } +/// Whether any channel of [c] is NaN or infinite. +/// +/// **Comparisons, not `isnan` and not the bits.** WGSL has no `isNan`, and +/// reading the exponent through `floatBitsToUint` takes `impellerc` down in +/// its GLSL ES output, which has no bit casts. A NaN is the one value unequal +/// to itself, and an infinity the one above every finite float. +bool NonFinite(vec3 c) { + return any(notEqual(c, c)) || any(greaterThan(abs(c), vec3(3.0e38))); +} + +/// `P6`: the material channel `FragInfo.debug_view` asks for, written in +/// place of [lit]. False, and nothing written, when no view is on or the +/// fragment sits left of the split; the caller then writes the light. +/// +/// **Display values, through the same exits the light takes.** The channel +/// is what an artist would read off the texture — an albedo as its sRGB +/// colour, a roughness as a grey — converted to linear so the composite's +/// encode hands it back unchanged; the composite leaves this side of the +/// split out of the exposure and the tone curve (`CompositeInfo.lens.y`). +/// The surface buffer and the weighted-blended targets are written as +/// [WriteSurface] writes them, so a debug view changes what the frame shows +/// and nothing the passes after it read. No fog: a channel seen through fog +/// is not the channel. +/// +/// [lit] is read by one view only, [DebugView.nonFinite], which shows a NaN +/// or an infinity as magenta over the light's own luminance in grey. +bool WriteDebugView(Surface s, vec3 lit) { + float view = frag_info.debug_view.x; + if (view < 0.5) return false; + if (gl_FragCoord.x < frag_info.debug_view.y * frag_info.debug_view.z) { + return false; + } + int code = int(view + 0.5); + vec3 shown = vec3(0.0); + if (code == 1) { + shown = LinearToSrgb(clamp(s.albedo, vec3(0.0), vec3(1.0))); + } else if (code == 2) { + shown = s.n * 0.5 + vec3(0.5); + } else if (code == 3) { + shown = vec3(clamp(s.roughness, 0.0, 1.0)); + } else if (code == 4) { + shown = vec3(clamp(s.metallic, 0.0, 1.0)); + } else if (code == 5) { + shown = vec3(clamp(s.occlusion, 0.0, 1.0)); + } else if (code == 6) { + shown = LinearToSrgb(clamp(s.emissive, vec3(0.0), vec3(1.0))); + } else if (code == 7) { + shown = vec3(fract(MapUv(kMapBaseColor)), 0.0); + } else if (code == 8) { + float grey = dot(LinearToSrgb(clamp(lit, vec3(0.0), vec3(1.0))), + vec3(0.2126, 0.7152, 0.0722)); + shown = NonFinite(lit) ? vec3(1.0, 0.0, 1.0) : vec3(grey * 0.5); + } + float weight = g_premultiply ? s.alpha : 1.0; + frag_color = vec4(SrgbToLinear(shown) * weight, s.alpha); + WriteSurfaceGeometry(s.roughness); + WriteWeightedBlended(); + return true; +} + #endif // SURFACE_GLSL_ // --- lib/irradiance.glsl --- @@ -19906,7 +21995,16 @@ float ShadowFactor(Surface s, LightSample light, int lightIndex) { // whole class of missing-shadow bug, and the last cascade is fitted to the // entire scene, so the fall-through always terminates somewhere real. int cascadeCount = int(frag_info.shadow_cascades.z + 0.5); + // `P7`: by depth along the view axis through an orthographic lens, whose + // cascades are slabs of the view's box rather than spheres about the eye. +#ifdef F3D_NO_FOG float viewDistance = length(v_world_position - frag_info.camera_position.xyz); +#else + float viewDistance = + Orthographic() + ? ViewDepth() + : length(v_world_position - frag_info.camera_position.xyz); +#endif int cascade = 0; if (cascadeCount > 1 && viewDistance > frag_info.shadow_cascades.x) cascade = 1; if (cascadeCount > 2 && viewDistance > frag_info.shadow_cascades.y) cascade = 2; @@ -20047,6 +22145,19 @@ float ShadowFactor(Surface s, LightSample light, int lightIndex) { // kernel, and how far under minus one the value sits is the light-bleeding // cut. A sign rather than another uniform, for the reason the softness // itself rides here. + // **What the see-through casters let through** — before the filter, whose + // soft path leaves early where it finds no blocker. One bilinear tap: a + // translucent caster's shadow is light shaded rather than light stopped, + // and its edge is softened by the filtering the tap already gets. Green + // and blue hold what was taken from red and green, alpha what was left of + // blue — the layout `shadow_transmittance.frag` explains. + if (frag_info.shadow_bias.w > 0.5) { + vec4 stored = textureLod(shadow_texture, clamp(uv, tileLo, tileHi), 0.0); + // Up to four: light a caster gathered, not only light it stopped. + vec3 through = clamp(vec3(1.0 - stored.g, 1.0 - stored.b, stored.a), 0.0, 4.0); + light_transmittance = mix(vec3(1.0), through, clamp(strength, 0.0, 1.0)); + } + float softness = frag_info.ambient_ground.w; float lit = 0.0; if (softness < 0.0) { @@ -20181,11 +22292,10 @@ void main() { float rim = pow(1.0 - s.n_dot_v, 3.0) * frag_info.material.w; vec3 ambient = s.albedo * (s.ambient + SampleLightmap()) * s.occlusion; - WriteSurface( - AccumulateLights(s) * s.occlusion + ambient + vec3(rim * 0.35) + - s.emissive, - s.alpha, - s.roughness); + vec3 lit = AccumulateLights(s) * s.occlusion + ambient + vec3(rim * 0.35) + + s.emissive; + if (WriteDebugView(s, lit)) return; + WriteSurface(lit, s.alpha, s.roughness); } ''', @@ -20310,6 +22420,13 @@ bool g_debug_surface_on = false; /// A global for the reason [g_debug_surface] is one. bool g_premultiply = false; +/// How much of what is already behind a blended surface comes through it — +/// a thin pane of glass, `M3`: what the blend multiplies the target by is +/// one minus the alpha, so the alpha written is the coverage less this share +/// of it, while the colour stays weighted by the coverage alone. Nought for +/// everything else, which writes what it always wrote. +float g_pass_through = 0.0; + // **A stage that needs none of this must be able to declare none of it.** On // Vulkan both stages' descriptors are merged into one set layout, and two // bindings with the same number in it is not a layout the specification @@ -20326,11 +22443,13 @@ bool g_premultiply = false; /// way to move offsets nobody expected to move. Three vec4s is a cheap price /// for not touching any of that. layout(std140) uniform FogInfo { - /// rgb: linear fog colour. w: density per metre, zero for no fog. + /// rgb: linear fog colour. w: density per metre at the eye, zero for no + /// fog. vec4 fog; /// xyz: camera position in world space. Duplicated from FragInfo so this - /// block stands alone; a vec3 is cheaper than a coupling. + /// block stands alone; a vec3 is cheaper than a coupling. w: how fast the + /// fog thins upwards, per metre — `P5`, see [ApplyFog]; nought is flat fog. vec4 eye; /// xyz: the direction the camera looks, as a unit vector in world space. @@ -20341,15 +22460,24 @@ layout(std140) uniform FogInfo { /// question [eye] does — where the camera is and which way it faces — and /// this is the block `color.glsl` can see. vec4 forward; + + /// x: one when the camera's projection is orthographic, nought when it is + /// perspective — `P7`, see [Orthographic]. y, z, w unused. Appended, so + /// every offset above stays where four backends agree on it. + vec4 projection; } fog_info; -/// How far this fragment is from the eye, in world metres. +/// Whether the camera is orthographic — `P7`. /// -/// What the fog fades by. Distance rather than depth, because fog is a -/// property of the air between two points and does not care which way the -/// camera happens to face. -float EyeDistance() { return distance(v_world_position, fog_info.eye.xyz); } +/// **What every eye-relative quantity has to ask first.** Through a +/// perspective lens the rays meet at the eye, so the way to the eye and the +/// distance to it are the fragment's own; through an orthographic one the +/// rays are parallel and the eye is only where the camera was put along its +/// axis, which moves nothing in the picture. Reading the eye's position as if +/// it were a point the light travels to slides highlights across the frame +/// as the camera pans and lays fog in rings round a point nobody sees. +bool Orthographic() { return fog_info.projection.x > 0.5; } /// How far this fragment is *along the view axis*, in world metres. /// @@ -20364,6 +22492,27 @@ float ViewDepth() { return dot(v_world_position - fog_info.eye.xyz, fog_info.forward.xyz); } +/// How far this fragment is from the eye, in world metres. +/// +/// What the fog fades by. Distance rather than depth, because fog is a +/// property of the air between two points and does not care which way the +/// camera happens to face — through a perspective lens. Through an +/// orthographic one every ray starts on the eye's plane, so the air a ray +/// crosses is its depth from that plane, never less than nought. +float EyeDistance() { + return Orthographic() ? max(ViewDepth(), 0.0) + : distance(v_world_position, fog_info.eye.xyz); +} + +/// The unit direction from this fragment back along the ray that reached +/// it: to the eye through a perspective lens, against the view axis through +/// an orthographic one — `P7`. What a highlight, a Fresnel term and a +/// reflection are measured from. +vec3 TowardsEye() { + return Orthographic() ? -fog_info.forward.xyz + : normalize(fog_info.eye.xyz - v_world_position); +} + #else // F3D_NO_FOG // The same two questions, answered without the block: a stage that declares no @@ -20456,6 +22605,16 @@ void WriteSurfaceGeometry(float roughness) { /// /// Exponential rather than linear, because linear fog has a visible plane /// where it starts and a dungeon corridor is exactly where that shows. +/// +/// **Height fog — `P5`.** `fog_info.fog.w` is the density at the eye and +/// `fog_info.eye.w` how fast it falls off upwards, per metre. The density +/// along the ray is then `density · e^(−falloff · Δy · t)` for t from nought +/// to one, and its mean over the ray is `(1 − e^(−k)) / k` with +/// `k = falloff · Δy`: an exact integral, so a fog lying on the ground costs +/// one exponential here and no march. Below a thousandth `k` is answered by +/// the first two terms of the same series, because the quotient there is +/// two nearly equal numbers divided by a small one. A falloff of nought skips +/// all of it, which keeps a flat fog the same to the bit. vec3 ApplyFog(vec3 color) { #ifdef F3D_NO_FOG return color; @@ -20463,6 +22622,11 @@ vec3 ApplyFog(vec3 color) { float density = fog_info.fog.w; if (density <= 0.0) return color; float d = EyeDistance(); + float falloff = fog_info.eye.w; + if (falloff > 0.0) { + float k = falloff * (v_world_position.y - fog_info.eye.y); + density *= abs(k) > 1e-3 ? (1.0 - exp(-k)) / k : 1.0 - 0.5 * k; + } return mix(fog_info.fog.rgb, color, clamp(exp(-density * d), 0.0, 1.0)); #endif } @@ -20517,7 +22681,8 @@ void WriteWeightedBlended() { /// which is exact, so an opaque draw writes what it always wrote. void WriteSurface(vec3 linearColor, float alpha, float roughness) { float weight = g_premultiply ? alpha : 1.0; - frag_color = vec4(ApplyFog(linearColor) * weight, alpha); + frag_color = vec4(ApplyFog(linearColor) * weight, + alpha * (1.0 - g_pass_through)); WriteSurfaceGeometry(roughness); WriteWeightedBlended(); } @@ -20960,9 +23125,35 @@ layout(std140) uniform CompositeInfo { /// offset above unchanged and the four backends do not have to agree about /// anything they had not already agreed on. vec4 contact; + + /// x: radial lens distortion — `P2`, barrel above nought, pincushion + /// below, nought off exactly. y: one while a debug view is on — `P6`, + /// nought otherwise. z: where the debug view starts, as a share of the + /// width from the left. w: unclaimed. Appended for the reason `contact` + /// was. + vec4 lens; } composite_info; +/// Where the lens bends [at] to: radially, by `1 + k·r²` on the frame's own +/// aspect, scaled so the frame's border stays on the frame — `P2`. +/// +/// **Normalised on the border, not on the corner alone.** A barrel (k above +/// nought) magnifies the middle and pulls everything else inwards, so the +/// corners are what reach furthest and are held there. A pincushion does the +/// opposite, and normalising it on the corners would send the middles of the +/// edges past the frame and stretch the last row of texels across them; it is +/// held at the edge nearest the middle instead, and the corners come in. +vec2 Distort(vec2 at, float k) { + float aspect = max(composite_info.look_more.w, 1e-4); + vec2 fromCentre = at - vec2(0.5); + vec2 onFrame = fromCentre * vec2(aspect, 1.0); + float r2 = dot(onFrame, onFrame); + float border = k > 0.0 ? 0.25 * (aspect * aspect + 1.0) + : 0.25 * min(aspect * aspect, 1.0); + return vec2(0.5) + fromCentre * (1.0 + k * r2) / (1.0 + k * border); +} + /// Rec. 709 luma, which is what the sRGB primaries weight to. float Luma(vec3 color) { return dot(color, vec3(0.2126, 0.7152, 0.0722)); } @@ -21262,18 +23453,25 @@ void main() { // // The offset grows from the centre outwards, which is what a real lens does: // a ray through the middle of the glass is not dispersed at all. + // + // `P2`: the lens's own bend, before anything is read, so the scene and + // everything laid over it — the glow, the occlusion, the contact shadow, + // the local exposure — bend together. Nought leaves `uv` the very value + // `v_uv` is, which every golden in the repository depends on. + float distortion = composite_info.lens.x; + vec2 uv = distortion != 0.0 ? Distort(v_uv, distortion) : v_uv; float dispersion = composite_info.look.w; vec4 scene; if (dispersion > 0.0) { - vec2 fromCentre = v_uv - vec2(0.5); + vec2 fromCentre = uv - vec2(0.5); vec2 step_uv = fromCentre * dispersion; - scene = texture(scene_texture, v_uv); - scene.r = texture(scene_texture, v_uv + step_uv).r; - scene.b = texture(scene_texture, v_uv - step_uv).b; + scene = texture(scene_texture, uv); + scene.r = texture(scene_texture, uv + step_uv).r; + scene.b = texture(scene_texture, uv - step_uv).b; } else { - scene = texture(scene_texture, v_uv); + scene = texture(scene_texture, uv); } - vec3 bloom = texture(bloom_texture, v_uv).rgb; + vec3 bloom = texture(bloom_texture, uv).rgb; // Four taps in a 2×2, which is not a general-purpose blur: the occlusion pass // rotates its kernel by the parity of the pixel, leaving a 2×2 pattern, and @@ -21282,10 +23480,10 @@ void main() { // one without the other either leaves the pattern or smears the contact // shadows this whole pass exists to draw. vec2 half_texel = composite_info.ao_texel.xy * 0.5; - vec4 occlusion = 0.25 * (texture(ao_texture, v_uv + vec2(half_texel.x, half_texel.y)) + - texture(ao_texture, v_uv + vec2(-half_texel.x, half_texel.y)) + - texture(ao_texture, v_uv + vec2(half_texel.x, -half_texel.y)) + - texture(ao_texture, v_uv + vec2(-half_texel.x, -half_texel.y))); + vec4 occlusion = 0.25 * (texture(ao_texture, uv + vec2(half_texel.x, half_texel.y)) + + texture(ao_texture, uv + vec2(-half_texel.x, half_texel.y)) + + texture(ao_texture, uv + vec2(half_texel.x, -half_texel.y)) + + texture(ao_texture, uv + vec2(-half_texel.x, -half_texel.y))); // The share left open is in a; the occlusion methods write it into every // channel, and the indirect one keeps its light in rgb. float ao = occlusion.a; @@ -21305,7 +23503,7 @@ void main() { // the whole point of a contact shadow is the first few centimetres at the // join, and a half-resolution one is the seam it exists to draw, blurred // away. - float contact = texture(contact_shadow_texture, v_uv).r; + float contact = texture(contact_shadow_texture, uv).r; ao *= mix(1.0, contact, clamp(composite_info.contact.x, 0.0, 1.0)); // Applied to the scene and **not** to the bloom, which is the whole reason @@ -21328,7 +23526,7 @@ void main() { // `R7`: each place of the scene at the exposure that shows it best, before // the glow is added and the curve applied. Bilinear from an eighth of the // frame: the stops were blurred wide, so there is no edge in them to keep. - float localStops = texture(local_exposure_texture, v_uv).r; + float localStops = texture(local_exposure_texture, uv).r; vec3 exposed = scene.rgb * exp2(localStops * composite_info.contact.w); vec3 color = exposed * ao + bloom * composite_info.params.y; @@ -21483,6 +23681,18 @@ void main() { } frag_color = vec4(encoded, scene.a); + + // `P6`: right of the split the materials wrote display values, not light — + // see `WriteDebugView` in `surface.glsl` — so the scene goes to the screen + // through the encode alone, with no exposure, curve, glow or grade on it. + // Chosen here rather than returned from the top: an early return on a + // per-pixel condition would leave every read above in non-uniform control + // flow, which WGSL refuses. + if (composite_info.lens.y > 0.5 && v_uv.x >= composite_info.lens.z) { + vec4 raw = textureLod(scene_texture, v_uv, 0.0); + frag_color = vec4(LinearToSrgb(clamp(raw.rgb, vec3(0.0), vec3(1.0))), + raw.a); + } } ''', @@ -22084,6 +24294,346 @@ void main() { vec4(Sharpen(smoothed, northRgb, southRgb, westRgb, eastRgb), 1.0); } +''', + 'SmaaEdges': r'''#version 300 es +precision highp float; +precision highp int; +precision highp sampler2D; +precision highp samplerCube; + +// SMAA 1x, first of three passes — `P1`: where the luma steps. +// +// Red marks a step across a pixel's left side, green across its top. The +// other two passes read nothing else, so this is where the method decides +// what an edge is: a step of at least `params.z` in luma, and not much +// smaller than the largest step beside it. That second test is what keeps +// SMAA off the inside of a high-contrast texture, which FXAA would smooth as +// though it were a staircase. +// +// `flutter3d_cpu`'s `SmaaEdgesShader` is this, line for line. +precision highp float; + +in vec2 v_uv; + +layout(location = 0) out vec4 frag_color; + +/// The finished picture, tone mapped and encoded. +uniform sampler2D source_texture; + +layout(std140) uniform SmaaInfo { + /// x, y: one texel. z: the smallest luma step that is an edge. w: how + /// many times smaller than the largest step beside it a step may be and + /// still count. + vec4 params; +} +smaa_info; + +/// Rec. 709 weights on the encoded picture. +float Luma(vec2 at) { + vec3 c = textureLod(source_texture, at, 0.0).rgb; + return dot(c, vec3(0.2126, 0.7152, 0.0722)); +} + +void main() { + vec2 texel = smaa_info.params.xy; + float middle = Luma(v_uv); + float left = Luma(v_uv + vec2(-texel.x, 0.0)); + float top = Luma(v_uv + vec2(0.0, -texel.y)); + vec2 delta = abs(vec2(middle - left, middle - top)); + vec2 edges = step(vec2(smaa_info.params.z), delta); + if (edges.x + edges.y == 0.0) { + frag_color = vec4(0.0); + return; + } + + float right = abs(middle - Luma(v_uv + vec2(texel.x, 0.0))); + float bottom = abs(middle - Luma(v_uv + vec2(0.0, texel.y))); + float leftLeft = abs(left - Luma(v_uv + vec2(-2.0 * texel.x, 0.0))); + float topTop = abs(top - Luma(v_uv + vec2(0.0, -2.0 * texel.y))); + float largest = max(max(max(delta.x, delta.y), max(right, bottom)), + max(leftLeft, topTop)); + edges *= step(vec2(largest), smaa_info.params.w * delta); + frag_color = vec4(edges, 0.0, 0.0); +} + +''', + 'SmaaWeights': r'''#version 300 es +precision highp float; +precision highp int; +precision highp sampler2D; +precision highp samplerCube; + +// SMAA 1x, second of three passes — `P1`: how far each pixel along an edge +// moves. +// +// From every pixel on an edge, a walk to both ends of the run it belongs +// to: the run ends where the edge stops or where an edge crosses it. Which +// side of each end a crossing stands on says what shape the staircase is — +// a step down, a step up, a U — and the line behind that shape, and this +// pixel's place along it, say how much of the pixel the line covers. That +// area is precomputed: the engine's `smaaArea` table, 16×16 texels for each +// pair of end codes, indexed by the square roots of the two distances. +// +// Red and green: the top edge's two shares, this pixel's and the one +// above's. Blue and alpha: the left edge's, this pixel's and the one to the +// left's. Orthogonal edges only; no diagonal search, no corner rounding. +// +// `flutter3d_cpu`'s `SmaaWeightsShader` is this, line for line. +precision highp float; + +in vec2 v_uv; + +layout(location = 0) out vec4 frag_color; + +/// The first pass's edges: red across the left side, green across the top. +uniform sampler2D edges_texture; + +/// The engine's `smaaArea` table, 80×80, filtered. +uniform sampler2D area_texture; + +layout(std140) uniform SmaaInfo { + /// x, y: one texel. z, w: the first pass's, unread here. + vec4 params; +} +smaa_info; + +/// The most pixels a search walks from the one it starts at, either way. +const int kSmaaMaxSearch = 32; + +vec4 Edges(vec2 offset) { + return textureLod(edges_texture, v_uv + offset * smaa_info.params.xy, 0.0); +} + +/// The table's texel for the two ends' codes and the square roots of the +/// two distances, filtered between square roots. +vec2 Area(float first, float last, float codeFirst, float codeLast) { + vec2 at = (16.0 * vec2(codeFirst, codeLast) + sqrt(vec2(first, last)) + 0.5) / + 80.0; + return textureLod(area_texture, at, 0.0).rg; +} + +void main() { + vec4 here = Edges(vec2(0.0)); + vec4 weights = vec4(0.0); + + if (here.g > 0.5) { + // Along a top edge, to the left: an end where this pixel's own left + // side is crossed, or where the next one has no top edge. + int first = kSmaaMaxSearch; + for (int i = 0; i < kSmaaMaxSearch; i++) { + if (Edges(vec2(-float(i), 0.0)).r > 0.5 || + Edges(vec2(-float(i) - 1.0, 0.0)).g < 0.5) { + first = i; + break; + } + } + int last = kSmaaMaxSearch; + for (int i = 0; i < kSmaaMaxSearch; i++) { + vec4 next = Edges(vec2(float(i) + 1.0, 0.0)); + if (next.r > 0.5 || next.g < 0.5) { + last = i; + break; + } + } + // A crossing on the near side of the edge counts three, on the far + // side one: the codes the bilinear fetch of the reference gives. + float f = float(first); + float l = float(last); + float codeFirst = first == kSmaaMaxSearch + ? 0.0 + : 3.0 * Edges(vec2(-f, 0.0)).r + + Edges(vec2(-f, -1.0)).r; + float codeLast = last == kSmaaMaxSearch + ? 0.0 + : 3.0 * Edges(vec2(l + 1.0, 0.0)).r + + Edges(vec2(l + 1.0, -1.0)).r; + weights.rg = Area(f, l, codeFirst, codeLast); + } + + if (here.r > 0.5) { + // Along a left edge, upwards and then down. + int first = kSmaaMaxSearch; + for (int i = 0; i < kSmaaMaxSearch; i++) { + if (Edges(vec2(0.0, -float(i))).g > 0.5 || + Edges(vec2(0.0, -float(i) - 1.0)).r < 0.5) { + first = i; + break; + } + } + int last = kSmaaMaxSearch; + for (int i = 0; i < kSmaaMaxSearch; i++) { + vec4 next = Edges(vec2(0.0, float(i) + 1.0)); + if (next.g > 0.5 || next.r < 0.5) { + last = i; + break; + } + } + float f = float(first); + float l = float(last); + float codeFirst = first == kSmaaMaxSearch + ? 0.0 + : 3.0 * Edges(vec2(0.0, -f)).g + + Edges(vec2(-1.0, -f)).g; + float codeLast = last == kSmaaMaxSearch + ? 0.0 + : 3.0 * Edges(vec2(0.0, l + 1.0)).g + + Edges(vec2(-1.0, l + 1.0)).g; + weights.ba = Area(f, l, codeFirst, codeLast); + } + + frag_color = weights; +} + +''', + 'SmaaBlend': r'''#version 300 es +precision highp float; +precision highp int; +precision highp sampler2D; +precision highp samplerCube; + +// SMAA 1x, last of three passes — `P1`: each pixel moved towards its +// neighbour across the edge by the share the second pass found. +// +// Four shares reach a pixel: its own for its top and left sides, and its +// right and bottom neighbours' far shares for the sides it shares with them. +// The larger pair wins — across or along — and the pixel is two filtered +// taps, each pulled that fraction of a texel towards its side. +// +// `flutter3d_cpu`'s `SmaaBlendShader` is this, line for line. +precision highp float; + +in vec2 v_uv; + +layout(location = 0) out vec4 frag_color; + +/// The finished picture, filtered. +uniform sampler2D source_texture; + +/// The second pass's shares. +uniform sampler2D blend_texture; + +layout(std140) uniform SmaaInfo { + /// x, y: one texel. z, w: the first pass's, unread here. + vec4 params; +} +smaa_info; + +void main() { + vec2 texel = smaa_info.params.xy; + float right = textureLod(blend_texture, v_uv + vec2(texel.x, 0.0), 0.0).a; + float bottom = textureLod(blend_texture, v_uv + vec2(0.0, texel.y), 0.0).g; + vec4 mine = textureLod(blend_texture, v_uv, 0.0); + float top = mine.r; + float left = mine.b; + if (right + bottom + top + left < 1e-5) { + frag_color = textureLod(source_texture, v_uv, 0.0); + return; + } + + bool across = max(right, left) > max(bottom, top); + float toward = across ? right : bottom; + float away = across ? left : top; + vec2 axis = across ? vec2(texel.x, 0.0) : vec2(0.0, texel.y); + vec4 first = textureLod(source_texture, v_uv + toward * axis, 0.0); + vec4 second = textureLod(source_texture, v_uv - away * axis, 0.0); + float total = toward + away; + frag_color = first * (toward / total) + second * (away / total); +} + +''', + 'LensFlare': r'''#version 300 es +precision highp float; +precision highp int; +precision highp sampler2D; +precision highp samplerCube; + +// Lens flare, added to the glow — `P2`. +// +// A bright light in the frame reflects between the elements of a lens and +// lands again as a row of ghosts on the line through the middle of the +// frame, on the far side, and as a ring round the middle. Both are drawn +// here from the glow itself: the bloom chain has already picked out what is +// bright and spread it, so reading it at the mirrored positions is what puts +// a ghost where the light's reflection would fall, and nothing that is not +// bright enough to bloom ever flares. +// +// Each ghost is weighted by how near the middle it lands, so ghosts thin out +// towards the edges and one that would land outside the frame is not drawn +// — no wrapping round, which reads as a light on the wrong side. The three +// channels are taken a little apart along the ghost's line, which is the +// colour fringe a coated lens leaves on its reflections. +// +// Drawn into the glow rather than into the picture so the composite adds it +// with the glow, at the glow's own intensity and before the tone map: a +// flare is light, and it saturates the way the rest of the light does. +// +// `flutter3d_cpu`'s `LensFlareShader` is this, line for line. +precision highp float; + +in vec2 v_uv; + +layout(location = 0) out vec4 frag_color; + +/// The glow, as the bloom chain finished it. +uniform sampler2D bloom_texture; + +layout(std140) uniform LensFlareInfo { + /// x: how much flare, against the glow it is drawn from. y: how many + /// ghosts, up to eight. z: how far apart they fall, as a fraction of the + /// way from the light to the middle. w: the halo's radius, as a fraction + /// of the frame's height. + vec4 params; + + /// x: how far apart the channels of a ghost are taken, in uv. y: the + /// halo's strength against the ghosts'. z: the frame's aspect, width over + /// height. w: unclaimed. + vec4 more; +} +lens_flare_info; + +/// The glow at [at], its channels taken [spread] apart along [along]. +vec3 Fringed(vec2 at, vec2 along, float spread) { + return vec3(textureLod(bloom_texture, at + along * spread, 0.0).r, + textureLod(bloom_texture, at, 0.0).g, + textureLod(bloom_texture, at - along * spread, 0.0).b); +} + +/// How much a reflection landing at [at] keeps: all of it in the middle, +/// falling off to nothing at the corners and beyond. +float Falloff(vec2 at, float power) { + float d = length(vec2(0.5) - at) / 0.70710678; + return pow(max(1.0 - d, 0.0), power); +} + +void main() { + vec3 glow = textureLod(bloom_texture, v_uv, 0.0).rgb; + float intensity = lens_flare_info.params.x; + // The reflection of the point at `v_uv` lands on the far side of the + // middle, so a ghost at `v_uv` comes from the light at `flipped`. + vec2 flipped = vec2(1.0) - v_uv; + vec2 towardMiddle = (vec2(0.5) - flipped) * lens_flare_info.params.z; + float reach = length(towardMiddle); + vec2 along = reach > 1e-6 ? towardMiddle / reach : vec2(0.0); + float spread = lens_flare_info.more.x; + + vec3 flare = vec3(0.0); + int ghosts = int(lens_flare_info.params.y + 0.5); + for (int i = 0; i < 8; i++) { + if (i >= ghosts) break; + vec2 at = flipped + towardMiddle * float(i); + flare += Fringed(at, along, spread) * Falloff(at, 10.0); + } + + // The ring: every light at one radius from the middle lands on it. + float aspect = max(lens_flare_info.more.z, 1e-4); + vec2 halo = along * vec2(1.0 / aspect, 1.0) * lens_flare_info.params.w; + vec2 haloAt = flipped + halo; + flare += Fringed(haloAt, along, spread) * Falloff(haloAt, 5.0) * + lens_flare_info.more.y; + + frag_color = vec4(glow + flare * intensity, 1.0); +} + ''', 'MrtProbe': r'''#version 300 es precision highp float; @@ -22252,6 +24802,13 @@ bool g_debug_surface_on = false; /// A global for the reason [g_debug_surface] is one. bool g_premultiply = false; +/// How much of what is already behind a blended surface comes through it — +/// a thin pane of glass, `M3`: what the blend multiplies the target by is +/// one minus the alpha, so the alpha written is the coverage less this share +/// of it, while the colour stays weighted by the coverage alone. Nought for +/// everything else, which writes what it always wrote. +float g_pass_through = 0.0; + // **A stage that needs none of this must be able to declare none of it.** On // Vulkan both stages' descriptors are merged into one set layout, and two // bindings with the same number in it is not a layout the specification @@ -22268,11 +24825,13 @@ bool g_premultiply = false; /// way to move offsets nobody expected to move. Three vec4s is a cheap price /// for not touching any of that. layout(std140) uniform FogInfo { - /// rgb: linear fog colour. w: density per metre, zero for no fog. + /// rgb: linear fog colour. w: density per metre at the eye, zero for no + /// fog. vec4 fog; /// xyz: camera position in world space. Duplicated from FragInfo so this - /// block stands alone; a vec3 is cheaper than a coupling. + /// block stands alone; a vec3 is cheaper than a coupling. w: how fast the + /// fog thins upwards, per metre — `P5`, see [ApplyFog]; nought is flat fog. vec4 eye; /// xyz: the direction the camera looks, as a unit vector in world space. @@ -22283,15 +24842,24 @@ layout(std140) uniform FogInfo { /// question [eye] does — where the camera is and which way it faces — and /// this is the block `color.glsl` can see. vec4 forward; + + /// x: one when the camera's projection is orthographic, nought when it is + /// perspective — `P7`, see [Orthographic]. y, z, w unused. Appended, so + /// every offset above stays where four backends agree on it. + vec4 projection; } fog_info; -/// How far this fragment is from the eye, in world metres. +/// Whether the camera is orthographic — `P7`. /// -/// What the fog fades by. Distance rather than depth, because fog is a -/// property of the air between two points and does not care which way the -/// camera happens to face. -float EyeDistance() { return distance(v_world_position, fog_info.eye.xyz); } +/// **What every eye-relative quantity has to ask first.** Through a +/// perspective lens the rays meet at the eye, so the way to the eye and the +/// distance to it are the fragment's own; through an orthographic one the +/// rays are parallel and the eye is only where the camera was put along its +/// axis, which moves nothing in the picture. Reading the eye's position as if +/// it were a point the light travels to slides highlights across the frame +/// as the camera pans and lays fog in rings round a point nobody sees. +bool Orthographic() { return fog_info.projection.x > 0.5; } /// How far this fragment is *along the view axis*, in world metres. /// @@ -22306,6 +24874,27 @@ float ViewDepth() { return dot(v_world_position - fog_info.eye.xyz, fog_info.forward.xyz); } +/// How far this fragment is from the eye, in world metres. +/// +/// What the fog fades by. Distance rather than depth, because fog is a +/// property of the air between two points and does not care which way the +/// camera happens to face — through a perspective lens. Through an +/// orthographic one every ray starts on the eye's plane, so the air a ray +/// crosses is its depth from that plane, never less than nought. +float EyeDistance() { + return Orthographic() ? max(ViewDepth(), 0.0) + : distance(v_world_position, fog_info.eye.xyz); +} + +/// The unit direction from this fragment back along the ray that reached +/// it: to the eye through a perspective lens, against the view axis through +/// an orthographic one — `P7`. What a highlight, a Fresnel term and a +/// reflection are measured from. +vec3 TowardsEye() { + return Orthographic() ? -fog_info.forward.xyz + : normalize(fog_info.eye.xyz - v_world_position); +} + #else // F3D_NO_FOG // The same two questions, answered without the block: a stage that declares no @@ -22398,6 +24987,16 @@ void WriteSurfaceGeometry(float roughness) { /// /// Exponential rather than linear, because linear fog has a visible plane /// where it starts and a dungeon corridor is exactly where that shows. +/// +/// **Height fog — `P5`.** `fog_info.fog.w` is the density at the eye and +/// `fog_info.eye.w` how fast it falls off upwards, per metre. The density +/// along the ray is then `density · e^(−falloff · Δy · t)` for t from nought +/// to one, and its mean over the ray is `(1 − e^(−k)) / k` with +/// `k = falloff · Δy`: an exact integral, so a fog lying on the ground costs +/// one exponential here and no march. Below a thousandth `k` is answered by +/// the first two terms of the same series, because the quotient there is +/// two nearly equal numbers divided by a small one. A falloff of nought skips +/// all of it, which keeps a flat fog the same to the bit. vec3 ApplyFog(vec3 color) { #ifdef F3D_NO_FOG return color; @@ -22405,6 +25004,11 @@ vec3 ApplyFog(vec3 color) { float density = fog_info.fog.w; if (density <= 0.0) return color; float d = EyeDistance(); + float falloff = fog_info.eye.w; + if (falloff > 0.0) { + float k = falloff * (v_world_position.y - fog_info.eye.y); + density *= abs(k) > 1e-3 ? (1.0 - exp(-k)) / k : 1.0 - 0.5 * k; + } return mix(fog_info.fog.rgb, color, clamp(exp(-density * d), 0.0, 1.0)); #endif } @@ -22459,7 +25063,8 @@ void WriteWeightedBlended() { /// which is exact, so an opaque draw writes what it always wrote. void WriteSurface(vec3 linearColor, float alpha, float roughness) { float weight = g_premultiply ? alpha : 1.0; - frag_color = vec4(ApplyFog(linearColor) * weight, alpha); + frag_color = vec4(ApplyFog(linearColor) * weight, + alpha * (1.0 - g_pass_through)); WriteSurfaceGeometry(roughness); WriteWeightedBlended(); } @@ -22628,24 +25233,63 @@ in vec3 v_world_position; layout(location = 0) out vec4 frag_color; -/// The lit shaders' block, declared again because this shader shares none of -/// their headers — it has a different vertex layout and none of their varyings. -/// -/// **The first two members of it, not all three.** `color.glsl` carries a -/// `forward` beside these, for the view axis the surface buffer measures its -/// depths along; a particle writes no surface buffer, so it neither declares -/// that member nor is bound one. The two blocks share a name and not a shape, -/// which is fine — they belong to different programs — and a check that binds -/// this one has to bind what it declares. +// --- lib/contributor_eye.glsl --- +// The eye and the fog, for the stages that share none of the lit path's +// headers — particles, mesh particles, splats. +// +// They have a vertex layout of their own and none of the lit shaders' +// varyings, so `color.glsl` does not apply; this is the part of it they need: +// the `FogInfo` block as the lit stages declare it, and the two eye-relative +// questions asked of a world position passed in rather than of a varying. +// +// **All four members, `P7`.** The block used to stop after `eye`, and through +// an orthographic camera the fog then lay in rings round the eye's position +// — a point the picture does not depend on — and a mesh particle's faces were +// lit by how squarely they faced it. A stage that declares this block has to +// be bound all four, which is what the contributors do. + +#ifndef CONTRIBUTOR_EYE_GLSL_ +#define CONTRIBUTOR_EYE_GLSL_ + layout(std140) uniform FogInfo { - /// rgb: linear fog colour. w: density per metre, zero for no fog. + /// rgb: linear fog colour. w: density per metre at the eye, zero for no + /// fog. vec4 fog; /// xyz: camera position in world space. vec4 eye; + + /// xyz: the direction the camera looks, as a unit vector in world space. + vec4 forward; + + /// x: one when the camera's projection is orthographic, nought when it is + /// perspective. + vec4 projection; } fog_info; +/// How much air lies between [world] and the eye, in world metres: the +/// distance to it through a perspective lens, the depth from its plane +/// through an orthographic one, where every ray starts on that plane. +float FogDistance(vec3 world) { + return fog_info.projection.x > 0.5 + ? max(dot(world - fog_info.eye.xyz, fog_info.forward.xyz), 0.0) + : distance(world, fog_info.eye.xyz); +} + +/// The unit direction from [world] back along the ray that reached it: to +/// the eye through a perspective lens, against the view axis through an +/// orthographic one. Nought where [world] is the eye. +vec3 TowardsEyeFrom(vec3 world) { + if (fog_info.projection.x > 0.5) return -fog_info.forward.xyz; + vec3 to_eye = fog_info.eye.xyz - world; + float length_to_eye = length(to_eye); + return length_to_eye > 0.0 ? to_eye / length_to_eye : vec3(0.0); +} + +#endif // CONTRIBUTOR_EYE_GLSL_ + + void main() { // Distance from the middle of the quad, where the corners sit at 1. vec2 centred = v_uv * 2.0 - 1.0; @@ -22665,7 +25309,7 @@ void main() { float fogged = 1.0; if (fog_info.fog.w > 0.0) { fogged = clamp( - exp(-fog_info.fog.w * distance(v_world_position, fog_info.eye.xyz)), + exp(-fog_info.fog.w * FogDistance(v_world_position)), 0.0, 1.0); } @@ -22814,17 +25458,63 @@ layout(location = 0) out vec4 frag_color; uniform sampler2D particle_texture; -/// Declared again for the same reason the other particle stages declare it: -/// this shader shares none of the lit path's headers. +// --- lib/contributor_eye.glsl --- +// The eye and the fog, for the stages that share none of the lit path's +// headers — particles, mesh particles, splats. +// +// They have a vertex layout of their own and none of the lit shaders' +// varyings, so `color.glsl` does not apply; this is the part of it they need: +// the `FogInfo` block as the lit stages declare it, and the two eye-relative +// questions asked of a world position passed in rather than of a varying. +// +// **All four members, `P7`.** The block used to stop after `eye`, and through +// an orthographic camera the fog then lay in rings round the eye's position +// — a point the picture does not depend on — and a mesh particle's faces were +// lit by how squarely they faced it. A stage that declares this block has to +// be bound all four, which is what the contributors do. + +#ifndef CONTRIBUTOR_EYE_GLSL_ +#define CONTRIBUTOR_EYE_GLSL_ + layout(std140) uniform FogInfo { - /// rgb: linear fog colour. w: density per metre, zero for no fog. + /// rgb: linear fog colour. w: density per metre at the eye, zero for no + /// fog. vec4 fog; /// xyz: camera position in world space. vec4 eye; + + /// xyz: the direction the camera looks, as a unit vector in world space. + vec4 forward; + + /// x: one when the camera's projection is orthographic, nought when it is + /// perspective. + vec4 projection; } fog_info; +/// How much air lies between [world] and the eye, in world metres: the +/// distance to it through a perspective lens, the depth from its plane +/// through an orthographic one, where every ray starts on that plane. +float FogDistance(vec3 world) { + return fog_info.projection.x > 0.5 + ? max(dot(world - fog_info.eye.xyz, fog_info.forward.xyz), 0.0) + : distance(world, fog_info.eye.xyz); +} + +/// The unit direction from [world] back along the ray that reached it: to +/// the eye through a perspective lens, against the view axis through an +/// orthographic one. Nought where [world] is the eye. +vec3 TowardsEyeFrom(vec3 world) { + if (fog_info.projection.x > 0.5) return -fog_info.forward.xyz; + vec3 to_eye = fog_info.eye.xyz - world; + float length_to_eye = length(to_eye); + return length_to_eye > 0.0 ? to_eye / length_to_eye : vec3(0.0); +} + +#endif // CONTRIBUTOR_EYE_GLSL_ + + void main() { // `texture`, not `textureLod`. The level is chosen from the derivative the // hardware computes for this fragment, which is the whole point of building @@ -22839,7 +25529,7 @@ void main() { float fogged = 1.0; if (fog_info.fog.w > 0.0) { fogged = clamp( - exp(-fog_info.fog.w * distance(v_world_position, fog_info.eye.xyz)), + exp(-fog_info.fog.w * FogDistance(v_world_position)), 0.0, 1.0); } @@ -23292,17 +25982,63 @@ layout(location = 0) out vec4 frag_color; uniform sampler2D six_way_positive; uniform sampler2D six_way_negative; -/// Declared again, as in the other particle stages, since this shares none of -/// the lit path's headers. +// --- lib/contributor_eye.glsl --- +// The eye and the fog, for the stages that share none of the lit path's +// headers — particles, mesh particles, splats. +// +// They have a vertex layout of their own and none of the lit shaders' +// varyings, so `color.glsl` does not apply; this is the part of it they need: +// the `FogInfo` block as the lit stages declare it, and the two eye-relative +// questions asked of a world position passed in rather than of a varying. +// +// **All four members, `P7`.** The block used to stop after `eye`, and through +// an orthographic camera the fog then lay in rings round the eye's position +// — a point the picture does not depend on — and a mesh particle's faces were +// lit by how squarely they faced it. A stage that declares this block has to +// be bound all four, which is what the contributors do. + +#ifndef CONTRIBUTOR_EYE_GLSL_ +#define CONTRIBUTOR_EYE_GLSL_ + layout(std140) uniform FogInfo { - /// rgb: linear fog colour. w: density per metre, zero for no fog. + /// rgb: linear fog colour. w: density per metre at the eye, zero for no + /// fog. vec4 fog; /// xyz: camera position in world space. vec4 eye; + + /// xyz: the direction the camera looks, as a unit vector in world space. + vec4 forward; + + /// x: one when the camera's projection is orthographic, nought when it is + /// perspective. + vec4 projection; } fog_info; +/// How much air lies between [world] and the eye, in world metres: the +/// distance to it through a perspective lens, the depth from its plane +/// through an orthographic one, where every ray starts on that plane. +float FogDistance(vec3 world) { + return fog_info.projection.x > 0.5 + ? max(dot(world - fog_info.eye.xyz, fog_info.forward.xyz), 0.0) + : distance(world, fog_info.eye.xyz); +} + +/// The unit direction from [world] back along the ray that reached it: to +/// the eye through a perspective lens, against the view axis through an +/// orthographic one. Nought where [world] is the eye. +vec3 TowardsEyeFrom(vec3 world) { + if (fog_info.projection.x > 0.5) return -fog_info.forward.xyz; + vec3 to_eye = fog_info.eye.xyz - world; + float length_to_eye = length(to_eye); + return length_to_eye > 0.0 ? to_eye / length_to_eye : vec3(0.0); +} + +#endif // CONTRIBUTOR_EYE_GLSL_ + + layout(std140) uniform SixWayInfo { /// xyz: the quad's right, which is the camera's, in world space. vec4 right; @@ -23364,7 +26100,7 @@ void main() { float fogged = 1.0; if (fog_info.fog.w > 0.0) { fogged = clamp( - exp(-fog_info.fog.w * distance(v_world_position, fog_info.eye.xyz)), + exp(-fog_info.fog.w * FogDistance(v_world_position)), 0.0, 1.0); } @@ -23517,24 +26253,63 @@ in vec3 v_world_position; layout(location = 0) out vec4 frag_color; -/// The lit shaders' block, declared again because this shader shares none of -/// their headers — it has a different vertex layout and none of their varyings. -/// -/// **The first two members of it, not all three.** `color.glsl` carries a -/// `forward` beside these, for the view axis the surface buffer measures its -/// depths along; a particle writes no surface buffer, so it neither declares -/// that member nor is bound one. The two blocks share a name and not a shape, -/// which is fine — they belong to different programs — and a check that binds -/// this one has to bind what it declares. +// --- lib/contributor_eye.glsl --- +// The eye and the fog, for the stages that share none of the lit path's +// headers — particles, mesh particles, splats. +// +// They have a vertex layout of their own and none of the lit shaders' +// varyings, so `color.glsl` does not apply; this is the part of it they need: +// the `FogInfo` block as the lit stages declare it, and the two eye-relative +// questions asked of a world position passed in rather than of a varying. +// +// **All four members, `P7`.** The block used to stop after `eye`, and through +// an orthographic camera the fog then lay in rings round the eye's position +// — a point the picture does not depend on — and a mesh particle's faces were +// lit by how squarely they faced it. A stage that declares this block has to +// be bound all four, which is what the contributors do. + +#ifndef CONTRIBUTOR_EYE_GLSL_ +#define CONTRIBUTOR_EYE_GLSL_ + layout(std140) uniform FogInfo { - /// rgb: linear fog colour. w: density per metre, zero for no fog. + /// rgb: linear fog colour. w: density per metre at the eye, zero for no + /// fog. vec4 fog; /// xyz: camera position in world space. vec4 eye; + + /// xyz: the direction the camera looks, as a unit vector in world space. + vec4 forward; + + /// x: one when the camera's projection is orthographic, nought when it is + /// perspective. + vec4 projection; } fog_info; +/// How much air lies between [world] and the eye, in world metres: the +/// distance to it through a perspective lens, the depth from its plane +/// through an orthographic one, where every ray starts on that plane. +float FogDistance(vec3 world) { + return fog_info.projection.x > 0.5 + ? max(dot(world - fog_info.eye.xyz, fog_info.forward.xyz), 0.0) + : distance(world, fog_info.eye.xyz); +} + +/// The unit direction from [world] back along the ray that reached it: to +/// the eye through a perspective lens, against the view axis through an +/// orthographic one. Nought where [world] is the eye. +vec3 TowardsEyeFrom(vec3 world) { + if (fog_info.projection.x > 0.5) return -fog_info.forward.xyz; + vec3 to_eye = fog_info.eye.xyz - world; + float length_to_eye = length(to_eye); + return length_to_eye > 0.0 ? to_eye / length_to_eye : vec3(0.0); +} + +#endif // CONTRIBUTOR_EYE_GLSL_ + + void main() { // Distance from the middle of the quad, where the corners sit at 1. vec2 centred = v_uv * 2.0 - 1.0; @@ -23554,7 +26329,7 @@ void main() { float fogged = 1.0; if (fog_info.fog.w > 0.0) { fogged = clamp( - exp(-fog_info.fog.w * distance(v_world_position, fog_info.eye.xyz)), + exp(-fog_info.fog.w * FogDistance(v_world_position)), 0.0, 1.0); } @@ -23703,17 +26478,63 @@ layout(location = 0) out vec4 frag_color; uniform sampler2D particle_texture; -/// Declared again for the same reason the other particle stages declare it: -/// this shader shares none of the lit path's headers. +// --- lib/contributor_eye.glsl --- +// The eye and the fog, for the stages that share none of the lit path's +// headers — particles, mesh particles, splats. +// +// They have a vertex layout of their own and none of the lit shaders' +// varyings, so `color.glsl` does not apply; this is the part of it they need: +// the `FogInfo` block as the lit stages declare it, and the two eye-relative +// questions asked of a world position passed in rather than of a varying. +// +// **All four members, `P7`.** The block used to stop after `eye`, and through +// an orthographic camera the fog then lay in rings round the eye's position +// — a point the picture does not depend on — and a mesh particle's faces were +// lit by how squarely they faced it. A stage that declares this block has to +// be bound all four, which is what the contributors do. + +#ifndef CONTRIBUTOR_EYE_GLSL_ +#define CONTRIBUTOR_EYE_GLSL_ + layout(std140) uniform FogInfo { - /// rgb: linear fog colour. w: density per metre, zero for no fog. + /// rgb: linear fog colour. w: density per metre at the eye, zero for no + /// fog. vec4 fog; /// xyz: camera position in world space. vec4 eye; + + /// xyz: the direction the camera looks, as a unit vector in world space. + vec4 forward; + + /// x: one when the camera's projection is orthographic, nought when it is + /// perspective. + vec4 projection; } fog_info; +/// How much air lies between [world] and the eye, in world metres: the +/// distance to it through a perspective lens, the depth from its plane +/// through an orthographic one, where every ray starts on that plane. +float FogDistance(vec3 world) { + return fog_info.projection.x > 0.5 + ? max(dot(world - fog_info.eye.xyz, fog_info.forward.xyz), 0.0) + : distance(world, fog_info.eye.xyz); +} + +/// The unit direction from [world] back along the ray that reached it: to +/// the eye through a perspective lens, against the view axis through an +/// orthographic one. Nought where [world] is the eye. +vec3 TowardsEyeFrom(vec3 world) { + if (fog_info.projection.x > 0.5) return -fog_info.forward.xyz; + vec3 to_eye = fog_info.eye.xyz - world; + float length_to_eye = length(to_eye); + return length_to_eye > 0.0 ? to_eye / length_to_eye : vec3(0.0); +} + +#endif // CONTRIBUTOR_EYE_GLSL_ + + void main() { // `texture`, not `textureLod`. The level is chosen from the derivative the // hardware computes for this fragment, which is the whole point of building @@ -23728,7 +26549,7 @@ void main() { float fogged = 1.0; if (fog_info.fog.w > 0.0) { fogged = clamp( - exp(-fog_info.fog.w * distance(v_world_position, fog_info.eye.xyz)), + exp(-fog_info.fog.w * FogDistance(v_world_position)), 0.0, 1.0); } @@ -24182,17 +27003,63 @@ layout(location = 0) out vec4 frag_color; uniform sampler2D six_way_positive; uniform sampler2D six_way_negative; -/// Declared again, as in the other particle stages, since this shares none of -/// the lit path's headers. +// --- lib/contributor_eye.glsl --- +// The eye and the fog, for the stages that share none of the lit path's +// headers — particles, mesh particles, splats. +// +// They have a vertex layout of their own and none of the lit shaders' +// varyings, so `color.glsl` does not apply; this is the part of it they need: +// the `FogInfo` block as the lit stages declare it, and the two eye-relative +// questions asked of a world position passed in rather than of a varying. +// +// **All four members, `P7`.** The block used to stop after `eye`, and through +// an orthographic camera the fog then lay in rings round the eye's position +// — a point the picture does not depend on — and a mesh particle's faces were +// lit by how squarely they faced it. A stage that declares this block has to +// be bound all four, which is what the contributors do. + +#ifndef CONTRIBUTOR_EYE_GLSL_ +#define CONTRIBUTOR_EYE_GLSL_ + layout(std140) uniform FogInfo { - /// rgb: linear fog colour. w: density per metre, zero for no fog. + /// rgb: linear fog colour. w: density per metre at the eye, zero for no + /// fog. vec4 fog; /// xyz: camera position in world space. vec4 eye; + + /// xyz: the direction the camera looks, as a unit vector in world space. + vec4 forward; + + /// x: one when the camera's projection is orthographic, nought when it is + /// perspective. + vec4 projection; } fog_info; +/// How much air lies between [world] and the eye, in world metres: the +/// distance to it through a perspective lens, the depth from its plane +/// through an orthographic one, where every ray starts on that plane. +float FogDistance(vec3 world) { + return fog_info.projection.x > 0.5 + ? max(dot(world - fog_info.eye.xyz, fog_info.forward.xyz), 0.0) + : distance(world, fog_info.eye.xyz); +} + +/// The unit direction from [world] back along the ray that reached it: to +/// the eye through a perspective lens, against the view axis through an +/// orthographic one. Nought where [world] is the eye. +vec3 TowardsEyeFrom(vec3 world) { + if (fog_info.projection.x > 0.5) return -fog_info.forward.xyz; + vec3 to_eye = fog_info.eye.xyz - world; + float length_to_eye = length(to_eye); + return length_to_eye > 0.0 ? to_eye / length_to_eye : vec3(0.0); +} + +#endif // CONTRIBUTOR_EYE_GLSL_ + + layout(std140) uniform SixWayInfo { /// xyz: the quad's right, which is the camera's, in world space. vec4 right; @@ -24254,7 +27121,7 @@ void main() { float fogged = 1.0; if (fog_info.fog.w > 0.0) { fogged = clamp( - exp(-fog_info.fog.w * distance(v_world_position, fog_info.eye.xyz)), + exp(-fog_info.fog.w * FogDistance(v_world_position)), 0.0, 1.0); } @@ -24307,19 +27174,63 @@ in vec3 v_world_position; layout(location = 0) out vec4 frag_color; -/// The same block the particle stage declares, for the same reason it declares -/// it: a different vertex layout and none of the lit shaders' varyings, so none -/// of their headers apply. Two members, not three — nothing here writes a -/// surface buffer, so there is no view axis to measure a depth along. +// --- lib/contributor_eye.glsl --- +// The eye and the fog, for the stages that share none of the lit path's +// headers — particles, mesh particles, splats. +// +// They have a vertex layout of their own and none of the lit shaders' +// varyings, so `color.glsl` does not apply; this is the part of it they need: +// the `FogInfo` block as the lit stages declare it, and the two eye-relative +// questions asked of a world position passed in rather than of a varying. +// +// **All four members, `P7`.** The block used to stop after `eye`, and through +// an orthographic camera the fog then lay in rings round the eye's position +// — a point the picture does not depend on — and a mesh particle's faces were +// lit by how squarely they faced it. A stage that declares this block has to +// be bound all four, which is what the contributors do. + +#ifndef CONTRIBUTOR_EYE_GLSL_ +#define CONTRIBUTOR_EYE_GLSL_ + layout(std140) uniform FogInfo { - /// rgb: linear fog colour. w: density per metre, zero for no fog. + /// rgb: linear fog colour. w: density per metre at the eye, zero for no + /// fog. vec4 fog; /// xyz: camera position in world space. vec4 eye; + + /// xyz: the direction the camera looks, as a unit vector in world space. + vec4 forward; + + /// x: one when the camera's projection is orthographic, nought when it is + /// perspective. + vec4 projection; } fog_info; +/// How much air lies between [world] and the eye, in world metres: the +/// distance to it through a perspective lens, the depth from its plane +/// through an orthographic one, where every ray starts on that plane. +float FogDistance(vec3 world) { + return fog_info.projection.x > 0.5 + ? max(dot(world - fog_info.eye.xyz, fog_info.forward.xyz), 0.0) + : distance(world, fog_info.eye.xyz); +} + +/// The unit direction from [world] back along the ray that reached it: to +/// the eye through a perspective lens, against the view axis through an +/// orthographic one. Nought where [world] is the eye. +vec3 TowardsEyeFrom(vec3 world) { + if (fog_info.projection.x > 0.5) return -fog_info.forward.xyz; + vec3 to_eye = fog_info.eye.xyz - world; + float length_to_eye = length(to_eye); + return length_to_eye > 0.0 ? to_eye / length_to_eye : vec3(0.0); +} + +#endif // CONTRIBUTOR_EYE_GLSL_ + + void main() { // `exp(-½ dᵀd)` with d already in standard deviations, which is what the // quad's own coordinates are. No matrix here: the inverse covariance was @@ -24345,7 +27256,7 @@ void main() { vec3 colour = v_color.rgb; if (fog_info.fog.w > 0.0) { float visibility = clamp( - exp(-fog_info.fog.w * distance(v_world_position, fog_info.eye.xyz)), + exp(-fog_info.fog.w * FogDistance(v_world_position)), 0.0, 1.0); colour = mix(fog_info.fog.rgb, colour, visibility); @@ -24445,16 +27356,63 @@ in vec3 v_world_position; layout(location = 0) out vec4 frag_color; -/// The block `splat.frag` declares, for the fog mix it makes. +// --- lib/contributor_eye.glsl --- +// The eye and the fog, for the stages that share none of the lit path's +// headers — particles, mesh particles, splats. +// +// They have a vertex layout of their own and none of the lit shaders' +// varyings, so `color.glsl` does not apply; this is the part of it they need: +// the `FogInfo` block as the lit stages declare it, and the two eye-relative +// questions asked of a world position passed in rather than of a varying. +// +// **All four members, `P7`.** The block used to stop after `eye`, and through +// an orthographic camera the fog then lay in rings round the eye's position +// — a point the picture does not depend on — and a mesh particle's faces were +// lit by how squarely they faced it. A stage that declares this block has to +// be bound all four, which is what the contributors do. + +#ifndef CONTRIBUTOR_EYE_GLSL_ +#define CONTRIBUTOR_EYE_GLSL_ + layout(std140) uniform FogInfo { - /// rgb: linear fog colour. w: density per metre, zero for no fog. + /// rgb: linear fog colour. w: density per metre at the eye, zero for no + /// fog. vec4 fog; /// xyz: camera position in world space. vec4 eye; + + /// xyz: the direction the camera looks, as a unit vector in world space. + vec4 forward; + + /// x: one when the camera's projection is orthographic, nought when it is + /// perspective. + vec4 projection; } fog_info; +/// How much air lies between [world] and the eye, in world metres: the +/// distance to it through a perspective lens, the depth from its plane +/// through an orthographic one, where every ray starts on that plane. +float FogDistance(vec3 world) { + return fog_info.projection.x > 0.5 + ? max(dot(world - fog_info.eye.xyz, fog_info.forward.xyz), 0.0) + : distance(world, fog_info.eye.xyz); +} + +/// The unit direction from [world] back along the ray that reached it: to +/// the eye through a perspective lens, against the view axis through an +/// orthographic one. Nought where [world] is the eye. +vec3 TowardsEyeFrom(vec3 world) { + if (fog_info.projection.x > 0.5) return -fog_info.forward.xyz; + vec3 to_eye = fog_info.eye.xyz - world; + float length_to_eye = length(to_eye); + return length_to_eye > 0.0 ? to_eye / length_to_eye : vec3(0.0); +} + +#endif // CONTRIBUTOR_EYE_GLSL_ + + /// `EngineTables.blueNoise`: 32 slices of 64 × 64 in an 8 × 4 atlas. uniform sampler2D blue_noise_texture; @@ -24524,7 +27482,7 @@ void main() { vec3 colour = v_color.rgb; if (fog_info.fog.w > 0.0) { float visibility = clamp( - exp(-fog_info.fog.w * distance(v_world_position, fog_info.eye.xyz)), + exp(-fog_info.fog.w * FogDistance(v_world_position)), 0.0, 1.0); colour = mix(fog_info.fog.rgb, colour, visibility); @@ -24569,27 +27527,74 @@ in vec3 v_normal; layout(location = 0) out vec4 frag_color; -/// The same block the other particle stage declares, for the same reason: this -/// shader shares none of the lit shaders' headers. +// --- lib/contributor_eye.glsl --- +// The eye and the fog, for the stages that share none of the lit path's +// headers — particles, mesh particles, splats. +// +// They have a vertex layout of their own and none of the lit shaders' +// varyings, so `color.glsl` does not apply; this is the part of it they need: +// the `FogInfo` block as the lit stages declare it, and the two eye-relative +// questions asked of a world position passed in rather than of a varying. +// +// **All four members, `P7`.** The block used to stop after `eye`, and through +// an orthographic camera the fog then lay in rings round the eye's position +// — a point the picture does not depend on — and a mesh particle's faces were +// lit by how squarely they faced it. A stage that declares this block has to +// be bound all four, which is what the contributors do. + +#ifndef CONTRIBUTOR_EYE_GLSL_ +#define CONTRIBUTOR_EYE_GLSL_ + layout(std140) uniform FogInfo { - /// rgb: linear fog colour. w: density per metre, zero for no fog. + /// rgb: linear fog colour. w: density per metre at the eye, zero for no + /// fog. vec4 fog; /// xyz: camera position in world space. vec4 eye; + + /// xyz: the direction the camera looks, as a unit vector in world space. + vec4 forward; + + /// x: one when the camera's projection is orthographic, nought when it is + /// perspective. + vec4 projection; } fog_info; +/// How much air lies between [world] and the eye, in world metres: the +/// distance to it through a perspective lens, the depth from its plane +/// through an orthographic one, where every ray starts on that plane. +float FogDistance(vec3 world) { + return fog_info.projection.x > 0.5 + ? max(dot(world - fog_info.eye.xyz, fog_info.forward.xyz), 0.0) + : distance(world, fog_info.eye.xyz); +} + +/// The unit direction from [world] back along the ray that reached it: to +/// the eye through a perspective lens, against the view axis through an +/// orthographic one. Nought where [world] is the eye. +vec3 TowardsEyeFrom(vec3 world) { + if (fog_info.projection.x > 0.5) return -fog_info.forward.xyz; + vec3 to_eye = fog_info.eye.xyz - world; + float length_to_eye = length(to_eye); + return length_to_eye > 0.0 ? to_eye / length_to_eye : vec3(0.0); +} + +#endif // CONTRIBUTOR_EYE_GLSL_ + + void main() { - vec3 to_eye = fog_info.eye.xyz - v_world_position; - float distance_to_eye = length(to_eye); + // Back along the ray that reached the fragment: to the eye, or against the + // view axis through an orthographic lens — `P7`. + vec3 towards_eye = TowardsEyeFrom(v_world_position); // `abs`, not `max(dot, 0)`. Nothing here is culled — a particle mesh is seen // from every side as it tumbles — so a back face is as visible as a front // one, and clamping would make half of every shard go black rather than dim. vec3 n = normalize(v_normal); - float facing = distance_to_eye > 0.0 - ? abs(dot(n, to_eye / distance_to_eye)) + float facing = dot(towards_eye, towards_eye) > 0.0 + ? abs(dot(n, towards_eye)) : 1.0; // Never all the way to zero. A silhouette edge is exactly perpendicular to @@ -24602,7 +27607,8 @@ void main() { // additive particle *add* fog to the wall behind it. float fogged = 1.0; if (fog_info.fog.w > 0.0) { - fogged = clamp(exp(-fog_info.fog.w * distance_to_eye), 0.0, 1.0); + fogged = clamp( + exp(-fog_info.fog.w * FogDistance(v_world_position)), 0.0, 1.0); } frag_color = vec4(v_color.rgb * v_color.a * intensity * fogged, 1.0); @@ -27178,15 +30184,26 @@ void main() { vec2 xy = vec2(v_uv.x * 2.0 - 1.0, 1.0 - v_uv.y * 2.0); vec4 nearH = shaft_info.inverse_view_projection * vec4(xy, 0.0, 1.0); vec4 farH = shaft_info.inverse_view_projection * vec4(xy, 1.0, 1.0); - vec3 origin = nearH.xyz / nearH.w; - vec3 along = normalize(farH.xyz / farH.w - origin); + vec3 nearPoint = nearH.xyz / nearH.w; + vec3 along = normalize(farH.xyz / farH.w - nearPoint); + float cosine = max(dot(along, shaft_info.forward.xyz), 1e-4); + + // **From the eye's plane, not the near plane** — `P7`, as + // `volumetric_fog.frag` already starts. The surface buffer's depth is + // measured from the eye, so a march from the near plane ran the near + // distance past the surface. Through a perspective lens that is a tenth of + // a metre; through an orthographic one the near plane can stand anywhere, + // behind the eye included, and the march ended that far off the wall. + vec3 origin = nearPoint - + along * (dot(nearPoint - shaft_info.camera.xyz, + shaft_info.forward.xyz) / + cosine); // How far there is air. The surface buffer holds depth along the view axis // in metres, so the distance along *this* ray is that over the cosine // between the two — a ray at the corner of the frame travels further than // the axis does to reach the same plane. float surfaceDepth = texture(surface_texture, v_uv).a; - float cosine = max(dot(along, shaft_info.forward.xyz), 1e-4); float toSurface = surfaceDepth > 0.0 ? surfaceDepth / cosine : 1e9; float distance = min(shaft_info.camera.w, toSurface); if (distance <= 0.0) { @@ -28830,6 +31847,263 @@ void main() { frag_color = vec4(mix(scene.rgb, shaded, mix_amount), scene.a); } +''', + 'Decal': r'''#version 300 es +precision highp float; +precision highp int; +precision highp sampler2D; +precision highp samplerCube; + +// Projected box decals — `P3`: a picture laid onto whatever geometry stands +// inside a box, as if it were part of that geometry's material. +// +// **The point under a pixel comes from the surface buffer, because it can +// come from nowhere else.** flutter_gpu cannot sample a depth attachment, so a +// decal cannot be the usual box drawn against the depth it would read. The +// scene pass already wrote a world normal and a depth along the view axis into +// its second attachment, and `WorldAt` turns the two back into a point, as +// the reflections and the occlusion do. +// +// **A material, not a sticker over the lit picture.** The light a surface was +// lit by is recovered from what the scene pass left: the lit colour over the +// albedo buffer's colour is the light that reached the point, shadows, +// probes and the sky's share included. The decal's colour replaces the +// albedo under that same light, so a decal in shadow is in shadow and a decal +// under a red lamp is red. It is what keeps this a fullscreen pass of two +// draws rather than a second lighting model with every light and map bound. +// +// **Two draws with one stage, because a pixel needs two terms.** The new +// colour is the old one times a factor (the albedo swap) plus a term (an +// unlit stack and an emitted light), and one blend cannot multiply and add. +// The first draw writes the factor under a multiplying blend, the second the +// term under an adding one; `params.w` says which. +// +// What the albedo cannot say is stated rather than guessed: a surface that +// wrote no albedo (unlit, or black) has no light to borrow, and there the +// decal is laid over the picture at its own colour. The surface's emission, +// its fog and its specular highlight are scaled with the light, which is +// close for a matte wall and wrong for a mirror. +precision highp float; + +in vec2 v_uv; + +layout(location = 0) out vec4 frag_color; + +uniform sampler2D surface_texture; +uniform sampler2D albedo_texture; + +/// Four pictures a draw may read, each a slot a decal names. A slot nobody +/// names is bound to a one-texel white, for the reason `lib/pbr.glsl` gives: +/// a declared sampler nobody binds is a crash on Metal. +uniform sampler2D decal_texture_0; +uniform sampler2D decal_texture_1; +uniform sampler2D decal_texture_2; +uniform sampler2D decal_texture_3; + +/// Decals per draw. Must match `_DecalBatch.maxDecals` in +/// `renderer_decal_pass.dart`; a frame with more draws again over what the +/// first draw left. +#define kMaxDecals 16 + +/// How little albedo still names a light. Below it the decal stops borrowing +/// the surface's light and is laid on at its own colour, through a ramp +/// rather than a step so a dark texture does not show a seam where it +/// crosses. +#define kAlbedoFloor 0.08 + +layout(std140) uniform DecalInfo { + /// Screen to world, carrying the framebuffer origin — see `UvFromNdc` in + /// `post/reflections.frag`. + mat4 inverse_view_projection; + /// xyz: the camera's position. w: unused. + vec4 camera; + /// xyz: the direction the camera looks; with [camera] it names the planes + /// the surface buffer's depths are measured against. w: unused. + vec4 forward; + /// x: how many decals. y, z: one texel of the target. w: which term this + /// draw writes, nought the factor and one the term. + vec4 params; + /// The view this draw paints, in the target's texture coordinates: xy + /// where it starts, zw how much of the target it spans. The surface buffer + /// holds every view side by side and the matrix above is this one's. + vec4 view; + /// Per slot: xy its size in texels, zw unused. + vec4 slots[4]; + /// The rows of each decal's world-to-box matrix: the box is the unit cube + /// about its origin, and its picture faces up its y axis. + vec4 axis_x[kMaxDecals]; + vec4 axis_y[kMaxDecals]; + vec4 axis_z[kMaxDecals]; + /// The part of its slot's picture a decal shows: xy where it starts in + /// texture coordinates, zw how far it reaches. + vec4 region[kMaxDecals]; + /// rgb: the tint, linear. a: the opacity. + vec4 color[kMaxDecals]; + /// x: the slot, negative for none. y: the cosine of the angle from the + /// box's up at which the decal is gone, z: the cosine width over which it + /// fades in from there. w: the share of the box's half height, from its + /// top and bottom faces, over which it fades out. + vec4 fade[kMaxDecals]; + /// rgb: light the painted colour emits, as a multiple of it. w: unused. + vec4 emissive[kMaxDecals]; +} +decal_info; + +vec3 DecodeOctahedral(vec2 e) { + e = e * 2.0 - 1.0; + vec3 n = vec3(e.xy, 1.0 - abs(e.x) - abs(e.y)); + float t = max(-n.z, 0.0); + n.x += n.x >= 0.0 ? -t : t; + n.y += n.y >= 0.0 ? -t : t; + return normalize(n); +} + +vec3 SrgbToLinear(vec3 srgb) { + return mix(srgb / 12.92, pow((srgb + vec3(0.055)) / 1.055, vec3(2.4)), + step(vec3(0.04045), srgb)); +} + +/// The world point at [uv], [depth] metres along the view axis: the ray +/// through the pixel, crossing the plane the depth names. See `WorldAt` in +/// `post/reflections.frag`, which this is, but for [uv] being the target's +/// rather than the view's. +vec3 WorldAt(vec2 uv, float depth) { + vec2 inView = (uv - decal_info.view.xy) / decal_info.view.zw; + vec2 xy = vec2(inView.x * 2.0 - 1.0, 1.0 - inView.y * 2.0); + vec4 nearH = decal_info.inverse_view_projection * vec4(xy, 0.0, 1.0); + vec4 farH = decal_info.inverse_view_projection * vec4(xy, 1.0, 1.0); + vec3 origin = nearH.xyz / nearH.w; + vec3 along = normalize(farH.xyz / farH.w - origin); + vec3 axis = decal_info.forward.xyz; + return origin + + along * ((depth - dot(origin - decal_info.camera.xyz, axis)) / + dot(along, axis)); +} + +/// How far the world moves across one texel along [step], taken toward the +/// neighbour nearer in depth. +/// +/// **Read from the buffer rather than from `dFdx`**, so the software backend, +/// which has no quads to difference, picks the same level. The nearer +/// neighbour, because the far one across a silhouette belongs to another +/// surface, and its distance would choose the blurriest level for a pixel +/// that is in the middle of a decal. +vec3 WorldStep(vec2 step, float depth, vec3 at) { + vec4 ahead = textureLod(surface_texture, v_uv + step, 0.0); + vec4 behind = textureLod(surface_texture, v_uv - step, 0.0); + float aheadGap = ahead.a > 0.0 ? abs(ahead.a - depth) : 1e30; + float behindGap = behind.a > 0.0 ? abs(behind.a - depth) : 1e30; + vec3 forward = WorldAt(v_uv + step, ahead.a) - at; + vec3 backward = at - WorldAt(v_uv - step, behind.a); + vec3 chosen = aheadGap <= behindGap ? forward : backward; + // Nothing drawn on either side: no footprint, which is the base level. + return min(aheadGap, behindGap) < 1e29 ? chosen : vec3(0.0); +} + +vec4 SampleSlot(float slot, vec2 uv, float lod) { + // A chain of selects rather than an index: GLSL ES 3.00 cannot index an + // array of samplers with anything but a constant. + vec4 picture = vec4(1.0); + if (slot > 2.5) { + picture = textureLod(decal_texture_3, uv, lod); + } else if (slot > 1.5) { + picture = textureLod(decal_texture_2, uv, lod); + } else if (slot > 0.5) { + picture = textureLod(decal_texture_1, uv, lod); + } else if (slot > -0.5) { + picture = textureLod(decal_texture_0, uv, lod); + } + return picture; +} + +void main() { + bool factor = decal_info.params.w < 0.5; + vec4 surface = textureLod(surface_texture, v_uv, 0.0); + + // What leaves the pixel as it was, for each draw: a factor of one, a term of + // nought. Alpha is not touched by either blend. + vec3 keepFactor = vec3(1.0); + vec3 keepTerm = vec3(0.0); + if (surface.a <= 0.0) { + frag_color = vec4(factor ? keepFactor : keepTerm, 1.0); + return; + } + + float depth = surface.a; + vec3 at = WorldAt(v_uv, depth); + vec3 normal = DecodeOctahedral(surface.rg); + vec3 dx = WorldStep(vec2(decal_info.params.y, 0.0), depth, at); + vec3 dy = WorldStep(vec2(0.0, decal_info.params.z), depth, at); + vec3 albedo = + SrgbToLinear(textureLod(albedo_texture, v_uv, 0.0).rgb); + + // Three stacks, decal over decal in order: the albedo the decals leave, the + // share of the picture an unlit stack leaves, and that stack's colour — + // premultiplied, as an over composite keeps it — and the emitted light. + vec3 painted = albedo; + float kept = 1.0; + vec3 laid = vec3(0.0); + vec3 emitted = vec3(0.0); + int count = int(decal_info.params.x + 0.5); + for (int i = 0; i < kMaxDecals; i++) { + if (i >= count) break; + vec4 rowX = decal_info.axis_x[i]; + vec4 rowY = decal_info.axis_y[i]; + vec4 rowZ = decal_info.axis_z[i]; + vec3 local = vec3(dot(rowX.xyz, at) + rowX.w, dot(rowY.xyz, at) + rowY.w, + dot(rowZ.xyz, at) + rowZ.w); + if (any(greaterThan(abs(local), vec3(0.5)))) continue; + + vec4 fade = decal_info.fade[i]; + // The box's up in the world is the gradient of its y, which is the row + // itself; normalised, it stays the up under a box scaled unevenly. + float facing = dot(normal, normalize(rowY.xyz)); + float byAngle = clamp((facing - fade.y) / max(fade.z, 1e-4), 0.0, 1.0); + float byDepth = fade.w > 0.0 + ? clamp((0.5 - abs(local.y)) / (fade.w * 0.5), 0.0, 1.0) + : 1.0; + + // Seen from above, along the box's down, x runs right and z runs down + // the picture: the picture is not mirrored on the face it is stamped on. + vec4 region = decal_info.region[i]; + vec2 uv = region.xy + (local.xz + 0.5) * region.zw; + + // The level from the footprint, as the software backend's sampler + // chooses one: the larger axis, in texels, over one pixel. + float slot = fade.x; + vec2 size = decal_info.slots[int(clamp(slot, 0.0, 3.0))].xy; + vec2 alongX = vec2(dot(rowX.xyz, dx), dot(rowZ.xyz, dx)) * region.zw; + vec2 alongY = vec2(dot(rowX.xyz, dy), dot(rowZ.xyz, dy)) * region.zw; + float footprint = max(max(abs(alongX.x), abs(alongY.x)) * size.x, + max(abs(alongX.y), abs(alongY.y)) * size.y); + float lod = footprint > 1.0 ? log2(footprint) : 0.0; + + vec4 picture = SampleSlot(slot, uv, lod); + vec4 tint = decal_info.color[i]; + vec3 colour = SrgbToLinear(picture.rgb) * tint.rgb; + float alpha = clamp(picture.a * tint.a * byAngle * byDepth, 0.0, 1.0); + + painted = mix(painted, colour, alpha); + kept *= 1.0 - alpha; + laid = mix(laid, colour, alpha); + emitted = mix(emitted, colour * decal_info.emissive[i].rgb, alpha); + } + + // How far the albedo names the light: one at the floor and over it. + float named = clamp(max(albedo.r, max(albedo.g, albedo.b)) / kAlbedoFloor, + 0.0, 1.0); + // The change of albedo under the light it was lit by, as a factor: the new + // albedo over the old where the old is over the floor, and exactly one + // where no decal reached, whatever the albedo. Written as one plus the + // change rather than as the ratio, because under the floor the ratio is not + // one for an untouched pixel and the ramp below would darken every dark + // surface in the frame. + vec3 swap = vec3(1.0) + (painted - albedo) / max(albedo, vec3(kAlbedoFloor)); + vec3 multiply = swap * named + vec3(kept * (1.0 - named)); + vec3 add = laid * (1.0 - named) + emitted; + frag_color = vec4(factor ? multiply : add, 1.0); +} + ''', 'ProbePrefilter': r'''#version 300 es precision highp float; @@ -28952,6 +32226,62 @@ void main() { frag_color = vec4(weight > 0.0 ? sum / weight : vec3(0.0), 1.0); } +''', + 'RenderTextureEncode': r'''#version 300 es +precision highp float; +precision highp int; +precision highp sampler2D; +precision highp samplerCube; + +// `P4`: what a `RenderTexture`'s camera saw, turned from linear light into +// the sRGB bytes a material's map is read as. +// +// **Encoded because every map is decoded.** A material reads its base colour +// and its emission through `SrgbToLinear`, so a texture holding linear light +// would be darkened a second time in every midtone by whatever showed it. In +// eight bits rather than half floats for the same reason: it is a picture, +// sampled like any other picture. +// +// **Exposure, and no tone map.** The texture is drawn into a frame that is +// tone mapped itself, so a curve applied here would be applied twice; what +// is above one after the camera's own exposure is clipped, as a screen +// clips it. +precision highp float; + +in vec2 v_uv; + +layout(location = 0) out vec4 frag_color; + +uniform sampler2D source_texture; + +layout(std140) uniform RenderTextureInfo { + /// x: the exposure the light is multiplied by before it is clipped. y: one + /// where the backend's first row is the bottom of a picture it draws. zw + /// unused. + vec4 params; +} +encode_info; + +vec3 LinearToSrgb(vec3 linear) { + return mix( + linear * 12.92, + 1.055 * pow(max(linear, vec3(0.0)), vec3(1.0 / 2.4)) - vec3(0.055), + step(vec3(0.0031308), linear)); +} + +void main() { + // **Written with the top of the picture in the first row, on every + // backend**, which is how a picture loaded from a file is uploaded and so + // how every material reads its maps. WebGL2 draws a picture with its + // bottom in the first row, so there the rows are turned over on the way + // through; a copy that kept them would show a monitor upside down in the + // browser alone. + vec2 uv = v_uv; + if (encode_info.params.y > 0.5) uv.y = 1.0 - uv.y; + vec3 light = textureLod(source_texture, uv, 0.0).rgb * encode_info.params.x; + frag_color = vec4(LinearToSrgb(clamp(light, vec3(0.0), vec3(1.0))), 1.0); +} + ''', 'ShadowDistance': r'''#version 300 es precision highp float; @@ -29080,6 +32410,13 @@ bool g_debug_surface_on = false; /// A global for the reason [g_debug_surface] is one. bool g_premultiply = false; +/// How much of what is already behind a blended surface comes through it — +/// a thin pane of glass, `M3`: what the blend multiplies the target by is +/// one minus the alpha, so the alpha written is the coverage less this share +/// of it, while the colour stays weighted by the coverage alone. Nought for +/// everything else, which writes what it always wrote. +float g_pass_through = 0.0; + // **A stage that needs none of this must be able to declare none of it.** On // Vulkan both stages' descriptors are merged into one set layout, and two // bindings with the same number in it is not a layout the specification @@ -29096,11 +32433,13 @@ bool g_premultiply = false; /// way to move offsets nobody expected to move. Three vec4s is a cheap price /// for not touching any of that. layout(std140) uniform FogInfo { - /// rgb: linear fog colour. w: density per metre, zero for no fog. + /// rgb: linear fog colour. w: density per metre at the eye, zero for no + /// fog. vec4 fog; /// xyz: camera position in world space. Duplicated from FragInfo so this - /// block stands alone; a vec3 is cheaper than a coupling. + /// block stands alone; a vec3 is cheaper than a coupling. w: how fast the + /// fog thins upwards, per metre — `P5`, see [ApplyFog]; nought is flat fog. vec4 eye; /// xyz: the direction the camera looks, as a unit vector in world space. @@ -29111,15 +32450,24 @@ layout(std140) uniform FogInfo { /// question [eye] does — where the camera is and which way it faces — and /// this is the block `color.glsl` can see. vec4 forward; + + /// x: one when the camera's projection is orthographic, nought when it is + /// perspective — `P7`, see [Orthographic]. y, z, w unused. Appended, so + /// every offset above stays where four backends agree on it. + vec4 projection; } fog_info; -/// How far this fragment is from the eye, in world metres. +/// Whether the camera is orthographic — `P7`. /// -/// What the fog fades by. Distance rather than depth, because fog is a -/// property of the air between two points and does not care which way the -/// camera happens to face. -float EyeDistance() { return distance(v_world_position, fog_info.eye.xyz); } +/// **What every eye-relative quantity has to ask first.** Through a +/// perspective lens the rays meet at the eye, so the way to the eye and the +/// distance to it are the fragment's own; through an orthographic one the +/// rays are parallel and the eye is only where the camera was put along its +/// axis, which moves nothing in the picture. Reading the eye's position as if +/// it were a point the light travels to slides highlights across the frame +/// as the camera pans and lays fog in rings round a point nobody sees. +bool Orthographic() { return fog_info.projection.x > 0.5; } /// How far this fragment is *along the view axis*, in world metres. /// @@ -29134,6 +32482,27 @@ float ViewDepth() { return dot(v_world_position - fog_info.eye.xyz, fog_info.forward.xyz); } +/// How far this fragment is from the eye, in world metres. +/// +/// What the fog fades by. Distance rather than depth, because fog is a +/// property of the air between two points and does not care which way the +/// camera happens to face — through a perspective lens. Through an +/// orthographic one every ray starts on the eye's plane, so the air a ray +/// crosses is its depth from that plane, never less than nought. +float EyeDistance() { + return Orthographic() ? max(ViewDepth(), 0.0) + : distance(v_world_position, fog_info.eye.xyz); +} + +/// The unit direction from this fragment back along the ray that reached +/// it: to the eye through a perspective lens, against the view axis through +/// an orthographic one — `P7`. What a highlight, a Fresnel term and a +/// reflection are measured from. +vec3 TowardsEye() { + return Orthographic() ? -fog_info.forward.xyz + : normalize(fog_info.eye.xyz - v_world_position); +} + #else // F3D_NO_FOG // The same two questions, answered without the block: a stage that declares no @@ -29226,6 +32595,16 @@ void WriteSurfaceGeometry(float roughness) { /// /// Exponential rather than linear, because linear fog has a visible plane /// where it starts and a dungeon corridor is exactly where that shows. +/// +/// **Height fog — `P5`.** `fog_info.fog.w` is the density at the eye and +/// `fog_info.eye.w` how fast it falls off upwards, per metre. The density +/// along the ray is then `density · e^(−falloff · Δy · t)` for t from nought +/// to one, and its mean over the ray is `(1 − e^(−k)) / k` with +/// `k = falloff · Δy`: an exact integral, so a fog lying on the ground costs +/// one exponential here and no march. Below a thousandth `k` is answered by +/// the first two terms of the same series, because the quotient there is +/// two nearly equal numbers divided by a small one. A falloff of nought skips +/// all of it, which keeps a flat fog the same to the bit. vec3 ApplyFog(vec3 color) { #ifdef F3D_NO_FOG return color; @@ -29233,6 +32612,11 @@ vec3 ApplyFog(vec3 color) { float density = fog_info.fog.w; if (density <= 0.0) return color; float d = EyeDistance(); + float falloff = fog_info.eye.w; + if (falloff > 0.0) { + float k = falloff * (v_world_position.y - fog_info.eye.y); + density *= abs(k) > 1e-3 ? (1.0 - exp(-k)) / k : 1.0 - 0.5 * k; + } return mix(fog_info.fog.rgb, color, clamp(exp(-density * d), 0.0, 1.0)); #endif } @@ -29287,7 +32671,8 @@ void WriteWeightedBlended() { /// which is exact, so an opaque draw writes what it always wrote. void WriteSurface(vec3 linearColor, float alpha, float roughness) { float weight = g_premultiply ? alpha : 1.0; - frag_color = vec4(ApplyFog(linearColor) * weight, alpha); + frag_color = vec4(ApplyFog(linearColor) * weight, + alpha * (1.0 - g_pass_through)); WriteSurfaceGeometry(roughness); WriteWeightedBlended(); } @@ -29463,6 +32848,13 @@ bool g_debug_surface_on = false; /// A global for the reason [g_debug_surface] is one. bool g_premultiply = false; +/// How much of what is already behind a blended surface comes through it — +/// a thin pane of glass, `M3`: what the blend multiplies the target by is +/// one minus the alpha, so the alpha written is the coverage less this share +/// of it, while the colour stays weighted by the coverage alone. Nought for +/// everything else, which writes what it always wrote. +float g_pass_through = 0.0; + // **A stage that needs none of this must be able to declare none of it.** On // Vulkan both stages' descriptors are merged into one set layout, and two // bindings with the same number in it is not a layout the specification @@ -29479,11 +32871,13 @@ bool g_premultiply = false; /// way to move offsets nobody expected to move. Three vec4s is a cheap price /// for not touching any of that. layout(std140) uniform FogInfo { - /// rgb: linear fog colour. w: density per metre, zero for no fog. + /// rgb: linear fog colour. w: density per metre at the eye, zero for no + /// fog. vec4 fog; /// xyz: camera position in world space. Duplicated from FragInfo so this - /// block stands alone; a vec3 is cheaper than a coupling. + /// block stands alone; a vec3 is cheaper than a coupling. w: how fast the + /// fog thins upwards, per metre — `P5`, see [ApplyFog]; nought is flat fog. vec4 eye; /// xyz: the direction the camera looks, as a unit vector in world space. @@ -29494,15 +32888,24 @@ layout(std140) uniform FogInfo { /// question [eye] does — where the camera is and which way it faces — and /// this is the block `color.glsl` can see. vec4 forward; + + /// x: one when the camera's projection is orthographic, nought when it is + /// perspective — `P7`, see [Orthographic]. y, z, w unused. Appended, so + /// every offset above stays where four backends agree on it. + vec4 projection; } fog_info; -/// How far this fragment is from the eye, in world metres. +/// Whether the camera is orthographic — `P7`. /// -/// What the fog fades by. Distance rather than depth, because fog is a -/// property of the air between two points and does not care which way the -/// camera happens to face. -float EyeDistance() { return distance(v_world_position, fog_info.eye.xyz); } +/// **What every eye-relative quantity has to ask first.** Through a +/// perspective lens the rays meet at the eye, so the way to the eye and the +/// distance to it are the fragment's own; through an orthographic one the +/// rays are parallel and the eye is only where the camera was put along its +/// axis, which moves nothing in the picture. Reading the eye's position as if +/// it were a point the light travels to slides highlights across the frame +/// as the camera pans and lays fog in rings round a point nobody sees. +bool Orthographic() { return fog_info.projection.x > 0.5; } /// How far this fragment is *along the view axis*, in world metres. /// @@ -29517,6 +32920,27 @@ float ViewDepth() { return dot(v_world_position - fog_info.eye.xyz, fog_info.forward.xyz); } +/// How far this fragment is from the eye, in world metres. +/// +/// What the fog fades by. Distance rather than depth, because fog is a +/// property of the air between two points and does not care which way the +/// camera happens to face — through a perspective lens. Through an +/// orthographic one every ray starts on the eye's plane, so the air a ray +/// crosses is its depth from that plane, never less than nought. +float EyeDistance() { + return Orthographic() ? max(ViewDepth(), 0.0) + : distance(v_world_position, fog_info.eye.xyz); +} + +/// The unit direction from this fragment back along the ray that reached +/// it: to the eye through a perspective lens, against the view axis through +/// an orthographic one — `P7`. What a highlight, a Fresnel term and a +/// reflection are measured from. +vec3 TowardsEye() { + return Orthographic() ? -fog_info.forward.xyz + : normalize(fog_info.eye.xyz - v_world_position); +} + #else // F3D_NO_FOG // The same two questions, answered without the block: a stage that declares no @@ -29609,6 +33033,1366 @@ void WriteSurfaceGeometry(float roughness) { /// /// Exponential rather than linear, because linear fog has a visible plane /// where it starts and a dungeon corridor is exactly where that shows. +/// +/// **Height fog — `P5`.** `fog_info.fog.w` is the density at the eye and +/// `fog_info.eye.w` how fast it falls off upwards, per metre. The density +/// along the ray is then `density · e^(−falloff · Δy · t)` for t from nought +/// to one, and its mean over the ray is `(1 − e^(−k)) / k` with +/// `k = falloff · Δy`: an exact integral, so a fog lying on the ground costs +/// one exponential here and no march. Below a thousandth `k` is answered by +/// the first two terms of the same series, because the quotient there is +/// two nearly equal numbers divided by a small one. A falloff of nought skips +/// all of it, which keeps a flat fog the same to the bit. +vec3 ApplyFog(vec3 color) { +#ifdef F3D_NO_FOG + return color; +#else + float density = fog_info.fog.w; + if (density <= 0.0) return color; + float d = EyeDistance(); + float falloff = fog_info.eye.w; + if (falloff > 0.0) { + float k = falloff * (v_world_position.y - fog_info.eye.y); + density *= abs(k) > 1e-3 ? (1.0 - exp(-k)) / k : 1.0 - 0.5 * k; + } + return mix(fog_info.fog.rgb, color, clamp(exp(-density * d), 0.0, 1.0)); +#endif +} + +/// How much a transparent fragment counts for against the others over its +/// pixel — `R8`. McGuire and Bavoil's depth weight (their equation 9): a near +/// layer outweighs a far one, which is all the ordering a weighted average +/// can keep. [alpha] multiplies it, as theirs does, so a faint layer counts +/// faintly. Depth along the view axis, in metres, the surface buffer's. +float WeightedBlendedWeight(float alpha) { + float z = abs(ViewDepth()); + float near = z / 5.0; + float far = z / 200.0; + float far3 = far * far * far; + return alpha * + clamp(10.0 / (1e-5 + near * near + far3 * far3), 1e-2, 3e3); +} + +/// What a transparent draw writes when the frame composites transparency +/// order-independently — `R8`. `fog_info.forward.w` says which: +/// +/// - 0: [frag_color] as it stands, the sorted blend's source. Every opaque +/// draw, and every draw in a frame that sorts. +/// - 1: the accumulation target's share — the colour, which the engine keeps +/// premultiplied, and the alpha, both times the weight. Added. +/// - 2: the revealage target's — the alpha alone, in every channel, which the +/// blend multiplies the target by one minus of. +/// - 3: both at once, the second into attachment one, where the surface +/// buffer would be; the pass that asks has no surface buffer attached. +/// +/// Selects rather than returns, because a phi of constants is what +/// SPIRV-Cross refuses. At nought the branch is not taken and [frag_color] +/// is untouched, which is what keeps a sorting frame byte-identical. +void WriteWeightedBlended() { +#ifndef F3D_NO_FOG + float mode = fog_info.forward.w; + if (mode > 0.5) { + float alpha = frag_color.a; + float weight = WeightedBlendedWeight(alpha); + vec4 accumulate = vec4(frag_color.rgb * weight, alpha * weight); + bool revealage = mode > 1.5 && mode < 2.5; + frag_color = revealage ? vec4(alpha) : accumulate; +#ifndef F3D_NO_SURFACE_BUFFER + if (mode > 2.5) frag_surface = vec4(alpha); +#endif + } +#endif +} + +/// The fog is mixed in before the weight, so a thin distant pane adds a thin +/// share of the fog too rather than all of it. Times one when nothing blends, +/// which is exact, so an opaque draw writes what it always wrote. +void WriteSurface(vec3 linearColor, float alpha, float roughness) { + float weight = g_premultiply ? alpha : 1.0; + frag_color = vec4(ApplyFog(linearColor) * weight, + alpha * (1.0 - g_pass_through)); + WriteSurfaceGeometry(roughness); + WriteWeightedBlended(); +} + +/// For a stage with no material to speak of. +/// +/// Fully rough, which is the honest default: a surface that cannot say how +/// polished it is should not be reflected off. +void WriteSurface(vec3 linearColor, float alpha) { + WriteSurface(linearColor, alpha, 1.0); +} + +/// Writes a value that is already display-referred. +/// +/// For debug output, where the colour is not a light value at all: a normal +/// encoded as RGB means nothing after a tone curve. Converting to linear here +/// means the composite pass's sRGB encode hands the original back unchanged, +/// provided the view also turns tone mapping and exposure off — which is what +/// `RenderSettings.tonemap` is for. +void WriteDisplayColor(vec3 displayColor, float alpha) { + frag_color = vec4(SrgbToLinear(displayColor), alpha); + WriteSurfaceGeometry(1.0); +} + +#endif // COLOR_GLSL_ + + +/// The base colour map, whose alpha is the mask. The same texture and the same +/// slot name the lit stages bind, so a caller that has one has it already. +uniform sampler2D base_color_texture; + +layout(std140) uniform MaskInfo { + // x: the cutoff, from `Material.alphaCutoff`. y: the base colour's own + // alpha, which glTF multiplies the texture's by, so a material faded to + // nothing casts nothing. z, w: unused. + vec4 mask; +} +mask_info; + +void main() { + float alpha = texture(base_color_texture, v_texcoord).a * mask_info.mask.y; + if (alpha < mask_info.mask.x) discard; + frag_color = vec4(gl_FragCoord.z, 0.0, 0.0, 1.0); +} + +''', + 'ShadowDistanceMasked': r'''#version 300 es +precision highp float; +precision highp int; +precision highp sampler2D; +precision highp samplerCube; + +// The point-light depth pass for a cut-out caster — `gfx-60n`. +// +// `shadow_distance.frag` with the same mask test `shadow_depth_masked.frag` +// carries, and a separate stage for the same reason: a caster that is not cut +// out keeps a pipeline with no sampler in it. +// +// Both stages exist because both shadow paths record a caster, and a leaf card +// lit by a torch is exactly as wrong as one lit by the sun. Point lights are +// where it shows worst, in fact: a cube face is a ninety-degree frustum with a +// caster close to it, so the slab of a foliage quad fills much more of the +// tile than it would in a cascade. + +#define F3D_NO_SURFACE_BUFFER +// No fog: shadow_depth.frag gives the reason. +#define F3D_NO_FOG +// --- lib/color.glsl --- +// Colour space helpers and the fragment output interface. +// +// Split out of surface.glsl so a shader that needs no material inputs — the +// normals debug view — can avoid DECLARING the FragInfo uniform block at all. +// That matters more than it looks: reflection metadata reports a block as +// present merely because it was declared, even when the compiled shader binds +// no such buffer, so a declared-but-unused block is indistinguishable from a +// used one until Metal crashes on the bind. + +#ifndef COLOR_GLSL_ +#define COLOR_GLSL_ + +precision highp float; + +const float kPi = 3.14159265359; + +// One varying set shared by every fragment shader, matching mesh.vert. +// +// All five are declared here, including the two the debug models never read: a +// fragment shader whose `in` block disagrees with the vertex shader's `out` +// block fails to link, and there is no partial-match rule to lean on. +in vec3 v_world_position; +in vec3 v_normal; +in vec2 v_texcoord; +in vec4 v_tangent; +in vec4 v_color; + +/// Where this fragment is in the level's lightmap. Zero from every vertex +/// stage but `mesh_lightmapped.vert`, and read only by the lit models, which +/// sample a one-texel black there when a material has no map. +in vec2 v_lightmap_uv; + +layout(location = 0) out vec4 frag_color; + +// The second attachment: what a screen-space effect needs to know about the +// surface it is looking at. World-space normal in rgb, and in a the depth along +// the view axis in world metres — not a window depth; `WriteSurfaceGeometry` +// says at length why not. +// +// Depth travels here rather than in a depth texture because flutter_gpu cannot +// sample one — the same reason the shadow pass writes its depth into a colour +// target. See ARCHITECTURE.md §2. +// +// Guarded, because not every stage that includes this header draws into a +// two-attachment target. The shadow pass draws into one, and a pipeline +// declaring an output its target has no slot for is a mismatch worth avoiding +// rather than discovering. +#ifndef F3D_NO_SURFACE_BUFFER +layout(location = 1) out vec4 frag_surface; + +/// The surface's own colour, sRGB-encoded, alpha one where a surface was +/// drawn — `L5`. The third attachment, present only when a pass reads it (the +/// indirect light does) and the device opens three; like the surface buffer, +/// written unconditionally and discarded when absent. Stored in the surface +/// buffer's format rather than eight bits a channel, and `Renderer` says why. +layout(location = 2) out vec4 frag_albedo; +#endif + +/// What [frag_albedo] carries: the lit models set it in `ReadSurface`, and a +/// stage that reflects nothing — unlit, the debug views — leaves it black, +/// which is what light bounced onto it would come to. +vec3 g_albedo = vec3(0.0); + +/// Octahedral encoding: a unit vector in two channels instead of three. +/// +/// Worth the arithmetic because the fourth channel is already spent on depth, +/// and without a free channel there is nowhere to put roughness — which is the +/// difference between a reflection that knows stone from a mirror and one that +/// does not. The error is well under a degree, far below anything a reflection +/// off rough stone would show. +vec2 EncodeOctahedral(vec3 n) { + n /= abs(n.x) + abs(n.y) + abs(n.z); + vec2 e = n.xy; + if (n.z < 0.0) { + e = (1.0 - abs(n.yx)) * vec2(n.x >= 0.0 ? 1.0 : -1.0, + n.y >= 0.0 ? 1.0 : -1.0); + } + return e * 0.5 + 0.5; +} + +/// Where a debug pass leaves the picture it wants shown instead of the normal. +/// +/// Declared here, in the header every lit shader includes **first**, and +/// written from surface.glsl, which is included after. The alternative was a +/// new member on a shared uniform block; a global costs nothing and moves no +/// offsets. It is read at the moment the surface buffer is written, which +/// happens after the lighting loop has run, so the value is there by then. +vec3 g_debug_surface = vec3(0.0); +bool g_debug_surface_on = false; + +/// Whether [WriteSurface] weights the colour by its alpha: set by +/// `ReadSurface` for a material that blends, and false for everything else. +/// +/// **The blend takes its source as premultiplied**, so a blended surface has +/// to hand it the colour times the alpha — a pane at a fifth of opaque adds a +/// fifth of its light, not all of it. glTF's blend mode is Porter and Duff's +/// over on straight colour, and this is the one place that turns the lit +/// radiance into what that means. An opaque or masked surface keeps its +/// colour whole: its alpha is not a coverage, and nothing blends it. +/// A global for the reason [g_debug_surface] is one. +bool g_premultiply = false; + +/// How much of what is already behind a blended surface comes through it — +/// a thin pane of glass, `M3`: what the blend multiplies the target by is +/// one minus the alpha, so the alpha written is the coverage less this share +/// of it, while the colour stays weighted by the coverage alone. Nought for +/// everything else, which writes what it always wrote. +float g_pass_through = 0.0; + +// **A stage that needs none of this must be able to declare none of it.** On +// Vulkan both stages' descriptors are merged into one set layout, and two +// bindings with the same number in it is not a layout the specification +// allows. A driver may accept it anyway; a Galaxy A55's refuses the pipeline +// with `ErrorUnknown` and no other word, which is how the shadow pass came to +// build everywhere except there — its only uniform block was this one, and it +// landed on the same binding as the vertex stage's first. +#ifndef F3D_NO_FOG + +/// Distance fog, in its own block rather than folded into FragInfo. +/// +/// Its own because color.glsl is included before FragInfo is declared, and +/// because appending to a block that half a dozen shaders already share is a +/// way to move offsets nobody expected to move. Three vec4s is a cheap price +/// for not touching any of that. +layout(std140) uniform FogInfo { + /// rgb: linear fog colour. w: density per metre at the eye, zero for no + /// fog. + vec4 fog; + + /// xyz: camera position in world space. Duplicated from FragInfo so this + /// block stands alone; a vec3 is cheaper than a coupling. w: how fast the + /// fog thins upwards, per metre — `P5`, see [ApplyFog]; nought is flat fog. + vec4 eye; + + /// xyz: the direction the camera looks, as a unit vector in world space. + /// w: what a transparent draw writes under weighted blended transparency — + /// `R8`, see `WriteWeightedBlended`. Zero for every other draw. + /// + /// Here rather than in a block of its own because it answers the same + /// question [eye] does — where the camera is and which way it faces — and + /// this is the block `color.glsl` can see. + vec4 forward; + + /// x: one when the camera's projection is orthographic, nought when it is + /// perspective — `P7`, see [Orthographic]. y, z, w unused. Appended, so + /// every offset above stays where four backends agree on it. + vec4 projection; +} +fog_info; + +/// Whether the camera is orthographic — `P7`. +/// +/// **What every eye-relative quantity has to ask first.** Through a +/// perspective lens the rays meet at the eye, so the way to the eye and the +/// distance to it are the fragment's own; through an orthographic one the +/// rays are parallel and the eye is only where the camera was put along its +/// axis, which moves nothing in the picture. Reading the eye's position as if +/// it were a point the light travels to slides highlights across the frame +/// as the camera pans and lays fog in rings round a point nobody sees. +bool Orthographic() { return fog_info.projection.x > 0.5; } + +/// How far this fragment is *along the view axis*, in world metres. +/// +/// What the surface buffer's alpha holds. Depth rather than distance, and the +/// difference only shows on an orthographic camera — where the rays through +/// the pixels are parallel instead of meeting at the eye, so a distance from +/// the eye names a sphere that the pixel's ray crosses somewhere the reader +/// cannot solve for. A depth along the axis names a plane, which every ray +/// crosses exactly once. See `WorldAtDepth` in `post/ssao.frag` for the +/// reconstruction both projections share. +float ViewDepth() { + return dot(v_world_position - fog_info.eye.xyz, fog_info.forward.xyz); +} + +/// How far this fragment is from the eye, in world metres. +/// +/// What the fog fades by. Distance rather than depth, because fog is a +/// property of the air between two points and does not care which way the +/// camera happens to face — through a perspective lens. Through an +/// orthographic one every ray starts on the eye's plane, so the air a ray +/// crosses is its depth from that plane, never less than nought. +float EyeDistance() { + return Orthographic() ? max(ViewDepth(), 0.0) + : distance(v_world_position, fog_info.eye.xyz); +} + +/// The unit direction from this fragment back along the ray that reached +/// it: to the eye through a perspective lens, against the view axis through +/// an orthographic one — `P7`. What a highlight, a Fresnel term and a +/// reflection are measured from. +vec3 TowardsEye() { + return Orthographic() ? -fog_info.forward.xyz + : normalize(fog_info.eye.xyz - v_world_position); +} + +#else // F3D_NO_FOG + +// The same two questions, answered without the block: a stage that declares no +// fog has no eye position to measure from either. Stubs rather than a guard at +// every call site, so that what includes this file reads the same whichever +// way it was compiled. +float EyeDistance() { return 0.0; } +float ViewDepth() { return 0.0; } + +#endif // F3D_NO_FOG + +/// sRGB to linear. Textures are authored in sRGB, but lighting is only correct +/// in linear space; skipping this is what makes naive renderers look muddy. +vec3 SrgbToLinear(vec3 srgb) { + return mix( + srgb / 12.92, + pow((srgb + vec3(0.055)) / 1.055, vec3(2.4)), + step(vec3(0.04045), srgb)); +} + +/// Linear to sRGB. The render target is a plain UNorm format rather than an +/// sRGB one, so the encode has to happen here. +vec3 LinearToSrgb(vec3 linear) { + return mix( + linear * 12.92, + 1.055 * pow(linear, vec3(1.0 / 2.4)) - vec3(0.055), + step(vec3(0.0031308), linear)); +} + +/// Writes scene-referred linear light into the HDR target. +/// +/// No tone map and no sRGB encode: those moved into the composite pass, which +/// is the entire point of rendering into `r16g16b16a16Float` first. Applying +/// them here meant every model wrote display-referred colour into an 8-bit +/// buffer, so anything above display white was gone before post-processing +/// could see it — and bloom is a function of exactly that. +/// +/// Exposure moved with them, for the same reason: it belongs on the same side +/// of the display transform as the tone map. +/// Records the geometry of this fragment for whatever runs after the scene. +/// +/// Called from the same place that writes colour, so a surface cannot be lit +/// into the frame without also describing itself — which is the failure that +/// leaves a screen-space effect reflecting whatever was in the buffer before. +/// +/// rg: octahedral normal. b: perceptual roughness. a: **depth along the view +/// axis, in world metres** — see [ViewDepth]. +/// +/// **Not `gl_FragCoord.z`, and that is a defect this channel carried until it +/// was looked at.** Window depth crowds every distant surface into the top of +/// its range — with a near plane of a tenth of a metre, everything past twenty +/// metres lives in the last half a hundredth of `[0, 1]` — and this attachment +/// is a half float, whose steps up there are about five ten-thousandths. So two +/// surfaces half a metre apart at twenty metres stored the *same* number, and +/// every screen-space pass that compares against this channel decided whole +/// bands of pixels by rounding. The occlusion pass drew them: vertical stripes +/// along the lines of constant depth on any wall receding from the camera, on +/// both GPU backends. The software rasteriser kept the channel at full +/// precision and drew the effect correctly, so it was the one that looked +/// wrong against the other two. +/// +/// A depth in metres has none of that: the exponent carries the range and the +/// mantissa carries the same relative precision everywhere, which at twenty +/// metres is a centimetre. Both numbers are measured in +/// `flutter3d/test/surface_depth_test.dart`. +/// +/// Zero still means nothing was drawn. The attachment is cleared to zero and +/// nothing is drawn in front of the near plane. +void WriteSurfaceGeometry(float roughness) { +#ifndef F3D_NO_SURFACE_BUFFER + // `L5`: the surface's colour, whatever the surface buffer ends up holding. + frag_albedo = vec4(LinearToSrgb(clamp(g_albedo, vec3(0.0), vec3(1.0))), 1.0); + // A debug pass takes the buffer over rather than getting one of its own. + // The surface buffer already has an attachment, a viewer and a golden; a + // second one would need all three built before it could answer anything. + if (g_debug_surface_on) { + frag_surface = vec4(g_debug_surface, ViewDepth()); + return; + } + // Reversed on a back face, as the lit normal is, so the occlusion and + // reflection passes see the side of a double-sided surface that faces them. + vec3 geometric = normalize(v_normal); + if (!gl_FrontFacing) geometric = -geometric; + frag_surface = vec4(EncodeOctahedral(geometric), + clamp(roughness, 0.0, 1.0), ViewDepth()); +#endif +} + +/// Fades [color] toward the fog with distance from the eye. +/// +/// Exponential rather than linear, because linear fog has a visible plane +/// where it starts and a dungeon corridor is exactly where that shows. +/// +/// **Height fog — `P5`.** `fog_info.fog.w` is the density at the eye and +/// `fog_info.eye.w` how fast it falls off upwards, per metre. The density +/// along the ray is then `density · e^(−falloff · Δy · t)` for t from nought +/// to one, and its mean over the ray is `(1 − e^(−k)) / k` with +/// `k = falloff · Δy`: an exact integral, so a fog lying on the ground costs +/// one exponential here and no march. Below a thousandth `k` is answered by +/// the first two terms of the same series, because the quotient there is +/// two nearly equal numbers divided by a small one. A falloff of nought skips +/// all of it, which keeps a flat fog the same to the bit. +vec3 ApplyFog(vec3 color) { +#ifdef F3D_NO_FOG + return color; +#else + float density = fog_info.fog.w; + if (density <= 0.0) return color; + float d = EyeDistance(); + float falloff = fog_info.eye.w; + if (falloff > 0.0) { + float k = falloff * (v_world_position.y - fog_info.eye.y); + density *= abs(k) > 1e-3 ? (1.0 - exp(-k)) / k : 1.0 - 0.5 * k; + } + return mix(fog_info.fog.rgb, color, clamp(exp(-density * d), 0.0, 1.0)); +#endif +} + +/// How much a transparent fragment counts for against the others over its +/// pixel — `R8`. McGuire and Bavoil's depth weight (their equation 9): a near +/// layer outweighs a far one, which is all the ordering a weighted average +/// can keep. [alpha] multiplies it, as theirs does, so a faint layer counts +/// faintly. Depth along the view axis, in metres, the surface buffer's. +float WeightedBlendedWeight(float alpha) { + float z = abs(ViewDepth()); + float near = z / 5.0; + float far = z / 200.0; + float far3 = far * far * far; + return alpha * + clamp(10.0 / (1e-5 + near * near + far3 * far3), 1e-2, 3e3); +} + +/// What a transparent draw writes when the frame composites transparency +/// order-independently — `R8`. `fog_info.forward.w` says which: +/// +/// - 0: [frag_color] as it stands, the sorted blend's source. Every opaque +/// draw, and every draw in a frame that sorts. +/// - 1: the accumulation target's share — the colour, which the engine keeps +/// premultiplied, and the alpha, both times the weight. Added. +/// - 2: the revealage target's — the alpha alone, in every channel, which the +/// blend multiplies the target by one minus of. +/// - 3: both at once, the second into attachment one, where the surface +/// buffer would be; the pass that asks has no surface buffer attached. +/// +/// Selects rather than returns, because a phi of constants is what +/// SPIRV-Cross refuses. At nought the branch is not taken and [frag_color] +/// is untouched, which is what keeps a sorting frame byte-identical. +void WriteWeightedBlended() { +#ifndef F3D_NO_FOG + float mode = fog_info.forward.w; + if (mode > 0.5) { + float alpha = frag_color.a; + float weight = WeightedBlendedWeight(alpha); + vec4 accumulate = vec4(frag_color.rgb * weight, alpha * weight); + bool revealage = mode > 1.5 && mode < 2.5; + frag_color = revealage ? vec4(alpha) : accumulate; +#ifndef F3D_NO_SURFACE_BUFFER + if (mode > 2.5) frag_surface = vec4(alpha); +#endif + } +#endif +} + +/// The fog is mixed in before the weight, so a thin distant pane adds a thin +/// share of the fog too rather than all of it. Times one when nothing blends, +/// which is exact, so an opaque draw writes what it always wrote. +void WriteSurface(vec3 linearColor, float alpha, float roughness) { + float weight = g_premultiply ? alpha : 1.0; + frag_color = vec4(ApplyFog(linearColor) * weight, + alpha * (1.0 - g_pass_through)); + WriteSurfaceGeometry(roughness); + WriteWeightedBlended(); +} + +/// For a stage with no material to speak of. +/// +/// Fully rough, which is the honest default: a surface that cannot say how +/// polished it is should not be reflected off. +void WriteSurface(vec3 linearColor, float alpha) { + WriteSurface(linearColor, alpha, 1.0); +} + +/// Writes a value that is already display-referred. +/// +/// For debug output, where the colour is not a light value at all: a normal +/// encoded as RGB means nothing after a tone curve. Converting to linear here +/// means the composite pass's sRGB encode hands the original back unchanged, +/// provided the view also turns tone mapping and exposure off — which is what +/// `RenderSettings.tonemap` is for. +void WriteDisplayColor(vec3 displayColor, float alpha) { + frag_color = vec4(SrgbToLinear(displayColor), alpha); + WriteSurfaceGeometry(1.0); +} + +#endif // COLOR_GLSL_ + + +layout(std140) uniform ShadowLight { + /// xyz: the light's world position. w: its range in metres. + vec4 light; +} +shadow_light; + +/// The base colour map, whose alpha is the mask. +uniform sampler2D base_color_texture; + +layout(std140) uniform MaskInfo { + // x: the cutoff, from `Material.alphaCutoff`. y: the base colour's own + // alpha. z, w: unused. + vec4 mask; +} +mask_info; + +void main() { + float alpha = texture(base_color_texture, v_texcoord).a * mask_info.mask.y; + if (alpha < mask_info.mask.x) discard; + + float range = max(shadow_light.light.w, 1e-4); + float distance = length(v_world_position - shadow_light.light.xyz); + frag_color = vec4(clamp(distance / range, 0.0, 1.0), 0.0, 0.0, 1.0); +} + +''', + 'CausticSurface': r'''#version 300 es +precision highp float; +precision highp int; +precision highp sampler2D; +precision highp samplerCube; + +// One side of a refracting caster, as the sun sees it — `ShadowSettings. +// caustics`. The surface's normal in the world, and its depth along the light +// in the same units the shadow atlas stores. +// +// Drawn twice into a small map of the caster alone: once nearest-first with +// the faces turned towards the light, once farthest-first with the faces +// turned away. `caustic_photon.vert` reads both, which is all a ray needs to +// be followed in at one side and out at the other. + +#define F3D_NO_SURFACE_BUFFER +#define F3D_NO_FOG + +// --- lib/color.glsl --- +// Colour space helpers and the fragment output interface. +// +// Split out of surface.glsl so a shader that needs no material inputs — the +// normals debug view — can avoid DECLARING the FragInfo uniform block at all. +// That matters more than it looks: reflection metadata reports a block as +// present merely because it was declared, even when the compiled shader binds +// no such buffer, so a declared-but-unused block is indistinguishable from a +// used one until Metal crashes on the bind. + +#ifndef COLOR_GLSL_ +#define COLOR_GLSL_ + +precision highp float; + +const float kPi = 3.14159265359; + +// One varying set shared by every fragment shader, matching mesh.vert. +// +// All five are declared here, including the two the debug models never read: a +// fragment shader whose `in` block disagrees with the vertex shader's `out` +// block fails to link, and there is no partial-match rule to lean on. +in vec3 v_world_position; +in vec3 v_normal; +in vec2 v_texcoord; +in vec4 v_tangent; +in vec4 v_color; + +/// Where this fragment is in the level's lightmap. Zero from every vertex +/// stage but `mesh_lightmapped.vert`, and read only by the lit models, which +/// sample a one-texel black there when a material has no map. +in vec2 v_lightmap_uv; + +layout(location = 0) out vec4 frag_color; + +// The second attachment: what a screen-space effect needs to know about the +// surface it is looking at. World-space normal in rgb, and in a the depth along +// the view axis in world metres — not a window depth; `WriteSurfaceGeometry` +// says at length why not. +// +// Depth travels here rather than in a depth texture because flutter_gpu cannot +// sample one — the same reason the shadow pass writes its depth into a colour +// target. See ARCHITECTURE.md §2. +// +// Guarded, because not every stage that includes this header draws into a +// two-attachment target. The shadow pass draws into one, and a pipeline +// declaring an output its target has no slot for is a mismatch worth avoiding +// rather than discovering. +#ifndef F3D_NO_SURFACE_BUFFER +layout(location = 1) out vec4 frag_surface; + +/// The surface's own colour, sRGB-encoded, alpha one where a surface was +/// drawn — `L5`. The third attachment, present only when a pass reads it (the +/// indirect light does) and the device opens three; like the surface buffer, +/// written unconditionally and discarded when absent. Stored in the surface +/// buffer's format rather than eight bits a channel, and `Renderer` says why. +layout(location = 2) out vec4 frag_albedo; +#endif + +/// What [frag_albedo] carries: the lit models set it in `ReadSurface`, and a +/// stage that reflects nothing — unlit, the debug views — leaves it black, +/// which is what light bounced onto it would come to. +vec3 g_albedo = vec3(0.0); + +/// Octahedral encoding: a unit vector in two channels instead of three. +/// +/// Worth the arithmetic because the fourth channel is already spent on depth, +/// and without a free channel there is nowhere to put roughness — which is the +/// difference between a reflection that knows stone from a mirror and one that +/// does not. The error is well under a degree, far below anything a reflection +/// off rough stone would show. +vec2 EncodeOctahedral(vec3 n) { + n /= abs(n.x) + abs(n.y) + abs(n.z); + vec2 e = n.xy; + if (n.z < 0.0) { + e = (1.0 - abs(n.yx)) * vec2(n.x >= 0.0 ? 1.0 : -1.0, + n.y >= 0.0 ? 1.0 : -1.0); + } + return e * 0.5 + 0.5; +} + +/// Where a debug pass leaves the picture it wants shown instead of the normal. +/// +/// Declared here, in the header every lit shader includes **first**, and +/// written from surface.glsl, which is included after. The alternative was a +/// new member on a shared uniform block; a global costs nothing and moves no +/// offsets. It is read at the moment the surface buffer is written, which +/// happens after the lighting loop has run, so the value is there by then. +vec3 g_debug_surface = vec3(0.0); +bool g_debug_surface_on = false; + +/// Whether [WriteSurface] weights the colour by its alpha: set by +/// `ReadSurface` for a material that blends, and false for everything else. +/// +/// **The blend takes its source as premultiplied**, so a blended surface has +/// to hand it the colour times the alpha — a pane at a fifth of opaque adds a +/// fifth of its light, not all of it. glTF's blend mode is Porter and Duff's +/// over on straight colour, and this is the one place that turns the lit +/// radiance into what that means. An opaque or masked surface keeps its +/// colour whole: its alpha is not a coverage, and nothing blends it. +/// A global for the reason [g_debug_surface] is one. +bool g_premultiply = false; + +/// How much of what is already behind a blended surface comes through it — +/// a thin pane of glass, `M3`: what the blend multiplies the target by is +/// one minus the alpha, so the alpha written is the coverage less this share +/// of it, while the colour stays weighted by the coverage alone. Nought for +/// everything else, which writes what it always wrote. +float g_pass_through = 0.0; + +// **A stage that needs none of this must be able to declare none of it.** On +// Vulkan both stages' descriptors are merged into one set layout, and two +// bindings with the same number in it is not a layout the specification +// allows. A driver may accept it anyway; a Galaxy A55's refuses the pipeline +// with `ErrorUnknown` and no other word, which is how the shadow pass came to +// build everywhere except there — its only uniform block was this one, and it +// landed on the same binding as the vertex stage's first. +#ifndef F3D_NO_FOG + +/// Distance fog, in its own block rather than folded into FragInfo. +/// +/// Its own because color.glsl is included before FragInfo is declared, and +/// because appending to a block that half a dozen shaders already share is a +/// way to move offsets nobody expected to move. Three vec4s is a cheap price +/// for not touching any of that. +layout(std140) uniform FogInfo { + /// rgb: linear fog colour. w: density per metre at the eye, zero for no + /// fog. + vec4 fog; + + /// xyz: camera position in world space. Duplicated from FragInfo so this + /// block stands alone; a vec3 is cheaper than a coupling. w: how fast the + /// fog thins upwards, per metre — `P5`, see [ApplyFog]; nought is flat fog. + vec4 eye; + + /// xyz: the direction the camera looks, as a unit vector in world space. + /// w: what a transparent draw writes under weighted blended transparency — + /// `R8`, see `WriteWeightedBlended`. Zero for every other draw. + /// + /// Here rather than in a block of its own because it answers the same + /// question [eye] does — where the camera is and which way it faces — and + /// this is the block `color.glsl` can see. + vec4 forward; + + /// x: one when the camera's projection is orthographic, nought when it is + /// perspective — `P7`, see [Orthographic]. y, z, w unused. Appended, so + /// every offset above stays where four backends agree on it. + vec4 projection; +} +fog_info; + +/// Whether the camera is orthographic — `P7`. +/// +/// **What every eye-relative quantity has to ask first.** Through a +/// perspective lens the rays meet at the eye, so the way to the eye and the +/// distance to it are the fragment's own; through an orthographic one the +/// rays are parallel and the eye is only where the camera was put along its +/// axis, which moves nothing in the picture. Reading the eye's position as if +/// it were a point the light travels to slides highlights across the frame +/// as the camera pans and lays fog in rings round a point nobody sees. +bool Orthographic() { return fog_info.projection.x > 0.5; } + +/// How far this fragment is *along the view axis*, in world metres. +/// +/// What the surface buffer's alpha holds. Depth rather than distance, and the +/// difference only shows on an orthographic camera — where the rays through +/// the pixels are parallel instead of meeting at the eye, so a distance from +/// the eye names a sphere that the pixel's ray crosses somewhere the reader +/// cannot solve for. A depth along the axis names a plane, which every ray +/// crosses exactly once. See `WorldAtDepth` in `post/ssao.frag` for the +/// reconstruction both projections share. +float ViewDepth() { + return dot(v_world_position - fog_info.eye.xyz, fog_info.forward.xyz); +} + +/// How far this fragment is from the eye, in world metres. +/// +/// What the fog fades by. Distance rather than depth, because fog is a +/// property of the air between two points and does not care which way the +/// camera happens to face — through a perspective lens. Through an +/// orthographic one every ray starts on the eye's plane, so the air a ray +/// crosses is its depth from that plane, never less than nought. +float EyeDistance() { + return Orthographic() ? max(ViewDepth(), 0.0) + : distance(v_world_position, fog_info.eye.xyz); +} + +/// The unit direction from this fragment back along the ray that reached +/// it: to the eye through a perspective lens, against the view axis through +/// an orthographic one — `P7`. What a highlight, a Fresnel term and a +/// reflection are measured from. +vec3 TowardsEye() { + return Orthographic() ? -fog_info.forward.xyz + : normalize(fog_info.eye.xyz - v_world_position); +} + +#else // F3D_NO_FOG + +// The same two questions, answered without the block: a stage that declares no +// fog has no eye position to measure from either. Stubs rather than a guard at +// every call site, so that what includes this file reads the same whichever +// way it was compiled. +float EyeDistance() { return 0.0; } +float ViewDepth() { return 0.0; } + +#endif // F3D_NO_FOG + +/// sRGB to linear. Textures are authored in sRGB, but lighting is only correct +/// in linear space; skipping this is what makes naive renderers look muddy. +vec3 SrgbToLinear(vec3 srgb) { + return mix( + srgb / 12.92, + pow((srgb + vec3(0.055)) / 1.055, vec3(2.4)), + step(vec3(0.04045), srgb)); +} + +/// Linear to sRGB. The render target is a plain UNorm format rather than an +/// sRGB one, so the encode has to happen here. +vec3 LinearToSrgb(vec3 linear) { + return mix( + linear * 12.92, + 1.055 * pow(linear, vec3(1.0 / 2.4)) - vec3(0.055), + step(vec3(0.0031308), linear)); +} + +/// Writes scene-referred linear light into the HDR target. +/// +/// No tone map and no sRGB encode: those moved into the composite pass, which +/// is the entire point of rendering into `r16g16b16a16Float` first. Applying +/// them here meant every model wrote display-referred colour into an 8-bit +/// buffer, so anything above display white was gone before post-processing +/// could see it — and bloom is a function of exactly that. +/// +/// Exposure moved with them, for the same reason: it belongs on the same side +/// of the display transform as the tone map. +/// Records the geometry of this fragment for whatever runs after the scene. +/// +/// Called from the same place that writes colour, so a surface cannot be lit +/// into the frame without also describing itself — which is the failure that +/// leaves a screen-space effect reflecting whatever was in the buffer before. +/// +/// rg: octahedral normal. b: perceptual roughness. a: **depth along the view +/// axis, in world metres** — see [ViewDepth]. +/// +/// **Not `gl_FragCoord.z`, and that is a defect this channel carried until it +/// was looked at.** Window depth crowds every distant surface into the top of +/// its range — with a near plane of a tenth of a metre, everything past twenty +/// metres lives in the last half a hundredth of `[0, 1]` — and this attachment +/// is a half float, whose steps up there are about five ten-thousandths. So two +/// surfaces half a metre apart at twenty metres stored the *same* number, and +/// every screen-space pass that compares against this channel decided whole +/// bands of pixels by rounding. The occlusion pass drew them: vertical stripes +/// along the lines of constant depth on any wall receding from the camera, on +/// both GPU backends. The software rasteriser kept the channel at full +/// precision and drew the effect correctly, so it was the one that looked +/// wrong against the other two. +/// +/// A depth in metres has none of that: the exponent carries the range and the +/// mantissa carries the same relative precision everywhere, which at twenty +/// metres is a centimetre. Both numbers are measured in +/// `flutter3d/test/surface_depth_test.dart`. +/// +/// Zero still means nothing was drawn. The attachment is cleared to zero and +/// nothing is drawn in front of the near plane. +void WriteSurfaceGeometry(float roughness) { +#ifndef F3D_NO_SURFACE_BUFFER + // `L5`: the surface's colour, whatever the surface buffer ends up holding. + frag_albedo = vec4(LinearToSrgb(clamp(g_albedo, vec3(0.0), vec3(1.0))), 1.0); + // A debug pass takes the buffer over rather than getting one of its own. + // The surface buffer already has an attachment, a viewer and a golden; a + // second one would need all three built before it could answer anything. + if (g_debug_surface_on) { + frag_surface = vec4(g_debug_surface, ViewDepth()); + return; + } + // Reversed on a back face, as the lit normal is, so the occlusion and + // reflection passes see the side of a double-sided surface that faces them. + vec3 geometric = normalize(v_normal); + if (!gl_FrontFacing) geometric = -geometric; + frag_surface = vec4(EncodeOctahedral(geometric), + clamp(roughness, 0.0, 1.0), ViewDepth()); +#endif +} + +/// Fades [color] toward the fog with distance from the eye. +/// +/// Exponential rather than linear, because linear fog has a visible plane +/// where it starts and a dungeon corridor is exactly where that shows. +/// +/// **Height fog — `P5`.** `fog_info.fog.w` is the density at the eye and +/// `fog_info.eye.w` how fast it falls off upwards, per metre. The density +/// along the ray is then `density · e^(−falloff · Δy · t)` for t from nought +/// to one, and its mean over the ray is `(1 − e^(−k)) / k` with +/// `k = falloff · Δy`: an exact integral, so a fog lying on the ground costs +/// one exponential here and no march. Below a thousandth `k` is answered by +/// the first two terms of the same series, because the quotient there is +/// two nearly equal numbers divided by a small one. A falloff of nought skips +/// all of it, which keeps a flat fog the same to the bit. +vec3 ApplyFog(vec3 color) { +#ifdef F3D_NO_FOG + return color; +#else + float density = fog_info.fog.w; + if (density <= 0.0) return color; + float d = EyeDistance(); + float falloff = fog_info.eye.w; + if (falloff > 0.0) { + float k = falloff * (v_world_position.y - fog_info.eye.y); + density *= abs(k) > 1e-3 ? (1.0 - exp(-k)) / k : 1.0 - 0.5 * k; + } + return mix(fog_info.fog.rgb, color, clamp(exp(-density * d), 0.0, 1.0)); +#endif +} + +/// How much a transparent fragment counts for against the others over its +/// pixel — `R8`. McGuire and Bavoil's depth weight (their equation 9): a near +/// layer outweighs a far one, which is all the ordering a weighted average +/// can keep. [alpha] multiplies it, as theirs does, so a faint layer counts +/// faintly. Depth along the view axis, in metres, the surface buffer's. +float WeightedBlendedWeight(float alpha) { + float z = abs(ViewDepth()); + float near = z / 5.0; + float far = z / 200.0; + float far3 = far * far * far; + return alpha * + clamp(10.0 / (1e-5 + near * near + far3 * far3), 1e-2, 3e3); +} + +/// What a transparent draw writes when the frame composites transparency +/// order-independently — `R8`. `fog_info.forward.w` says which: +/// +/// - 0: [frag_color] as it stands, the sorted blend's source. Every opaque +/// draw, and every draw in a frame that sorts. +/// - 1: the accumulation target's share — the colour, which the engine keeps +/// premultiplied, and the alpha, both times the weight. Added. +/// - 2: the revealage target's — the alpha alone, in every channel, which the +/// blend multiplies the target by one minus of. +/// - 3: both at once, the second into attachment one, where the surface +/// buffer would be; the pass that asks has no surface buffer attached. +/// +/// Selects rather than returns, because a phi of constants is what +/// SPIRV-Cross refuses. At nought the branch is not taken and [frag_color] +/// is untouched, which is what keeps a sorting frame byte-identical. +void WriteWeightedBlended() { +#ifndef F3D_NO_FOG + float mode = fog_info.forward.w; + if (mode > 0.5) { + float alpha = frag_color.a; + float weight = WeightedBlendedWeight(alpha); + vec4 accumulate = vec4(frag_color.rgb * weight, alpha * weight); + bool revealage = mode > 1.5 && mode < 2.5; + frag_color = revealage ? vec4(alpha) : accumulate; +#ifndef F3D_NO_SURFACE_BUFFER + if (mode > 2.5) frag_surface = vec4(alpha); +#endif + } +#endif +} + +/// The fog is mixed in before the weight, so a thin distant pane adds a thin +/// share of the fog too rather than all of it. Times one when nothing blends, +/// which is exact, so an opaque draw writes what it always wrote. +void WriteSurface(vec3 linearColor, float alpha, float roughness) { + float weight = g_premultiply ? alpha : 1.0; + frag_color = vec4(ApplyFog(linearColor) * weight, + alpha * (1.0 - g_pass_through)); + WriteSurfaceGeometry(roughness); + WriteWeightedBlended(); +} + +/// For a stage with no material to speak of. +/// +/// Fully rough, which is the honest default: a surface that cannot say how +/// polished it is should not be reflected off. +void WriteSurface(vec3 linearColor, float alpha) { + WriteSurface(linearColor, alpha, 1.0); +} + +/// Writes a value that is already display-referred. +/// +/// For debug output, where the colour is not a light value at all: a normal +/// encoded as RGB means nothing after a tone curve. Converting to linear here +/// means the composite pass's sRGB encode hands the original back unchanged, +/// provided the view also turns tone mapping and exposure off — which is what +/// `RenderSettings.tonemap` is for. +void WriteDisplayColor(vec3 displayColor, float alpha) { + frag_color = vec4(SrgbToLinear(displayColor), alpha); + WriteSurfaceGeometry(1.0); +} + +#endif // COLOR_GLSL_ + + +void main() { + frag_color = vec4(normalize(v_normal), gl_FragCoord.z); +} + +''', + 'CausticPhoton': r'''#version 300 es +precision highp float; +precision highp int; +precision highp sampler2D; +precision highp samplerCube; + +// A photon's quad, added into the sun's atlas — `ShadowSettings.caustics`. +// +// The atlas's green and blue hold what was taken from red and green, and +// its alpha what was left of blue (`shadow_transmittance.frag`). The pass +// blends colour as destination minus source and alpha as destination plus +// source, so this gives back light: red comes in as nought and the depth in +// it is untouched. +// +// The falloff is (1 − r²)², whose integral over the quad is π/3 of its area; +// the vertex stage has already divided by that, so photons spread evenly add +// up to the light that fell on them. + +precision highp float; + +in vec4 v_color; +in vec2 v_uv; + +layout(location = 0) out vec4 frag_color; + +void main() { + float r2 = dot(v_uv, v_uv); + if (r2 >= 1.0) discard; + float k = (1.0 - r2) * (1.0 - r2); + vec3 e = v_color.rgb * k; + frag_color = vec4(0.0, e.r, e.g, e.b); +} + +''', + 'ShadowTransmittance': r'''#version 300 es +precision highp float; +precision highp int; +precision highp sampler2D; +precision highp samplerCube; + +// What a see-through caster lets through to the sun's shadow map — +// `ShadowSettings.translucentCasters`. +// +// Drawn after the opaque casters into the same atlas, depth-tested against +// them and writing no depth of its own, so a pane behind a wall adds nothing +// and two panes in a row both count. +// +// **The atlas's other three channels, in a layout chosen so that every other +// stage keeps writing exactly what it wrote.** Red stays the depth. Green and +// blue hold how much of red and green is *taken away*, and alpha how much of +// blue is *let through*: an opaque caster writes (z, 0, 0, 1) and so says +// "nothing taken" without knowing this stage exists, and so does the static +// tile's copy. The pass blends green and blue as `src + dst·(1 − src)`, which +// is what absorption does over layers — 1 − (1 − a)(1 − b) — and leaves red +// alone because red comes in as nought; alpha it multiplies. +// +// What one surface lets through: +// +// * the share of it that is not there at all, 1 − opacity; +// * of the share that is, what its transmission passes, tinted by its +// colour; +// * and of that, what is not reflected away at its surface, from the +// Schlick term of its index — which is what makes the rim of a glass, met +// at a grazing angle, darker than its middle. +// +// Where refracted light lands — the bright line down a tube's shadow — is +// not here: that needs the ray followed through both surfaces, and this +// stage sees one at a time. What it does offer is the means to say so from +// outside: the material's base colour map multiplies what it lets through, +// and neither is held to one, so a caster that is only a shadow — a card +// marked `ShadowCastingMode.shadowsOnly`, carrying a picture of where the +// light went — can darken the floor where it went away and brighten it, +// past one, where it gathered. +// +// The body's share is taken as a square root because a closed caster is +// recorded from both of its sides: light crosses its body once, and its two +// surfaces between them give it once. A caster recorded from one side only +// comes out lighter than it is, never darker. + +#define F3D_NO_SURFACE_BUFFER +#define F3D_NO_FOG + +// --- lib/color.glsl --- +// Colour space helpers and the fragment output interface. +// +// Split out of surface.glsl so a shader that needs no material inputs — the +// normals debug view — can avoid DECLARING the FragInfo uniform block at all. +// That matters more than it looks: reflection metadata reports a block as +// present merely because it was declared, even when the compiled shader binds +// no such buffer, so a declared-but-unused block is indistinguishable from a +// used one until Metal crashes on the bind. + +#ifndef COLOR_GLSL_ +#define COLOR_GLSL_ + +precision highp float; + +const float kPi = 3.14159265359; + +// One varying set shared by every fragment shader, matching mesh.vert. +// +// All five are declared here, including the two the debug models never read: a +// fragment shader whose `in` block disagrees with the vertex shader's `out` +// block fails to link, and there is no partial-match rule to lean on. +in vec3 v_world_position; +in vec3 v_normal; +in vec2 v_texcoord; +in vec4 v_tangent; +in vec4 v_color; + +/// Where this fragment is in the level's lightmap. Zero from every vertex +/// stage but `mesh_lightmapped.vert`, and read only by the lit models, which +/// sample a one-texel black there when a material has no map. +in vec2 v_lightmap_uv; + +layout(location = 0) out vec4 frag_color; + +// The second attachment: what a screen-space effect needs to know about the +// surface it is looking at. World-space normal in rgb, and in a the depth along +// the view axis in world metres — not a window depth; `WriteSurfaceGeometry` +// says at length why not. +// +// Depth travels here rather than in a depth texture because flutter_gpu cannot +// sample one — the same reason the shadow pass writes its depth into a colour +// target. See ARCHITECTURE.md §2. +// +// Guarded, because not every stage that includes this header draws into a +// two-attachment target. The shadow pass draws into one, and a pipeline +// declaring an output its target has no slot for is a mismatch worth avoiding +// rather than discovering. +#ifndef F3D_NO_SURFACE_BUFFER +layout(location = 1) out vec4 frag_surface; + +/// The surface's own colour, sRGB-encoded, alpha one where a surface was +/// drawn — `L5`. The third attachment, present only when a pass reads it (the +/// indirect light does) and the device opens three; like the surface buffer, +/// written unconditionally and discarded when absent. Stored in the surface +/// buffer's format rather than eight bits a channel, and `Renderer` says why. +layout(location = 2) out vec4 frag_albedo; +#endif + +/// What [frag_albedo] carries: the lit models set it in `ReadSurface`, and a +/// stage that reflects nothing — unlit, the debug views — leaves it black, +/// which is what light bounced onto it would come to. +vec3 g_albedo = vec3(0.0); + +/// Octahedral encoding: a unit vector in two channels instead of three. +/// +/// Worth the arithmetic because the fourth channel is already spent on depth, +/// and without a free channel there is nowhere to put roughness — which is the +/// difference between a reflection that knows stone from a mirror and one that +/// does not. The error is well under a degree, far below anything a reflection +/// off rough stone would show. +vec2 EncodeOctahedral(vec3 n) { + n /= abs(n.x) + abs(n.y) + abs(n.z); + vec2 e = n.xy; + if (n.z < 0.0) { + e = (1.0 - abs(n.yx)) * vec2(n.x >= 0.0 ? 1.0 : -1.0, + n.y >= 0.0 ? 1.0 : -1.0); + } + return e * 0.5 + 0.5; +} + +/// Where a debug pass leaves the picture it wants shown instead of the normal. +/// +/// Declared here, in the header every lit shader includes **first**, and +/// written from surface.glsl, which is included after. The alternative was a +/// new member on a shared uniform block; a global costs nothing and moves no +/// offsets. It is read at the moment the surface buffer is written, which +/// happens after the lighting loop has run, so the value is there by then. +vec3 g_debug_surface = vec3(0.0); +bool g_debug_surface_on = false; + +/// Whether [WriteSurface] weights the colour by its alpha: set by +/// `ReadSurface` for a material that blends, and false for everything else. +/// +/// **The blend takes its source as premultiplied**, so a blended surface has +/// to hand it the colour times the alpha — a pane at a fifth of opaque adds a +/// fifth of its light, not all of it. glTF's blend mode is Porter and Duff's +/// over on straight colour, and this is the one place that turns the lit +/// radiance into what that means. An opaque or masked surface keeps its +/// colour whole: its alpha is not a coverage, and nothing blends it. +/// A global for the reason [g_debug_surface] is one. +bool g_premultiply = false; + +/// How much of what is already behind a blended surface comes through it — +/// a thin pane of glass, `M3`: what the blend multiplies the target by is +/// one minus the alpha, so the alpha written is the coverage less this share +/// of it, while the colour stays weighted by the coverage alone. Nought for +/// everything else, which writes what it always wrote. +float g_pass_through = 0.0; + +// **A stage that needs none of this must be able to declare none of it.** On +// Vulkan both stages' descriptors are merged into one set layout, and two +// bindings with the same number in it is not a layout the specification +// allows. A driver may accept it anyway; a Galaxy A55's refuses the pipeline +// with `ErrorUnknown` and no other word, which is how the shadow pass came to +// build everywhere except there — its only uniform block was this one, and it +// landed on the same binding as the vertex stage's first. +#ifndef F3D_NO_FOG + +/// Distance fog, in its own block rather than folded into FragInfo. +/// +/// Its own because color.glsl is included before FragInfo is declared, and +/// because appending to a block that half a dozen shaders already share is a +/// way to move offsets nobody expected to move. Three vec4s is a cheap price +/// for not touching any of that. +layout(std140) uniform FogInfo { + /// rgb: linear fog colour. w: density per metre at the eye, zero for no + /// fog. + vec4 fog; + + /// xyz: camera position in world space. Duplicated from FragInfo so this + /// block stands alone; a vec3 is cheaper than a coupling. w: how fast the + /// fog thins upwards, per metre — `P5`, see [ApplyFog]; nought is flat fog. + vec4 eye; + + /// xyz: the direction the camera looks, as a unit vector in world space. + /// w: what a transparent draw writes under weighted blended transparency — + /// `R8`, see `WriteWeightedBlended`. Zero for every other draw. + /// + /// Here rather than in a block of its own because it answers the same + /// question [eye] does — where the camera is and which way it faces — and + /// this is the block `color.glsl` can see. + vec4 forward; + + /// x: one when the camera's projection is orthographic, nought when it is + /// perspective — `P7`, see [Orthographic]. y, z, w unused. Appended, so + /// every offset above stays where four backends agree on it. + vec4 projection; +} +fog_info; + +/// Whether the camera is orthographic — `P7`. +/// +/// **What every eye-relative quantity has to ask first.** Through a +/// perspective lens the rays meet at the eye, so the way to the eye and the +/// distance to it are the fragment's own; through an orthographic one the +/// rays are parallel and the eye is only where the camera was put along its +/// axis, which moves nothing in the picture. Reading the eye's position as if +/// it were a point the light travels to slides highlights across the frame +/// as the camera pans and lays fog in rings round a point nobody sees. +bool Orthographic() { return fog_info.projection.x > 0.5; } + +/// How far this fragment is *along the view axis*, in world metres. +/// +/// What the surface buffer's alpha holds. Depth rather than distance, and the +/// difference only shows on an orthographic camera — where the rays through +/// the pixels are parallel instead of meeting at the eye, so a distance from +/// the eye names a sphere that the pixel's ray crosses somewhere the reader +/// cannot solve for. A depth along the axis names a plane, which every ray +/// crosses exactly once. See `WorldAtDepth` in `post/ssao.frag` for the +/// reconstruction both projections share. +float ViewDepth() { + return dot(v_world_position - fog_info.eye.xyz, fog_info.forward.xyz); +} + +/// How far this fragment is from the eye, in world metres. +/// +/// What the fog fades by. Distance rather than depth, because fog is a +/// property of the air between two points and does not care which way the +/// camera happens to face — through a perspective lens. Through an +/// orthographic one every ray starts on the eye's plane, so the air a ray +/// crosses is its depth from that plane, never less than nought. +float EyeDistance() { + return Orthographic() ? max(ViewDepth(), 0.0) + : distance(v_world_position, fog_info.eye.xyz); +} + +/// The unit direction from this fragment back along the ray that reached +/// it: to the eye through a perspective lens, against the view axis through +/// an orthographic one — `P7`. What a highlight, a Fresnel term and a +/// reflection are measured from. +vec3 TowardsEye() { + return Orthographic() ? -fog_info.forward.xyz + : normalize(fog_info.eye.xyz - v_world_position); +} + +#else // F3D_NO_FOG + +// The same two questions, answered without the block: a stage that declares no +// fog has no eye position to measure from either. Stubs rather than a guard at +// every call site, so that what includes this file reads the same whichever +// way it was compiled. +float EyeDistance() { return 0.0; } +float ViewDepth() { return 0.0; } + +#endif // F3D_NO_FOG + +/// sRGB to linear. Textures are authored in sRGB, but lighting is only correct +/// in linear space; skipping this is what makes naive renderers look muddy. +vec3 SrgbToLinear(vec3 srgb) { + return mix( + srgb / 12.92, + pow((srgb + vec3(0.055)) / 1.055, vec3(2.4)), + step(vec3(0.04045), srgb)); +} + +/// Linear to sRGB. The render target is a plain UNorm format rather than an +/// sRGB one, so the encode has to happen here. +vec3 LinearToSrgb(vec3 linear) { + return mix( + linear * 12.92, + 1.055 * pow(linear, vec3(1.0 / 2.4)) - vec3(0.055), + step(vec3(0.0031308), linear)); +} + +/// Writes scene-referred linear light into the HDR target. +/// +/// No tone map and no sRGB encode: those moved into the composite pass, which +/// is the entire point of rendering into `r16g16b16a16Float` first. Applying +/// them here meant every model wrote display-referred colour into an 8-bit +/// buffer, so anything above display white was gone before post-processing +/// could see it — and bloom is a function of exactly that. +/// +/// Exposure moved with them, for the same reason: it belongs on the same side +/// of the display transform as the tone map. +/// Records the geometry of this fragment for whatever runs after the scene. +/// +/// Called from the same place that writes colour, so a surface cannot be lit +/// into the frame without also describing itself — which is the failure that +/// leaves a screen-space effect reflecting whatever was in the buffer before. +/// +/// rg: octahedral normal. b: perceptual roughness. a: **depth along the view +/// axis, in world metres** — see [ViewDepth]. +/// +/// **Not `gl_FragCoord.z`, and that is a defect this channel carried until it +/// was looked at.** Window depth crowds every distant surface into the top of +/// its range — with a near plane of a tenth of a metre, everything past twenty +/// metres lives in the last half a hundredth of `[0, 1]` — and this attachment +/// is a half float, whose steps up there are about five ten-thousandths. So two +/// surfaces half a metre apart at twenty metres stored the *same* number, and +/// every screen-space pass that compares against this channel decided whole +/// bands of pixels by rounding. The occlusion pass drew them: vertical stripes +/// along the lines of constant depth on any wall receding from the camera, on +/// both GPU backends. The software rasteriser kept the channel at full +/// precision and drew the effect correctly, so it was the one that looked +/// wrong against the other two. +/// +/// A depth in metres has none of that: the exponent carries the range and the +/// mantissa carries the same relative precision everywhere, which at twenty +/// metres is a centimetre. Both numbers are measured in +/// `flutter3d/test/surface_depth_test.dart`. +/// +/// Zero still means nothing was drawn. The attachment is cleared to zero and +/// nothing is drawn in front of the near plane. +void WriteSurfaceGeometry(float roughness) { +#ifndef F3D_NO_SURFACE_BUFFER + // `L5`: the surface's colour, whatever the surface buffer ends up holding. + frag_albedo = vec4(LinearToSrgb(clamp(g_albedo, vec3(0.0), vec3(1.0))), 1.0); + // A debug pass takes the buffer over rather than getting one of its own. + // The surface buffer already has an attachment, a viewer and a golden; a + // second one would need all three built before it could answer anything. + if (g_debug_surface_on) { + frag_surface = vec4(g_debug_surface, ViewDepth()); + return; + } + // Reversed on a back face, as the lit normal is, so the occlusion and + // reflection passes see the side of a double-sided surface that faces them. + vec3 geometric = normalize(v_normal); + if (!gl_FrontFacing) geometric = -geometric; + frag_surface = vec4(EncodeOctahedral(geometric), + clamp(roughness, 0.0, 1.0), ViewDepth()); +#endif +} + +/// Fades [color] toward the fog with distance from the eye. +/// +/// Exponential rather than linear, because linear fog has a visible plane +/// where it starts and a dungeon corridor is exactly where that shows. +/// +/// **Height fog — `P5`.** `fog_info.fog.w` is the density at the eye and +/// `fog_info.eye.w` how fast it falls off upwards, per metre. The density +/// along the ray is then `density · e^(−falloff · Δy · t)` for t from nought +/// to one, and its mean over the ray is `(1 − e^(−k)) / k` with +/// `k = falloff · Δy`: an exact integral, so a fog lying on the ground costs +/// one exponential here and no march. Below a thousandth `k` is answered by +/// the first two terms of the same series, because the quotient there is +/// two nearly equal numbers divided by a small one. A falloff of nought skips +/// all of it, which keeps a flat fog the same to the bit. vec3 ApplyFog(vec3 color) { #ifdef F3D_NO_FOG return color; @@ -29616,6 +34400,11 @@ vec3 ApplyFog(vec3 color) { float density = fog_info.fog.w; if (density <= 0.0) return color; float d = EyeDistance(); + float falloff = fog_info.eye.w; + if (falloff > 0.0) { + float k = falloff * (v_world_position.y - fog_info.eye.y); + density *= abs(k) > 1e-3 ? (1.0 - exp(-k)) / k : 1.0 - 0.5 * k; + } return mix(fog_info.fog.rgb, color, clamp(exp(-density * d), 0.0, 1.0)); #endif } @@ -29670,7 +34459,8 @@ void WriteWeightedBlended() { /// which is exact, so an opaque draw writes what it always wrote. void WriteSurface(vec3 linearColor, float alpha, float roughness) { float weight = g_premultiply ? alpha : 1.0; - frag_color = vec4(ApplyFog(linearColor) * weight, alpha); + frag_color = vec4(ApplyFog(linearColor) * weight, + alpha * (1.0 - g_pass_through)); WriteSurfaceGeometry(roughness); WriteWeightedBlended(); } @@ -29698,407 +34488,55 @@ void WriteDisplayColor(vec3 displayColor, float alpha) { #endif // COLOR_GLSL_ -/// The base colour map, whose alpha is the mask. The same texture and the same -/// slot name the lit stages bind, so a caller that has one has it already. +/// The material's base colour map, or a white texel when it has none. uniform sampler2D base_color_texture; -layout(std140) uniform MaskInfo { - // x: the cutoff, from `Material.alphaCutoff`. y: the base colour's own - // alpha, which glTF multiplies the texture's by, so a material faded to - // nothing casts nothing. z, w: unused. - vec4 mask; -} -mask_info; - -void main() { - float alpha = texture(base_color_texture, v_texcoord).a * mask_info.mask.y; - if (alpha < mask_info.mask.x) discard; - frag_color = vec4(gl_FragCoord.z, 0.0, 0.0, 1.0); -} - -''', - 'ShadowDistanceMasked': r'''#version 300 es -precision highp float; -precision highp int; -precision highp sampler2D; -precision highp samplerCube; - -// The point-light depth pass for a cut-out caster — `gfx-60n`. -// -// `shadow_distance.frag` with the same mask test `shadow_depth_masked.frag` -// carries, and a separate stage for the same reason: a caster that is not cut -// out keeps a pipeline with no sampler in it. -// -// Both stages exist because both shadow paths record a caster, and a leaf card -// lit by a torch is exactly as wrong as one lit by the sun. Point lights are -// where it shows worst, in fact: a cube face is a ninety-degree frustum with a -// caster close to it, so the slab of a foliage quad fills much more of the -// tile than it would in a cascade. - -#define F3D_NO_SURFACE_BUFFER -// No fog: shadow_depth.frag gives the reason. -#define F3D_NO_FOG -// --- lib/color.glsl --- -// Colour space helpers and the fragment output interface. -// -// Split out of surface.glsl so a shader that needs no material inputs — the -// normals debug view — can avoid DECLARING the FragInfo uniform block at all. -// That matters more than it looks: reflection metadata reports a block as -// present merely because it was declared, even when the compiled shader binds -// no such buffer, so a declared-but-unused block is indistinguishable from a -// used one until Metal crashes on the bind. - -#ifndef COLOR_GLSL_ -#define COLOR_GLSL_ - -precision highp float; - -const float kPi = 3.14159265359; - -// One varying set shared by every fragment shader, matching mesh.vert. -// -// All five are declared here, including the two the debug models never read: a -// fragment shader whose `in` block disagrees with the vertex shader's `out` -// block fails to link, and there is no partial-match rule to lean on. -in vec3 v_world_position; -in vec3 v_normal; -in vec2 v_texcoord; -in vec4 v_tangent; -in vec4 v_color; - -/// Where this fragment is in the level's lightmap. Zero from every vertex -/// stage but `mesh_lightmapped.vert`, and read only by the lit models, which -/// sample a one-texel black there when a material has no map. -in vec2 v_lightmap_uv; - -layout(location = 0) out vec4 frag_color; - -// The second attachment: what a screen-space effect needs to know about the -// surface it is looking at. World-space normal in rgb, and in a the depth along -// the view axis in world metres — not a window depth; `WriteSurfaceGeometry` -// says at length why not. -// -// Depth travels here rather than in a depth texture because flutter_gpu cannot -// sample one — the same reason the shadow pass writes its depth into a colour -// target. See ARCHITECTURE.md §2. -// -// Guarded, because not every stage that includes this header draws into a -// two-attachment target. The shadow pass draws into one, and a pipeline -// declaring an output its target has no slot for is a mismatch worth avoiding -// rather than discovering. -#ifndef F3D_NO_SURFACE_BUFFER -layout(location = 1) out vec4 frag_surface; - -/// The surface's own colour, sRGB-encoded, alpha one where a surface was -/// drawn — `L5`. The third attachment, present only when a pass reads it (the -/// indirect light does) and the device opens three; like the surface buffer, -/// written unconditionally and discarded when absent. Stored in the surface -/// buffer's format rather than eight bits a channel, and `Renderer` says why. -layout(location = 2) out vec4 frag_albedo; -#endif - -/// What [frag_albedo] carries: the lit models set it in `ReadSurface`, and a -/// stage that reflects nothing — unlit, the debug views — leaves it black, -/// which is what light bounced onto it would come to. -vec3 g_albedo = vec3(0.0); - -/// Octahedral encoding: a unit vector in two channels instead of three. -/// -/// Worth the arithmetic because the fourth channel is already spent on depth, -/// and without a free channel there is nowhere to put roughness — which is the -/// difference between a reflection that knows stone from a mirror and one that -/// does not. The error is well under a degree, far below anything a reflection -/// off rough stone would show. -vec2 EncodeOctahedral(vec3 n) { - n /= abs(n.x) + abs(n.y) + abs(n.z); - vec2 e = n.xy; - if (n.z < 0.0) { - e = (1.0 - abs(n.yx)) * vec2(n.x >= 0.0 ? 1.0 : -1.0, - n.y >= 0.0 ? 1.0 : -1.0); - } - return e * 0.5 + 0.5; -} - -/// Where a debug pass leaves the picture it wants shown instead of the normal. -/// -/// Declared here, in the header every lit shader includes **first**, and -/// written from surface.glsl, which is included after. The alternative was a -/// new member on a shared uniform block; a global costs nothing and moves no -/// offsets. It is read at the moment the surface buffer is written, which -/// happens after the lighting loop has run, so the value is there by then. -vec3 g_debug_surface = vec3(0.0); -bool g_debug_surface_on = false; - -/// Whether [WriteSurface] weights the colour by its alpha: set by -/// `ReadSurface` for a material that blends, and false for everything else. -/// -/// **The blend takes its source as premultiplied**, so a blended surface has -/// to hand it the colour times the alpha — a pane at a fifth of opaque adds a -/// fifth of its light, not all of it. glTF's blend mode is Porter and Duff's -/// over on straight colour, and this is the one place that turns the lit -/// radiance into what that means. An opaque or masked surface keeps its -/// colour whole: its alpha is not a coverage, and nothing blends it. -/// A global for the reason [g_debug_surface] is one. -bool g_premultiply = false; - -// **A stage that needs none of this must be able to declare none of it.** On -// Vulkan both stages' descriptors are merged into one set layout, and two -// bindings with the same number in it is not a layout the specification -// allows. A driver may accept it anyway; a Galaxy A55's refuses the pipeline -// with `ErrorUnknown` and no other word, which is how the shadow pass came to -// build everywhere except there — its only uniform block was this one, and it -// landed on the same binding as the vertex stage's first. -#ifndef F3D_NO_FOG - -/// Distance fog, in its own block rather than folded into FragInfo. -/// -/// Its own because color.glsl is included before FragInfo is declared, and -/// because appending to a block that half a dozen shaders already share is a -/// way to move offsets nobody expected to move. Three vec4s is a cheap price -/// for not touching any of that. -layout(std140) uniform FogInfo { - /// rgb: linear fog colour. w: density per metre, zero for no fog. - vec4 fog; - - /// xyz: camera position in world space. Duplicated from FragInfo so this - /// block stands alone; a vec3 is cheaper than a coupling. - vec4 eye; - - /// xyz: the direction the camera looks, as a unit vector in world space. - /// w: what a transparent draw writes under weighted blended transparency — - /// `R8`, see `WriteWeightedBlended`. Zero for every other draw. - /// - /// Here rather than in a block of its own because it answers the same - /// question [eye] does — where the camera is and which way it faces — and - /// this is the block `color.glsl` can see. - vec4 forward; -} -fog_info; - -/// How far this fragment is from the eye, in world metres. -/// -/// What the fog fades by. Distance rather than depth, because fog is a -/// property of the air between two points and does not care which way the -/// camera happens to face. -float EyeDistance() { return distance(v_world_position, fog_info.eye.xyz); } - -/// How far this fragment is *along the view axis*, in world metres. -/// -/// What the surface buffer's alpha holds. Depth rather than distance, and the -/// difference only shows on an orthographic camera — where the rays through -/// the pixels are parallel instead of meeting at the eye, so a distance from -/// the eye names a sphere that the pixel's ray crosses somewhere the reader -/// cannot solve for. A depth along the axis names a plane, which every ray -/// crosses exactly once. See `WorldAtDepth` in `post/ssao.frag` for the -/// reconstruction both projections share. -float ViewDepth() { - return dot(v_world_position - fog_info.eye.xyz, fog_info.forward.xyz); -} - -#else // F3D_NO_FOG - -// The same two questions, answered without the block: a stage that declares no -// fog has no eye position to measure from either. Stubs rather than a guard at -// every call site, so that what includes this file reads the same whichever -// way it was compiled. -float EyeDistance() { return 0.0; } -float ViewDepth() { return 0.0; } - -#endif // F3D_NO_FOG - -/// sRGB to linear. Textures are authored in sRGB, but lighting is only correct -/// in linear space; skipping this is what makes naive renderers look muddy. -vec3 SrgbToLinear(vec3 srgb) { - return mix( - srgb / 12.92, - pow((srgb + vec3(0.055)) / 1.055, vec3(2.4)), - step(vec3(0.04045), srgb)); -} - -/// Linear to sRGB. The render target is a plain UNorm format rather than an -/// sRGB one, so the encode has to happen here. -vec3 LinearToSrgb(vec3 linear) { - return mix( - linear * 12.92, - 1.055 * pow(linear, vec3(1.0 / 2.4)) - vec3(0.055), - step(vec3(0.0031308), linear)); -} - -/// Writes scene-referred linear light into the HDR target. -/// -/// No tone map and no sRGB encode: those moved into the composite pass, which -/// is the entire point of rendering into `r16g16b16a16Float` first. Applying -/// them here meant every model wrote display-referred colour into an 8-bit -/// buffer, so anything above display white was gone before post-processing -/// could see it — and bloom is a function of exactly that. -/// -/// Exposure moved with them, for the same reason: it belongs on the same side -/// of the display transform as the tone map. -/// Records the geometry of this fragment for whatever runs after the scene. -/// -/// Called from the same place that writes colour, so a surface cannot be lit -/// into the frame without also describing itself — which is the failure that -/// leaves a screen-space effect reflecting whatever was in the buffer before. -/// -/// rg: octahedral normal. b: perceptual roughness. a: **depth along the view -/// axis, in world metres** — see [ViewDepth]. -/// -/// **Not `gl_FragCoord.z`, and that is a defect this channel carried until it -/// was looked at.** Window depth crowds every distant surface into the top of -/// its range — with a near plane of a tenth of a metre, everything past twenty -/// metres lives in the last half a hundredth of `[0, 1]` — and this attachment -/// is a half float, whose steps up there are about five ten-thousandths. So two -/// surfaces half a metre apart at twenty metres stored the *same* number, and -/// every screen-space pass that compares against this channel decided whole -/// bands of pixels by rounding. The occlusion pass drew them: vertical stripes -/// along the lines of constant depth on any wall receding from the camera, on -/// both GPU backends. The software rasteriser kept the channel at full -/// precision and drew the effect correctly, so it was the one that looked -/// wrong against the other two. -/// -/// A depth in metres has none of that: the exponent carries the range and the -/// mantissa carries the same relative precision everywhere, which at twenty -/// metres is a centimetre. Both numbers are measured in -/// `flutter3d/test/surface_depth_test.dart`. -/// -/// Zero still means nothing was drawn. The attachment is cleared to zero and -/// nothing is drawn in front of the near plane. -void WriteSurfaceGeometry(float roughness) { -#ifndef F3D_NO_SURFACE_BUFFER - // `L5`: the surface's colour, whatever the surface buffer ends up holding. - frag_albedo = vec4(LinearToSrgb(clamp(g_albedo, vec3(0.0), vec3(1.0))), 1.0); - // A debug pass takes the buffer over rather than getting one of its own. - // The surface buffer already has an attachment, a viewer and a golden; a - // second one would need all three built before it could answer anything. - if (g_debug_surface_on) { - frag_surface = vec4(g_debug_surface, ViewDepth()); - return; - } - // Reversed on a back face, as the lit normal is, so the occlusion and - // reflection passes see the side of a double-sided surface that faces them. - vec3 geometric = normalize(v_normal); - if (!gl_FrontFacing) geometric = -geometric; - frag_surface = vec4(EncodeOctahedral(geometric), - clamp(roughness, 0.0, 1.0), ViewDepth()); -#endif -} - -/// Fades [color] toward the fog with distance from the eye. -/// -/// Exponential rather than linear, because linear fog has a visible plane -/// where it starts and a dungeon corridor is exactly where that shows. -vec3 ApplyFog(vec3 color) { -#ifdef F3D_NO_FOG - return color; -#else - float density = fog_info.fog.w; - if (density <= 0.0) return color; - float d = EyeDistance(); - return mix(fog_info.fog.rgb, color, clamp(exp(-density * d), 0.0, 1.0)); -#endif -} - -/// How much a transparent fragment counts for against the others over its -/// pixel — `R8`. McGuire and Bavoil's depth weight (their equation 9): a near -/// layer outweighs a far one, which is all the ordering a weighted average -/// can keep. [alpha] multiplies it, as theirs does, so a faint layer counts -/// faintly. Depth along the view axis, in metres, the surface buffer's. -float WeightedBlendedWeight(float alpha) { - float z = abs(ViewDepth()); - float near = z / 5.0; - float far = z / 200.0; - float far3 = far * far * far; - return alpha * - clamp(10.0 / (1e-5 + near * near + far3 * far3), 1e-2, 3e3); -} - -/// What a transparent draw writes when the frame composites transparency -/// order-independently — `R8`. `fog_info.forward.w` says which: -/// -/// - 0: [frag_color] as it stands, the sorted blend's source. Every opaque -/// draw, and every draw in a frame that sorts. -/// - 1: the accumulation target's share — the colour, which the engine keeps -/// premultiplied, and the alpha, both times the weight. Added. -/// - 2: the revealage target's — the alpha alone, in every channel, which the -/// blend multiplies the target by one minus of. -/// - 3: both at once, the second into attachment one, where the surface -/// buffer would be; the pass that asks has no surface buffer attached. -/// -/// Selects rather than returns, because a phi of constants is what -/// SPIRV-Cross refuses. At nought the branch is not taken and [frag_color] -/// is untouched, which is what keeps a sorting frame byte-identical. -void WriteWeightedBlended() { -#ifndef F3D_NO_FOG - float mode = fog_info.forward.w; - if (mode > 0.5) { - float alpha = frag_color.a; - float weight = WeightedBlendedWeight(alpha); - vec4 accumulate = vec4(frag_color.rgb * weight, alpha * weight); - bool revealage = mode > 1.5 && mode < 2.5; - frag_color = revealage ? vec4(alpha) : accumulate; -#ifndef F3D_NO_SURFACE_BUFFER - if (mode > 2.5) frag_surface = vec4(alpha); -#endif - } -#endif -} - -/// The fog is mixed in before the weight, so a thin distant pane adds a thin -/// share of the fog too rather than all of it. Times one when nothing blends, -/// which is exact, so an opaque draw writes what it always wrote. -void WriteSurface(vec3 linearColor, float alpha, float roughness) { - float weight = g_premultiply ? alpha : 1.0; - frag_color = vec4(ApplyFog(linearColor) * weight, alpha); - WriteSurfaceGeometry(roughness); - WriteWeightedBlended(); -} - -/// For a stage with no material to speak of. -/// -/// Fully rough, which is the honest default: a surface that cannot say how -/// polished it is should not be reflected off. -void WriteSurface(vec3 linearColor, float alpha) { - WriteSurface(linearColor, alpha, 1.0); -} - -/// Writes a value that is already display-referred. -/// -/// For debug output, where the colour is not a light value at all: a normal -/// encoded as RGB means nothing after a tone curve. Converting to linear here -/// means the composite pass's sRGB encode hands the original back unchanged, -/// provided the view also turns tone mapping and exposure off — which is what -/// `RenderSettings.tonemap` is for. -void WriteDisplayColor(vec3 displayColor, float alpha) { - frag_color = vec4(SrgbToLinear(displayColor), alpha); - WriteSurfaceGeometry(1.0); -} - -#endif // COLOR_GLSL_ - +layout(std140) uniform TransmittanceInfo { + /// rgb: the colour light comes out of it, not held to one: the base colour, and for a + /// material that transmits, times what its volume leaves after its + /// thickness. a: its opacity — one for a material that is not blended, and + /// for one that transmits, whose alpha describes its look rather than + /// holes in it. + vec4 color; -layout(std140) uniform ShadowLight { - /// xyz: the light's world position. w: its range in metres. + /// xyz: the direction towards the light, in the world. w unused. vec4 light; -} -shadow_light; - -/// The base colour map, whose alpha is the mask. -uniform sampler2D base_color_texture; -layout(std140) uniform MaskInfo { - // x: the cutoff, from `Material.alphaCutoff`. y: the base colour's own - // alpha. z, w: unused. - vec4 mask; + /// x: the transmission, nought for a material that has none. y: the + /// reflectance head-on, ((n − 1) / (n + 1))² of its index. z: one when its + /// photons are followed and its light is given back by them, nought + /// otherwise. w unused. + vec4 params; } -mask_info; +transmittance_info; void main() { - float alpha = texture(base_color_texture, v_texcoord).a * mask_info.mask.y; - if (alpha < mask_info.mask.x) discard; - - float range = max(shadow_light.light.w, 1e-4); - float distance = length(v_world_position - shadow_light.light.xyz); - frag_color = vec4(clamp(distance / range, 0.0, 1.0), 0.0, 0.0, 1.0); + float opacity = clamp(transmittance_info.color.a, 0.0, 1.0); + float transmission = clamp(transmittance_info.params.x, 0.0, 1.0); + vec3 tint = max(transmittance_info.color.rgb, vec3(0.0)) * + texture(base_color_texture, v_texcoord).rgb; + vec3 body = vec3(1.0 - opacity) + opacity * transmission * tint; + + float facing = clamp( + abs(dot(normalize(v_normal), transmittance_info.light.xyz)), 0.0, 1.0); + float f0 = clamp(transmittance_info.params.y, 0.0, 1.0); + float fresnel = f0 + (1.0 - f0) * pow(1.0 - facing, 5.0); + // **What is reflected is lost to the shadow only as far as it is turned + // away.** Light meeting a surface at an angle θ to its normal and + // reflected leaves deflected by π − 2θ: back where it came from head-on, + // and hardly turned at all at grazing, where Fresnel reflects nearly all + // of it — it carries on down and lands beside where it would have. Taken + // as lost, that grazing light gave a clear glass tube's shadow a dark + // outline as wide as a texel or two, where a real one has a hairline. The + // share lost is 1 − cos(π − 2θ) = 2 cos²θ, held to one: whole from about + // 45° to head-on, nothing at grazing. + float lost = fresnel * min(2.0 * facing * facing, 1.0); + + vec3 through = sqrt(max(body, vec3(0.0))) * (1.0 - lost); + // A caster whose photons are followed (`ShadowSettings.caustics`) stops + // all of the light here; the photon pass gives it back where it lands. + if (transmittance_info.params.z > 0.5) through = vec3(0.0); + frag_color = vec4(0.0, 1.0 - through.r, 1.0 - through.g, through.b); } ''', @@ -30518,6 +34956,214 @@ void main() { frag_color = vec4(linear * v_tint.rgb, 1.0); } +''', + 'SkyPhysical': r'''#version 300 es +precision highp float; +precision highp int; +precision highp sampler2D; +precision highp samplerCube; + +// The sky as air: sunlight scattered once by molecules (Rayleigh) and by haze +// (Mie) along the view ray, the sun's disc seen through what the air leaves of +// it, stars at night, and the ground below the horizon. +// +// **Marched per pixel rather than read from a precomputed table.** A table is +// what a sky this size usually becomes, and it is two render passes into +// float targets before the scene, plus a sampler the sky's pipeline would have +// to be measured taking — `sky.vert` says why nothing is assumed about what +// reaches it. Sixteen samples along the view and eight towards the sun at each +// is 144 exponentials a pixel, paid only where no geometry covers the sky, +// because the stage runs after the opaque half and fails the depth test under +// it. `SkySettings.physical` documents the cost where a caller will read it. +// +// **Single scattering only**, and that is visible: the horizon at noon comes +// out a pale cyan rather than white, because light scattered twice is what +// whitens it. Measured against a 128 × 64 march of the same model, the +// march here is within two per cent of it at the zenith and four at the +// horizon. Two choices buy that. The view samples are spaced quadratically, +// short near the eye where a horizontal ray spends its densest air; spaced +// evenly, sixteen of them put the noon horizon's blue at less than half of +// what it is. And each sample is dimmed by the air up to its middle rather +// than its far end, which was another fifteen per cent. +// +// The same arithmetic runs in Dart twice: `PhysicalSky.radiance`, for a fog +// colour, a sunlight colour and an environment map built from the sky, and +// `SkyPhysicalShader` in the software rasteriser. A test evaluates both. +precision highp float; + +in vec3 v_ray; +in vec4 v_rayleigh; +in vec4 v_mie; +in vec4 v_sun; +in vec4 v_planet; +in vec4 v_stars; +in vec4 v_disc; + +layout(location = 0) out vec4 frag_color; + +// No surface output, for the reason `sky.frag` records at length: on Impeller, +// a second output in a single-attachment pass took the process down. + +const int kViewSteps = 16; +const int kLightSteps = 8; +const float kPi = 3.14159265; + +/// How far a ray from radius [r], at cosine [mu] to the local up, runs before +/// it leaves a sphere of [radius]; negative when it misses. +/// +/// `(r - radius) * (r + radius)` rather than `r * r - radius * radius`: the +/// two squares are 4·10⁷ km² apiece and their difference near the ground is a +/// few hundred, which the subtraction of the squares loses most of in single +/// precision. +float Leave(float r, float mu, float radius) { + float b = r * mu; + float d = b * b - (r - radius) * (r + radius); + return d < 0.0 ? -1.0 : -b + sqrt(d); +} + +/// How far the same ray runs before it meets the ground, negative when it +/// does not. +float Meet(float r, float mu, float radius) { + float b = r * mu; + float d = b * b - (r - radius) * (r + radius); + return d < 0.0 ? -1.0 : -b - sqrt(d); +} + +/// The air between [p] and space towards [s]: x molecules, y haze, each as a +/// length of air at ground density. +vec2 SunwardAir(vec3 p, vec3 s) { + float r = length(p); + float span = Leave(r, dot(p, s) / r, v_planet.y); + float stride = span / float(kLightSteps); + vec2 air = vec2(0.0); + for (int j = 0; j < kLightSteps; j++) { + vec3 q = p + s * ((float(j) + 0.5) * stride); + float h = length(q) - v_planet.x; + air += exp(-h / vec2(v_rayleigh.w, v_mie.z)) * stride; + } + return air; +} + +/// What a length of air lets through, per channel. +vec3 Through(vec2 air) { + return exp(-(v_rayleigh.rgb * air.x + vec3(v_mie.y * air.y))); +} + +/// A hash of three small whole numbers into [0, 1), with no sine in it. +/// +/// `fract(sin(x) * 43758.5)` is the usual one and it is wrong here: the sine +/// of a large argument is computed differently by every GPU and by the +/// software rasteriser, so the stars would be in different places on each +/// backend. This is products and `fract`s of numbers below a few thousand, +/// which single precision answers alike everywhere to the last few bits. +float Hash(vec3 p) { + p = fract(p * 0.1031); + p += dot(p, p.zyx + 31.32); + return fract((p.x + p.y) * p.z); +} + +/// The stars in direction [d]: a grid of cells on each face of a cube, a share +/// [v_stars.y] of them holding a star at a place and a brightness of its own. +float StarField(vec3 d) { + vec3 a = abs(d); + float face; + vec2 uv; + if (a.x >= a.y && a.x >= a.z) { + face = d.x > 0.0 ? 0.0 : 1.0; + uv = d.zy / a.x; + } else if (a.y >= a.z) { + face = d.y > 0.0 ? 2.0 : 3.0; + uv = d.xz / a.y; + } else { + face = d.z > 0.0 ? 4.0 : 5.0; + uv = d.xy / a.z; + } + vec2 grid = (uv * 0.5 + 0.5) * v_stars.z; + vec2 cell = floor(grid); + vec3 key = vec3(cell, face); + if (Hash(key) >= v_stars.y) return 0.0; + vec2 centre = vec2(Hash(key + vec3(17.0, 0.0, 0.0)), + Hash(key + vec3(0.0, 29.0, 0.0))) * 0.6 + 0.2; + float off = length(grid - cell - centre); + float magnitude = Hash(key + vec3(0.0, 0.0, 13.0)); + return (1.0 - smoothstep(0.0, 0.35, off)) * + (0.15 + 0.85 * magnitude * magnitude * magnitude); +} + +void main() { + vec3 d = normalize(v_ray); + vec3 s = v_sun.xyz; + vec3 eye = vec3(0.0, v_planet.z, 0.0); + + float ground = Meet(v_planet.z, d.y, v_planet.x); + bool grounded = ground > 0.0; + float span = grounded ? ground : Leave(v_planet.z, d.y, v_planet.y); + + vec2 seenAir = vec2(0.0); + vec3 molecules = vec3(0.0); + vec3 haze = vec3(0.0); + for (int i = 0; i < kViewSteps; i++) { + float a0 = float(i) / float(kViewSteps); + float a1 = float(i + 1) / float(kViewSteps); + float t = span * 0.5 * (a0 * a0 + a1 * a1); + float stride = span * (a1 * a1 - a0 * a0); + vec3 p = eye + d * t; + float r = length(p); + vec2 air = exp(-(r - v_planet.x) / vec2(v_rayleigh.w, v_mie.z)) * stride; + seenAir += air; + // In the planet's shadow: this sample is lit by nothing. + if (Meet(r, dot(p, s) / r, v_planet.x) > 0.0) continue; + vec3 through = Through(seenAir - 0.5 * air + SunwardAir(p, s)); + molecules += air.x * through; + haze += air.y * through; + } + + float mu = dot(d, s); + float g = v_mie.w; + float gg = g * g; + float rayleighPhase = 3.0 / (16.0 * kPi) * (1.0 + mu * mu); + // Cornette–Shanks, which is Henyey–Greenstein with the Rayleigh term's + // shape folded in. g is held below one by the renderer, so the base of the + // power never reaches nought. + float miePhase = 3.0 / (8.0 * kPi) * ((1.0 - gg) * (1.0 + mu * mu)) / + ((2.0 + gg) * pow(1.0 + gg - 2.0 * g * mu, 1.5)); + + vec3 colour = v_sun.w * (molecules * v_rayleigh.rgb * rayleighPhase + + haze * (v_mie.x * miePhase)); + vec3 seen = Through(seenAir); + + if (grounded) { + // The ground, lit by what the air leaves of the sun and seen through the + // air in front of it. Lambertian, and with no light from the sky itself, + // so a ground in the sun's shadow is black; it is what fills the lower + // half of an environment map built from this sky, not a terrain. + vec3 p = eye + d * span; + float lit = dot(normalize(p), s); + if (lit > 0.0) { + colour += seen * Through(SunwardAir(p, s)) * + (v_planet.w / kPi * lit * v_sun.w); + } + } else { + // The disc, through the air in front of it: this is what turns it red + // at sunset with no colour anybody chose. `sky.frag` explains the + // guard and why the soft edge travels as a difference. + float disc = v_disc.y > 0.0 + ? smoothstep(v_disc.x - v_disc.y, v_disc.x, mu) + : step(v_disc.x, mu); + colour += seen * (disc * v_disc.z); + + // Stars, as the sun goes down: a fade from the sun six degrees above + // the horizon to eleven below, rather than the sky's own brightness, + // which would need the exposure this stage does not know. + float night = 1.0 - smoothstep(-0.2, 0.1, s.y); + if (v_stars.x > 0.0 && night > 0.0) { + colour += seen * (v_stars.x * night * StarField(d)); + } + } + + frag_color = vec4(colour, 1.0); +} + ''', 'Luminance': r'''#version 300 es precision highp float; @@ -30800,6 +35446,13 @@ bool g_debug_surface_on = false; /// A global for the reason [g_debug_surface] is one. bool g_premultiply = false; +/// How much of what is already behind a blended surface comes through it — +/// a thin pane of glass, `M3`: what the blend multiplies the target by is +/// one minus the alpha, so the alpha written is the coverage less this share +/// of it, while the colour stays weighted by the coverage alone. Nought for +/// everything else, which writes what it always wrote. +float g_pass_through = 0.0; + // **A stage that needs none of this must be able to declare none of it.** On // Vulkan both stages' descriptors are merged into one set layout, and two // bindings with the same number in it is not a layout the specification @@ -30816,11 +35469,13 @@ bool g_premultiply = false; /// way to move offsets nobody expected to move. Three vec4s is a cheap price /// for not touching any of that. layout(std140) uniform FogInfo { - /// rgb: linear fog colour. w: density per metre, zero for no fog. + /// rgb: linear fog colour. w: density per metre at the eye, zero for no + /// fog. vec4 fog; /// xyz: camera position in world space. Duplicated from FragInfo so this - /// block stands alone; a vec3 is cheaper than a coupling. + /// block stands alone; a vec3 is cheaper than a coupling. w: how fast the + /// fog thins upwards, per metre — `P5`, see [ApplyFog]; nought is flat fog. vec4 eye; /// xyz: the direction the camera looks, as a unit vector in world space. @@ -30831,15 +35486,24 @@ layout(std140) uniform FogInfo { /// question [eye] does — where the camera is and which way it faces — and /// this is the block `color.glsl` can see. vec4 forward; + + /// x: one when the camera's projection is orthographic, nought when it is + /// perspective — `P7`, see [Orthographic]. y, z, w unused. Appended, so + /// every offset above stays where four backends agree on it. + vec4 projection; } fog_info; -/// How far this fragment is from the eye, in world metres. +/// Whether the camera is orthographic — `P7`. /// -/// What the fog fades by. Distance rather than depth, because fog is a -/// property of the air between two points and does not care which way the -/// camera happens to face. -float EyeDistance() { return distance(v_world_position, fog_info.eye.xyz); } +/// **What every eye-relative quantity has to ask first.** Through a +/// perspective lens the rays meet at the eye, so the way to the eye and the +/// distance to it are the fragment's own; through an orthographic one the +/// rays are parallel and the eye is only where the camera was put along its +/// axis, which moves nothing in the picture. Reading the eye's position as if +/// it were a point the light travels to slides highlights across the frame +/// as the camera pans and lays fog in rings round a point nobody sees. +bool Orthographic() { return fog_info.projection.x > 0.5; } /// How far this fragment is *along the view axis*, in world metres. /// @@ -30854,6 +35518,27 @@ float ViewDepth() { return dot(v_world_position - fog_info.eye.xyz, fog_info.forward.xyz); } +/// How far this fragment is from the eye, in world metres. +/// +/// What the fog fades by. Distance rather than depth, because fog is a +/// property of the air between two points and does not care which way the +/// camera happens to face — through a perspective lens. Through an +/// orthographic one every ray starts on the eye's plane, so the air a ray +/// crosses is its depth from that plane, never less than nought. +float EyeDistance() { + return Orthographic() ? max(ViewDepth(), 0.0) + : distance(v_world_position, fog_info.eye.xyz); +} + +/// The unit direction from this fragment back along the ray that reached +/// it: to the eye through a perspective lens, against the view axis through +/// an orthographic one — `P7`. What a highlight, a Fresnel term and a +/// reflection are measured from. +vec3 TowardsEye() { + return Orthographic() ? -fog_info.forward.xyz + : normalize(fog_info.eye.xyz - v_world_position); +} + #else // F3D_NO_FOG // The same two questions, answered without the block: a stage that declares no @@ -30946,6 +35631,16 @@ void WriteSurfaceGeometry(float roughness) { /// /// Exponential rather than linear, because linear fog has a visible plane /// where it starts and a dungeon corridor is exactly where that shows. +/// +/// **Height fog — `P5`.** `fog_info.fog.w` is the density at the eye and +/// `fog_info.eye.w` how fast it falls off upwards, per metre. The density +/// along the ray is then `density · e^(−falloff · Δy · t)` for t from nought +/// to one, and its mean over the ray is `(1 − e^(−k)) / k` with +/// `k = falloff · Δy`: an exact integral, so a fog lying on the ground costs +/// one exponential here and no march. Below a thousandth `k` is answered by +/// the first two terms of the same series, because the quotient there is +/// two nearly equal numbers divided by a small one. A falloff of nought skips +/// all of it, which keeps a flat fog the same to the bit. vec3 ApplyFog(vec3 color) { #ifdef F3D_NO_FOG return color; @@ -30953,6 +35648,11 @@ vec3 ApplyFog(vec3 color) { float density = fog_info.fog.w; if (density <= 0.0) return color; float d = EyeDistance(); + float falloff = fog_info.eye.w; + if (falloff > 0.0) { + float k = falloff * (v_world_position.y - fog_info.eye.y); + density *= abs(k) > 1e-3 ? (1.0 - exp(-k)) / k : 1.0 - 0.5 * k; + } return mix(fog_info.fog.rgb, color, clamp(exp(-density * d), 0.0, 1.0)); #endif } @@ -31007,7 +35707,8 @@ void WriteWeightedBlended() { /// which is exact, so an opaque draw writes what it always wrote. void WriteSurface(vec3 linearColor, float alpha, float roughness) { float weight = g_premultiply ? alpha : 1.0; - frag_color = vec4(ApplyFog(linearColor) * weight, alpha); + frag_color = vec4(ApplyFog(linearColor) * weight, + alpha * (1.0 - g_pass_through)); WriteSurfaceGeometry(roughness); WriteWeightedBlended(); } @@ -31696,6 +36397,13 @@ bool g_debug_surface_on = false; /// A global for the reason [g_debug_surface] is one. bool g_premultiply = false; +/// How much of what is already behind a blended surface comes through it — +/// a thin pane of glass, `M3`: what the blend multiplies the target by is +/// one minus the alpha, so the alpha written is the coverage less this share +/// of it, while the colour stays weighted by the coverage alone. Nought for +/// everything else, which writes what it always wrote. +float g_pass_through = 0.0; + // **A stage that needs none of this must be able to declare none of it.** On // Vulkan both stages' descriptors are merged into one set layout, and two // bindings with the same number in it is not a layout the specification @@ -31712,11 +36420,13 @@ bool g_premultiply = false; /// way to move offsets nobody expected to move. Three vec4s is a cheap price /// for not touching any of that. layout(std140) uniform FogInfo { - /// rgb: linear fog colour. w: density per metre, zero for no fog. + /// rgb: linear fog colour. w: density per metre at the eye, zero for no + /// fog. vec4 fog; /// xyz: camera position in world space. Duplicated from FragInfo so this - /// block stands alone; a vec3 is cheaper than a coupling. + /// block stands alone; a vec3 is cheaper than a coupling. w: how fast the + /// fog thins upwards, per metre — `P5`, see [ApplyFog]; nought is flat fog. vec4 eye; /// xyz: the direction the camera looks, as a unit vector in world space. @@ -31727,15 +36437,24 @@ layout(std140) uniform FogInfo { /// question [eye] does — where the camera is and which way it faces — and /// this is the block `color.glsl` can see. vec4 forward; + + /// x: one when the camera's projection is orthographic, nought when it is + /// perspective — `P7`, see [Orthographic]. y, z, w unused. Appended, so + /// every offset above stays where four backends agree on it. + vec4 projection; } fog_info; -/// How far this fragment is from the eye, in world metres. +/// Whether the camera is orthographic — `P7`. /// -/// What the fog fades by. Distance rather than depth, because fog is a -/// property of the air between two points and does not care which way the -/// camera happens to face. -float EyeDistance() { return distance(v_world_position, fog_info.eye.xyz); } +/// **What every eye-relative quantity has to ask first.** Through a +/// perspective lens the rays meet at the eye, so the way to the eye and the +/// distance to it are the fragment's own; through an orthographic one the +/// rays are parallel and the eye is only where the camera was put along its +/// axis, which moves nothing in the picture. Reading the eye's position as if +/// it were a point the light travels to slides highlights across the frame +/// as the camera pans and lays fog in rings round a point nobody sees. +bool Orthographic() { return fog_info.projection.x > 0.5; } /// How far this fragment is *along the view axis*, in world metres. /// @@ -31750,6 +36469,27 @@ float ViewDepth() { return dot(v_world_position - fog_info.eye.xyz, fog_info.forward.xyz); } +/// How far this fragment is from the eye, in world metres. +/// +/// What the fog fades by. Distance rather than depth, because fog is a +/// property of the air between two points and does not care which way the +/// camera happens to face — through a perspective lens. Through an +/// orthographic one every ray starts on the eye's plane, so the air a ray +/// crosses is its depth from that plane, never less than nought. +float EyeDistance() { + return Orthographic() ? max(ViewDepth(), 0.0) + : distance(v_world_position, fog_info.eye.xyz); +} + +/// The unit direction from this fragment back along the ray that reached +/// it: to the eye through a perspective lens, against the view axis through +/// an orthographic one — `P7`. What a highlight, a Fresnel term and a +/// reflection are measured from. +vec3 TowardsEye() { + return Orthographic() ? -fog_info.forward.xyz + : normalize(fog_info.eye.xyz - v_world_position); +} + #else // F3D_NO_FOG // The same two questions, answered without the block: a stage that declares no @@ -31842,6 +36582,16 @@ void WriteSurfaceGeometry(float roughness) { /// /// Exponential rather than linear, because linear fog has a visible plane /// where it starts and a dungeon corridor is exactly where that shows. +/// +/// **Height fog — `P5`.** `fog_info.fog.w` is the density at the eye and +/// `fog_info.eye.w` how fast it falls off upwards, per metre. The density +/// along the ray is then `density · e^(−falloff · Δy · t)` for t from nought +/// to one, and its mean over the ray is `(1 − e^(−k)) / k` with +/// `k = falloff · Δy`: an exact integral, so a fog lying on the ground costs +/// one exponential here and no march. Below a thousandth `k` is answered by +/// the first two terms of the same series, because the quotient there is +/// two nearly equal numbers divided by a small one. A falloff of nought skips +/// all of it, which keeps a flat fog the same to the bit. vec3 ApplyFog(vec3 color) { #ifdef F3D_NO_FOG return color; @@ -31849,6 +36599,11 @@ vec3 ApplyFog(vec3 color) { float density = fog_info.fog.w; if (density <= 0.0) return color; float d = EyeDistance(); + float falloff = fog_info.eye.w; + if (falloff > 0.0) { + float k = falloff * (v_world_position.y - fog_info.eye.y); + density *= abs(k) > 1e-3 ? (1.0 - exp(-k)) / k : 1.0 - 0.5 * k; + } return mix(fog_info.fog.rgb, color, clamp(exp(-density * d), 0.0, 1.0)); #endif } @@ -31903,7 +36658,8 @@ void WriteWeightedBlended() { /// which is exact, so an opaque draw writes what it always wrote. void WriteSurface(vec3 linearColor, float alpha, float roughness) { float weight = g_premultiply ? alpha : 1.0; - frag_color = vec4(ApplyFog(linearColor) * weight, alpha); + frag_color = vec4(ApplyFog(linearColor) * weight, + alpha * (1.0 - g_pass_through)); WriteSurfaceGeometry(roughness); WriteWeightedBlended(); } @@ -32171,8 +36927,9 @@ layout(std140) uniform FragInfo { vec4 material; /// x: alpha cutoff (negative when the material is not masked: -1 opaque, - /// -0.5 blended, -2 hashed), y: normal scale, z: occlusion strength, - /// w: emissive strength. + /// -0.5 blended, -2 hashed; above one the cutoff plus one, drawn as + /// coverage — `P7`), y: normal scale, z: occlusion strength, w: emissive + /// strength. vec4 material2; /// x: exposure, y: active light count, z: index of the shadow-casting light. @@ -32227,7 +36984,9 @@ layout(std140) uniform FragInfo { vec4 ambient_ground; /// x, y, z: the depth bias of each cascade, in that cascade's own normalized - /// depth. w unused. + /// depth. w: one when the atlas carries what see-through casters let + /// through and this draw is to be shaded by it, nought otherwise — + /// `ShadowSettings.translucentCasters`; see `ShadowFactor`. /// /// `ShadowSettings.bias` is one number and a cascade's depth range is not: /// a near cascade is stretched towards the light when a caster stands @@ -32248,6 +37007,15 @@ layout(std140) uniform FragInfo { /// while a temporal resolve runs, minus one otherwise — `S3`, which steps /// the soft shadow's rotation by it. vec4 target_origin; + + /// x: which debug view replaces the light — `P6`, `DebugView.code`, nought + /// for none. y: where it starts, as a share of the target's width from the + /// left; nought is the whole frame. z: the target's width in pixels, which + /// turns the share into a column. w unused. + /// + /// Appended for the reason `ambient_sky` was: every offset above stays + /// where the four backends already agree on it. + vec4 debug_view; } frag_info; @@ -32338,8 +37106,25 @@ Surface ReadSurface() { // number in a block six shaders share, and -1 already meant "not masked"; // anything more negative was free. See [MaterialAlphaMode.hashed]. float cutoff = frag_info.material2.x; - if (cutoff >= 0.0) { + if (cutoff > 1.0) { + // **Coverage instead of a cut — `P7`.** One above the cutoff says the + // pass multisamples and turns this fragment's alpha into the share of + // samples it covers, so nothing is discarded: the alpha is sharpened to + // run from nought to one across about a pixel either side of the cutoff, + // and the resolve smooths the edge as it smooths a triangle's. Unsharpened, + // a texture's soft alpha would cover half the samples of every pixel it + // fades across and draw a screen door. Branched on a uniform, so the + // derivative is taken in uniform control flow, as WGSL requires. + float edge = cutoff - 1.0; + s.alpha = clamp((s.alpha - edge) / max(fwidth(s.alpha), 1e-4) + 0.5, + 0.0, 1.0); + } else if (cutoff >= 0.0) { if (s.alpha < cutoff) discard; + // What survives the cut is a surface, and opaque: the texture's alpha has + // done its work. Written as it was, it went into the frame's alpha, and + // whatever read the frame as premultiplied — a golden's capture — divided + // the colour by it and lit the inside of every leaf towards its rim. + s.alpha = 1.0; } else if (cutoff < -1.5) { // **Stochastic instead of a threshold.** A leaf texture at 40% opacity is // either entirely there or entirely gone under a fixed cutoff, so a fern @@ -32376,7 +37161,15 @@ Surface ReadSurface() { // double-sided material ever draws a back face, since everything else has // them culled. if (!gl_FrontFacing) s.n = -s.n; +#ifdef F3D_NO_FOG + // The stages without the fog block — shadows and the id pass — light + // nothing, and keep the eye's point. s.v = normalize(frag_info.camera_position.xyz - v_world_position); +#else + // `P7`: against the view axis through an orthographic lens, where the + // eye's point is only where the camera was put. + s.v = TowardsEye(); +#endif // Clamped away from zero: a grazing view direction otherwise divides by zero // in the specular visibility term. s.n_dot_v = max(dot(s.n, s.v), 1e-4); @@ -32857,6 +37650,15 @@ LightSample SampleLight(int index, Surface s) { /// returns `ShadowFactor(...)`; an unlit one returns 1. float LightVisibility(Surface s, LightSample light, int index); +/// What the see-through casters between the sun and this fragment let +/// through, per channel — `ShadowSettings.translucentCasters`. Set by +/// `ShadowFactor` for the light it shadows and reset to one before every +/// light, so a model that samples no shadow map, and every light but the +/// sun, leaves it white. A colour beside the visibility rather than folded +/// into it, because visibility is one number in every model and coloured +/// light is not. +vec3 light_transmittance = vec3(1.0); + /// A model's per-light term, defined by each fragment shader. /// /// A prototype here and the definition in the model is what lets the loop below @@ -33275,6 +38077,7 @@ vec3 AccumulateLights(Surface s) { if (i >= count) break; LightSample light = SampleLight(i, s); if (light.n_dot_l <= 0.0) continue; + light_transmittance = vec3(1.0); // A light from the list has no shadow row to read — see `LightHasShadow`. // A branch rather than something folded into the two calls, because both // index tables eight entries wide and the ninth light would read past them @@ -33284,12 +38087,73 @@ vec3 AccumulateLights(Surface s) { PointShadowFactor(v_world_position, s.n, i) : 1.0; if (visibility <= 0.0) continue; - total += ShadeLight(s, light) * light.radiance * light.n_dot_l * visibility; + total += ShadeLight(s, light) * light.radiance * light.n_dot_l * visibility * + light_transmittance; } return total; } +/// Whether any channel of [c] is NaN or infinite. +/// +/// **Comparisons, not `isnan` and not the bits.** WGSL has no `isNan`, and +/// reading the exponent through `floatBitsToUint` takes `impellerc` down in +/// its GLSL ES output, which has no bit casts. A NaN is the one value unequal +/// to itself, and an infinity the one above every finite float. +bool NonFinite(vec3 c) { + return any(notEqual(c, c)) || any(greaterThan(abs(c), vec3(3.0e38))); +} + +/// `P6`: the material channel `FragInfo.debug_view` asks for, written in +/// place of [lit]. False, and nothing written, when no view is on or the +/// fragment sits left of the split; the caller then writes the light. +/// +/// **Display values, through the same exits the light takes.** The channel +/// is what an artist would read off the texture — an albedo as its sRGB +/// colour, a roughness as a grey — converted to linear so the composite's +/// encode hands it back unchanged; the composite leaves this side of the +/// split out of the exposure and the tone curve (`CompositeInfo.lens.y`). +/// The surface buffer and the weighted-blended targets are written as +/// [WriteSurface] writes them, so a debug view changes what the frame shows +/// and nothing the passes after it read. No fog: a channel seen through fog +/// is not the channel. +/// +/// [lit] is read by one view only, [DebugView.nonFinite], which shows a NaN +/// or an infinity as magenta over the light's own luminance in grey. +bool WriteDebugView(Surface s, vec3 lit) { + float view = frag_info.debug_view.x; + if (view < 0.5) return false; + if (gl_FragCoord.x < frag_info.debug_view.y * frag_info.debug_view.z) { + return false; + } + int code = int(view + 0.5); + vec3 shown = vec3(0.0); + if (code == 1) { + shown = LinearToSrgb(clamp(s.albedo, vec3(0.0), vec3(1.0))); + } else if (code == 2) { + shown = s.n * 0.5 + vec3(0.5); + } else if (code == 3) { + shown = vec3(clamp(s.roughness, 0.0, 1.0)); + } else if (code == 4) { + shown = vec3(clamp(s.metallic, 0.0, 1.0)); + } else if (code == 5) { + shown = vec3(clamp(s.occlusion, 0.0, 1.0)); + } else if (code == 6) { + shown = LinearToSrgb(clamp(s.emissive, vec3(0.0), vec3(1.0))); + } else if (code == 7) { + shown = vec3(fract(MapUv(kMapBaseColor)), 0.0); + } else if (code == 8) { + float grey = dot(LinearToSrgb(clamp(lit, vec3(0.0), vec3(1.0))), + vec3(0.2126, 0.7152, 0.0722)); + shown = NonFinite(lit) ? vec3(1.0, 0.0, 1.0) : vec3(grey * 0.5); + } + float weight = g_premultiply ? s.alpha : 1.0; + frag_color = vec4(SrgbToLinear(shown) * weight, s.alpha); + WriteSurfaceGeometry(s.roughness); + WriteWeightedBlended(); + return true; +} + #endif // SURFACE_GLSL_ @@ -33361,7 +38225,16 @@ float ShadowFactor(Surface s, LightSample light, int lightIndex) { // whole class of missing-shadow bug, and the last cascade is fitted to the // entire scene, so the fall-through always terminates somewhere real. int cascadeCount = int(frag_info.shadow_cascades.z + 0.5); + // `P7`: by depth along the view axis through an orthographic lens, whose + // cascades are slabs of the view's box rather than spheres about the eye. +#ifdef F3D_NO_FOG float viewDistance = length(v_world_position - frag_info.camera_position.xyz); +#else + float viewDistance = + Orthographic() + ? ViewDepth() + : length(v_world_position - frag_info.camera_position.xyz); +#endif int cascade = 0; if (cascadeCount > 1 && viewDistance > frag_info.shadow_cascades.x) cascade = 1; if (cascadeCount > 2 && viewDistance > frag_info.shadow_cascades.y) cascade = 2; @@ -33502,6 +38375,19 @@ float ShadowFactor(Surface s, LightSample light, int lightIndex) { // kernel, and how far under minus one the value sits is the light-bleeding // cut. A sign rather than another uniform, for the reason the softness // itself rides here. + // **What the see-through casters let through** — before the filter, whose + // soft path leaves early where it finds no blocker. One bilinear tap: a + // translucent caster's shadow is light shaded rather than light stopped, + // and its edge is softened by the filtering the tap already gets. Green + // and blue hold what was taken from red and green, alpha what was left of + // blue — the layout `shadow_transmittance.frag` explains. + if (frag_info.shadow_bias.w > 0.5) { + vec4 stored = textureLod(shadow_texture, clamp(uv, tileLo, tileHi), 0.0); + // Up to four: light a caster gathered, not only light it stopped. + vec3 through = clamp(vec3(1.0 - stored.g, 1.0 - stored.b, stored.a), 0.0, 4.0); + light_transmittance = mix(vec3(1.0), through, clamp(strength, 0.0, 1.0)); + } + float softness = frag_info.ambient_ground.w; float lit = 0.0; if (softness < 0.0) { @@ -33690,7 +38576,9 @@ void main() { frag_info.material.z; vec3 ambient = s.albedo * s.ambient; - WriteSurface(AccumulateLights(s) + ambient, 1.0, 1.0); + vec3 lit = AccumulateLights(s) + ambient; + if (WriteDebugView(s, lit)) return; + WriteSurface(lit, 1.0, 1.0); } ''', diff --git a/packages/flutter3d_webgl/lib/flutter3d_webgl.dart b/packages/flutter3d_webgl/lib/flutter3d_webgl.dart index 198833bc8..606fd4e2c 100644 --- a/packages/flutter3d_webgl/lib/flutter3d_webgl.dart +++ b/packages/flutter3d_webgl/lib/flutter3d_webgl.dart @@ -25,12 +25,15 @@ /// of date. library; -export 'src/open.dart'; - /// The `webgl` section of a loadable bundle, as the packer writes it and the -/// device reads it. No browser in it, so a harness on the VM can write one. +/// device reads it. No browser in it, so a harness on the VM can write one; +/// it lives in `flutter3d_shaders` since P8, where a build hook with no +/// Flutter SDK can write one too, and is still exported here by name. +export 'package:flutter3d_shaders/translate.dart' + show WebGlSectionSources, decodeWebGlSection, encodeWebGlSection; + +export 'src/open.dart'; export 'src/webgl_backend_registration.dart'; -export 'src/webgl_bundle_section.dart'; export 'src/webgl_device.dart'; export 'src/webgl_frame_presenter.dart'; export 'src/webgl_shaders.dart'; diff --git a/packages/flutter3d_webgl/lib/src/webgl_device.dart b/packages/flutter3d_webgl/lib/src/webgl_device.dart index 70b02b770..5dc00ebb9 100644 --- a/packages/flutter3d_webgl/lib/src/webgl_device.dart +++ b/packages/flutter3d_webgl/lib/src/webgl_device.dart @@ -519,6 +519,11 @@ final class WebGlDevice implements GraphicsDevice { // OpenGL ES has no glPolygonMode. See canDrawPolygonMode. bool get supportsWireframe => false; + /// True where the context multisamples offscreen targets, which is where + /// coverage has samples to spread over — `P7`. + @override + bool get supportsAlphaToCoverage => _msaaSamples > 1; + @override // `DEPTH24_STENCIL8` is WebGL2 core, and it is the format every depth // attachment this backend makes is allocated in. diff --git a/packages/flutter3d_webgl/lib/src/webgl_encoder.dart b/packages/flutter3d_webgl/lib/src/webgl_encoder.dart index c6788114e..bccc787a2 100644 --- a/packages/flutter3d_webgl/lib/src/webgl_encoder.dart +++ b/packages/flutter3d_webgl/lib/src/webgl_encoder.dart @@ -187,6 +187,10 @@ final class WebGlEncoder implements CommandEncoder { // scissor per tile. _gl.enable(web.WebGLRenderingContext.SCISSOR_TEST); + // Off at every pass's start, as `setAlphaToCoverage` promises: it is + // global state, and a pass of leaves left it on for the bloom after it. + _gl.disable(web.WebGLRenderingContext.SAMPLE_ALPHA_TO_COVERAGE); + // Depth testing follows the attachment rather than being switched on for // every pass. Without a depth buffer GL specifies the test as passing // always, so leaving it enabled was harmless and dishonest; a pass that has @@ -422,6 +426,13 @@ final class WebGlEncoder implements CommandEncoder { @override void setDepthWrite(bool enabled) => _gl.depthMask(enabled); + /// `SAMPLE_ALPHA_TO_COVERAGE` — `P7`: in a pass of one sample GL turns + /// coverage into nothing, which is what the interface promises. + @override + void setAlphaToCoverage(bool enabled) => enabled + ? _gl.enable(web.WebGLRenderingContext.SAMPLE_ALPHA_TO_COVERAGE) + : _gl.disable(web.WebGLRenderingContext.SAMPLE_ALPHA_TO_COVERAGE); + @override void setDepthCompare(CompareFunction compare) => _gl.depthFunc(compareFunctionToGl(compare)); @@ -890,8 +901,17 @@ final class WebGlEncoder implements CommandEncoder { } @override - void draw({int instanceCount = 1}) { + void draw({int instanceCount = 1, int firstIndex = 0, int? indexCount}) { + final window = indexWindow( + _indexCount, + firstIndex: firstIndex, + indexCount: indexCount, + ); if (instanceCount <= 0) return; + // WebGL2 says where a window starts as a byte offset into the bound + // buffer, so the start is added to the one the binding already carries. + final offset = + _indexOffset + window.first * (_indexType == IndexType.int16 ? 2 : 4); _clearWhatWasNotBound(); if (instanceCount == 1) { // Not `drawElementsInstanced` with a count of one. They are specified to @@ -901,16 +921,16 @@ final class WebGlEncoder implements CommandEncoder { // driver disagrees with the specification. _gl.drawElements( _primitive, - _indexCount, + window.count, indexTypeToGl(_indexType), - _indexOffset, + offset, ); } else { _gl.drawElementsInstanced( _primitive, - _indexCount, + window.count, indexTypeToGl(_indexType), - _indexOffset, + offset, instanceCount, ); } diff --git a/packages/flutter3d_webgl/lib/src/webgl_loaded_shaders.dart b/packages/flutter3d_webgl/lib/src/webgl_loaded_shaders.dart index 7baadd032..8a185baf7 100644 --- a/packages/flutter3d_webgl/lib/src/webgl_loaded_shaders.dart +++ b/packages/flutter3d_webgl/lib/src/webgl_loaded_shaders.dart @@ -12,9 +12,9 @@ library; import 'dart:typed_data'; import 'package:flutter3d_hardware/flutter3d_hardware.dart'; +import 'package:flutter3d_shaders/translate.dart' show decodeWebGlSection; import 'package:web/web.dart' as web; -import 'webgl_bundle_section.dart'; import 'webgl_shaders.dart'; /// A [LoadedShaderLibrary] over sources a bundle carried. diff --git a/packages/flutter3d_webgl/test/bundle_section_test.dart b/packages/flutter3d_webgl/test/bundle_section_test.dart index cb7042261..ea302e755 100644 --- a/packages/flutter3d_webgl/test/bundle_section_test.dart +++ b/packages/flutter3d_webgl/test/bundle_section_test.dart @@ -9,7 +9,7 @@ library; import 'dart:convert'; import 'dart:typed_data'; -import 'package:flutter3d_webgl/src/webgl_bundle_section.dart'; +import 'package:flutter3d_shaders/translate.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { diff --git a/packages/flutter3d_webgl/test/cross_backend_test.dart b/packages/flutter3d_webgl/test/cross_backend_test.dart index e94ad0c5d..649d9224b 100644 --- a/packages/flutter3d_webgl/test/cross_backend_test.dart +++ b/packages/flutter3d_webgl/test/cross_backend_test.dart @@ -98,6 +98,25 @@ const int _channel = 8; /// pointed at and agreed with Impeller to the pixel for six sessions. /// const Map _budgets = { + // 0.199% measured, on the silhouette; the stage the bundle's WebGL2 + // section carries agrees with Impeller's inside it. + 'material-language': 0.25, + 'material-light-hook': 0.4, + 'material-instance-data': 0.3, + 'widget-scene': 0.01, + // 0.595% measured, on the discs' rims and nowhere else: coverage here, + // the hard cutoff on Impeller — `Material.alphaToCoverage`, two of four. + 'alpha-to-coverage': 0.7, + // 0.394% measured, on the spheres' and the floor's edges, which WebGL2 + // does not multisample as Impeller does; the highlights, the fog and the + // sky agree. + 'orthographic-metal': 0.5, + 'orthographic-shadows': 0.3, + 'orthographic-particles': 0.01, + // 0.359% measured, on the silhouette: `shadow-teapot`'s edge between + // WebGL2 and Impeller, and the normals' half shows it in brighter colours + // than the lit half's. + 'debug-view-split': 0.45, // **Zero measured, and it was 0.558%.** Both this and the occlusion below // reconstruct a world point from the depth stored in the surface buffer, and // both were handed a matrix adjusted to this backend's `[-1, 1]` clip range @@ -229,6 +248,14 @@ const Map _budgets = { 'cascade-walk': 0.04, 'clearcoat-car-paint': 0.2, 'easu-half': 0.9, + // 0.591% measured, every pixel of it on an edge: WebGL2 already draws + // `shadow-teapot`'s silhouette 0.28% apart from Impeller's, and SMAA reads + // a slightly different staircase there and moves its neighbours with it. + 'lens-flare': 0.01, + // 0.388% measured, on the silhouette: `shadow-teapot`'s 0.28% between + // WebGL2 and Impeller, magnified by the barrel. + 'lens-distortion': 0.45, + 'smaa-teapot': 0.65, 'evsm-soft': 0.4, // 0.047% measured: the air right round a torch, where the light falls off // as one over the distance squared, is shadowed by one unfiltered tap of @@ -251,6 +278,10 @@ const Map _budgets = { 'sheen-fabric': 0.23, // 0.000% measured. It was 2.039%, and not sampling: the puff's hash used // 64-bit integers, so a browser baked a different sheet. + // `P5`. 11 of 172800 measured against Impeller on 2026-10-01, the floor's + // far edge; the night sky 0. + 'sky-physical-dusk': 0.01, + 'sky-physical-night': 0.01, 'smoke-six-way': 0.01, 'splat-gltf': 0.01, // 0.001% measured, since the hash reads the pixel from the top on every @@ -292,6 +323,14 @@ const Map _budgets = { // pixels here and on Impeller. Two-sided recording took the dots away, and // the difference went with them. 'window-interior': 0.01, + // `P3`. 0 of 172800 measured against Impeller on 2026-10-01. + 'decal-floor': 0.01, + // `P4`. 0.172% and 0.193% measured against Impeller on 2026-10-01, single + // pixels along silhouette edges, where the two multisample differently; + // the reflection and the monitor's picture, which this backend writes + // with its rows turned over, agree inside. + 'planar-mirror': 0.2, + 'render-texture': 0.22, }; /// Scenes budgeted before this set had a picture of them. diff --git a/packages/flutter3d_webgl/test/engine_parity_test.dart b/packages/flutter3d_webgl/test/engine_parity_test.dart index 08bd3c061..27c7230d6 100644 --- a/packages/flutter3d_webgl/test/engine_parity_test.dart +++ b/packages/flutter3d_webgl/test/engine_parity_test.dart @@ -3180,6 +3180,7 @@ void _linkTests() { ('ParticleMeshVertex', 'ParticleMesh'), ('SkyVertex', 'Sky'), ('SkyCubeVertex', 'SkyCube'), + ('SkyPhysicalVertex', 'SkyPhysical'), ]; for (final (vertex, fragment) in pairs) { diff --git a/packages/flutter3d_webgl/test/glsl_translate_test.dart b/packages/flutter3d_webgl/test/glsl_translate_test.dart index e5f570331..ece366bf0 100644 --- a/packages/flutter3d_webgl/test/glsl_translate_test.dart +++ b/packages/flutter3d_webgl/test/glsl_translate_test.dart @@ -7,7 +7,7 @@ /// at offsets nobody wrote to. library; -import 'package:flutter3d_webgl/src/glsl_translate.dart'; +import 'package:flutter3d_shaders/translate.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { diff --git a/packages/flutter3d_webgl/test/goldens/alpha-to-coverage.png b/packages/flutter3d_webgl/test/goldens/alpha-to-coverage.png new file mode 100644 index 000000000..cca40bc47 Binary files /dev/null and b/packages/flutter3d_webgl/test/goldens/alpha-to-coverage.png differ diff --git a/packages/flutter3d_webgl/test/goldens/debug-view-split.png b/packages/flutter3d_webgl/test/goldens/debug-view-split.png new file mode 100644 index 000000000..ac925cf61 Binary files /dev/null and b/packages/flutter3d_webgl/test/goldens/debug-view-split.png differ diff --git a/packages/flutter3d_webgl/test/goldens/decal-floor.png b/packages/flutter3d_webgl/test/goldens/decal-floor.png new file mode 100644 index 000000000..2eef1e462 Binary files /dev/null and b/packages/flutter3d_webgl/test/goldens/decal-floor.png differ diff --git a/packages/flutter3d_webgl/test/goldens/lens-distortion.png b/packages/flutter3d_webgl/test/goldens/lens-distortion.png new file mode 100644 index 000000000..2dba36025 Binary files /dev/null and b/packages/flutter3d_webgl/test/goldens/lens-distortion.png differ diff --git a/packages/flutter3d_webgl/test/goldens/lens-flare.png b/packages/flutter3d_webgl/test/goldens/lens-flare.png new file mode 100644 index 000000000..1c779ed91 Binary files /dev/null and b/packages/flutter3d_webgl/test/goldens/lens-flare.png differ diff --git a/packages/flutter3d_webgl/test/goldens/material-instance-data.png b/packages/flutter3d_webgl/test/goldens/material-instance-data.png new file mode 100644 index 000000000..6f65f166e Binary files /dev/null and b/packages/flutter3d_webgl/test/goldens/material-instance-data.png differ diff --git a/packages/flutter3d_webgl/test/goldens/material-language.png b/packages/flutter3d_webgl/test/goldens/material-language.png new file mode 100644 index 000000000..454ad4dfe Binary files /dev/null and b/packages/flutter3d_webgl/test/goldens/material-language.png differ diff --git a/packages/flutter3d_webgl/test/goldens/material-light-hook.png b/packages/flutter3d_webgl/test/goldens/material-light-hook.png new file mode 100644 index 000000000..5a3155067 Binary files /dev/null and b/packages/flutter3d_webgl/test/goldens/material-light-hook.png differ diff --git a/packages/flutter3d_webgl/test/goldens/orthographic-metal.png b/packages/flutter3d_webgl/test/goldens/orthographic-metal.png new file mode 100644 index 000000000..3b380f9b6 Binary files /dev/null and b/packages/flutter3d_webgl/test/goldens/orthographic-metal.png differ diff --git a/packages/flutter3d_webgl/test/goldens/orthographic-particles.png b/packages/flutter3d_webgl/test/goldens/orthographic-particles.png new file mode 100644 index 000000000..08feb8a25 Binary files /dev/null and b/packages/flutter3d_webgl/test/goldens/orthographic-particles.png differ diff --git a/packages/flutter3d_webgl/test/goldens/orthographic-shadows.png b/packages/flutter3d_webgl/test/goldens/orthographic-shadows.png new file mode 100644 index 000000000..686f6eb0f Binary files /dev/null and b/packages/flutter3d_webgl/test/goldens/orthographic-shadows.png differ diff --git a/packages/flutter3d_webgl/test/goldens/planar-mirror.png b/packages/flutter3d_webgl/test/goldens/planar-mirror.png new file mode 100644 index 000000000..b95e8a5ba Binary files /dev/null and b/packages/flutter3d_webgl/test/goldens/planar-mirror.png differ diff --git a/packages/flutter3d_webgl/test/goldens/render-texture.png b/packages/flutter3d_webgl/test/goldens/render-texture.png new file mode 100644 index 000000000..1dfdde068 Binary files /dev/null and b/packages/flutter3d_webgl/test/goldens/render-texture.png differ diff --git a/packages/flutter3d_webgl/test/goldens/sky-physical-dusk.png b/packages/flutter3d_webgl/test/goldens/sky-physical-dusk.png new file mode 100644 index 000000000..9e76fb1c3 Binary files /dev/null and b/packages/flutter3d_webgl/test/goldens/sky-physical-dusk.png differ diff --git a/packages/flutter3d_webgl/test/goldens/sky-physical-night.png b/packages/flutter3d_webgl/test/goldens/sky-physical-night.png new file mode 100644 index 000000000..8f69001d3 Binary files /dev/null and b/packages/flutter3d_webgl/test/goldens/sky-physical-night.png differ diff --git a/packages/flutter3d_webgl/test/goldens/smaa-teapot.png b/packages/flutter3d_webgl/test/goldens/smaa-teapot.png new file mode 100644 index 000000000..7798a4d36 Binary files /dev/null and b/packages/flutter3d_webgl/test/goldens/smaa-teapot.png differ diff --git a/packages/flutter3d_webgl/test/goldens/widget-scene.png b/packages/flutter3d_webgl/test/goldens/widget-scene.png new file mode 100644 index 000000000..ae1f46c3c Binary files /dev/null and b/packages/flutter3d_webgl/test/goldens/widget-scene.png differ diff --git a/packages/flutter3d_webgl/test/lighting_models_test.dart b/packages/flutter3d_webgl/test/lighting_models_test.dart index e4985b65f..3fd1c19cd 100644 --- a/packages/flutter3d_webgl/test/lighting_models_test.dart +++ b/packages/flutter3d_webgl/test/lighting_models_test.dart @@ -6,7 +6,7 @@ /// `lighting-unlit` came back as an empty frame — the background colour in every /// pixel — while the other five drew a sphere. The golden set found it, once /// there was a golden set; this says the same thing in one second instead of a -/// browser run over seventy-eight scenes, and says it per model rather than per +/// browser run over ninety-five scenes, and says it per model rather than per /// picture. /// /// It deliberately does not compare against a reference. What a shader *should* diff --git a/packages/flutter3d_webgl/tool/generate_shaders.dart b/packages/flutter3d_webgl/tool/generate_shaders.dart index 9b07928d1..cbfae3b3d 100644 --- a/packages/flutter3d_webgl/tool/generate_shaders.dart +++ b/packages/flutter3d_webgl/tool/generate_shaders.dart @@ -16,7 +16,7 @@ library; import 'dart:convert'; import 'dart:io'; -import 'package:flutter3d_webgl/src/glsl_translate.dart'; +import 'package:flutter3d_shaders/translate.dart'; import 'source_package.dart'; diff --git a/packages/flutter3d_webgl/tool/golden_web.sh b/packages/flutter3d_webgl/tool/golden_web.sh index 2dffea504..b050be9af 100755 --- a/packages/flutter3d_webgl/tool/golden_web.sh +++ b/packages/flutter3d_webgl/tool/golden_web.sh @@ -16,7 +16,7 @@ # the same one either way, so this script has no reason to read it. # # **One build for the whole suite, and for both browser backends.** The scene is -# a query parameter rather than a compile-time define, so the seventy-eight scenes +# a query parameter rather than a compile-time define, so the ninety-five scenes # are one dart2js run and a navigation each, rather than a dart2js run each. # That is the only reason this is minutes rather than an hour, and it is why the # backend arrives the same way: a define per backend would have spent the saving diff --git a/packages/flutter3d_webgl/tool/pack_shaders.dart b/packages/flutter3d_webgl/tool/pack_shaders.dart index abff2e2a7..96bcb3336 100644 --- a/packages/flutter3d_webgl/tool/pack_shaders.dart +++ b/packages/flutter3d_webgl/tool/pack_shaders.dart @@ -60,8 +60,7 @@ import 'dart:typed_data'; // The container alone: the package's barrel reaches `package:flutter`, which // a `dart run` tool cannot load. `shader_bundle.dart` says so. import 'package:flutter3d_hardware/shader_bundle.dart'; -import 'package:flutter3d_webgl/src/glsl_translate.dart'; -import 'package:flutter3d_webgl/src/webgl_bundle_section.dart'; +import 'package:flutter3d_shaders/translate.dart'; import 'source_package.dart'; diff --git a/packages/flutter3d_webgpu/CHANGELOG.md b/packages/flutter3d_webgpu/CHANGELOG.md index ad3ae5e31..82a3a3b1a 100644 --- a/packages/flutter3d_webgpu/CHANGELOG.md +++ b/packages/flutter3d_webgpu/CHANGELOG.md @@ -1,3 +1,48 @@ +## Unreleased + +- **A stage loaded from a bundle says what it declares.** + `WebGpuStage.declared` is the section's own blocks and samplers, which on + this backend are the pipeline's layout, and a loaded library hands it to + the renderer as the handle's `kept`. Without it the renderer asked the + lighting model what to bind, and a lit material that declares a map it + never reads had the draw refused. + +- The shader table regenerated for the caustic stages. +- The shader table regenerated for the painted, unclamped transmittance. +- The shader table regenerated for `ShadowTransmittance` and the coloured + sun shadow (`ShadowSettings.translucentCasters`). +- **Alpha to coverage**: a pipeline's `alphaToCoverageEnabled`, part of its + signature, set only where the pass multisamples. + +- **The generated tables carry the orthographic camera's paths**, and + `orthographic-metal` is in the WebGPU reference set. + +- **The generated tables carry the debug views**, and `debug-view-split` + is in the WebGPU reference set. + +- **`LensFlare` translated, and the composite's distortion**, through + glslang and naga; both lens scenes match Impeller's to the pixel. +- **SMAA 1x's three stages translated**, through glslang and naga like every + other stage; `smaa-teapot` is in the WebGPU reference set and matches + Impeller's to the pixel. + +**The generated tables carry the decal stage** of `flutter3d_shaders`, +through glslang and naga like every other stage. + +**The generated tables carry `PlanarReflection` and +`RenderTextureEncode`**, through glslang and naga like every other stage, +and the `planar-mirror` and `render-texture` goldens are in this set, both 0 +of 172800 pixels from Impeller. + +**The generated tables carry `SkyPhysical`, `SkyPhysicalVertex` and the +height fog in `ApplyFog`**, through glslang and naga like every other stage. + +**`prepareStage`, the WGSL compile and the section writer moved to +`flutter3d_shaders`** for the reason that package's changelog gives. The +tools and tests here import them from `package:flutter3d_shaders/compile.dart`, +and `flutter3d_webgl` is no longer a dev dependency: it was here only for +`resolveIncludes`. + ## 0.8.2+1 **Resolves on Flutter 3.44 and Dart 3.12.0.** The constraints asked for Dart diff --git a/packages/flutter3d_webgpu/lib/engine_shaders.dart b/packages/flutter3d_webgpu/lib/engine_shaders.dart index dd3f1f32f..30ed9346c 100644 --- a/packages/flutter3d_webgpu/lib/engine_shaders.dart +++ b/packages/flutter3d_webgpu/lib/engine_shaders.dart @@ -34,12 +34,13 @@ struct gl_PerVertex { } struct VertexOutput { - @location(6) member: vec3, - @location(2) member_1: vec3, - @location(4) member_2: vec2, - @location(3) member_3: vec4, + @location(7) member: vec3, + @location(3) member_1: vec3, + @location(5) member_2: vec2, + @location(4) member_3: vec4, @location(0) member_4: vec4, - @location(1) member_5: vec2, + @location(2) member_5: vec2, + @location(1) member_6: vec4, @builtin(position) gl_Position: vec4, } @@ -63,49 +64,50 @@ var v_tangent: vec4; var v_color: vec4; var color_1: vec4; var v_lightmap_uv: vec2; +var v_instance: vec4; var unnamed: gl_PerVertex = gl_PerVertex(vec4(0f, 0f, 0f, 1f), 1f, array(), array()); fn AddMorphTargetAt_u0028_i1_u003b_f1_u003b_f1_u003b_f1_u003b_vf3_u003b_vf3_u003b_vf4_u003b(t: ptr, weight: ptr, column: ptr, rowStep: ptr, position_1: ptr>, normal_1: ptr>, tangent_1: ptr>) { var row: f32; - let _e40 = (*t); - let _e44 = (*rowStep); - row = ((f32((_e40 * 3i)) + 0.5f) * _e44); - let _e46 = (*column); - let _e47 = row; - let _e49 = textureSampleLevel(morph_texture_tex, morph_texture_smp, vec2(_e46, _e47), 0f); - let _e51 = (*weight); - let _e53 = (*position_1); - (*position_1) = (_e53 + (_e49.xyz * _e51)); - let _e55 = (*column); - let _e56 = row; - let _e57 = (*rowStep); - let _e60 = textureSampleLevel(morph_texture_tex, morph_texture_smp, vec2(_e55, (_e56 + _e57)), 0f); - let _e62 = (*weight); - let _e64 = (*normal_1); - (*normal_1) = (_e64 + (_e60.xyz * _e62)); - let _e66 = (*column); - let _e67 = row; - let _e68 = (*rowStep); - let _e72 = textureSampleLevel(morph_texture_tex, morph_texture_smp, vec2(_e66, (_e67 + (_e68 * 2f))), 0f); - let _e74 = (*weight); - let _e76 = (*tangent_1); - let _e78 = (_e76.xyz + (_e72.xyz * _e74)); - (*tangent_1)[0u] = _e78.x; - (*tangent_1)[1u] = _e78.y; - (*tangent_1)[2u] = _e78.z; + let _e42 = (*t); + let _e46 = (*rowStep); + row = ((f32((_e42 * 3i)) + 0.5f) * _e46); + let _e48 = (*column); + let _e49 = row; + let _e51 = textureSampleLevel(morph_texture_tex, morph_texture_smp, vec2(_e48, _e49), 0f); + let _e53 = (*weight); + let _e55 = (*position_1); + (*position_1) = (_e55 + (_e51.xyz * _e53)); + let _e57 = (*column); + let _e58 = row; + let _e59 = (*rowStep); + let _e62 = textureSampleLevel(morph_texture_tex, morph_texture_smp, vec2(_e57, (_e58 + _e59)), 0f); + let _e64 = (*weight); + let _e66 = (*normal_1); + (*normal_1) = (_e66 + (_e62.xyz * _e64)); + let _e68 = (*column); + let _e69 = row; + let _e70 = (*rowStep); + let _e74 = textureSampleLevel(morph_texture_tex, morph_texture_smp, vec2(_e68, (_e69 + (_e70 * 2f))), 0f); + let _e76 = (*weight); + let _e78 = (*tangent_1); + let _e80 = (_e78.xyz + (_e74.xyz * _e76)); + (*tangent_1)[0u] = _e80.x; + (*tangent_1)[1u] = _e80.y; + (*tangent_1)[2u] = _e80.z; return; } fn MorphColumn_u0028_() -> f32 { - let _e32 = gl_VertexIndex_1; - let _e37 = morph_info.morph_params[1u]; - return ((f32(_e32) + 0.5f) * _e37); + let _e34 = gl_VertexIndex_1; + let _e39 = morph_info.morph_params[1u]; + return ((f32(_e34) + 0.5f) * _e39); } fn MorphCount_u0028_() -> i32 { - let _e34 = morph_info.morph_params[0u]; - return i32((_e34 + 0.5f)); + let _e36 = morph_info.morph_params[0u]; + return i32((_e36 + 0.5f)); } fn ApplyMorph_u0028_vf3_u003b_vf3_u003b_vf4_u003b(position_2: ptr>, normal_2: ptr>, tangent_2: ptr>) { @@ -122,61 +124,61 @@ fn ApplyMorph_u0028_vf3_u003b_vf3_u003b_vf4_u003b(position_2: ptr; var param_6: vec4; - let _e47 = MorphCount_u0028_(); - count = _e47; - let _e48 = count; - if (_e48 <= 0i) { + let _e49 = MorphCount_u0028_(); + count = _e49; + let _e50 = count; + if (_e50 <= 0i) { return; } - let _e50 = MorphColumn_u0028_(); - column_1 = _e50; - let _e53 = morph_info.morph_params[2u]; - rowStep_1 = _e53; + let _e52 = MorphColumn_u0028_(); + column_1 = _e52; + let _e55 = morph_info.morph_params[2u]; + rowStep_1 = _e55; i = 0i; loop { - let _e54 = i; - if (_e54 < 8i) { - let _e56 = i; - let _e57 = count; - if (_e56 >= _e57) { + let _e56 = i; + if (_e56 < 8i) { + let _e58 = i; + let _e59 = count; + if (_e58 >= _e59) { break; } - let _e59 = i; let _e61 = i; - let _e72 = morph_info.morph_weights[(_e59 / 4i)][(_e61 - (i32(floor((f32(_e61) / f32(4i)))) * 4i))]; - weight_1 = _e72; - let _e73 = weight_1; - if (_e73 == 0f) { + let _e63 = i; + let _e74 = morph_info.morph_weights[(_e61 / 4i)][(_e63 - (i32(floor((f32(_e63) / f32(4i)))) * 4i))]; + weight_1 = _e74; + let _e75 = weight_1; + if (_e75 == 0f) { continue; } - let _e75 = i; - param = _e75; - let _e76 = weight_1; - param_1 = _e76; - let _e77 = column_1; - param_2 = _e77; - let _e78 = rowStep_1; - param_3 = _e78; - let _e79 = (*position_2); - param_4 = _e79; - let _e80 = (*normal_2); - param_5 = _e80; - let _e81 = (*tangent_2); - param_6 = _e81; + let _e77 = i; + param = _e77; + let _e78 = weight_1; + param_1 = _e78; + let _e79 = column_1; + param_2 = _e79; + let _e80 = rowStep_1; + param_3 = _e80; + let _e81 = (*position_2); + param_4 = _e81; + let _e82 = (*normal_2); + param_5 = _e82; + let _e83 = (*tangent_2); + param_6 = _e83; AddMorphTargetAt_u0028_i1_u003b_f1_u003b_f1_u003b_f1_u003b_vf3_u003b_vf3_u003b_vf4_u003b((¶m), (¶m_1), (¶m_2), (¶m_3), (¶m_4), (¶m_5), (¶m_6)); - let _e82 = param_4; - (*position_2) = _e82; - let _e83 = param_5; - (*normal_2) = _e83; - let _e84 = param_6; - (*tangent_2) = _e84; + let _e84 = param_4; + (*position_2) = _e84; + let _e85 = param_5; + (*normal_2) = _e85; + let _e86 = param_6; + (*tangent_2) = _e86; continue; } else { break; } continuing { - let _e85 = i; - i = (_e85 + 1i); + let _e87 = i; + i = (_e87 + 1i); } } return; @@ -193,56 +195,57 @@ fn main_1() { var mirrored: bool; var local: f32; - let _e41 = position_3; - morphed_position = _e41; - let _e42 = normal_3; - morphed_normal = _e42; - let _e43 = tangent_3; - morphed_tangent = _e43; - let _e44 = morphed_position; - param_7 = _e44; - let _e45 = morphed_normal; - param_8 = _e45; - let _e46 = morphed_tangent; - param_9 = _e46; + let _e43 = position_3; + morphed_position = _e43; + let _e44 = normal_3; + morphed_normal = _e44; + let _e45 = tangent_3; + morphed_tangent = _e45; + let _e46 = morphed_position; + param_7 = _e46; + let _e47 = morphed_normal; + param_8 = _e47; + let _e48 = morphed_tangent; + param_9 = _e48; ApplyMorph_u0028_vf3_u003b_vf3_u003b_vf4_u003b((¶m_7), (¶m_8), (¶m_9)); - let _e47 = param_7; - morphed_position = _e47; - let _e48 = param_8; - morphed_normal = _e48; - let _e49 = param_9; - morphed_tangent = _e49; - let _e51 = frame_info.model; - let _e52 = morphed_position; - world = (_e51 * vec4(_e52.x, _e52.y, _e52.z, 1f)); - let _e58 = world; - v_world_position = _e58.xyz; - let _e61 = frame_info.normal_matrix; - let _e69 = morphed_normal; - v_normal = (mat3x3(_e61[0].xyz, _e61[1].xyz, _e61[2].xyz) * _e69); - let _e71 = texcoord_1; - v_texcoord = _e71; - let _e73 = frame_info.model; - mirrored = (determinant(mat3x3(_e73[0].xyz, _e73[1].xyz, _e73[2].xyz)) < 0f); - let _e84 = frame_info.model; - let _e92 = morphed_tangent; - let _e94 = (mat3x3(_e84[0].xyz, _e84[1].xyz, _e84[2].xyz) * _e92.xyz); - let _e95 = mirrored; - if _e95 { - let _e97 = morphed_tangent[3u]; - local = -(_e97); - } else { - let _e100 = morphed_tangent[3u]; - local = _e100; - } - let _e101 = local; - v_tangent = vec4(_e94.x, _e94.y, _e94.z, _e101); - let _e106 = color_1; - v_color = _e106; + let _e49 = param_7; + morphed_position = _e49; + let _e50 = param_8; + morphed_normal = _e50; + let _e51 = param_9; + morphed_tangent = _e51; + let _e53 = frame_info.model; + let _e54 = morphed_position; + world = (_e53 * vec4(_e54.x, _e54.y, _e54.z, 1f)); + let _e60 = world; + v_world_position = _e60.xyz; + let _e63 = frame_info.normal_matrix; + let _e71 = morphed_normal; + v_normal = (mat3x3(_e63[0].xyz, _e63[1].xyz, _e63[2].xyz) * _e71); + let _e73 = texcoord_1; + v_texcoord = _e73; + let _e75 = frame_info.model; + mirrored = (determinant(mat3x3(_e75[0].xyz, _e75[1].xyz, _e75[2].xyz)) < 0f); + let _e86 = frame_info.model; + let _e94 = morphed_tangent; + let _e96 = (mat3x3(_e86[0].xyz, _e86[1].xyz, _e86[2].xyz) * _e94.xyz); + let _e97 = mirrored; + if _e97 { + let _e99 = morphed_tangent[3u]; + local = -(_e99); + } else { + let _e102 = morphed_tangent[3u]; + local = _e102; + } + let _e103 = local; + v_tangent = vec4(_e96.x, _e96.y, _e96.z, _e103); + let _e108 = color_1; + v_color = _e108; v_lightmap_uv = vec2(0f, 0f); - let _e108 = frame_info.mvp; - let _e109 = morphed_position; - unnamed.gl_Position = (_e108 * vec4(_e109.x, _e109.y, _e109.z, 1f)); + v_instance = vec4(0f, 0f, 0f, 0f); + let _e110 = frame_info.mvp; + let _e111 = morphed_position; + unnamed.gl_Position = (_e110 * vec4(_e111.x, _e111.y, _e111.z, 1f)); return; } @@ -255,14 +258,15 @@ fn main(@builtin(vertex_index) gl_VertexIndex: u32, @location(0) position: vec3< texcoord_1 = texcoord; color_1 = color; main_1(); - let _e21 = v_world_position; - let _e22 = v_normal; - let _e23 = v_texcoord; - let _e24 = v_tangent; - let _e25 = v_color; - let _e26 = v_lightmap_uv; - let _e27 = unnamed.gl_Position; - return VertexOutput(_e21, _e22, _e23, _e24, _e25, _e26, _e27); + let _e22 = v_world_position; + let _e23 = v_normal; + let _e24 = v_texcoord; + let _e25 = v_tangent; + let _e26 = v_color; + let _e27 = v_lightmap_uv; + let _e28 = v_instance; + let _e29 = unnamed.gl_Position; + return VertexOutput(_e22, _e23, _e24, _e25, _e26, _e27, _e28, _e29); } ''', attributes: [ @@ -424,7 +428,7 @@ struct gl_PerVertex { } struct VertexOutput { - @location(5) member: vec2, + @location(6) member: vec2, @builtin(position) gl_Position: vec4, } @@ -491,12 +495,13 @@ struct gl_PerVertex { } struct VertexOutput { - @location(6) member: vec3, - @location(2) member_1: vec3, - @location(3) member_2: vec4, - @location(4) member_3: vec2, + @location(7) member: vec3, + @location(3) member_1: vec3, + @location(4) member_2: vec4, + @location(5) member_3: vec2, @location(0) member_4: vec4, - @location(1) member_5: vec2, + @location(2) member_5: vec2, + @location(1) member_6: vec4, @builtin(position) gl_Position: vec4, } @@ -524,49 +529,50 @@ var texcoord_1: vec2; var v_color: vec4; var color_1: vec4; var v_lightmap_uv: vec2; +var v_instance: vec4; var unnamed: gl_PerVertex = gl_PerVertex(vec4(0f, 0f, 0f, 1f), 1f, array(), array()); fn AddMorphTargetAt_u0028_i1_u003b_f1_u003b_f1_u003b_f1_u003b_vf3_u003b_vf3_u003b_vf4_u003b(t: ptr, weight: ptr, column: ptr, rowStep: ptr, position_1: ptr>, normal_1: ptr>, tangent_1: ptr>) { var row: f32; - let _e47 = (*t); - let _e51 = (*rowStep); - row = ((f32((_e47 * 3i)) + 0.5f) * _e51); - let _e53 = (*column); - let _e54 = row; - let _e56 = textureSampleLevel(morph_texture_tex, morph_texture_smp, vec2(_e53, _e54), 0f); - let _e58 = (*weight); - let _e60 = (*position_1); - (*position_1) = (_e60 + (_e56.xyz * _e58)); - let _e62 = (*column); - let _e63 = row; - let _e64 = (*rowStep); - let _e67 = textureSampleLevel(morph_texture_tex, morph_texture_smp, vec2(_e62, (_e63 + _e64)), 0f); - let _e69 = (*weight); - let _e71 = (*normal_1); - (*normal_1) = (_e71 + (_e67.xyz * _e69)); - let _e73 = (*column); - let _e74 = row; - let _e75 = (*rowStep); - let _e79 = textureSampleLevel(morph_texture_tex, morph_texture_smp, vec2(_e73, (_e74 + (_e75 * 2f))), 0f); - let _e81 = (*weight); - let _e83 = (*tangent_1); - let _e85 = (_e83.xyz + (_e79.xyz * _e81)); - (*tangent_1)[0u] = _e85.x; - (*tangent_1)[1u] = _e85.y; - (*tangent_1)[2u] = _e85.z; + let _e49 = (*t); + let _e53 = (*rowStep); + row = ((f32((_e49 * 3i)) + 0.5f) * _e53); + let _e55 = (*column); + let _e56 = row; + let _e58 = textureSampleLevel(morph_texture_tex, morph_texture_smp, vec2(_e55, _e56), 0f); + let _e60 = (*weight); + let _e62 = (*position_1); + (*position_1) = (_e62 + (_e58.xyz * _e60)); + let _e64 = (*column); + let _e65 = row; + let _e66 = (*rowStep); + let _e69 = textureSampleLevel(morph_texture_tex, morph_texture_smp, vec2(_e64, (_e65 + _e66)), 0f); + let _e71 = (*weight); + let _e73 = (*normal_1); + (*normal_1) = (_e73 + (_e69.xyz * _e71)); + let _e75 = (*column); + let _e76 = row; + let _e77 = (*rowStep); + let _e81 = textureSampleLevel(morph_texture_tex, morph_texture_smp, vec2(_e75, (_e76 + (_e77 * 2f))), 0f); + let _e83 = (*weight); + let _e85 = (*tangent_1); + let _e87 = (_e85.xyz + (_e81.xyz * _e83)); + (*tangent_1)[0u] = _e87.x; + (*tangent_1)[1u] = _e87.y; + (*tangent_1)[2u] = _e87.z; return; } fn MorphColumn_u0028_() -> f32 { - let _e39 = gl_VertexIndex_1; - let _e44 = morph_info.morph_params[1u]; - return ((f32(_e39) + 0.5f) * _e44); + let _e41 = gl_VertexIndex_1; + let _e46 = morph_info.morph_params[1u]; + return ((f32(_e41) + 0.5f) * _e46); } fn MorphCount_u0028_() -> i32 { - let _e41 = morph_info.morph_params[0u]; - return i32((_e41 + 0.5f)); + let _e43 = morph_info.morph_params[0u]; + return i32((_e43 + 0.5f)); } fn ApplyMorph_u0028_vf3_u003b_vf3_u003b_vf4_u003b(position_2: ptr>, normal_2: ptr>, tangent_2: ptr>) { @@ -583,69 +589,69 @@ fn ApplyMorph_u0028_vf3_u003b_vf3_u003b_vf4_u003b(position_2: ptr; var param_6: vec4; - let _e54 = MorphCount_u0028_(); - count = _e54; - let _e55 = count; - if (_e55 <= 0i) { + let _e56 = MorphCount_u0028_(); + count = _e56; + let _e57 = count; + if (_e57 <= 0i) { return; } - let _e57 = MorphColumn_u0028_(); - column_1 = _e57; - let _e60 = morph_info.morph_params[2u]; - rowStep_1 = _e60; + let _e59 = MorphColumn_u0028_(); + column_1 = _e59; + let _e62 = morph_info.morph_params[2u]; + rowStep_1 = _e62; i = 0i; loop { - let _e61 = i; - if (_e61 < 8i) { - let _e63 = i; - let _e64 = count; - if (_e63 >= _e64) { + let _e63 = i; + if (_e63 < 8i) { + let _e65 = i; + let _e66 = count; + if (_e65 >= _e66) { break; } - let _e66 = i; let _e68 = i; - let _e79 = morph_info.morph_weights[(_e66 / 4i)][(_e68 - (i32(floor((f32(_e68) / f32(4i)))) * 4i))]; - weight_1 = _e79; - let _e80 = weight_1; - if (_e80 == 0f) { + let _e70 = i; + let _e81 = morph_info.morph_weights[(_e68 / 4i)][(_e70 - (i32(floor((f32(_e70) / f32(4i)))) * 4i))]; + weight_1 = _e81; + let _e82 = weight_1; + if (_e82 == 0f) { continue; } - let _e82 = i; - param = _e82; - let _e83 = weight_1; - param_1 = _e83; - let _e84 = column_1; - param_2 = _e84; - let _e85 = rowStep_1; - param_3 = _e85; - let _e86 = (*position_2); - param_4 = _e86; - let _e87 = (*normal_2); - param_5 = _e87; - let _e88 = (*tangent_2); - param_6 = _e88; + let _e84 = i; + param = _e84; + let _e85 = weight_1; + param_1 = _e85; + let _e86 = column_1; + param_2 = _e86; + let _e87 = rowStep_1; + param_3 = _e87; + let _e88 = (*position_2); + param_4 = _e88; + let _e89 = (*normal_2); + param_5 = _e89; + let _e90 = (*tangent_2); + param_6 = _e90; AddMorphTargetAt_u0028_i1_u003b_f1_u003b_f1_u003b_f1_u003b_vf3_u003b_vf3_u003b_vf4_u003b((¶m), (¶m_1), (¶m_2), (¶m_3), (¶m_4), (¶m_5), (¶m_6)); - let _e89 = param_4; - (*position_2) = _e89; - let _e90 = param_5; - (*normal_2) = _e90; - let _e91 = param_6; - (*tangent_2) = _e91; + let _e91 = param_4; + (*position_2) = _e91; + let _e92 = param_5; + (*normal_2) = _e92; + let _e93 = param_6; + (*tangent_2) = _e93; continue; } else { break; } continuing { - let _e92 = i; - i = (_e92 + 1i); + let _e94 = i; + i = (_e94 + 1i); } } return; } fn JointIndex_u0028_f1_u003b(joint: ptr) -> i32 { - let _e40 = (*joint); - return clamp(i32(_e40), 0i, 63i); + let _e42 = (*joint); + return clamp(i32(_e42), 0i, 63i); } fn SkinMatrix_u0028_() -> mat4x4 { @@ -657,48 +663,48 @@ fn SkinMatrix_u0028_() -> mat4x4 { var param_9: f32; var param_10: f32; - let _e47 = weights_1[0u]; - let _e49 = weights_1[1u]; - let _e52 = weights_1[2u]; - let _e55 = weights_1[3u]; - total = (((_e47 + _e49) + _e52) + _e55); - let _e57 = total; - if (_e57 > 0.00001f) { - let _e59 = weights_1; - let _e60 = total; - local = (_e59 / vec4(_e60)); + let _e49 = weights_1[0u]; + let _e51 = weights_1[1u]; + let _e54 = weights_1[2u]; + let _e57 = weights_1[3u]; + total = (((_e49 + _e51) + _e54) + _e57); + let _e59 = total; + if (_e59 > 0.00001f) { + let _e61 = weights_1; + let _e62 = total; + local = (_e61 / vec4(_e62)); } else { local = vec4(1f, 0f, 0f, 0f); } - let _e63 = local; - w = _e63; - let _e65 = w[0u]; - let _e67 = joints_1[0u]; - param_7 = _e67; - let _e68 = JointIndex_u0028_f1_u003b((¶m_7)); - let _e71 = skin_info.joint_matrices[_e68]; - let _e72 = (_e71 * _e65); - let _e74 = w[1u]; - let _e76 = joints_1[1u]; - param_8 = _e76; - let _e77 = JointIndex_u0028_f1_u003b((¶m_8)); - let _e80 = skin_info.joint_matrices[_e77]; - let _e81 = (_e80 * _e74); - let _e94 = mat4x4((_e72[0] + _e81[0]), (_e72[1] + _e81[1]), (_e72[2] + _e81[2]), (_e72[3] + _e81[3])); - let _e96 = w[2u]; - let _e98 = joints_1[2u]; - param_9 = _e98; - let _e99 = JointIndex_u0028_f1_u003b((¶m_9)); - let _e102 = skin_info.joint_matrices[_e99]; - let _e103 = (_e102 * _e96); - let _e116 = mat4x4((_e94[0] + _e103[0]), (_e94[1] + _e103[1]), (_e94[2] + _e103[2]), (_e94[3] + _e103[3])); - let _e118 = w[3u]; - let _e120 = joints_1[3u]; - param_10 = _e120; - let _e121 = JointIndex_u0028_f1_u003b((¶m_10)); - let _e124 = skin_info.joint_matrices[_e121]; - let _e125 = (_e124 * _e118); - return mat4x4((_e116[0] + _e125[0]), (_e116[1] + _e125[1]), (_e116[2] + _e125[2]), (_e116[3] + _e125[3])); + let _e65 = local; + w = _e65; + let _e67 = w[0u]; + let _e69 = joints_1[0u]; + param_7 = _e69; + let _e70 = JointIndex_u0028_f1_u003b((¶m_7)); + let _e73 = skin_info.joint_matrices[_e70]; + let _e74 = (_e73 * _e67); + let _e76 = w[1u]; + let _e78 = joints_1[1u]; + param_8 = _e78; + let _e79 = JointIndex_u0028_f1_u003b((¶m_8)); + let _e82 = skin_info.joint_matrices[_e79]; + let _e83 = (_e82 * _e76); + let _e96 = mat4x4((_e74[0] + _e83[0]), (_e74[1] + _e83[1]), (_e74[2] + _e83[2]), (_e74[3] + _e83[3])); + let _e98 = w[2u]; + let _e100 = joints_1[2u]; + param_9 = _e100; + let _e101 = JointIndex_u0028_f1_u003b((¶m_9)); + let _e104 = skin_info.joint_matrices[_e101]; + let _e105 = (_e104 * _e98); + let _e118 = mat4x4((_e96[0] + _e105[0]), (_e96[1] + _e105[1]), (_e96[2] + _e105[2]), (_e96[3] + _e105[3])); + let _e120 = w[3u]; + let _e122 = joints_1[3u]; + param_10 = _e122; + let _e123 = JointIndex_u0028_f1_u003b((¶m_10)); + let _e126 = skin_info.joint_matrices[_e123]; + let _e127 = (_e126 * _e120); + return mat4x4((_e118[0] + _e127[0]), (_e118[1] + _e127[1]), (_e118[2] + _e127[2]), (_e118[3] + _e127[3])); } fn main_1() { @@ -715,67 +721,68 @@ fn main_1() { var mirrored: bool; var local_1: f32; - let _e51 = SkinMatrix_u0028_(); - skin = _e51; - let _e52 = position_3; - morphed_position = _e52; - let _e53 = normal_3; - morphed_normal = _e53; - let _e54 = tangent_3; - morphed_tangent = _e54; - let _e55 = morphed_position; - param_11 = _e55; - let _e56 = morphed_normal; - param_12 = _e56; - let _e57 = morphed_tangent; - param_13 = _e57; + let _e53 = SkinMatrix_u0028_(); + skin = _e53; + let _e54 = position_3; + morphed_position = _e54; + let _e55 = normal_3; + morphed_normal = _e55; + let _e56 = tangent_3; + morphed_tangent = _e56; + let _e57 = morphed_position; + param_11 = _e57; + let _e58 = morphed_normal; + param_12 = _e58; + let _e59 = morphed_tangent; + param_13 = _e59; ApplyMorph_u0028_vf3_u003b_vf3_u003b_vf4_u003b((¶m_11), (¶m_12), (¶m_13)); - let _e58 = param_11; - morphed_position = _e58; - let _e59 = param_12; - morphed_normal = _e59; - let _e60 = param_13; - morphed_tangent = _e60; - let _e62 = frame_info.model; - let _e63 = skin; - skinnedModel = (_e62 * _e63); - let _e65 = skinnedModel; - let _e66 = morphed_position; - world = (_e65 * vec4(_e66.x, _e66.y, _e66.z, 1f)); - let _e72 = world; - v_world_position = _e72.xyz; - let _e74 = skin; - skinRotation = mat3x3(_e74[0].xyz, _e74[1].xyz, _e74[2].xyz); - let _e83 = frame_info.normal_matrix; - let _e91 = skinRotation; - let _e92 = morphed_normal; - v_normal = (mat3x3(_e83[0].xyz, _e83[1].xyz, _e83[2].xyz) * (_e91 * _e92)); - let _e96 = frame_info.model; - let _e106 = skinRotation; - mirrored = ((determinant(mat3x3(_e96[0].xyz, _e96[1].xyz, _e96[2].xyz)) < 0f) != (determinant(_e106) < 0f)); - let _e111 = frame_info.model; - let _e119 = skinRotation; - let _e120 = morphed_tangent; - let _e123 = (mat3x3(_e111[0].xyz, _e111[1].xyz, _e111[2].xyz) * (_e119 * _e120.xyz)); - let _e124 = mirrored; - if _e124 { - let _e126 = morphed_tangent[3u]; - local_1 = -(_e126); - } else { - let _e129 = morphed_tangent[3u]; - local_1 = _e129; - } - let _e130 = local_1; - v_tangent = vec4(_e123.x, _e123.y, _e123.z, _e130); - let _e135 = texcoord_1; - v_texcoord = _e135; - let _e136 = color_1; - v_color = _e136; + let _e60 = param_11; + morphed_position = _e60; + let _e61 = param_12; + morphed_normal = _e61; + let _e62 = param_13; + morphed_tangent = _e62; + let _e64 = frame_info.model; + let _e65 = skin; + skinnedModel = (_e64 * _e65); + let _e67 = skinnedModel; + let _e68 = morphed_position; + world = (_e67 * vec4(_e68.x, _e68.y, _e68.z, 1f)); + let _e74 = world; + v_world_position = _e74.xyz; + let _e76 = skin; + skinRotation = mat3x3(_e76[0].xyz, _e76[1].xyz, _e76[2].xyz); + let _e85 = frame_info.normal_matrix; + let _e93 = skinRotation; + let _e94 = morphed_normal; + v_normal = (mat3x3(_e85[0].xyz, _e85[1].xyz, _e85[2].xyz) * (_e93 * _e94)); + let _e98 = frame_info.model; + let _e108 = skinRotation; + mirrored = ((determinant(mat3x3(_e98[0].xyz, _e98[1].xyz, _e98[2].xyz)) < 0f) != (determinant(_e108) < 0f)); + let _e113 = frame_info.model; + let _e121 = skinRotation; + let _e122 = morphed_tangent; + let _e125 = (mat3x3(_e113[0].xyz, _e113[1].xyz, _e113[2].xyz) * (_e121 * _e122.xyz)); + let _e126 = mirrored; + if _e126 { + let _e128 = morphed_tangent[3u]; + local_1 = -(_e128); + } else { + let _e131 = morphed_tangent[3u]; + local_1 = _e131; + } + let _e132 = local_1; + v_tangent = vec4(_e125.x, _e125.y, _e125.z, _e132); + let _e137 = texcoord_1; + v_texcoord = _e137; + let _e138 = color_1; + v_color = _e138; v_lightmap_uv = vec2(0f, 0f); - let _e138 = frame_info.mvp; - let _e139 = skin; - let _e140 = morphed_position; - unnamed.gl_Position = (_e138 * (_e139 * vec4(_e140.x, _e140.y, _e140.z, 1f))); + v_instance = vec4(0f, 0f, 0f, 0f); + let _e140 = frame_info.mvp; + let _e141 = skin; + let _e142 = morphed_position; + unnamed.gl_Position = (_e140 * (_e141 * vec4(_e142.x, _e142.y, _e142.z, 1f))); return; } @@ -790,14 +797,15 @@ fn main(@builtin(vertex_index) gl_VertexIndex: u32, @location(6) weights: vec4[ @@ -924,12 +932,13 @@ struct gl_PerVertex { } struct VertexOutput { - @location(6) member: vec3, - @location(2) member_1: vec3, - @location(4) member_2: vec2, - @location(3) member_3: vec4, + @location(7) member: vec3, + @location(3) member_1: vec3, + @location(5) member_2: vec2, + @location(4) member_3: vec4, @location(0) member_4: vec4, - @location(1) member_5: vec2, + @location(2) member_5: vec2, + @location(1) member_6: vec4, @builtin(position) gl_Position: vec4, } @@ -964,49 +973,51 @@ var v_color: vec4; var color_1: vec4; var i_color_1: vec4; var v_lightmap_uv: vec2; +var v_instance: vec4; +var i_data_1: vec4; var unnamed: gl_PerVertex = gl_PerVertex(vec4(0f, 0f, 0f, 1f), 1f, array(), array()); fn AddMorphTargetAt_u0028_i1_u003b_f1_u003b_f1_u003b_f1_u003b_vf3_u003b_vf3_u003b_vf4_u003b(t: ptr, weight: ptr, column: ptr, rowStep: ptr, position_1: ptr>, normal_1: ptr>, tangent_1: ptr>) { var row: f32; - let _e48 = (*t); - let _e52 = (*rowStep); - row = ((f32((_e48 * 3i)) + 0.5f) * _e52); - let _e54 = (*column); - let _e55 = row; - let _e57 = textureSampleLevel(morph_texture_tex, morph_texture_smp, vec2(_e54, _e55), 0f); - let _e59 = (*weight); - let _e61 = (*position_1); - (*position_1) = (_e61 + (_e57.xyz * _e59)); - let _e63 = (*column); - let _e64 = row; - let _e65 = (*rowStep); - let _e68 = textureSampleLevel(morph_texture_tex, morph_texture_smp, vec2(_e63, (_e64 + _e65)), 0f); - let _e70 = (*weight); - let _e72 = (*normal_1); - (*normal_1) = (_e72 + (_e68.xyz * _e70)); - let _e74 = (*column); - let _e75 = row; - let _e76 = (*rowStep); - let _e80 = textureSampleLevel(morph_texture_tex, morph_texture_smp, vec2(_e74, (_e75 + (_e76 * 2f))), 0f); - let _e82 = (*weight); - let _e84 = (*tangent_1); - let _e86 = (_e84.xyz + (_e80.xyz * _e82)); - (*tangent_1)[0u] = _e86.x; - (*tangent_1)[1u] = _e86.y; - (*tangent_1)[2u] = _e86.z; + let _e50 = (*t); + let _e54 = (*rowStep); + row = ((f32((_e50 * 3i)) + 0.5f) * _e54); + let _e56 = (*column); + let _e57 = row; + let _e59 = textureSampleLevel(morph_texture_tex, morph_texture_smp, vec2(_e56, _e57), 0f); + let _e61 = (*weight); + let _e63 = (*position_1); + (*position_1) = (_e63 + (_e59.xyz * _e61)); + let _e65 = (*column); + let _e66 = row; + let _e67 = (*rowStep); + let _e70 = textureSampleLevel(morph_texture_tex, morph_texture_smp, vec2(_e65, (_e66 + _e67)), 0f); + let _e72 = (*weight); + let _e74 = (*normal_1); + (*normal_1) = (_e74 + (_e70.xyz * _e72)); + let _e76 = (*column); + let _e77 = row; + let _e78 = (*rowStep); + let _e82 = textureSampleLevel(morph_texture_tex, morph_texture_smp, vec2(_e76, (_e77 + (_e78 * 2f))), 0f); + let _e84 = (*weight); + let _e86 = (*tangent_1); + let _e88 = (_e86.xyz + (_e82.xyz * _e84)); + (*tangent_1)[0u] = _e88.x; + (*tangent_1)[1u] = _e88.y; + (*tangent_1)[2u] = _e88.z; return; } fn MorphColumn_u0028_() -> f32 { - let _e40 = gl_VertexIndex_1; - let _e45 = morph_info.morph_params[1u]; - return ((f32(_e40) + 0.5f) * _e45); + let _e42 = gl_VertexIndex_1; + let _e47 = morph_info.morph_params[1u]; + return ((f32(_e42) + 0.5f) * _e47); } fn MorphCount_u0028_() -> i32 { - let _e42 = morph_info.morph_params[0u]; - return i32((_e42 + 0.5f)); + let _e44 = morph_info.morph_params[0u]; + return i32((_e44 + 0.5f)); } fn ApplyMorph_u0028_vf3_u003b_vf3_u003b_vf4_u003b(position_2: ptr>, normal_2: ptr>, tangent_2: ptr>) { @@ -1023,61 +1034,61 @@ fn ApplyMorph_u0028_vf3_u003b_vf3_u003b_vf4_u003b(position_2: ptr; var param_6: vec4; - let _e55 = MorphCount_u0028_(); - count = _e55; - let _e56 = count; - if (_e56 <= 0i) { + let _e57 = MorphCount_u0028_(); + count = _e57; + let _e58 = count; + if (_e58 <= 0i) { return; } - let _e58 = MorphColumn_u0028_(); - column_1 = _e58; - let _e61 = morph_info.morph_params[2u]; - rowStep_1 = _e61; + let _e60 = MorphColumn_u0028_(); + column_1 = _e60; + let _e63 = morph_info.morph_params[2u]; + rowStep_1 = _e63; i = 0i; loop { - let _e62 = i; - if (_e62 < 8i) { - let _e64 = i; - let _e65 = count; - if (_e64 >= _e65) { + let _e64 = i; + if (_e64 < 8i) { + let _e66 = i; + let _e67 = count; + if (_e66 >= _e67) { break; } - let _e67 = i; let _e69 = i; - let _e80 = morph_info.morph_weights[(_e67 / 4i)][(_e69 - (i32(floor((f32(_e69) / f32(4i)))) * 4i))]; - weight_1 = _e80; - let _e81 = weight_1; - if (_e81 == 0f) { + let _e71 = i; + let _e82 = morph_info.morph_weights[(_e69 / 4i)][(_e71 - (i32(floor((f32(_e71) / f32(4i)))) * 4i))]; + weight_1 = _e82; + let _e83 = weight_1; + if (_e83 == 0f) { continue; } - let _e83 = i; - param = _e83; - let _e84 = weight_1; - param_1 = _e84; - let _e85 = column_1; - param_2 = _e85; - let _e86 = rowStep_1; - param_3 = _e86; - let _e87 = (*position_2); - param_4 = _e87; - let _e88 = (*normal_2); - param_5 = _e88; - let _e89 = (*tangent_2); - param_6 = _e89; + let _e85 = i; + param = _e85; + let _e86 = weight_1; + param_1 = _e86; + let _e87 = column_1; + param_2 = _e87; + let _e88 = rowStep_1; + param_3 = _e88; + let _e89 = (*position_2); + param_4 = _e89; + let _e90 = (*normal_2); + param_5 = _e90; + let _e91 = (*tangent_2); + param_6 = _e91; AddMorphTargetAt_u0028_i1_u003b_f1_u003b_f1_u003b_f1_u003b_vf3_u003b_vf3_u003b_vf4_u003b((¶m), (¶m_1), (¶m_2), (¶m_3), (¶m_4), (¶m_5), (¶m_6)); - let _e90 = param_4; - (*position_2) = _e90; - let _e91 = param_5; - (*normal_2) = _e91; - let _e92 = param_6; - (*tangent_2) = _e92; + let _e92 = param_4; + (*position_2) = _e92; + let _e93 = param_5; + (*normal_2) = _e93; + let _e94 = param_6; + (*tangent_2) = _e94; continue; } else { break; } continuing { - let _e93 = i; - i = (_e93 + 1i); + let _e95 = i; + i = (_e95 + 1i); } } return; @@ -1102,86 +1113,86 @@ fn ApplyMorphInstanced_u0028_i1_u003b_vf3_u003b_vf3_u003b_vf4_u003b(instance: pt var param_15: vec3; var param_16: vec4; - let _e63 = morph_instance_info.instance_params[0u]; - if (_e63 < 0.5f) { - let _e65 = (*position_3); - param_7 = _e65; - let _e66 = (*normal_3); - param_8 = _e66; - let _e67 = (*tangent_3); - param_9 = _e67; + let _e65 = morph_instance_info.instance_params[0u]; + if (_e65 < 0.5f) { + let _e67 = (*position_3); + param_7 = _e67; + let _e68 = (*normal_3); + param_8 = _e68; + let _e69 = (*tangent_3); + param_9 = _e69; ApplyMorph_u0028_vf3_u003b_vf3_u003b_vf4_u003b((¶m_7), (¶m_8), (¶m_9)); - let _e68 = param_7; - (*position_3) = _e68; - let _e69 = param_8; - (*normal_3) = _e69; - let _e70 = param_9; - (*tangent_3) = _e70; + let _e70 = param_7; + (*position_3) = _e70; + let _e71 = param_8; + (*normal_3) = _e71; + let _e72 = param_9; + (*tangent_3) = _e72; return; } - let _e71 = MorphCount_u0028_(); - count_1 = _e71; - let _e72 = count_1; - if (_e72 <= 0i) { + let _e73 = MorphCount_u0028_(); + count_1 = _e73; + let _e74 = count_1; + if (_e74 <= 0i) { return; } - let _e74 = MorphColumn_u0028_(); - deltaColumn = _e74; - let _e77 = morph_info.morph_params[2u]; - deltaRowStep = _e77; - let _e78 = (*instance); - let _e83 = morph_instance_info.instance_params[2u]; - row_1 = ((f32(_e78) + 0.5f) * _e83); + let _e76 = MorphColumn_u0028_(); + deltaColumn = _e76; + let _e79 = morph_info.morph_params[2u]; + deltaRowStep = _e79; + let _e80 = (*instance); + let _e85 = morph_instance_info.instance_params[2u]; + row_1 = ((f32(_e80) + 0.5f) * _e85); i_1 = 0i; loop { - let _e85 = i_1; - if (_e85 < 8i) { - let _e87 = i_1; - let _e88 = count_1; - if (_e87 >= _e88) { + let _e87 = i_1; + if (_e87 < 8i) { + let _e89 = i_1; + let _e90 = count_1; + if (_e89 >= _e90) { break; } - let _e90 = i_1; - let _e96 = morph_instance_info.instance_params[1u]; - column_2 = ((f32((_e90 / 4i)) + 0.5f) * _e96); - let _e98 = column_2; - let _e99 = row_1; - let _e101 = textureSampleLevel(morph_instance_weights_tex, morph_instance_weights_smp, vec2(_e98, _e99), 0f); - let _e102 = i_1; - let _e109 = (_e102 - (i32(floor((f32(_e102) / f32(4i)))) * 4i)); - weight_2 = select(select(select(_e101[0i], _e101[1i], (1i == _e109)), _e101[2i], (2i == _e109)), _e101[3i], (3i == _e109)); - let _e124 = weight_2; - if (_e124 == 0f) { + let _e92 = i_1; + let _e98 = morph_instance_info.instance_params[1u]; + column_2 = ((f32((_e92 / 4i)) + 0.5f) * _e98); + let _e100 = column_2; + let _e101 = row_1; + let _e103 = textureSampleLevel(morph_instance_weights_tex, morph_instance_weights_smp, vec2(_e100, _e101), 0f); + let _e104 = i_1; + let _e111 = (_e104 - (i32(floor((f32(_e104) / f32(4i)))) * 4i)); + weight_2 = select(select(select(_e103[0i], _e103[1i], (1i == _e111)), _e103[2i], (2i == _e111)), _e103[3i], (3i == _e111)); + let _e126 = weight_2; + if (_e126 == 0f) { continue; } - let _e126 = i_1; - param_10 = _e126; - let _e127 = weight_2; - param_11 = _e127; - let _e128 = deltaColumn; - param_12 = _e128; - let _e129 = deltaRowStep; - param_13 = _e129; - let _e130 = (*position_3); - param_14 = _e130; - let _e131 = (*normal_3); - param_15 = _e131; - let _e132 = (*tangent_3); - param_16 = _e132; + let _e128 = i_1; + param_10 = _e128; + let _e129 = weight_2; + param_11 = _e129; + let _e130 = deltaColumn; + param_12 = _e130; + let _e131 = deltaRowStep; + param_13 = _e131; + let _e132 = (*position_3); + param_14 = _e132; + let _e133 = (*normal_3); + param_15 = _e133; + let _e134 = (*tangent_3); + param_16 = _e134; AddMorphTargetAt_u0028_i1_u003b_f1_u003b_f1_u003b_f1_u003b_vf3_u003b_vf3_u003b_vf4_u003b((¶m_10), (¶m_11), (¶m_12), (¶m_13), (¶m_14), (¶m_15), (¶m_16)); - let _e133 = param_14; - (*position_3) = _e133; - let _e134 = param_15; - (*normal_3) = _e134; - let _e135 = param_16; - (*tangent_3) = _e135; + let _e135 = param_14; + (*position_3) = _e135; + let _e136 = param_15; + (*normal_3) = _e136; + let _e137 = param_16; + (*tangent_3) = _e137; continue; } else { break; } continuing { - let _e136 = i_1; - i_1 = (_e136 + 1i); + let _e138 = i_1; + i_1 = (_e138 + 1i); } } return; @@ -1202,89 +1213,91 @@ fn main_1() { var mirrored: bool; var local_1: f32; - let _e54 = i_row0_1[0u]; - let _e56 = i_row1_1[0u]; - let _e58 = i_row2_1[0u]; - let _e59 = vec4(_e54, _e56, _e58, 0f); - let _e61 = i_row0_1[1u]; - let _e63 = i_row1_1[1u]; - let _e65 = i_row2_1[1u]; - let _e66 = vec4(_e61, _e63, _e65, 0f); - let _e68 = i_row0_1[2u]; - let _e70 = i_row1_1[2u]; - let _e72 = i_row2_1[2u]; - let _e73 = vec4(_e68, _e70, _e72, 0f); - let _e75 = i_row0_1[3u]; - let _e77 = i_row1_1[3u]; - let _e79 = i_row2_1[3u]; - let _e80 = vec4(_e75, _e77, _e79, 1f); - instance_1 = mat4x4(vec4(_e59.x, _e59.y, _e59.z, _e59.w), vec4(_e66.x, _e66.y, _e66.z, _e66.w), vec4(_e73.x, _e73.y, _e73.z, _e73.w), vec4(_e80.x, _e80.y, _e80.z, _e80.w)); - let _e102 = position_4; - morphed_position = _e102; - let _e103 = normal_4; - morphed_normal = _e103; - let _e104 = tangent_4; - morphed_tangent = _e104; - let _e105 = gl_InstanceIndex_1; - param_17 = _e105; - let _e106 = morphed_position; - param_18 = _e106; - let _e107 = morphed_normal; - param_19 = _e107; - let _e108 = morphed_tangent; - param_20 = _e108; + let _e56 = i_row0_1[0u]; + let _e58 = i_row1_1[0u]; + let _e60 = i_row2_1[0u]; + let _e61 = vec4(_e56, _e58, _e60, 0f); + let _e63 = i_row0_1[1u]; + let _e65 = i_row1_1[1u]; + let _e67 = i_row2_1[1u]; + let _e68 = vec4(_e63, _e65, _e67, 0f); + let _e70 = i_row0_1[2u]; + let _e72 = i_row1_1[2u]; + let _e74 = i_row2_1[2u]; + let _e75 = vec4(_e70, _e72, _e74, 0f); + let _e77 = i_row0_1[3u]; + let _e79 = i_row1_1[3u]; + let _e81 = i_row2_1[3u]; + let _e82 = vec4(_e77, _e79, _e81, 1f); + instance_1 = mat4x4(vec4(_e61.x, _e61.y, _e61.z, _e61.w), vec4(_e68.x, _e68.y, _e68.z, _e68.w), vec4(_e75.x, _e75.y, _e75.z, _e75.w), vec4(_e82.x, _e82.y, _e82.z, _e82.w)); + let _e104 = position_4; + morphed_position = _e104; + let _e105 = normal_4; + morphed_normal = _e105; + let _e106 = tangent_4; + morphed_tangent = _e106; + let _e107 = gl_InstanceIndex_1; + param_17 = _e107; + let _e108 = morphed_position; + param_18 = _e108; + let _e109 = morphed_normal; + param_19 = _e109; + let _e110 = morphed_tangent; + param_20 = _e110; ApplyMorphInstanced_u0028_i1_u003b_vf3_u003b_vf3_u003b_vf4_u003b((¶m_17), (¶m_18), (¶m_19), (¶m_20)); - let _e109 = param_18; - morphed_position = _e109; - let _e110 = param_19; - morphed_normal = _e110; - let _e111 = param_20; - morphed_tangent = _e111; - let _e112 = instance_1; - let _e113 = morphed_position; - local = (_e112 * vec4(_e113.x, _e113.y, _e113.z, 1f)); - let _e120 = frame_info.model; - let _e121 = local; - world = (_e120 * _e121); - let _e123 = world; - v_world_position = _e123.xyz; - let _e125 = instance_1; - rotation = mat3x3(_e125[0].xyz, _e125[1].xyz, _e125[2].xyz); - let _e134 = frame_info.normal_matrix; - let _e142 = rotation; - let _e143 = morphed_normal; - v_normal = (mat3x3(_e134[0].xyz, _e134[1].xyz, _e134[2].xyz) * normalize((_e142 * _e143))); - let _e147 = texcoord_1; - v_texcoord = _e147; - let _e149 = frame_info.model; - let _e159 = rotation; - mirrored = ((determinant(mat3x3(_e149[0].xyz, _e149[1].xyz, _e149[2].xyz)) < 0f) != (determinant(_e159) < 0f)); - let _e164 = frame_info.model; - let _e172 = rotation; - let _e173 = morphed_tangent; - let _e176 = (mat3x3(_e164[0].xyz, _e164[1].xyz, _e164[2].xyz) * (_e172 * _e173.xyz)); - let _e177 = mirrored; - if _e177 { - let _e179 = morphed_tangent[3u]; - local_1 = -(_e179); + let _e111 = param_18; + morphed_position = _e111; + let _e112 = param_19; + morphed_normal = _e112; + let _e113 = param_20; + morphed_tangent = _e113; + let _e114 = instance_1; + let _e115 = morphed_position; + local = (_e114 * vec4(_e115.x, _e115.y, _e115.z, 1f)); + let _e122 = frame_info.model; + let _e123 = local; + world = (_e122 * _e123); + let _e125 = world; + v_world_position = _e125.xyz; + let _e127 = instance_1; + rotation = mat3x3(_e127[0].xyz, _e127[1].xyz, _e127[2].xyz); + let _e136 = frame_info.normal_matrix; + let _e144 = rotation; + let _e145 = morphed_normal; + v_normal = (mat3x3(_e136[0].xyz, _e136[1].xyz, _e136[2].xyz) * normalize((_e144 * _e145))); + let _e149 = texcoord_1; + v_texcoord = _e149; + let _e151 = frame_info.model; + let _e161 = rotation; + mirrored = ((determinant(mat3x3(_e151[0].xyz, _e151[1].xyz, _e151[2].xyz)) < 0f) != (determinant(_e161) < 0f)); + let _e166 = frame_info.model; + let _e174 = rotation; + let _e175 = morphed_tangent; + let _e178 = (mat3x3(_e166[0].xyz, _e166[1].xyz, _e166[2].xyz) * (_e174 * _e175.xyz)); + let _e179 = mirrored; + if _e179 { + let _e181 = morphed_tangent[3u]; + local_1 = -(_e181); } else { - let _e182 = morphed_tangent[3u]; - local_1 = _e182; + let _e184 = morphed_tangent[3u]; + local_1 = _e184; } - let _e183 = local_1; - v_tangent = vec4(_e176.x, _e176.y, _e176.z, _e183); - let _e188 = color_1; - let _e189 = i_color_1; - v_color = (_e188 * _e189); + let _e185 = local_1; + v_tangent = vec4(_e178.x, _e178.y, _e178.z, _e185); + let _e190 = color_1; + let _e191 = i_color_1; + v_color = (_e190 * _e191); v_lightmap_uv = vec2(0f, 0f); - let _e192 = frame_info.mvp; - let _e193 = local; - unnamed.gl_Position = (_e192 * _e193); + let _e193 = i_data_1; + v_instance = _e193; + let _e195 = frame_info.mvp; + let _e196 = local; + unnamed.gl_Position = (_e195 * _e196); return; } @vertex -fn main(@builtin(vertex_index) gl_VertexIndex: u32, @location(5) i_row0_: vec4, @location(6) i_row1_: vec4, @location(7) i_row2_: vec4, @location(0) position: vec3, @location(1) normal: vec3, @location(3) tangent: vec4, @builtin(instance_index) gl_InstanceIndex: u32, @location(2) texcoord: vec2, @location(4) color: vec4, @location(8) i_color: vec4) -> VertexOutput { +fn main(@builtin(vertex_index) gl_VertexIndex: u32, @location(5) i_row0_: vec4, @location(6) i_row1_: vec4, @location(7) i_row2_: vec4, @location(0) position: vec3, @location(1) normal: vec3, @location(3) tangent: vec4, @builtin(instance_index) gl_InstanceIndex: u32, @location(2) texcoord: vec2, @location(4) color: vec4, @location(8) i_color: vec4, @location(9) i_data: vec4) -> VertexOutput { gl_VertexIndex_1 = i32(gl_VertexIndex); i_row0_1 = i_row0_; i_row1_1 = i_row1_; @@ -1296,15 +1309,17 @@ fn main(@builtin(vertex_index) gl_VertexIndex: u32, @location(5) i_row0_: vec4[ @@ -1353,6 +1368,11 @@ fn main(@builtin(vertex_index) gl_VertexIndex: u32, @location(5) i_row0_: vec4[ WebGpuBlock( @@ -1444,12 +1464,13 @@ struct gl_PerVertex { } struct VertexOutput { - @location(6) member: vec3, - @location(2) member_1: vec3, - @location(4) member_2: vec2, - @location(3) member_3: vec4, + @location(7) member: vec3, + @location(3) member_1: vec3, + @location(5) member_2: vec2, + @location(4) member_3: vec4, @location(0) member_4: vec4, - @location(1) member_5: vec2, + @location(2) member_5: vec2, + @location(1) member_6: vec4, @builtin(position) gl_Position: vec4, } @@ -1473,49 +1494,50 @@ var v_tangent: vec4; var v_color: vec4; var v_lightmap_uv: vec2; var color_1: vec4; +var v_instance: vec4; var unnamed: gl_PerVertex = gl_PerVertex(vec4(0f, 0f, 0f, 1f), 1f, array(), array()); fn AddMorphTargetAt_u0028_i1_u003b_f1_u003b_f1_u003b_f1_u003b_vf3_u003b_vf3_u003b_vf4_u003b(t: ptr, weight: ptr, column: ptr, rowStep: ptr, position_1: ptr>, normal_1: ptr>, tangent_1: ptr>) { var row: f32; - let _e40 = (*t); - let _e44 = (*rowStep); - row = ((f32((_e40 * 3i)) + 0.5f) * _e44); - let _e46 = (*column); - let _e47 = row; - let _e49 = textureSampleLevel(morph_texture_tex, morph_texture_smp, vec2(_e46, _e47), 0f); - let _e51 = (*weight); - let _e53 = (*position_1); - (*position_1) = (_e53 + (_e49.xyz * _e51)); - let _e55 = (*column); - let _e56 = row; - let _e57 = (*rowStep); - let _e60 = textureSampleLevel(morph_texture_tex, morph_texture_smp, vec2(_e55, (_e56 + _e57)), 0f); - let _e62 = (*weight); - let _e64 = (*normal_1); - (*normal_1) = (_e64 + (_e60.xyz * _e62)); - let _e66 = (*column); - let _e67 = row; - let _e68 = (*rowStep); - let _e72 = textureSampleLevel(morph_texture_tex, morph_texture_smp, vec2(_e66, (_e67 + (_e68 * 2f))), 0f); - let _e74 = (*weight); - let _e76 = (*tangent_1); - let _e78 = (_e76.xyz + (_e72.xyz * _e74)); - (*tangent_1)[0u] = _e78.x; - (*tangent_1)[1u] = _e78.y; - (*tangent_1)[2u] = _e78.z; + let _e42 = (*t); + let _e46 = (*rowStep); + row = ((f32((_e42 * 3i)) + 0.5f) * _e46); + let _e48 = (*column); + let _e49 = row; + let _e51 = textureSampleLevel(morph_texture_tex, morph_texture_smp, vec2(_e48, _e49), 0f); + let _e53 = (*weight); + let _e55 = (*position_1); + (*position_1) = (_e55 + (_e51.xyz * _e53)); + let _e57 = (*column); + let _e58 = row; + let _e59 = (*rowStep); + let _e62 = textureSampleLevel(morph_texture_tex, morph_texture_smp, vec2(_e57, (_e58 + _e59)), 0f); + let _e64 = (*weight); + let _e66 = (*normal_1); + (*normal_1) = (_e66 + (_e62.xyz * _e64)); + let _e68 = (*column); + let _e69 = row; + let _e70 = (*rowStep); + let _e74 = textureSampleLevel(morph_texture_tex, morph_texture_smp, vec2(_e68, (_e69 + (_e70 * 2f))), 0f); + let _e76 = (*weight); + let _e78 = (*tangent_1); + let _e80 = (_e78.xyz + (_e74.xyz * _e76)); + (*tangent_1)[0u] = _e80.x; + (*tangent_1)[1u] = _e80.y; + (*tangent_1)[2u] = _e80.z; return; } fn MorphColumn_u0028_() -> f32 { - let _e32 = gl_VertexIndex_1; - let _e37 = morph_info.morph_params[1u]; - return ((f32(_e32) + 0.5f) * _e37); + let _e34 = gl_VertexIndex_1; + let _e39 = morph_info.morph_params[1u]; + return ((f32(_e34) + 0.5f) * _e39); } fn MorphCount_u0028_() -> i32 { - let _e34 = morph_info.morph_params[0u]; - return i32((_e34 + 0.5f)); + let _e36 = morph_info.morph_params[0u]; + return i32((_e36 + 0.5f)); } fn ApplyMorph_u0028_vf3_u003b_vf3_u003b_vf4_u003b(position_2: ptr>, normal_2: ptr>, tangent_2: ptr>) { @@ -1532,61 +1554,61 @@ fn ApplyMorph_u0028_vf3_u003b_vf3_u003b_vf4_u003b(position_2: ptr; var param_6: vec4; - let _e47 = MorphCount_u0028_(); - count = _e47; - let _e48 = count; - if (_e48 <= 0i) { + let _e49 = MorphCount_u0028_(); + count = _e49; + let _e50 = count; + if (_e50 <= 0i) { return; } - let _e50 = MorphColumn_u0028_(); - column_1 = _e50; - let _e53 = morph_info.morph_params[2u]; - rowStep_1 = _e53; + let _e52 = MorphColumn_u0028_(); + column_1 = _e52; + let _e55 = morph_info.morph_params[2u]; + rowStep_1 = _e55; i = 0i; loop { - let _e54 = i; - if (_e54 < 8i) { - let _e56 = i; - let _e57 = count; - if (_e56 >= _e57) { + let _e56 = i; + if (_e56 < 8i) { + let _e58 = i; + let _e59 = count; + if (_e58 >= _e59) { break; } - let _e59 = i; let _e61 = i; - let _e72 = morph_info.morph_weights[(_e59 / 4i)][(_e61 - (i32(floor((f32(_e61) / f32(4i)))) * 4i))]; - weight_1 = _e72; - let _e73 = weight_1; - if (_e73 == 0f) { + let _e63 = i; + let _e74 = morph_info.morph_weights[(_e61 / 4i)][(_e63 - (i32(floor((f32(_e63) / f32(4i)))) * 4i))]; + weight_1 = _e74; + let _e75 = weight_1; + if (_e75 == 0f) { continue; } - let _e75 = i; - param = _e75; - let _e76 = weight_1; - param_1 = _e76; - let _e77 = column_1; - param_2 = _e77; - let _e78 = rowStep_1; - param_3 = _e78; - let _e79 = (*position_2); - param_4 = _e79; - let _e80 = (*normal_2); - param_5 = _e80; - let _e81 = (*tangent_2); - param_6 = _e81; + let _e77 = i; + param = _e77; + let _e78 = weight_1; + param_1 = _e78; + let _e79 = column_1; + param_2 = _e79; + let _e80 = rowStep_1; + param_3 = _e80; + let _e81 = (*position_2); + param_4 = _e81; + let _e82 = (*normal_2); + param_5 = _e82; + let _e83 = (*tangent_2); + param_6 = _e83; AddMorphTargetAt_u0028_i1_u003b_f1_u003b_f1_u003b_f1_u003b_vf3_u003b_vf3_u003b_vf4_u003b((¶m), (¶m_1), (¶m_2), (¶m_3), (¶m_4), (¶m_5), (¶m_6)); - let _e82 = param_4; - (*position_2) = _e82; - let _e83 = param_5; - (*normal_2) = _e83; - let _e84 = param_6; - (*tangent_2) = _e84; + let _e84 = param_4; + (*position_2) = _e84; + let _e85 = param_5; + (*normal_2) = _e85; + let _e86 = param_6; + (*tangent_2) = _e86; continue; } else { break; } continuing { - let _e85 = i; - i = (_e85 + 1i); + let _e87 = i; + i = (_e87 + 1i); } } return; @@ -1603,56 +1625,57 @@ fn main_1() { var mirrored: bool; var local: f32; - let _e41 = position_3; - morphed_position = _e41; - let _e42 = normal_3; - morphed_normal = _e42; - let _e43 = tangent_3; - morphed_tangent = _e43; - let _e44 = morphed_position; - param_7 = _e44; - let _e45 = morphed_normal; - param_8 = _e45; - let _e46 = morphed_tangent; - param_9 = _e46; + let _e43 = position_3; + morphed_position = _e43; + let _e44 = normal_3; + morphed_normal = _e44; + let _e45 = tangent_3; + morphed_tangent = _e45; + let _e46 = morphed_position; + param_7 = _e46; + let _e47 = morphed_normal; + param_8 = _e47; + let _e48 = morphed_tangent; + param_9 = _e48; ApplyMorph_u0028_vf3_u003b_vf3_u003b_vf4_u003b((¶m_7), (¶m_8), (¶m_9)); - let _e47 = param_7; - morphed_position = _e47; - let _e48 = param_8; - morphed_normal = _e48; - let _e49 = param_9; - morphed_tangent = _e49; - let _e51 = frame_info.model; - let _e52 = morphed_position; - world = (_e51 * vec4(_e52.x, _e52.y, _e52.z, 1f)); - let _e58 = world; - v_world_position = _e58.xyz; - let _e61 = frame_info.normal_matrix; - let _e69 = morphed_normal; - v_normal = (mat3x3(_e61[0].xyz, _e61[1].xyz, _e61[2].xyz) * _e69); - let _e71 = texcoord_1; - v_texcoord = _e71; - let _e73 = frame_info.model; - mirrored = (determinant(mat3x3(_e73[0].xyz, _e73[1].xyz, _e73[2].xyz)) < 0f); - let _e84 = frame_info.model; - let _e92 = morphed_tangent; - let _e94 = (mat3x3(_e84[0].xyz, _e84[1].xyz, _e84[2].xyz) * _e92.xyz); - let _e95 = mirrored; - if _e95 { - let _e97 = morphed_tangent[3u]; - local = -(_e97); - } else { - let _e100 = morphed_tangent[3u]; - local = _e100; - } - let _e101 = local; - v_tangent = vec4(_e94.x, _e94.y, _e94.z, _e101); + let _e49 = param_7; + morphed_position = _e49; + let _e50 = param_8; + morphed_normal = _e50; + let _e51 = param_9; + morphed_tangent = _e51; + let _e53 = frame_info.model; + let _e54 = morphed_position; + world = (_e53 * vec4(_e54.x, _e54.y, _e54.z, 1f)); + let _e60 = world; + v_world_position = _e60.xyz; + let _e63 = frame_info.normal_matrix; + let _e71 = morphed_normal; + v_normal = (mat3x3(_e63[0].xyz, _e63[1].xyz, _e63[2].xyz) * _e71); + let _e73 = texcoord_1; + v_texcoord = _e73; + let _e75 = frame_info.model; + mirrored = (determinant(mat3x3(_e75[0].xyz, _e75[1].xyz, _e75[2].xyz)) < 0f); + let _e86 = frame_info.model; + let _e94 = morphed_tangent; + let _e96 = (mat3x3(_e86[0].xyz, _e86[1].xyz, _e86[2].xyz) * _e94.xyz); + let _e97 = mirrored; + if _e97 { + let _e99 = morphed_tangent[3u]; + local = -(_e99); + } else { + let _e102 = morphed_tangent[3u]; + local = _e102; + } + let _e103 = local; + v_tangent = vec4(_e96.x, _e96.y, _e96.z, _e103); v_color = vec4(1f, 1f, 1f, 1f); - let _e106 = color_1; - v_lightmap_uv = _e106.xy; - let _e109 = frame_info.mvp; - let _e110 = morphed_position; - unnamed.gl_Position = (_e109 * vec4(_e110.x, _e110.y, _e110.z, 1f)); + let _e108 = color_1; + v_lightmap_uv = _e108.xy; + v_instance = vec4(0f, 0f, 0f, 0f); + let _e111 = frame_info.mvp; + let _e112 = morphed_position; + unnamed.gl_Position = (_e111 * vec4(_e112.x, _e112.y, _e112.z, 1f)); return; } @@ -1665,14 +1688,15 @@ fn main(@builtin(vertex_index) gl_VertexIndex: u32, @location(0) position: vec3< texcoord_1 = texcoord; color_1 = color; main_1(); - let _e21 = v_world_position; - let _e22 = v_normal; - let _e23 = v_texcoord; - let _e24 = v_tangent; - let _e25 = v_color; - let _e26 = v_lightmap_uv; - let _e27 = unnamed.gl_Position; - return VertexOutput(_e21, _e22, _e23, _e24, _e25, _e26, _e27); + let _e22 = v_world_position; + let _e23 = v_normal; + let _e24 = v_texcoord; + let _e25 = v_tangent; + let _e26 = v_color; + let _e27 = v_lightmap_uv; + let _e28 = v_instance; + let _e29 = unnamed.gl_Position; + return VertexOutput(_e22, _e23, _e24, _e25, _e26, _e27, _e28, _e29); } ''', attributes: [ @@ -3028,12 +3052,13 @@ struct gl_PerVertex { struct VertexOutput { @builtin(position) gl_Position: vec4, - @location(6) member: vec3, - @location(2) member_1: vec3, - @location(4) member_2: vec2, - @location(3) member_3: vec4, + @location(7) member: vec3, + @location(3) member_1: vec3, + @location(5) member_2: vec2, + @location(4) member_3: vec4, @location(0) member_4: vec4, - @location(1) member_5: vec2, + @location(2) member_5: vec2, + @location(1) member_6: vec4, } @group(0) @binding(1) @@ -3052,30 +3077,31 @@ var v_tangent: vec4; var v_color: vec4; var color_1: vec4; var v_lightmap_uv: vec2; +var v_instance: vec4; fn ToPixels_u0028_vf4_u003b_vf2_u003b(clip: ptr>, viewport: ptr>) -> vec2 { - let _e32 = (*clip); - let _e35 = (*clip)[3u]; - let _e38 = (*viewport); - return (((_e32.xy / vec2(_e35)) * _e38) * 0.5f); + let _e34 = (*clip); + let _e37 = (*clip)[3u]; + let _e40 = (*viewport); + return (((_e34.xy / vec2(_e37)) * _e40) * 0.5f); } fn InFront_u0028_vf4_u003b_vf4_u003b(from_: ptr>, to: ptr>) -> vec4 { var t: f32; - let _e34 = (*from_)[3u]; - if (_e34 >= 0.0001f) { - let _e36 = (*from_); - return _e36; - } - let _e38 = (*from_)[3u]; - let _e41 = (*to)[3u]; - let _e43 = (*from_)[3u]; - t = ((0.0001f - _e38) / (_e41 - _e43)); - let _e46 = (*from_); - let _e47 = (*to); - let _e48 = t; - return mix(_e46, _e47, vec4(clamp(_e48, 0f, 1f))); + let _e36 = (*from_)[3u]; + if (_e36 >= 0.0001f) { + let _e38 = (*from_); + return _e38; + } + let _e40 = (*from_)[3u]; + let _e43 = (*to)[3u]; + let _e45 = (*from_)[3u]; + t = ((0.0001f - _e40) / (_e43 - _e45)); + let _e48 = (*from_); + let _e49 = (*to); + let _e50 = t; + return mix(_e48, _e49, vec4(clamp(_e50, 0f, 1f))); } fn main_1() { @@ -3104,102 +3130,103 @@ fn main_1() { var local_1: vec2; var offset: vec2; - let _e55 = params.viewport; - viewport_1 = _e55.xy; - let _e58 = tangent_1[3u]; - halfWidth = abs(_e58); - let _e61 = tangent_1[3u]; - side = select(1f, -1f, (_e61 < 0f)); - let _e65 = frame_info.mvp; - let _e66 = position_1; - here = (_e65 * vec4(_e66.x, _e66.y, _e66.z, 1f)); - let _e73 = frame_info.mvp; - let _e74 = normal_1; - before = (_e73 * vec4(_e74.x, _e74.y, _e74.z, 1f)); - let _e81 = frame_info.mvp; - let _e82 = tangent_1; - let _e83 = _e82.xyz; - after = (_e81 * vec4(_e83.x, _e83.y, _e83.z, 1f)); - let _e90 = here[3u]; - if (_e90 < 0.0001f) { - let _e93 = after[3u]; - if (_e93 >= 0.0001f) { - let _e95 = here; - param = _e95; - let _e96 = after; - param_1 = _e96; - let _e97 = InFront_u0028_vf4_u003b_vf4_u003b((¶m), (¶m_1)); - local = _e97; + let _e57 = params.viewport; + viewport_1 = _e57.xy; + let _e60 = tangent_1[3u]; + halfWidth = abs(_e60); + let _e63 = tangent_1[3u]; + side = select(1f, -1f, (_e63 < 0f)); + let _e67 = frame_info.mvp; + let _e68 = position_1; + here = (_e67 * vec4(_e68.x, _e68.y, _e68.z, 1f)); + let _e75 = frame_info.mvp; + let _e76 = normal_1; + before = (_e75 * vec4(_e76.x, _e76.y, _e76.z, 1f)); + let _e83 = frame_info.mvp; + let _e84 = tangent_1; + let _e85 = _e84.xyz; + after = (_e83 * vec4(_e85.x, _e85.y, _e85.z, 1f)); + let _e92 = here[3u]; + if (_e92 < 0.0001f) { + let _e95 = after[3u]; + if (_e95 >= 0.0001f) { + let _e97 = here; + param = _e97; + let _e98 = after; + param_1 = _e98; + let _e99 = InFront_u0028_vf4_u003b_vf4_u003b((¶m), (¶m_1)); + local = _e99; } else { - let _e98 = here; - param_2 = _e98; - let _e99 = before; - param_3 = _e99; - let _e100 = InFront_u0028_vf4_u003b_vf4_u003b((¶m_2), (¶m_3)); - local = _e100; - } - let _e101 = local; - here = _e101; - } - let _e102 = before; - param_4 = _e102; - let _e103 = here; - param_5 = _e103; - let _e104 = InFront_u0028_vf4_u003b_vf4_u003b((¶m_4), (¶m_5)); - before = _e104; - let _e105 = after; - param_6 = _e105; - let _e106 = here; - param_7 = _e106; - let _e107 = InFront_u0028_vf4_u003b_vf4_u003b((¶m_6), (¶m_7)); - after = _e107; - let _e108 = after; - param_8 = _e108; - let _e109 = viewport_1; - param_9 = _e109; - let _e110 = ToPixels_u0028_vf4_u003b_vf2_u003b((¶m_8), (¶m_9)); - let _e111 = before; - param_10 = _e111; - let _e112 = viewport_1; - param_11 = _e112; - let _e113 = ToPixels_u0028_vf4_u003b_vf2_u003b((¶m_10), (¶m_11)); - dir = (_e110 - _e113); - let _e115 = dir; - dirLength = length(_e115); - let _e117 = dirLength; - if (_e117 > 0.000000001f) { - let _e120 = dir[1u]; - let _e123 = dir[0u]; - let _e125 = dirLength; - local_1 = (vec2(-(_e120), _e123) / vec2(_e125)); + let _e100 = here; + param_2 = _e100; + let _e101 = before; + param_3 = _e101; + let _e102 = InFront_u0028_vf4_u003b_vf4_u003b((¶m_2), (¶m_3)); + local = _e102; + } + let _e103 = local; + here = _e103; + } + let _e104 = before; + param_4 = _e104; + let _e105 = here; + param_5 = _e105; + let _e106 = InFront_u0028_vf4_u003b_vf4_u003b((¶m_4), (¶m_5)); + before = _e106; + let _e107 = after; + param_6 = _e107; + let _e108 = here; + param_7 = _e108; + let _e109 = InFront_u0028_vf4_u003b_vf4_u003b((¶m_6), (¶m_7)); + after = _e109; + let _e110 = after; + param_8 = _e110; + let _e111 = viewport_1; + param_9 = _e111; + let _e112 = ToPixels_u0028_vf4_u003b_vf2_u003b((¶m_8), (¶m_9)); + let _e113 = before; + param_10 = _e113; + let _e114 = viewport_1; + param_11 = _e114; + let _e115 = ToPixels_u0028_vf4_u003b_vf2_u003b((¶m_10), (¶m_11)); + dir = (_e112 - _e115); + let _e117 = dir; + dirLength = length(_e117); + let _e119 = dirLength; + if (_e119 > 0.000000001f) { + let _e122 = dir[1u]; + let _e125 = dir[0u]; + let _e127 = dirLength; + local_1 = (vec2(-(_e122), _e125) / vec2(_e127)); } else { local_1 = vec2(1f, 0f); } - let _e128 = local_1; - segmentNormal = _e128; - let _e129 = segmentNormal; - let _e130 = halfWidth; - let _e132 = side; - offset = ((_e129 * _e130) * _e132); - let _e134 = here; - let _e136 = offset; - let _e137 = viewport_1; - let _e141 = here[3u]; - let _e143 = (_e134.xy + ((_e136 / (_e137 * 0.5f)) * _e141)); - let _e144 = here; - let _e145 = _e144.zw; - unnamed.gl_Position = vec4(_e143.x, _e143.y, _e145.x, _e145.y); - let _e153 = frame_info.model; - let _e154 = position_1; - v_world_position = (_e153 * vec4(_e154.x, _e154.y, _e154.z, 1f)).xyz; - let _e162 = frame_info.normal_matrix; - v_normal = normalize((mat3x3(_e162[0].xyz, _e162[1].xyz, _e162[2].xyz) * vec3(0f, 1f, 0f))); - let _e172 = texcoord_1; - v_texcoord = _e172; + let _e130 = local_1; + segmentNormal = _e130; + let _e131 = segmentNormal; + let _e132 = halfWidth; + let _e134 = side; + offset = ((_e131 * _e132) * _e134); + let _e136 = here; + let _e138 = offset; + let _e139 = viewport_1; + let _e143 = here[3u]; + let _e145 = (_e136.xy + ((_e138 / (_e139 * 0.5f)) * _e143)); + let _e146 = here; + let _e147 = _e146.zw; + unnamed.gl_Position = vec4(_e145.x, _e145.y, _e147.x, _e147.y); + let _e155 = frame_info.model; + let _e156 = position_1; + v_world_position = (_e155 * vec4(_e156.x, _e156.y, _e156.z, 1f)).xyz; + let _e164 = frame_info.normal_matrix; + v_normal = normalize((mat3x3(_e164[0].xyz, _e164[1].xyz, _e164[2].xyz) * vec3(0f, 1f, 0f))); + let _e174 = texcoord_1; + v_texcoord = _e174; v_tangent = vec4(1f, 0f, 0f, 1f); - let _e173 = color_1; - v_color = _e173; + let _e175 = color_1; + v_color = _e175; v_lightmap_uv = vec2(0f, 0f); + v_instance = vec4(0f, 0f, 0f, 0f); return; } @@ -3211,14 +3238,15 @@ fn main(@location(3) tangent: vec4, @location(0) position: vec3, @loca texcoord_1 = texcoord; color_1 = color; main_1(); - let _e18 = unnamed.gl_Position; - let _e19 = v_world_position; - let _e20 = v_normal; - let _e21 = v_texcoord; - let _e22 = v_tangent; - let _e23 = v_color; - let _e24 = v_lightmap_uv; - return VertexOutput(_e18, _e19, _e20, _e21, _e22, _e23, _e24); + let _e19 = unnamed.gl_Position; + let _e20 = v_world_position; + let _e21 = v_normal; + let _e22 = v_texcoord; + let _e23 = v_tangent; + let _e24 = v_color; + let _e25 = v_lightmap_uv; + let _e26 = v_instance; + return VertexOutput(_e19, _e20, _e21, _e22, _e23, _e24, _e25, _e26); } ''', attributes: [ @@ -3299,8 +3327,8 @@ struct ParticleInfo { struct VertexOutput { @location(0) member: vec4, - @location(5) member_1: vec2, - @location(6) member_2: vec3, + @location(6) member_1: vec2, + @location(7) member_2: vec3, @builtin(position) gl_Position: vec4, } @@ -3389,8 +3417,8 @@ struct ParticleMeshInfo { struct VertexOutput { @location(0) member: vec4, - @location(6) member_1: vec3, - @location(2) member_2: vec3, + @location(7) member_1: vec3, + @location(3) member_2: vec3, @builtin(position) gl_Position: vec4, } @@ -3484,8 +3512,20 @@ fn main(@location(2) i_position: vec3, @location(0) position: vec3, @l ], samplers: [], ), - 'ShadowTileResetVertex': WebGpuStage( + 'CausticPhotonVertex': WebGpuStage( wgsl: r''' +struct CausticInfo { + map_to_world: mat4x4, + world_to_map: mat4x4, + world_to_tile: mat4x4, + world_to_clip: mat4x4, + grid: vec4, + light: vec4, + optics: vec4, + tint: vec4, + attenuation: vec4, +} + struct gl_PerVertex { @builtin(position) gl_Position: vec4, gl_PointSize: f32, @@ -3494,74 +3534,664 @@ struct gl_PerVertex { } struct VertexOutput { - @location(5) member: vec2, + @location(6) member: vec2, + @location(0) member_1: vec4, @builtin(position) gl_Position: vec4, } +@group(0) @binding(5) +var caustic_front_tex: texture_2d; +@group(0) @binding(6) +var caustic_front_smp: sampler; +@group(0) @binding(0) +var caustic_info: CausticInfo; +@group(0) @binding(1) +var caustic_back_tex: texture_2d; +@group(0) @binding(2) +var caustic_back_smp: sampler; +@group(0) @binding(3) +var caustic_depth_tex: texture_2d; +@group(0) @binding(4) +var caustic_depth_smp: sampler; var v_uv: vec2; -var texcoord_1: vec2; +var corner_1: vec2; +var v_color: vec4; var unnamed: gl_PerVertex = gl_PerVertex(vec4(0f, 0f, 0f, 1f), 1f, array(), array()); -var position_1: vec2; +var gl_InstanceIndex_1: i32; + +fn AtLeast_u0028_vf2_u003b_vf2_u003b_f1_u003b(a: ptr>, fallback: ptr>, least: ptr) -> vec2 { + var size: f32; + var local: vec2; + + let _e50 = (*a); + size = length(_e50); + let _e52 = size; + if (_e52 < 0.000000001f) { + let _e54 = (*fallback); + let _e56 = (*least); + return (normalize(_e54) * _e56); + } + let _e58 = size; + let _e59 = (*least); + if (_e58 < _e59) { + let _e61 = (*a); + let _e62 = (*least); + let _e63 = size; + local = (_e61 * (_e62 / _e63)); + } else { + let _e66 = (*a); + local = _e66; + } + let _e67 = local; + return _e67; +} + +fn Schlick_u0028_f1_u003b_f1_u003b(f0_: ptr, cosine: ptr) -> f32 { + var c: f32; + + let _e48 = (*cosine); + c = clamp((1f - _e48), 0f, 1f); + let _e51 = (*f0_); + let _e52 = (*f0_); + let _e54 = c; + let _e56 = c; + let _e58 = c; + let _e60 = c; + let _e62 = c; + return (_e51 + ((((((1f - _e52) * _e54) * _e56) * _e58) * _e60) * _e62)); +} + +fn MapUv_u0028_vf4_u003b(p: ptr>) -> vec2 { + let _e47 = (*p)[0u]; + let _e51 = (*p)[1u]; + return vec2(((_e47 * 0.5f) + 0.5f), (0.5f - (_e51 * 0.5f))); +} + +fn Follow_u0028_vf2_u003b_vf3_u003b(uv: ptr>, energy: ptr>) -> vec4 { + var front: vec4; + var ndc: vec2; + var entry: vec3; + var range: f32; + var l: vec3; + var n1_: vec3; + var index: f32; + var inside: vec3; + var back: vec4; + var across: f32; + var exit: vec3; + var there: vec4; + var param: vec4; + var n2_: vec3; + var local_1: vec3; + var out_ray: vec3; + var f0_1: f32; + var param_1: f32; + var param_2: f32; + var param_3: f32; + var param_4: f32; + var fading: f32; + var p_1: vec3; + var down: f32; + var k: i32; + var q: vec4; + var receiver: f32; + var param_5: vec4; + var advance: f32; + var landed: vec4; + var under: f32; + var param_6: vec4; + var clip: vec4; + var phi_100_: bool; + + (*energy) = vec3(0f, 0f, 0f); + let _e80 = (*uv); + let _e81 = textureSampleLevel(caustic_front_tex, caustic_front_smp, _e80, 0f); + front = _e81; + let _e83 = front[3u]; + let _e84 = (_e83 >= 1f); + phi_100_ = _e84; + if !(_e84) { + let _e86 = front; + let _e88 = front; + phi_100_ = (dot(_e86.xyz, _e88.xyz) < 0.25f); + } + let _e93 = phi_100_; + if _e93 { + return vec4(0f, 0f, 0f, 0f); + } + let _e95 = (*uv)[0u]; + let _e99 = (*uv)[1u]; + ndc = vec2(((_e95 * 2f) - 1f), ((0.5f - _e99) * 2f)); + let _e104 = caustic_info.map_to_world; + let _e105 = ndc; + let _e107 = front[3u]; + entry = (_e104 * vec4(_e105.x, _e105.y, _e107, 1f)).xyz; + let _e114 = caustic_info.map_to_world; + range = length((_e114 * vec4(0f, 0f, 1f, 0f)).xyz); + let _e119 = caustic_info.light; + l = normalize(_e119.xyz); + let _e122 = front; + n1_ = normalize(_e122.xyz); + let _e125 = n1_; + let _e126 = l; + if (dot(_e125, _e126) > 0f) { + let _e129 = n1_; + n1_ = -(_e129); + } + let _e133 = caustic_info.optics[0u]; + index = max(_e133, 1f); + let _e135 = l; + let _e136 = n1_; + let _e137 = index; + inside = refract(_e135, _e136, (1f / _e137)); + let _e140 = (*uv); + let _e141 = textureSampleLevel(caustic_back_tex, caustic_back_smp, _e140, 0f); + back = _e141; + let _e143 = back[3u]; + let _e145 = front[3u]; + if (_e143 <= _e145) { + return vec4(0f, 0f, 0f, 0f); + } + let _e148 = back[3u]; + let _e150 = front[3u]; + let _e152 = range; + across = ((_e148 - _e150) * _e152); + let _e154 = entry; + let _e155 = inside; + let _e156 = across; + let _e157 = inside; + let _e158 = l; + exit = (_e154 + (_e155 * (_e156 / max(dot(_e157, _e158), 0.2f)))); + let _e165 = caustic_info.world_to_map; + let _e166 = exit; + param = (_e165 * vec4(_e166.x, _e166.y, _e166.z, 1f)); + let _e172 = MapUv_u0028_vf4_u003b((¶m)); + let _e176 = textureSampleLevel(caustic_back_tex, caustic_back_smp, clamp(_e172, vec2(0f), vec2(1f)), 0f); + there = _e176; + let _e177 = there; + let _e179 = there; + if (dot(_e177.xyz, _e179.xyz) > 0.25f) { + let _e183 = there; + local_1 = normalize(_e183.xyz); + } else { + let _e186 = back; + local_1 = normalize(_e186.xyz); + } + let _e189 = local_1; + n2_ = _e189; + let _e190 = n2_; + let _e191 = inside; + if (dot(_e190, _e191) < 0f) { + let _e194 = n2_; + n2_ = -(_e194); + } + let _e196 = inside; + let _e197 = n2_; + let _e199 = index; + out_ray = refract(_e196, -(_e197), _e199); + let _e201 = out_ray; + let _e202 = out_ray; + if (dot(_e201, _e202) < 0.000001f) { + return vec4(0f, 0f, 0f, 0f); + } + let _e205 = out_ray; + out_ray = normalize(_e205); + let _e207 = out_ray; + let _e208 = l; + if (dot(_e207, _e208) < 0.3f) { + return vec4(0f, 0f, 0f, 0f); + } + let _e213 = caustic_info.optics[1u]; + f0_1 = _e213; + let _e215 = caustic_info.tint; + let _e217 = l; + let _e218 = n1_; + let _e221 = f0_1; + param_1 = _e221; + param_2 = abs(dot(_e217, _e218)); + let _e222 = Schlick_u0028_f1_u003b_f1_u003b((¶m_1), (¶m_2)); + let _e225 = out_ray; + let _e226 = n2_; + let _e229 = f0_1; + param_3 = _e229; + param_4 = abs(dot(_e225, _e226)); + let _e230 = Schlick_u0028_f1_u003b_f1_u003b((¶m_3), (¶m_4)); + (*energy) = ((_e215.xyz * (1f - _e222)) * (1f - _e230)); + let _e235 = caustic_info.optics[2u]; + fading = _e235; + let _e236 = fading; + if (_e236 > 0f) { + let _e239 = caustic_info.attenuation; + let _e242 = exit; + let _e243 = entry; + let _e246 = fading; + let _e250 = (*energy); + (*energy) = (_e250 * pow(max(_e239.xyz, vec3(0.0001f, 0.0001f, 0.0001f)), vec3((length((_e242 - _e243)) / _e246)))); + } + let _e252 = exit; + p_1 = _e252; + let _e253 = out_ray; + let _e254 = l; + down = max(dot(_e253, _e254), 0.05f); + k = 0i; + loop { + let _e257 = k; + if (_e257 < 4i) { + let _e260 = caustic_info.world_to_tile; + let _e261 = p_1; + q = (_e260 * vec4(_e261.x, _e261.y, _e261.z, 1f)); + let _e267 = q; + param_5 = _e267; + let _e268 = MapUv_u0028_vf4_u003b((¶m_5)); + let _e272 = textureSampleLevel(caustic_depth_tex, caustic_depth_smp, clamp(_e268, vec2(0f), vec2(1f)), 0f); + receiver = _e272.x; + let _e274 = receiver; + let _e276 = q[2u]; + let _e278 = range; + let _e280 = down; + advance = (((_e274 - _e276) * _e278) / _e280); + let _e282 = k; + if (_e282 == 0i) { + let _e284 = advance; + advance = max(_e284, 0f); + } + let _e286 = out_ray; + let _e287 = advance; + let _e289 = p_1; + p_1 = (_e289 + (_e286 * _e287)); + continue; + } else { + break; + } + continuing { + let _e291 = k; + k = (_e291 + 1i); + } + } + let _e294 = caustic_info.world_to_tile; + let _e295 = p_1; + landed = (_e294 * vec4(_e295.x, _e295.y, _e295.z, 1f)); + let _e301 = landed; + param_6 = _e301; + let _e302 = MapUv_u0028_vf4_u003b((¶m_6)); + let _e306 = textureSampleLevel(caustic_depth_tex, caustic_depth_smp, clamp(_e302, vec2(0f), vec2(1f)), 0f); + under = _e306.x; + let _e308 = under; + let _e310 = landed[2u]; + let _e313 = range; + if ((abs((_e308 - _e310)) * _e313) > 0.02f) { + return vec4(0f, 0f, 0f, 0f); + } + let _e317 = caustic_info.world_to_clip; + let _e318 = p_1; + clip = (_e317 * vec4(_e318.x, _e318.y, _e318.z, 1f)); + let _e324 = clip; + let _e327 = clip[3u]; + let _e329 = (_e324.xy / vec2(_e327)); + return vec4(_e329.x, _e329.y, 0f, 1f); +} fn main_1() { - let _e7 = texcoord_1; - v_uv = _e7; - let _e8 = position_1; - unnamed.gl_Position = vec4(_e8.x, _e8.y, 1f, 1f); + var n: f32; + var id: f32; + var column: f32; + var row: f32; + var uv_1: vec2; + var texel: f32; + var here: vec4; + var energy_1: vec3; + var param_7: vec2; + var param_8: vec3; + var spacing_x: vec2; + var spacing_y: vec2; + var next_x: vec4; + var unused: vec3; + var param_9: vec2; + var param_10: vec3; + var a_1: vec2; + var local_2: vec2; + var prev_x: vec4; + var param_11: vec2; + var param_12: vec3; + var next_y: vec4; + var param_13: vec2; + var param_14: vec3; + var b: vec2; + var local_3: vec2; + var prev_y: vec4; + var param_15: vec2; + var param_16: vec3; + var least_1: f32; + var param_17: vec2; + var param_18: vec2; + var param_19: f32; + var param_20: vec2; + var param_21: vec2; + var param_22: f32; + var area: f32; + var share: f32; + + let _e83 = corner_1; + v_uv = _e83; + v_color = vec4(0f, 0f, 0f, 0f); + unnamed.gl_Position = vec4(4f, 4f, 0.5f, 1f); + let _e87 = caustic_info.grid[0u]; + n = _e87; + let _e88 = gl_InstanceIndex_1; + id = f32(_e88); + let _e90 = id; + let _e91 = n; + column = (_e90 - (floor((_e90 / _e91)) * _e91)); + let _e96 = id; + let _e97 = n; + row = floor((_e96 / _e97)); + let _e100 = column; + let _e102 = n; + let _e104 = row; + let _e106 = n; + uv_1 = vec2(((_e100 + 0.5f) / _e102), ((_e104 + 0.5f) / _e106)); + let _e109 = n; + texel = (1f / _e109); + let _e111 = uv_1; + param_7 = _e111; + let _e112 = Follow_u0028_vf2_u003b_vf3_u003b((¶m_7), (¶m_8)); + let _e113 = param_8; + energy_1 = _e113; + here = _e112; + let _e115 = here[3u]; + if (_e115 < 0.5f) { + return; + } + let _e119 = caustic_info.grid[2u]; + spacing_x = vec2(_e119, 0f); + let _e123 = caustic_info.grid[3u]; + spacing_y = vec2(0f, _e123); + let _e125 = uv_1; + let _e126 = texel; + param_9 = (_e125 + vec2(_e126, 0f)); + let _e129 = Follow_u0028_vf2_u003b_vf3_u003b((¶m_9), (¶m_10)); + let _e130 = param_10; + unused = _e130; + next_x = _e129; + let _e132 = next_x[3u]; + if (_e132 > 0.5f) { + let _e134 = next_x; + let _e136 = here; + local_2 = (_e134.xy - _e136.xy); + } else { + let _e139 = spacing_x; + local_2 = _e139; + } + let _e140 = local_2; + a_1 = _e140; + let _e142 = next_x[3u]; + if (_e142 < 0.5f) { + let _e144 = uv_1; + let _e145 = texel; + param_11 = (_e144 - vec2(_e145, 0f)); + let _e148 = Follow_u0028_vf2_u003b_vf3_u003b((¶m_11), (¶m_12)); + let _e149 = param_12; + unused = _e149; + prev_x = _e148; + let _e151 = prev_x[3u]; + if (_e151 > 0.5f) { + let _e153 = here; + let _e155 = prev_x; + a_1 = (_e153.xy - _e155.xy); + } + } + let _e158 = uv_1; + let _e159 = texel; + param_13 = (_e158 + vec2(0f, _e159)); + let _e162 = Follow_u0028_vf2_u003b_vf3_u003b((¶m_13), (¶m_14)); + let _e163 = param_14; + unused = _e163; + next_y = _e162; + let _e165 = next_y[3u]; + if (_e165 > 0.5f) { + let _e167 = next_y; + let _e169 = here; + local_3 = (_e167.xy - _e169.xy); + } else { + let _e172 = spacing_y; + local_3 = _e172; + } + let _e173 = local_3; + b = _e173; + let _e175 = next_y[3u]; + if (_e175 < 0.5f) { + let _e177 = uv_1; + let _e178 = texel; + param_15 = (_e177 - vec2(0f, _e178)); + let _e181 = Follow_u0028_vf2_u003b_vf3_u003b((¶m_15), (¶m_16)); + let _e182 = param_16; + unused = _e182; + prev_y = _e181; + let _e184 = prev_y[3u]; + if (_e184 > 0.5f) { + let _e186 = here; + let _e188 = prev_y; + b = (_e186.xy - _e188.xy); + } + } + let _e193 = caustic_info.grid[1u]; + least_1 = _e193; + let _e194 = a_1; + param_17 = (_e194 * 1.5f); + let _e196 = spacing_x; + param_18 = _e196; + let _e197 = least_1; + param_19 = _e197; + let _e198 = AtLeast_u0028_vf2_u003b_vf2_u003b_f1_u003b((¶m_17), (¶m_18), (¶m_19)); + a_1 = _e198; + let _e199 = b; + param_20 = (_e199 * 1.5f); + let _e201 = spacing_y; + param_21 = _e201; + let _e202 = least_1; + param_22 = _e202; + let _e203 = AtLeast_u0028_vf2_u003b_vf2_u003b_f1_u003b((¶m_20), (¶m_21), (¶m_22)); + b = _e203; + let _e205 = a_1[0u]; + let _e207 = b[1u]; + let _e210 = a_1[1u]; + let _e212 = b[0u]; + let _e216 = least_1; + let _e217 = least_1; + area = max(abs(((_e205 * _e207) - (_e210 * _e212))), (_e216 * _e217)); + let _e220 = here; + let _e223 = corner_1[0u]; + let _e224 = a_1; + let _e228 = corner_1[1u]; + let _e229 = b; + let _e231 = ((_e220.xy + (_e224 * _e223)) + (_e229 * _e228)); + unnamed.gl_Position = vec4(_e231.x, _e231.y, 0.5f, 1f); + let _e238 = caustic_info.grid[2u]; + let _e241 = caustic_info.grid[3u]; + share = (_e238 * _e241); + let _e243 = energy_1; + let _e244 = share; + let _e246 = area; + let _e249 = ((_e243 * _e244) / vec3((1.0471976f * _e246))); + v_color = vec4(_e249.x, _e249.y, _e249.z, 0f); return; } @vertex -fn main(@location(1) texcoord: vec2, @location(0) position: vec2) -> VertexOutput { - texcoord_1 = texcoord; - position_1 = position; +fn main(@location(0) corner: vec2, @builtin(instance_index) gl_InstanceIndex: u32) -> VertexOutput { + corner_1 = corner; + gl_InstanceIndex_1 = i32(gl_InstanceIndex); main_1(); - let _e7 = v_uv; - let _e8 = unnamed.gl_Position; - return VertexOutput(_e7, _e8); + let _e9 = v_uv; + let _e10 = v_color; + let _e11 = unnamed.gl_Position; + return VertexOutput(_e9, _e10, _e11); } ''', attributes: [ WebGpuAttribute( - name: 'position', + name: 'corner', location: 0, format: VertexFormat.float32x2, ), - WebGpuAttribute( - name: 'texcoord', - location: 1, - format: VertexFormat.float32x2, - ), ], - blocks: [], - samplers: [], - ), - 'SkyVertex': WebGpuStage( - wgsl: r''' -struct gl_PerVertex { - @builtin(position) gl_Position: vec4, - gl_PointSize: f32, - gl_ClipDistance: array, - gl_CullDistance: array, -} - -struct VertexOutput { - @location(4) member: vec3, - @location(7) member_1: vec4, - @location(2) member_2: vec4, - @location(3) member_3: vec4, - @location(5) member_4: vec4, - @location(1) member_5: vec4, - @location(0) member_6: vec4, - @builtin(position) gl_Position: vec4, -} - -var v_ray: vec3; -var corner_ray_1: vec3; -var v_zenith: vec4; -var zenith_1: vec4; -var v_horizon: vec4; -var horizon_1: vec4; + blocks: [ + WebGpuBlock( + name: 'CausticInfo', + group: 0, + binding: 0, + sizeInBytes: 336, + members: [ + WebGpuBlockMember( + name: 'map_to_world', + offsetInBytes: 0, + sizeInBytes: 64, + ), + WebGpuBlockMember( + name: 'world_to_map', + offsetInBytes: 64, + sizeInBytes: 64, + ), + WebGpuBlockMember( + name: 'world_to_tile', + offsetInBytes: 128, + sizeInBytes: 64, + ), + WebGpuBlockMember( + name: 'world_to_clip', + offsetInBytes: 192, + sizeInBytes: 64, + ), + WebGpuBlockMember( + name: 'grid', + offsetInBytes: 256, + sizeInBytes: 16, + ), + WebGpuBlockMember( + name: 'light', + offsetInBytes: 272, + sizeInBytes: 16, + ), + WebGpuBlockMember( + name: 'optics', + offsetInBytes: 288, + sizeInBytes: 16, + ), + WebGpuBlockMember( + name: 'tint', + offsetInBytes: 304, + sizeInBytes: 16, + ), + WebGpuBlockMember( + name: 'attenuation', + offsetInBytes: 320, + sizeInBytes: 16, + ), + ], + ), + ], + samplers: [ + WebGpuSampler( + name: 'caustic_back', + group: 0, + textureBinding: 1, + samplerBinding: 2, + dimension: WebGpuTextureDimension.twoDimensional, + ), + WebGpuSampler( + name: 'caustic_depth', + group: 0, + textureBinding: 3, + samplerBinding: 4, + dimension: WebGpuTextureDimension.twoDimensional, + ), + WebGpuSampler( + name: 'caustic_front', + group: 0, + textureBinding: 5, + samplerBinding: 6, + dimension: WebGpuTextureDimension.twoDimensional, + ), + ], + ), + 'ShadowTileResetVertex': WebGpuStage( + wgsl: r''' +struct gl_PerVertex { + @builtin(position) gl_Position: vec4, + gl_PointSize: f32, + gl_ClipDistance: array, + gl_CullDistance: array, +} + +struct VertexOutput { + @location(6) member: vec2, + @builtin(position) gl_Position: vec4, +} + +var v_uv: vec2; +var texcoord_1: vec2; +var unnamed: gl_PerVertex = gl_PerVertex(vec4(0f, 0f, 0f, 1f), 1f, array(), array()); +var position_1: vec2; + +fn main_1() { + let _e7 = texcoord_1; + v_uv = _e7; + let _e8 = position_1; + unnamed.gl_Position = vec4(_e8.x, _e8.y, 1f, 1f); + return; +} + +@vertex +fn main(@location(1) texcoord: vec2, @location(0) position: vec2) -> VertexOutput { + texcoord_1 = texcoord; + position_1 = position; + main_1(); + let _e7 = v_uv; + let _e8 = unnamed.gl_Position; + return VertexOutput(_e7, _e8); +} +''', + attributes: [ + WebGpuAttribute( + name: 'position', + location: 0, + format: VertexFormat.float32x2, + ), + WebGpuAttribute( + name: 'texcoord', + location: 1, + format: VertexFormat.float32x2, + ), + ], + blocks: [], + samplers: [], + ), + 'SkyVertex': WebGpuStage( + wgsl: r''' +struct gl_PerVertex { + @builtin(position) gl_Position: vec4, + gl_PointSize: f32, + gl_ClipDistance: array, + gl_CullDistance: array, +} + +struct VertexOutput { + @location(6) member: vec3, + @location(11) member_1: vec4, + @location(2) member_2: vec4, + @location(4) member_3: vec4, + @location(9) member_4: vec4, + @location(1) member_5: vec4, + @location(0) member_6: vec4, + @builtin(position) gl_Position: vec4, +} + +var v_ray: vec3; +var corner_ray_1: vec3; +var v_zenith: vec4; +var zenith_1: vec4; +var v_horizon: vec4; +var horizon_1: vec4; var v_nadir: vec4; var nadir_1: vec4; var v_sun: vec4; @@ -3670,8 +4300,8 @@ struct gl_PerVertex { } struct VertexOutput { - @location(4) member: vec3, - @location(6) member_1: vec4, + @location(6) member: vec3, + @location(10) member_1: vec4, @builtin(position) gl_Position: vec4, } @@ -3724,6 +4354,130 @@ fn main(@location(1) corner_ray: vec3, @location(2) tint: vec4, @locat blocks: [], samplers: [], ), + 'SkyPhysicalVertex': WebGpuStage( + wgsl: r''' +struct gl_PerVertex { + @builtin(position) gl_Position: vec4, + gl_PointSize: f32, + gl_ClipDistance: array, + gl_CullDistance: array, +} + +struct VertexOutput { + @location(6) member: vec3, + @location(7) member_1: vec4, + @location(3) member_2: vec4, + @location(9) member_3: vec4, + @location(5) member_4: vec4, + @location(8) member_5: vec4, + @location(0) member_6: vec4, + @builtin(position) gl_Position: vec4, +} + +var v_ray: vec3; +var corner_ray_1: vec3; +var v_rayleigh: vec4; +var rayleigh_1: vec4; +var v_mie: vec4; +var mie_1: vec4; +var v_sun: vec4; +var sun_1: vec4; +var v_planet: vec4; +var planet_1: vec4; +var v_stars: vec4; +var stars_1: vec4; +var v_disc: vec4; +var disc_1: vec4; +var unnamed: gl_PerVertex = gl_PerVertex(vec4(0f, 0f, 0f, 1f), 1f, array(), array()); +var position_1: vec2; + +fn main_1() { + let _e20 = corner_ray_1; + v_ray = _e20; + let _e21 = rayleigh_1; + v_rayleigh = _e21; + let _e22 = mie_1; + v_mie = _e22; + let _e23 = sun_1; + v_sun = _e23; + let _e24 = planet_1; + v_planet = _e24; + let _e25 = stars_1; + v_stars = _e25; + let _e26 = disc_1; + v_disc = _e26; + let _e27 = position_1; + unnamed.gl_Position = vec4(_e27.x, _e27.y, 0.999999f, 1f); + return; +} + +@vertex +fn main(@location(1) corner_ray: vec3, @location(2) rayleigh: vec4, @location(3) mie: vec4, @location(4) sun: vec4, @location(5) planet: vec4, @location(6) stars: vec4, @location(7) disc: vec4, @location(0) position: vec2) -> VertexOutput { + corner_ray_1 = corner_ray; + rayleigh_1 = rayleigh; + mie_1 = mie; + sun_1 = sun; + planet_1 = planet; + stars_1 = stars; + disc_1 = disc; + position_1 = position; + main_1(); + let _e25 = v_ray; + let _e26 = v_rayleigh; + let _e27 = v_mie; + let _e28 = v_sun; + let _e29 = v_planet; + let _e30 = v_stars; + let _e31 = v_disc; + let _e32 = unnamed.gl_Position; + return VertexOutput(_e25, _e26, _e27, _e28, _e29, _e30, _e31, _e32); +} +''', + attributes: [ + WebGpuAttribute( + name: 'position', + location: 0, + format: VertexFormat.float32x2, + ), + WebGpuAttribute( + name: 'corner_ray', + location: 1, + format: VertexFormat.float32x3, + ), + WebGpuAttribute( + name: 'rayleigh', + location: 2, + format: VertexFormat.float32x4, + ), + WebGpuAttribute( + name: 'mie', + location: 3, + format: VertexFormat.float32x4, + ), + WebGpuAttribute( + name: 'sun', + location: 4, + format: VertexFormat.float32x4, + ), + WebGpuAttribute( + name: 'planet', + location: 5, + format: VertexFormat.float32x4, + ), + WebGpuAttribute( + name: 'stars', + location: 6, + format: VertexFormat.float32x4, + ), + WebGpuAttribute( + name: 'disc', + location: 7, + format: VertexFormat.float32x4, + ), + ], + blocks: [], + samplers: [], + ), 'VertexTextureProbeVertex': WebGpuStage( wgsl: r''' struct ProbeInfo { @@ -3814,12 +4568,13 @@ struct gl_PerVertex { } struct VertexOutput { - @location(6) member: vec3, - @location(2) member_1: vec3, - @location(3) member_2: vec4, - @location(4) member_3: vec2, + @location(7) member: vec3, + @location(3) member_1: vec3, + @location(4) member_2: vec4, + @location(5) member_3: vec2, @location(0) member_4: vec4, - @location(1) member_5: vec2, + @location(2) member_5: vec2, + @location(1) member_6: vec4, @builtin(position) gl_Position: vec4, } @@ -3836,28 +4591,29 @@ var v_texcoord: vec2; var v_color: vec4; var color_1: vec4; var v_lightmap_uv: vec2; +var v_instance: vec4; var unnamed: gl_PerVertex = gl_PerVertex(vec4(0f, 0f, 0f, 1f), 1f, array(), array()); fn ImpostorRight_u0028_vf3_u003b(d: ptr>) -> vec3 { var up: vec3; - let _e32 = (*d)[1u]; - up = select(vec3(0f, 1f, 0f), vec3(0f, 0f, -1f), vec3((abs(_e32) > 0.999f))); - let _e37 = up; - let _e38 = (*d); - return normalize(cross(_e37, _e38)); + let _e34 = (*d)[1u]; + up = select(vec3(0f, 1f, 0f), vec3(0f, 0f, -1f), vec3((abs(_e34) > 0.999f))); + let _e39 = up; + let _e40 = (*d); + return normalize(cross(_e39, _e40)); } fn MvpRow_u0028_i1_u003b(r: ptr) -> vec4 { - let _e30 = (*r); - let _e34 = frame_info.mvp[0][_e30]; - let _e35 = (*r); - let _e39 = frame_info.mvp[1][_e35]; - let _e40 = (*r); - let _e44 = frame_info.mvp[2][_e40]; - let _e45 = (*r); - let _e49 = frame_info.mvp[3][_e45]; - return vec4(_e34, _e39, _e44, _e49); + let _e32 = (*r); + let _e36 = frame_info.mvp[0][_e32]; + let _e37 = (*r); + let _e41 = frame_info.mvp[1][_e37]; + let _e42 = (*r); + let _e46 = frame_info.mvp[2][_e42]; + let _e47 = (*r); + let _e51 = frame_info.mvp[3][_e47]; + return vec4(_e36, _e41, _e46, _e51); } fn main_1() { @@ -3884,95 +4640,96 @@ fn main_1() { var up_1: vec3; var corner: vec3; - let _e52 = tangent_1[3u]; - radius = _e52; - let _e53 = position_1; - let _e55 = texcoord_1[0u]; - let _e59 = texcoord_1[1u]; - let _e63 = radius; - centre = (_e53 - (vec3(((_e55 * 2f) - 1f), (1f - (_e59 * 2f)), 0f) * _e63)); + let _e54 = tangent_1[3u]; + radius = _e54; + let _e55 = position_1; + let _e57 = texcoord_1[0u]; + let _e61 = texcoord_1[1u]; + let _e65 = radius; + centre = (_e55 - (vec3(((_e57 * 2f) - 1f), (1f - (_e61 * 2f)), 0f) * _e65)); param = 0i; - let _e66 = MvpRow_u0028_i1_u003b((¶m)); - rx = _e66; + let _e68 = MvpRow_u0028_i1_u003b((¶m)); + rx = _e68; param_1 = 1i; - let _e67 = MvpRow_u0028_i1_u003b((¶m_1)); - ry = _e67; + let _e69 = MvpRow_u0028_i1_u003b((¶m_1)); + ry = _e69; param_2 = 2i; - let _e68 = MvpRow_u0028_i1_u003b((¶m_2)); - rz = _e68; + let _e70 = MvpRow_u0028_i1_u003b((¶m_2)); + rz = _e70; param_3 = 3i; - let _e69 = MvpRow_u0028_i1_u003b((¶m_3)); - rw = _e69; - let _e70 = ry; - let _e72 = rw; - yw = cross(_e70.xyz, _e72.xyz); - let _e75 = rw; - let _e77 = rx; - wx = cross(_e75.xyz, _e77.xyz); - let _e80 = rx; - let _e82 = ry; - xy = cross(_e80.xyz, _e82.xyz); - let _e85 = rx; - let _e87 = yw; - det = dot(_e85.xyz, _e87); - let _e90 = rx[3u]; - let _e91 = yw; - let _e94 = ry[3u]; - let _e95 = wx; - let _e99 = rw[3u]; - let _e100 = xy; - let _e104 = det; - let _e107 = det; - eye = (-((((_e91 * _e90) + (_e95 * _e94)) + (_e100 * _e99))) / vec3(select(1f, _e107, (abs(_e104) > 0.00000000000000000001f)))); - let _e111 = det; - if (abs(_e111) > 0.00000000000000000001f) { - let _e114 = eye; - let _e115 = centre; - local = (_e114 - _e115); - } else { - let _e117 = rz; - local = -(_e117.xyz); - } - let _e120 = local; - toEye = _e120; - let _e121 = toEye; - let _e122 = toEye; - let _e125 = toEye; - let _e126 = normal_1; - d_1 = normalize(select(_e126, _e125, vec3((dot(_e121, _e122) > 0.00000000000000000001f)))); - let _e130 = d_1; - param_4 = _e130; - let _e131 = ImpostorRight_u0028_vf3_u003b((¶m_4)); - right = _e131; + let _e71 = MvpRow_u0028_i1_u003b((¶m_3)); + rw = _e71; + let _e72 = ry; + let _e74 = rw; + yw = cross(_e72.xyz, _e74.xyz); + let _e77 = rw; + let _e79 = rx; + wx = cross(_e77.xyz, _e79.xyz); + let _e82 = rx; + let _e84 = ry; + xy = cross(_e82.xyz, _e84.xyz); + let _e87 = rx; + let _e89 = yw; + det = dot(_e87.xyz, _e89); + let _e92 = rx[3u]; + let _e93 = yw; + let _e96 = ry[3u]; + let _e97 = wx; + let _e101 = rw[3u]; + let _e102 = xy; + let _e106 = det; + let _e109 = det; + eye = (-((((_e93 * _e92) + (_e97 * _e96)) + (_e102 * _e101))) / vec3(select(1f, _e109, (abs(_e106) > 0.00000000000000000001f)))); + let _e113 = det; + if (abs(_e113) > 0.00000000000000000001f) { + let _e116 = eye; + let _e117 = centre; + local = (_e116 - _e117); + } else { + let _e119 = rz; + local = -(_e119.xyz); + } + let _e122 = local; + toEye = _e122; + let _e123 = toEye; + let _e124 = toEye; + let _e127 = toEye; + let _e128 = normal_1; + d_1 = normalize(select(_e128, _e127, vec3((dot(_e123, _e124) > 0.00000000000000000001f)))); let _e132 = d_1; - let _e133 = right; - up_1 = cross(_e132, _e133); - let _e135 = centre; - let _e136 = right; - let _e138 = texcoord_1[0u]; - let _e142 = up_1; - let _e144 = texcoord_1[1u]; - let _e149 = radius; - corner = (_e135 + (((_e136 * ((_e138 * 2f) - 1f)) + (_e142 * (1f - (_e144 * 2f)))) * _e149)); - let _e153 = frame_info.model; - let _e154 = corner; - v_world_position = (_e153 * vec4(_e154.x, _e154.y, _e154.z, 1f)).xyz; - let _e162 = frame_info.normal_matrix; - let _e170 = d_1; - v_normal = normalize((mat3x3(_e162[0].xyz, _e162[1].xyz, _e162[2].xyz) * _e170)); - let _e174 = frame_info.model; - let _e182 = right; - let _e184 = normalize((mat3x3(_e174[0].xyz, _e174[1].xyz, _e174[2].xyz) * _e182)); - v_tangent = vec4(_e184.x, _e184.y, _e184.z, 1f); - let _e189 = texcoord_1; - v_texcoord = _e189; - let _e190 = d_1; - let _e192 = color_1[3u]; - v_color = vec4(_e190.x, _e190.y, _e190.z, _e192); + param_4 = _e132; + let _e133 = ImpostorRight_u0028_vf3_u003b((¶m_4)); + right = _e133; + let _e134 = d_1; + let _e135 = right; + up_1 = cross(_e134, _e135); + let _e137 = centre; + let _e138 = right; + let _e140 = texcoord_1[0u]; + let _e144 = up_1; + let _e146 = texcoord_1[1u]; + let _e151 = radius; + corner = (_e137 + (((_e138 * ((_e140 * 2f) - 1f)) + (_e144 * (1f - (_e146 * 2f)))) * _e151)); + let _e155 = frame_info.model; + let _e156 = corner; + v_world_position = (_e155 * vec4(_e156.x, _e156.y, _e156.z, 1f)).xyz; + let _e164 = frame_info.normal_matrix; + let _e172 = d_1; + v_normal = normalize((mat3x3(_e164[0].xyz, _e164[1].xyz, _e164[2].xyz) * _e172)); + let _e176 = frame_info.model; + let _e184 = right; + let _e186 = normalize((mat3x3(_e176[0].xyz, _e176[1].xyz, _e176[2].xyz) * _e184)); + v_tangent = vec4(_e186.x, _e186.y, _e186.z, 1f); + let _e191 = texcoord_1; + v_texcoord = _e191; + let _e192 = d_1; + let _e194 = color_1[3u]; + v_color = vec4(_e192.x, _e192.y, _e192.z, _e194); v_lightmap_uv = vec2(0f, 0f); - let _e198 = frame_info.mvp; - let _e199 = corner; - unnamed.gl_Position = (_e198 * vec4(_e199.x, _e199.y, _e199.z, 1f)); + v_instance = vec4(0f, 0f, 0f, 0f); + let _e200 = frame_info.mvp; + let _e201 = corner; + unnamed.gl_Position = (_e200 * vec4(_e201.x, _e201.y, _e201.z, 1f)); return; } @@ -3984,14 +4741,15 @@ fn main(@location(3) tangent: vec4, @location(0) position: vec3, @loca normal_1 = normal; color_1 = color; main_1(); - let _e18 = v_world_position; - let _e19 = v_normal; - let _e20 = v_tangent; - let _e21 = v_texcoord; - let _e22 = v_color; - let _e23 = v_lightmap_uv; - let _e24 = unnamed.gl_Position; - return VertexOutput(_e18, _e19, _e20, _e21, _e22, _e23, _e24); + let _e19 = v_world_position; + let _e20 = v_normal; + let _e21 = v_tangent; + let _e22 = v_texcoord; + let _e23 = v_color; + let _e24 = v_lightmap_uv; + let _e25 = v_instance; + let _e26 = unnamed.gl_Position; + return VertexOutput(_e19, _e20, _e21, _e22, _e23, _e24, _e25, _e26); } ''', attributes: [ @@ -4066,6 +4824,7 @@ struct FogInfo { fog: vec4, eye: vec4, forward: vec4, + projection: vec4, } struct FragInfo { @@ -4088,6 +4847,7 @@ struct FragInfo { ambient_ground: vec4, shadow_bias: vec4, target_origin: vec4, + debug_view: vec4, } struct FragmentOutput { @@ -4100,9 +4860,11 @@ var g_albedo: vec3; var g_debug_surface: vec3; var g_debug_surface_on: bool; var g_premultiply: bool; -var v_world_position_1: vec3; +var g_pass_through: f32; +var light_transmittance: vec3; @group(1) @binding(0) var fog_info: FogInfo; +var v_world_position_1: vec3; var frag_albedo: vec4; var frag_surface: vec4; var v_normal_1: vec3; @@ -4116,14 +4878,15 @@ var base_color_texture_tex: texture_2d; var base_color_texture_smp: sampler; var v_texcoord_1: vec2; var v_color_1: vec4; +var gl_FragCoord_1: vec4; var v_tangent_1: vec4; var v_lightmap_uv_1: vec2; fn ViewDepth_u0028_() -> f32 { - let _e75 = v_world_position_1; - let _e77 = fog_info.eye; - let _e81 = fog_info.forward; - return dot((_e75 - _e77.xyz), _e81.xyz); + let _e89 = v_world_position_1; + let _e91 = fog_info.eye; + let _e95 = fog_info.forward; + return dot((_e89 - _e91.xyz), _e95.xyz); } fn WeightedBlendedWeight_u0028_f1_u003b(alpha: ptr) -> f32 { @@ -4132,22 +4895,22 @@ fn WeightedBlendedWeight_u0028_f1_u003b(alpha: ptr) -> f32 { var far: f32; var far3_: f32; - let _e80 = ViewDepth_u0028_(); - z = abs(_e80); - let _e82 = z; - near = (_e82 / 5f); - let _e84 = z; - far = (_e84 / 200f); - let _e86 = far; - let _e87 = far; - let _e89 = far; - far3_ = ((_e86 * _e87) * _e89); - let _e91 = (*alpha); - let _e92 = near; - let _e93 = near; - let _e96 = far3_; - let _e97 = far3_; - return (_e91 * clamp((10f / ((0.00001f + (_e92 * _e93)) + (_e96 * _e97))), 0.01f, 3000f)); + let _e94 = ViewDepth_u0028_(); + z = abs(_e94); + let _e96 = z; + near = (_e96 / 5f); + let _e98 = z; + far = (_e98 / 200f); + let _e100 = far; + let _e101 = far; + let _e103 = far; + far3_ = ((_e100 * _e101) * _e103); + let _e105 = (*alpha); + let _e106 = near; + let _e107 = near; + let _e110 = far3_; + let _e111 = far3_; + return (_e105 * clamp((10f / ((0.00001f + (_e106 * _e107)) + (_e110 * _e111))), 0.01f, 3000f)); } fn WriteWeightedBlended_u0028_() { @@ -4159,39 +4922,39 @@ fn WriteWeightedBlended_u0028_() { var revealage: bool; var local: vec4; - let _e84 = fog_info.forward[3u]; - mode = _e84; - let _e85 = mode; - if (_e85 > 0.5f) { - let _e88 = frag_color[3u]; - alpha_1 = _e88; - let _e89 = alpha_1; - param = _e89; - let _e90 = WeightedBlendedWeight_u0028_f1_u003b((¶m)); - weight = _e90; - let _e91 = frag_color; - let _e93 = weight; - let _e94 = (_e91.xyz * _e93); - let _e95 = alpha_1; - let _e96 = weight; - accumulate = vec4(_e94.x, _e94.y, _e94.z, (_e95 * _e96)); - let _e102 = mode; - let _e104 = mode; - revealage = ((_e102 > 1.5f) && (_e104 < 2.5f)); - let _e107 = revealage; - if _e107 { - let _e108 = alpha_1; - local = vec4(_e108); + let _e98 = fog_info.forward[3u]; + mode = _e98; + let _e99 = mode; + if (_e99 > 0.5f) { + let _e102 = frag_color[3u]; + alpha_1 = _e102; + let _e103 = alpha_1; + param = _e103; + let _e104 = WeightedBlendedWeight_u0028_f1_u003b((¶m)); + weight = _e104; + let _e105 = frag_color; + let _e107 = weight; + let _e108 = (_e105.xyz * _e107); + let _e109 = alpha_1; + let _e110 = weight; + accumulate = vec4(_e108.x, _e108.y, _e108.z, (_e109 * _e110)); + let _e116 = mode; + let _e118 = mode; + revealage = ((_e116 > 1.5f) && (_e118 < 2.5f)); + let _e121 = revealage; + if _e121 { + let _e122 = alpha_1; + local = vec4(_e122); } else { - let _e110 = accumulate; - local = _e110; + let _e124 = accumulate; + local = _e124; } - let _e111 = local; - frag_color = _e111; - let _e112 = mode; - if (_e112 > 2.5f) { - let _e114 = alpha_1; - frag_surface = vec4(_e114); + let _e125 = local; + frag_color = _e125; + let _e126 = mode; + if (_e126 > 2.5f) { + let _e128 = alpha_1; + frag_surface = vec4(_e128); } } return; @@ -4200,29 +4963,29 @@ fn WriteWeightedBlended_u0028_() { fn EncodeOctahedral_u0028_vf3_u003b(n: ptr>) -> vec2 { var e: vec2; - let _e78 = (*n)[0u]; - let _e81 = (*n)[1u]; - let _e85 = (*n)[2u]; - let _e88 = (*n); - (*n) = (_e88 / vec3(((abs(_e78) + abs(_e81)) + abs(_e85)))); - let _e91 = (*n); - e = _e91.xy; - let _e94 = (*n)[2u]; - if (_e94 < 0f) { - let _e96 = (*n); - let _e102 = (*n)[0u]; - let _e106 = (*n)[1u]; - e = ((vec2(1f) - abs(_e96.yx)) * vec2(select(-1f, 1f, (_e102 >= 0f)), select(-1f, 1f, (_e106 >= 0f)))); + let _e92 = (*n)[0u]; + let _e95 = (*n)[1u]; + let _e99 = (*n)[2u]; + let _e102 = (*n); + (*n) = (_e102 / vec3(((abs(_e92) + abs(_e95)) + abs(_e99)))); + let _e105 = (*n); + e = _e105.xy; + let _e108 = (*n)[2u]; + if (_e108 < 0f) { + let _e110 = (*n); + let _e116 = (*n)[0u]; + let _e120 = (*n)[1u]; + e = ((vec2(1f) - abs(_e110.yx)) * vec2(select(-1f, 1f, (_e116 >= 0f)), select(-1f, 1f, (_e120 >= 0f)))); } - let _e111 = e; - return ((_e111 * 0.5f) + vec2(0.5f)); + let _e125 = e; + return ((_e125 * 0.5f) + vec2(0.5f)); } fn LinearToSrgb_u0028_vf3_u003b(linear: ptr>) -> vec3 { - let _e76 = (*linear); - let _e78 = (*linear); - let _e82 = (*linear); - return mix((_e76 * 12.92f), ((pow(_e78, vec3(0.41666666f, 0.41666666f, 0.41666666f)) * 1.055f) - vec3(0.055f, 0.055f, 0.055f)), step(vec3(0.0031308f, 0.0031308f, 0.0031308f), _e82)); + let _e90 = (*linear); + let _e92 = (*linear); + let _e96 = (*linear); + return mix((_e90 * 12.92f), ((pow(_e92, vec3(0.41666666f, 0.41666666f, 0.41666666f)) * 1.055f) - vec3(0.055f, 0.055f, 0.055f)), step(vec3(0.0031308f, 0.0031308f, 0.0031308f), _e96)); } fn WriteSurfaceGeometry_u0028_f1_u003b(roughness: ptr) { @@ -4230,57 +4993,96 @@ fn WriteSurfaceGeometry_u0028_f1_u003b(roughness: ptr) { var geometric: vec3; var param_2: vec3; - let _e79 = g_albedo; - param_1 = clamp(_e79, vec3(0f, 0f, 0f), vec3(1f, 1f, 1f)); - let _e81 = LinearToSrgb_u0028_vf3_u003b((¶m_1)); - frag_albedo = vec4(_e81.x, _e81.y, _e81.z, 1f); - let _e86 = g_debug_surface_on; - if _e86 { - let _e87 = g_debug_surface; - let _e88 = ViewDepth_u0028_(); - frag_surface = vec4(_e87.x, _e87.y, _e87.z, _e88); + let _e93 = g_albedo; + param_1 = clamp(_e93, vec3(0f, 0f, 0f), vec3(1f, 1f, 1f)); + let _e95 = LinearToSrgb_u0028_vf3_u003b((¶m_1)); + frag_albedo = vec4(_e95.x, _e95.y, _e95.z, 1f); + let _e100 = g_debug_surface_on; + if _e100 { + let _e101 = g_debug_surface; + let _e102 = ViewDepth_u0028_(); + frag_surface = vec4(_e101.x, _e101.y, _e101.z, _e102); return; } - let _e93 = v_normal_1; - geometric = normalize(_e93); - let _e95 = gl_FrontFacing_1; - if !(_e95) { - let _e97 = geometric; - geometric = -(_e97); - } - let _e99 = geometric; - param_2 = _e99; - let _e100 = EncodeOctahedral_u0028_vf3_u003b((¶m_2)); - let _e101 = (*roughness); - let _e103 = ViewDepth_u0028_(); - frag_surface = vec4(_e100.x, _e100.y, clamp(_e101, 0f, 1f), _e103); + let _e107 = v_normal_1; + geometric = normalize(_e107); + let _e109 = gl_FrontFacing_1; + if !(_e109) { + let _e111 = geometric; + geometric = -(_e111); + } + let _e113 = geometric; + param_2 = _e113; + let _e114 = EncodeOctahedral_u0028_vf3_u003b((¶m_2)); + let _e115 = (*roughness); + let _e117 = ViewDepth_u0028_(); + frag_surface = vec4(_e114.x, _e114.y, clamp(_e115, 0f, 1f), _e117); return; } +fn Orthographic_u0028_() -> bool { + let _e91 = fog_info.projection[0u]; + return (_e91 > 0.5f); +} + fn EyeDistance_u0028_() -> f32 { - let _e75 = v_world_position_1; - let _e77 = fog_info.eye; - return distance(_e75, _e77.xyz); + var local_1: f32; + + let _e90 = Orthographic_u0028_(); + if _e90 { + let _e91 = ViewDepth_u0028_(); + local_1 = max(_e91, 0f); + } else { + let _e93 = v_world_position_1; + let _e95 = fog_info.eye; + local_1 = distance(_e93, _e95.xyz); + } + let _e98 = local_1; + return _e98; } fn ApplyFog_u0028_vf3_u003b(color: ptr>) -> vec3 { var density: f32; var d: f32; + var falloff: f32; + var k: f32; + var local_2: f32; - let _e80 = fog_info.fog[3u]; - density = _e80; - let _e81 = density; - if (_e81 <= 0f) { - let _e83 = (*color); - return _e83; - } - let _e84 = EyeDistance_u0028_(); - d = _e84; - let _e86 = fog_info.fog; - let _e88 = (*color); - let _e89 = density; - let _e91 = d; - return mix(_e86.xyz, _e88, vec3(clamp(exp((-(_e89) * _e91)), 0f, 1f))); + let _e97 = fog_info.fog[3u]; + density = _e97; + let _e98 = density; + if (_e98 <= 0f) { + let _e100 = (*color); + return _e100; + } + let _e101 = EyeDistance_u0028_(); + d = _e101; + let _e104 = fog_info.eye[3u]; + falloff = _e104; + let _e105 = falloff; + if (_e105 > 0f) { + let _e107 = falloff; + let _e109 = v_world_position_1[1u]; + let _e112 = fog_info.eye[1u]; + k = (_e107 * (_e109 - _e112)); + let _e115 = k; + if (abs(_e115) > 0.001f) { + let _e118 = k; + let _e122 = k; + local_2 = ((1f - exp(-(_e118))) / _e122); + } else { + let _e124 = k; + local_2 = (1f - (0.5f * _e124)); + } + let _e127 = local_2; + let _e128 = density; + density = (_e128 * _e127); + } + let _e131 = fog_info.fog; + let _e133 = (*color); + let _e134 = density; + let _e136 = d; + return mix(_e131.xyz, _e133, vec3(clamp(exp((-(_e134) * _e136)), 0f, 1f))); } fn WriteSurface_u0028_vf3_u003b_f1_u003b_f1_u003b(linearColor: ptr>, alpha_2: ptr, roughness_1: ptr) { @@ -4288,18 +5090,19 @@ fn WriteSurface_u0028_vf3_u003b_f1_u003b_f1_u003b(linearColor: ptr; var param_4: f32; - let _e81 = g_premultiply; - let _e82 = (*alpha_2); - weight_1 = select(1f, _e82, _e81); - let _e84 = (*linearColor); - param_3 = _e84; - let _e85 = ApplyFog_u0028_vf3_u003b((¶m_3)); - let _e86 = weight_1; - let _e87 = (_e85 * _e86); - let _e88 = (*alpha_2); - frag_color = vec4(_e87.x, _e87.y, _e87.z, _e88); - let _e93 = (*roughness_1); - param_4 = _e93; + let _e95 = g_premultiply; + let _e96 = (*alpha_2); + weight_1 = select(1f, _e96, _e95); + let _e98 = (*linearColor); + param_3 = _e98; + let _e99 = ApplyFog_u0028_vf3_u003b((¶m_3)); + let _e100 = weight_1; + let _e101 = (_e99 * _e100); + let _e102 = (*alpha_2); + let _e103 = g_pass_through; + frag_color = vec4(_e101.x, _e101.y, _e101.z, (_e102 * (1f - _e103))); + let _e110 = (*roughness_1); + param_4 = _e110; WriteSurfaceGeometry_u0028_f1_u003b((¶m_4)); WriteWeightedBlended_u0028_(); return; @@ -4310,144 +5113,318 @@ fn WriteSurface_u0028_vf3_u003b_f1_u003b(linearColor_1: ptr> var param_6: f32; var param_7: f32; - let _e80 = (*linearColor_1); - param_5 = _e80; - let _e81 = (*alpha_3); - param_6 = _e81; + let _e94 = (*linearColor_1); + param_5 = _e94; + let _e95 = (*alpha_3); + param_6 = _e95; param_7 = 1f; WriteSurface_u0028_vf3_u003b_f1_u003b_f1_u003b((¶m_5), (¶m_6), (¶m_7)); return; } fn SrgbToLinear_u0028_vf3_u003b(srgb: ptr>) -> vec3 { - let _e76 = (*srgb); - let _e79 = (*srgb); - let _e84 = (*srgb); - return mix((_e76 / vec3(12.92f)), pow(((_e79 + vec3(0.055f, 0.055f, 0.055f)) / vec3(1.055f)), vec3(2.4f, 2.4f, 2.4f)), step(vec3(0.04045f, 0.04045f, 0.04045f), _e84)); + let _e90 = (*srgb); + let _e93 = (*srgb); + let _e98 = (*srgb); + return mix((_e90 / vec3(12.92f)), pow(((_e93 + vec3(0.055f, 0.055f, 0.055f)) / vec3(1.055f)), vec3(2.4f, 2.4f, 2.4f)), step(vec3(0.04045f, 0.04045f, 0.04045f), _e98)); +} + +fn NonFinite_u0028_vf3_u003b(c: ptr>) -> bool { + var phi_661_: bool; + + let _e90 = (*c); + let _e91 = (*c); + let _e93 = any((_e90 != _e91)); + phi_661_ = _e93; + if !(_e93) { + let _e95 = (*c); + phi_661_ = any((abs(_e95) > vec3(300000000000000000000000000000000000000f, 300000000000000000000000000000000000000f, 300000000000000000000000000000000000000f))); + } + let _e100 = phi_661_; + return _e100; +} + +fn WriteDebugView_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b_vf3_u003b(s: ptr, lit: ptr>) -> bool { + var view: f32; + var code: i32; + var shown: vec3; + var param_8: vec3; + var param_9: vec3; + var grey: f32; + var param_10: vec3; + var param_11: vec3; + var local_3: vec3; + var weight_2: f32; + var local_4: f32; + var param_12: vec3; + var param_13: f32; + + let _e106 = frag_info.debug_view[0u]; + view = _e106; + let _e107 = view; + if (_e107 < 0.5f) { + return false; + } + let _e110 = gl_FragCoord_1[0u]; + let _e113 = frag_info.debug_view[1u]; + let _e116 = frag_info.debug_view[2u]; + if (_e110 < (_e113 * _e116)) { + return false; + } + let _e119 = view; + code = i32((_e119 + 0.5f)); + shown = vec3(0f, 0f, 0f); + let _e122 = code; + if (_e122 == 1i) { + let _e125 = (*s).albedo; + param_8 = clamp(_e125, vec3(0f, 0f, 0f), vec3(1f, 1f, 1f)); + let _e127 = LinearToSrgb_u0028_vf3_u003b((¶m_8)); + shown = _e127; + } else { + let _e128 = code; + if (_e128 == 2i) { + let _e131 = (*s).n; + shown = ((_e131 * 0.5f) + vec3(0.5f, 0.5f, 0.5f)); + } else { + let _e134 = code; + if (_e134 == 3i) { + let _e137 = (*s).roughness; + shown = vec3(clamp(_e137, 0f, 1f)); + } else { + let _e140 = code; + if (_e140 == 4i) { + let _e143 = (*s).metallic; + shown = vec3(clamp(_e143, 0f, 1f)); + } else { + let _e146 = code; + if (_e146 == 5i) { + let _e149 = (*s).occlusion; + shown = vec3(clamp(_e149, 0f, 1f)); + } else { + let _e152 = code; + if (_e152 == 6i) { + let _e155 = (*s).emissive; + param_9 = clamp(_e155, vec3(0f, 0f, 0f), vec3(1f, 1f, 1f)); + let _e157 = LinearToSrgb_u0028_vf3_u003b((¶m_9)); + shown = _e157; + } else { + let _e158 = code; + if (_e158 == 7i) { + let _e160 = v_texcoord_1; + let _e161 = fract(_e160); + shown = vec3(_e161.x, _e161.y, 0f); + } else { + let _e165 = code; + if (_e165 == 8i) { + let _e167 = (*lit); + param_10 = clamp(_e167, vec3(0f, 0f, 0f), vec3(1f, 1f, 1f)); + let _e169 = LinearToSrgb_u0028_vf3_u003b((¶m_10)); + grey = dot(_e169, vec3(0.2126f, 0.7152f, 0.0722f)); + let _e171 = (*lit); + param_11 = _e171; + let _e172 = NonFinite_u0028_vf3_u003b((¶m_11)); + if _e172 { + local_3 = vec3(1f, 0f, 1f); + } else { + let _e173 = grey; + local_3 = vec3((_e173 * 0.5f)); + } + let _e176 = local_3; + shown = _e176; + } + } + } + } + } + } + } + } + let _e177 = g_premultiply; + if _e177 { + let _e179 = (*s).alpha; + local_4 = _e179; + } else { + local_4 = 1f; + } + let _e180 = local_4; + weight_2 = _e180; + let _e181 = shown; + param_12 = _e181; + let _e182 = SrgbToLinear_u0028_vf3_u003b((¶m_12)); + let _e183 = weight_2; + let _e184 = (_e182 * _e183); + let _e186 = (*s).alpha; + frag_color = vec4(_e184.x, _e184.y, _e184.z, _e186); + let _e192 = (*s).roughness; + param_13 = _e192; + WriteSurfaceGeometry_u0028_f1_u003b((¶m_13)); + WriteWeightedBlended_u0028_(); + return true; +} + +fn TowardsEye_u0028_() -> vec3 { + var local_5: vec3; + + let _e90 = Orthographic_u0028_(); + if _e90 { + let _e92 = fog_info.forward; + local_5 = -(_e92.xyz); + } else { + let _e96 = fog_info.eye; + let _e98 = v_world_position_1; + local_5 = normalize((_e96.xyz - _e98)); + } + let _e101 = local_5; + return _e101; } fn MaterialLodBias_u0028_() -> f32 { - let _e77 = frag_info.target_origin[1u]; - return _e77; + let _e91 = frag_info.target_origin[1u]; + return _e91; } fn ReadSurface_u0028_() -> Surface { var texel: vec4; - var s: Surface; - var param_8: vec3; - var param_9: vec3; + var s_1: Surface; + var param_14: vec3; + var param_15: vec3; var cutoff: f32; + var edge: f32; var anchored: vec3; var noise: f32; - let _e82 = v_texcoord_1; - let _e83 = MaterialLodBias_u0028_(); - let _e84 = textureSampleBias(base_color_texture_tex, base_color_texture_smp, _e82, _e83); - texel = _e84; - let _e85 = texel; - param_8 = _e85.xyz; - let _e87 = SrgbToLinear_u0028_vf3_u003b((¶m_8)); - let _e89 = frag_info.base_color; - param_9 = _e89.xyz; - let _e91 = SrgbToLinear_u0028_vf3_u003b((¶m_9)); - let _e93 = v_color_1; - s.albedo = ((_e87 * _e91) * _e93.xyz); - let _e98 = texel[3u]; - let _e101 = frag_info.base_color[3u]; - let _e104 = v_color_1[3u]; - s.alpha = ((_e98 * _e101) * _e104); - let _e108 = s.albedo; - g_albedo = _e108; - let _e111 = frag_info.material2_[0u]; - cutoff = _e111; - let _e112 = cutoff; - if (_e112 >= 0f) { - let _e115 = s.alpha; - let _e116 = cutoff; - if (_e115 < _e116) { - discard; - } + let _e97 = v_texcoord_1; + let _e98 = MaterialLodBias_u0028_(); + let _e99 = textureSampleBias(base_color_texture_tex, base_color_texture_smp, _e97, _e98); + texel = _e99; + let _e100 = texel; + param_14 = _e100.xyz; + let _e102 = SrgbToLinear_u0028_vf3_u003b((¶m_14)); + let _e104 = frag_info.base_color; + param_15 = _e104.xyz; + let _e106 = SrgbToLinear_u0028_vf3_u003b((¶m_15)); + let _e108 = v_color_1; + s_1.albedo = ((_e102 * _e106) * _e108.xyz); + let _e113 = texel[3u]; + let _e116 = frag_info.base_color[3u]; + let _e119 = v_color_1[3u]; + s_1.alpha = ((_e113 * _e116) * _e119); + let _e123 = s_1.albedo; + g_albedo = _e123; + let _e126 = frag_info.material2_[0u]; + cutoff = _e126; + let _e127 = cutoff; + if (_e127 > 1f) { + let _e129 = cutoff; + edge = (_e129 - 1f); + let _e132 = s_1.alpha; + let _e133 = edge; + let _e136 = s_1.alpha; + let _e137 = fwidth(_e136); + s_1.alpha = clamp((((_e132 - _e133) / max(_e137, 0.0001f)) + 0.5f), 0f, 1f); } else { - let _e118 = cutoff; - if (_e118 < -1.5f) { - let _e120 = v_world_position_1; - anchored = floor((_e120 * 16f)); - let _e123 = anchored; - noise = fract((sin(dot(_e123, vec3(12.9898f, 78.233f, 37.719f))) * 43758.547f)); - let _e129 = s.alpha; - let _e130 = noise; - if (_e129 < _e130) { + let _e143 = cutoff; + if (_e143 >= 0f) { + let _e146 = s_1.alpha; + let _e147 = cutoff; + if (_e146 < _e147) { discard; } + s_1.alpha = 1f; + } else { + let _e150 = cutoff; + if (_e150 < -1.5f) { + let _e152 = v_world_position_1; + anchored = floor((_e152 * 16f)); + let _e155 = anchored; + noise = fract((sin(dot(_e155, vec3(12.9898f, 78.233f, 37.719f))) * 43758.547f)); + let _e161 = s_1.alpha; + let _e162 = noise; + if (_e161 < _e162) { + discard; + } + } } } - let _e132 = cutoff; - let _e134 = cutoff; - g_premultiply = ((_e132 < 0f) && (_e134 > -0.75f)); - let _e137 = v_normal_1; - s.n = normalize(_e137); - let _e140 = gl_FrontFacing_1; - if !(_e140) { - let _e143 = s.n; - s.n = -(_e143); - } - let _e147 = frag_info.camera_position; - let _e149 = v_world_position_1; - s.v = normalize((_e147.xyz - _e149)); - let _e154 = s.n; - let _e156 = s.v; - s.n_dot_v = max(dot(_e154, _e156), 0.0001f); - let _e162 = frag_info.material[0u]; - s.metallic = clamp(_e162, 0f, 1f); - let _e167 = frag_info.material[1u]; - s.roughness = clamp(_e167, 0.02f, 1f); - let _e171 = frag_info.ambient_ground; - let _e174 = frag_info.ambient_sky; - let _e178 = s.n[1u]; - let _e185 = frag_info.material[2u]; - s.ambient = (mix(_e171.xyz, _e174.xyz, vec3(((_e178 * 0.5f) + 0.5f))) * _e185); - let _e190 = frag_info.frame_params[0u]; - s.exposure = max(_e190, 0f); - s.occlusion = 1f; - s.emissive = vec3(0f, 0f, 0f); - let _e195 = s; - return _e195; + let _e164 = cutoff; + let _e166 = cutoff; + g_premultiply = ((_e164 < 0f) && (_e166 > -0.75f)); + let _e169 = v_normal_1; + s_1.n = normalize(_e169); + let _e172 = gl_FrontFacing_1; + if !(_e172) { + let _e175 = s_1.n; + s_1.n = -(_e175); + } + let _e178 = TowardsEye_u0028_(); + s_1.v = _e178; + let _e181 = s_1.n; + let _e183 = s_1.v; + s_1.n_dot_v = max(dot(_e181, _e183), 0.0001f); + let _e189 = frag_info.material[0u]; + s_1.metallic = clamp(_e189, 0f, 1f); + let _e194 = frag_info.material[1u]; + s_1.roughness = clamp(_e194, 0.02f, 1f); + let _e198 = frag_info.ambient_ground; + let _e201 = frag_info.ambient_sky; + let _e205 = s_1.n[1u]; + let _e212 = frag_info.material[2u]; + s_1.ambient = (mix(_e198.xyz, _e201.xyz, vec3(((_e205 * 0.5f) + 0.5f))) * _e212); + let _e217 = frag_info.frame_params[0u]; + s_1.exposure = max(_e217, 0f); + s_1.occlusion = 1f; + s_1.emissive = vec3(0f, 0f, 0f); + let _e222 = s_1; + return _e222; } fn main_1() { - var s_1: Surface; - var param_10: vec3; - var param_11: f32; + var s_2: Surface; + var param_16: Surface; + var param_17: vec3; + var param_18: vec3; + var param_19: f32; g_albedo = vec3(0f, 0f, 0f); g_debug_surface = vec3(0f, 0f, 0f); g_debug_surface_on = false; g_premultiply = false; - let _e78 = ReadSurface_u0028_(); - s_1 = _e78; + g_pass_through = 0f; + light_transmittance = vec3(1f, 1f, 1f); + let _e94 = ReadSurface_u0028_(); + s_2 = _e94; g_albedo = vec3(0f, 0f, 0f); - let _e80 = s_1.albedo; - param_10 = _e80; - let _e82 = s_1.alpha; - param_11 = _e82; - WriteSurface_u0028_vf3_u003b_f1_u003b((¶m_10), (¶m_11)); + let _e95 = s_2; + param_16 = _e95; + let _e97 = s_2.albedo; + param_17 = _e97; + let _e98 = WriteDebugView_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b_vf3_u003b((¶m_16), (¶m_17)); + if _e98 { + return; + } + let _e100 = s_2.albedo; + param_18 = _e100; + let _e102 = s_2.alpha; + param_19 = _e102; + WriteSurface_u0028_vf3_u003b_f1_u003b((¶m_18), (¶m_19)); return; } @fragment -fn main(@location(6) v_world_position: vec3, @location(2) v_normal: vec3, @builtin(front_facing) gl_FrontFacing: bool, @location(4) v_texcoord: vec2, @location(0) v_color: vec4, @location(3) v_tangent: vec4, @location(1) v_lightmap_uv: vec2) -> FragmentOutput { +fn main(@location(7) v_world_position: vec3, @location(3) v_normal: vec3, @builtin(front_facing) gl_FrontFacing: bool, @location(5) v_texcoord: vec2, @location(0) v_color: vec4, @builtin(position) gl_FragCoord: vec4, @location(4) v_tangent: vec4, @location(2) v_lightmap_uv: vec2) -> FragmentOutput { v_world_position_1 = v_world_position; v_normal_1 = v_normal; gl_FrontFacing_1 = gl_FrontFacing; v_texcoord_1 = v_texcoord; v_color_1 = v_color; + gl_FragCoord_1 = gl_FragCoord; v_tangent_1 = v_tangent; v_lightmap_uv_1 = v_lightmap_uv; main_1(); - let _e17 = frag_albedo; - let _e18 = frag_surface; - let _e19 = frag_color; - return FragmentOutput(_e17, _e18, _e19); + let _e19 = frag_albedo; + let _e20 = frag_surface; + let _e21 = frag_color; + return FragmentOutput(_e19, _e20, _e21); } ''', attributes: [], @@ -4456,7 +5433,7 @@ fn main(@location(6) v_world_position: vec3, @location(2) v_normal: vec3[ WebGpuBlockMember(name: 'fog', offsetInBytes: 0, sizeInBytes: 16), WebGpuBlockMember(name: 'eye', offsetInBytes: 16, sizeInBytes: 16), @@ -4465,13 +5442,18 @@ fn main(@location(6) v_world_position: vec3, @location(2) v_normal: vec3[ WebGpuBlockMember( name: 'light_position', @@ -4568,6 +5550,11 @@ fn main(@location(6) v_world_position: vec3, @location(2) v_normal: vec3, eye: vec4, forward: vec4, + projection: vec4, } struct FragInfo { @@ -4623,15 +5611,18 @@ struct FragInfo { ambient_ground: vec4, shadow_bias: vec4, target_origin: vec4, + debug_view: vec4, } var g_albedo: vec3; var g_debug_surface: vec3; var g_debug_surface_on: bool; var g_premultiply: bool; -var v_world_position_1: vec3; +var g_pass_through: f32; +var light_transmittance: vec3; @group(1) @binding(0) var fog_info: FogInfo; +var v_world_position_1: vec3; var frag_color: vec4; @group(1) @binding(1) var frag_info: FragInfo; @@ -4647,10 +5638,10 @@ var v_tangent_1: vec4; var v_lightmap_uv_1: vec2; fn ViewDepth_u0028_() -> f32 { - let _e67 = v_world_position_1; - let _e69 = fog_info.eye; - let _e73 = fog_info.forward; - return dot((_e67 - _e69.xyz), _e73.xyz); + let _e71 = v_world_position_1; + let _e73 = fog_info.eye; + let _e77 = fog_info.forward; + return dot((_e71 - _e73.xyz), _e77.xyz); } fn WeightedBlendedWeight_u0028_f1_u003b(alpha: ptr) -> f32 { @@ -4659,22 +5650,22 @@ fn WeightedBlendedWeight_u0028_f1_u003b(alpha: ptr) -> f32 { var far: f32; var far3_: f32; - let _e72 = ViewDepth_u0028_(); - z = abs(_e72); - let _e74 = z; - near = (_e74 / 5f); - let _e76 = z; - far = (_e76 / 200f); - let _e78 = far; - let _e79 = far; - let _e81 = far; - far3_ = ((_e78 * _e79) * _e81); - let _e83 = (*alpha); - let _e84 = near; - let _e85 = near; - let _e88 = far3_; - let _e89 = far3_; - return (_e83 * clamp((10f / ((0.00001f + (_e84 * _e85)) + (_e88 * _e89))), 0.01f, 3000f)); + let _e76 = ViewDepth_u0028_(); + z = abs(_e76); + let _e78 = z; + near = (_e78 / 5f); + let _e80 = z; + far = (_e80 / 200f); + let _e82 = far; + let _e83 = far; + let _e85 = far; + far3_ = ((_e82 * _e83) * _e85); + let _e87 = (*alpha); + let _e88 = near; + let _e89 = near; + let _e92 = far3_; + let _e93 = far3_; + return (_e87 * clamp((10f / ((0.00001f + (_e88 * _e89)) + (_e92 * _e93))), 0.01f, 3000f)); } fn WriteWeightedBlended_u0028_() { @@ -4686,35 +5677,35 @@ fn WriteWeightedBlended_u0028_() { var revealage: bool; var local: vec4; - let _e76 = fog_info.forward[3u]; - mode = _e76; - let _e77 = mode; - if (_e77 > 0.5f) { - let _e80 = frag_color[3u]; - alpha_1 = _e80; - let _e81 = alpha_1; - param = _e81; - let _e82 = WeightedBlendedWeight_u0028_f1_u003b((¶m)); - weight = _e82; - let _e83 = frag_color; - let _e85 = weight; - let _e86 = (_e83.xyz * _e85); - let _e87 = alpha_1; - let _e88 = weight; - accumulate = vec4(_e86.x, _e86.y, _e86.z, (_e87 * _e88)); - let _e94 = mode; - let _e96 = mode; - revealage = ((_e94 > 1.5f) && (_e96 < 2.5f)); - let _e99 = revealage; - if _e99 { - let _e100 = alpha_1; - local = vec4(_e100); + let _e80 = fog_info.forward[3u]; + mode = _e80; + let _e81 = mode; + if (_e81 > 0.5f) { + let _e84 = frag_color[3u]; + alpha_1 = _e84; + let _e85 = alpha_1; + param = _e85; + let _e86 = WeightedBlendedWeight_u0028_f1_u003b((¶m)); + weight = _e86; + let _e87 = frag_color; + let _e89 = weight; + let _e90 = (_e87.xyz * _e89); + let _e91 = alpha_1; + let _e92 = weight; + accumulate = vec4(_e90.x, _e90.y, _e90.z, (_e91 * _e92)); + let _e98 = mode; + let _e100 = mode; + revealage = ((_e98 > 1.5f) && (_e100 < 2.5f)); + let _e103 = revealage; + if _e103 { + let _e104 = alpha_1; + local = vec4(_e104); } else { - let _e102 = accumulate; - local = _e102; + let _e106 = accumulate; + local = _e106; } - let _e103 = local; - frag_color = _e103; + let _e107 = local; + frag_color = _e107; } return; } @@ -4723,30 +5714,69 @@ fn WriteSurfaceGeometry_u0028_f1_u003b(roughness: ptr) { return; } +fn Orthographic_u0028_() -> bool { + let _e73 = fog_info.projection[0u]; + return (_e73 > 0.5f); +} + fn EyeDistance_u0028_() -> f32 { - let _e67 = v_world_position_1; - let _e69 = fog_info.eye; - return distance(_e67, _e69.xyz); + var local_1: f32; + + let _e72 = Orthographic_u0028_(); + if _e72 { + let _e73 = ViewDepth_u0028_(); + local_1 = max(_e73, 0f); + } else { + let _e75 = v_world_position_1; + let _e77 = fog_info.eye; + local_1 = distance(_e75, _e77.xyz); + } + let _e80 = local_1; + return _e80; } fn ApplyFog_u0028_vf3_u003b(color: ptr>) -> vec3 { var density: f32; var d: f32; + var falloff: f32; + var k: f32; + var local_2: f32; - let _e72 = fog_info.fog[3u]; - density = _e72; - let _e73 = density; - if (_e73 <= 0f) { - let _e75 = (*color); - return _e75; + let _e79 = fog_info.fog[3u]; + density = _e79; + let _e80 = density; + if (_e80 <= 0f) { + let _e82 = (*color); + return _e82; + } + let _e83 = EyeDistance_u0028_(); + d = _e83; + let _e86 = fog_info.eye[3u]; + falloff = _e86; + let _e87 = falloff; + if (_e87 > 0f) { + let _e89 = falloff; + let _e91 = v_world_position_1[1u]; + let _e94 = fog_info.eye[1u]; + k = (_e89 * (_e91 - _e94)); + let _e97 = k; + if (abs(_e97) > 0.001f) { + let _e100 = k; + let _e104 = k; + local_2 = ((1f - exp(-(_e100))) / _e104); + } else { + let _e106 = k; + local_2 = (1f - (0.5f * _e106)); + } + let _e109 = local_2; + let _e110 = density; + density = (_e110 * _e109); } - let _e76 = EyeDistance_u0028_(); - d = _e76; - let _e78 = fog_info.fog; - let _e80 = (*color); - let _e81 = density; - let _e83 = d; - return mix(_e78.xyz, _e80, vec3(clamp(exp((-(_e81) * _e83)), 0f, 1f))); + let _e113 = fog_info.fog; + let _e115 = (*color); + let _e116 = density; + let _e118 = d; + return mix(_e113.xyz, _e115, vec3(clamp(exp((-(_e116) * _e118)), 0f, 1f))); } fn WriteSurface_u0028_vf3_u003b_f1_u003b_f1_u003b(linearColor: ptr>, alpha_2: ptr, roughness_1: ptr) { @@ -4754,18 +5784,19 @@ fn WriteSurface_u0028_vf3_u003b_f1_u003b_f1_u003b(linearColor: ptr; var param_2: f32; - let _e73 = g_premultiply; - let _e74 = (*alpha_2); - weight_1 = select(1f, _e74, _e73); - let _e76 = (*linearColor); - param_1 = _e76; - let _e77 = ApplyFog_u0028_vf3_u003b((¶m_1)); - let _e78 = weight_1; - let _e79 = (_e77 * _e78); - let _e80 = (*alpha_2); - frag_color = vec4(_e79.x, _e79.y, _e79.z, _e80); - let _e85 = (*roughness_1); - param_2 = _e85; + let _e77 = g_premultiply; + let _e78 = (*alpha_2); + weight_1 = select(1f, _e78, _e77); + let _e80 = (*linearColor); + param_1 = _e80; + let _e81 = ApplyFog_u0028_vf3_u003b((¶m_1)); + let _e82 = weight_1; + let _e83 = (_e81 * _e82); + let _e84 = (*alpha_2); + let _e85 = g_pass_through; + frag_color = vec4(_e83.x, _e83.y, _e83.z, (_e84 * (1f - _e85))); + let _e92 = (*roughness_1); + param_2 = _e92; WriteSurfaceGeometry_u0028_f1_u003b((¶m_2)); WriteWeightedBlended_u0028_(); return; @@ -4776,25 +5807,41 @@ fn WriteSurface_u0028_vf3_u003b_f1_u003b(linearColor_1: ptr> var param_4: f32; var param_5: f32; - let _e72 = (*linearColor_1); - param_3 = _e72; - let _e73 = (*alpha_3); - param_4 = _e73; + let _e76 = (*linearColor_1); + param_3 = _e76; + let _e77 = (*alpha_3); + param_4 = _e77; param_5 = 1f; WriteSurface_u0028_vf3_u003b_f1_u003b_f1_u003b((¶m_3), (¶m_4), (¶m_5)); return; } +fn TowardsEye_u0028_() -> vec3 { + var local_3: vec3; + + let _e72 = Orthographic_u0028_(); + if _e72 { + let _e74 = fog_info.forward; + local_3 = -(_e74.xyz); + } else { + let _e78 = fog_info.eye; + let _e80 = v_world_position_1; + local_3 = normalize((_e78.xyz - _e80)); + } + let _e83 = local_3; + return _e83; +} + fn SrgbToLinear_u0028_vf3_u003b(srgb: ptr>) -> vec3 { - let _e68 = (*srgb); - let _e71 = (*srgb); - let _e76 = (*srgb); - return mix((_e68 / vec3(12.92f)), pow(((_e71 + vec3(0.055f, 0.055f, 0.055f)) / vec3(1.055f)), vec3(2.4f, 2.4f, 2.4f)), step(vec3(0.04045f, 0.04045f, 0.04045f), _e76)); + let _e72 = (*srgb); + let _e75 = (*srgb); + let _e80 = (*srgb); + return mix((_e72 / vec3(12.92f)), pow(((_e75 + vec3(0.055f, 0.055f, 0.055f)) / vec3(1.055f)), vec3(2.4f, 2.4f, 2.4f)), step(vec3(0.04045f, 0.04045f, 0.04045f), _e80)); } fn MaterialLodBias_u0028_() -> f32 { - let _e69 = frag_info.target_origin[1u]; - return _e69; + let _e73 = frag_info.target_origin[1u]; + return _e73; } fn ReadSurface_u0028_() -> Surface { @@ -4803,81 +5850,93 @@ fn ReadSurface_u0028_() -> Surface { var param_6: vec3; var param_7: vec3; var cutoff: f32; + var edge: f32; var anchored: vec3; var noise: f32; - let _e74 = v_texcoord_1; - let _e75 = MaterialLodBias_u0028_(); - let _e76 = textureSampleBias(base_color_texture_tex, base_color_texture_smp, _e74, _e75); - texel = _e76; - let _e77 = texel; - param_6 = _e77.xyz; - let _e79 = SrgbToLinear_u0028_vf3_u003b((¶m_6)); - let _e81 = frag_info.base_color; - param_7 = _e81.xyz; - let _e83 = SrgbToLinear_u0028_vf3_u003b((¶m_7)); - let _e85 = v_color_1; - s.albedo = ((_e79 * _e83) * _e85.xyz); - let _e90 = texel[3u]; - let _e93 = frag_info.base_color[3u]; - let _e96 = v_color_1[3u]; - s.alpha = ((_e90 * _e93) * _e96); - let _e100 = s.albedo; - g_albedo = _e100; - let _e103 = frag_info.material2_[0u]; - cutoff = _e103; - let _e104 = cutoff; - if (_e104 >= 0f) { - let _e107 = s.alpha; - let _e108 = cutoff; - if (_e107 < _e108) { - discard; - } + let _e79 = v_texcoord_1; + let _e80 = MaterialLodBias_u0028_(); + let _e81 = textureSampleBias(base_color_texture_tex, base_color_texture_smp, _e79, _e80); + texel = _e81; + let _e82 = texel; + param_6 = _e82.xyz; + let _e84 = SrgbToLinear_u0028_vf3_u003b((¶m_6)); + let _e86 = frag_info.base_color; + param_7 = _e86.xyz; + let _e88 = SrgbToLinear_u0028_vf3_u003b((¶m_7)); + let _e90 = v_color_1; + s.albedo = ((_e84 * _e88) * _e90.xyz); + let _e95 = texel[3u]; + let _e98 = frag_info.base_color[3u]; + let _e101 = v_color_1[3u]; + s.alpha = ((_e95 * _e98) * _e101); + let _e105 = s.albedo; + g_albedo = _e105; + let _e108 = frag_info.material2_[0u]; + cutoff = _e108; + let _e109 = cutoff; + if (_e109 > 1f) { + let _e111 = cutoff; + edge = (_e111 - 1f); + let _e114 = s.alpha; + let _e115 = edge; + let _e118 = s.alpha; + let _e119 = fwidth(_e118); + s.alpha = clamp((((_e114 - _e115) / max(_e119, 0.0001f)) + 0.5f), 0f, 1f); } else { - let _e110 = cutoff; - if (_e110 < -1.5f) { - let _e112 = v_world_position_1; - anchored = floor((_e112 * 16f)); - let _e115 = anchored; - noise = fract((sin(dot(_e115, vec3(12.9898f, 78.233f, 37.719f))) * 43758.547f)); - let _e121 = s.alpha; - let _e122 = noise; - if (_e121 < _e122) { + let _e125 = cutoff; + if (_e125 >= 0f) { + let _e128 = s.alpha; + let _e129 = cutoff; + if (_e128 < _e129) { discard; } + s.alpha = 1f; + } else { + let _e132 = cutoff; + if (_e132 < -1.5f) { + let _e134 = v_world_position_1; + anchored = floor((_e134 * 16f)); + let _e137 = anchored; + noise = fract((sin(dot(_e137, vec3(12.9898f, 78.233f, 37.719f))) * 43758.547f)); + let _e143 = s.alpha; + let _e144 = noise; + if (_e143 < _e144) { + discard; + } + } } } - let _e124 = cutoff; - let _e126 = cutoff; - g_premultiply = ((_e124 < 0f) && (_e126 > -0.75f)); - let _e129 = v_normal_1; - s.n = normalize(_e129); - let _e132 = gl_FrontFacing_1; - if !(_e132) { - let _e135 = s.n; - s.n = -(_e135); - } - let _e139 = frag_info.camera_position; - let _e141 = v_world_position_1; - s.v = normalize((_e139.xyz - _e141)); - let _e146 = s.n; - let _e148 = s.v; - s.n_dot_v = max(dot(_e146, _e148), 0.0001f); - let _e154 = frag_info.material[0u]; - s.metallic = clamp(_e154, 0f, 1f); - let _e159 = frag_info.material[1u]; - s.roughness = clamp(_e159, 0.02f, 1f); - let _e163 = frag_info.ambient_ground; - let _e166 = frag_info.ambient_sky; - let _e170 = s.n[1u]; - let _e177 = frag_info.material[2u]; - s.ambient = (mix(_e163.xyz, _e166.xyz, vec3(((_e170 * 0.5f) + 0.5f))) * _e177); - let _e182 = frag_info.frame_params[0u]; - s.exposure = max(_e182, 0f); + let _e146 = cutoff; + let _e148 = cutoff; + g_premultiply = ((_e146 < 0f) && (_e148 > -0.75f)); + let _e151 = v_normal_1; + s.n = normalize(_e151); + let _e154 = gl_FrontFacing_1; + if !(_e154) { + let _e157 = s.n; + s.n = -(_e157); + } + let _e160 = TowardsEye_u0028_(); + s.v = _e160; + let _e163 = s.n; + let _e165 = s.v; + s.n_dot_v = max(dot(_e163, _e165), 0.0001f); + let _e171 = frag_info.material[0u]; + s.metallic = clamp(_e171, 0f, 1f); + let _e176 = frag_info.material[1u]; + s.roughness = clamp(_e176, 0.02f, 1f); + let _e180 = frag_info.ambient_ground; + let _e183 = frag_info.ambient_sky; + let _e187 = s.n[1u]; + let _e194 = frag_info.material[2u]; + s.ambient = (mix(_e180.xyz, _e183.xyz, vec3(((_e187 * 0.5f) + 0.5f))) * _e194); + let _e199 = frag_info.frame_params[0u]; + s.exposure = max(_e199, 0f); s.occlusion = 1f; s.emissive = vec3(0f, 0f, 0f); - let _e187 = s; - return _e187; + let _e204 = s; + return _e204; } fn main_1() { @@ -4889,18 +5948,20 @@ fn main_1() { g_debug_surface = vec3(0f, 0f, 0f); g_debug_surface_on = false; g_premultiply = false; - let _e70 = ReadSurface_u0028_(); - s_1 = _e70; - let _e72 = s_1.albedo; - param_8 = _e72; - let _e74 = s_1.alpha; - param_9 = _e74; + g_pass_through = 0f; + light_transmittance = vec3(1f, 1f, 1f); + let _e74 = ReadSurface_u0028_(); + s_1 = _e74; + let _e76 = s_1.albedo; + param_8 = _e76; + let _e78 = s_1.alpha; + param_9 = _e78; WriteSurface_u0028_vf3_u003b_f1_u003b((¶m_8), (¶m_9)); return; } @fragment -fn main(@location(6) v_world_position: vec3, @location(4) v_texcoord: vec2, @location(0) v_color: vec4, @location(2) v_normal: vec3, @builtin(front_facing) gl_FrontFacing: bool, @location(3) v_tangent: vec4, @location(1) v_lightmap_uv: vec2) -> @location(0) vec4 { +fn main(@location(7) v_world_position: vec3, @location(5) v_texcoord: vec2, @location(0) v_color: vec4, @location(3) v_normal: vec3, @builtin(front_facing) gl_FrontFacing: bool, @location(4) v_tangent: vec4, @location(2) v_lightmap_uv: vec2) -> @location(0) vec4 { v_world_position_1 = v_world_position; v_texcoord_1 = v_texcoord; v_color_1 = v_color; @@ -4919,7 +5980,7 @@ fn main(@location(6) v_world_position: vec3, @location(4) v_texcoord: vec2< name: 'FogInfo', group: 1, binding: 0, - sizeInBytes: 48, + sizeInBytes: 64, members: [ WebGpuBlockMember(name: 'fog', offsetInBytes: 0, sizeInBytes: 16), WebGpuBlockMember(name: 'eye', offsetInBytes: 16, sizeInBytes: 16), @@ -4928,13 +5989,18 @@ fn main(@location(6) v_world_position: vec3, @location(4) v_texcoord: vec2< offsetInBytes: 32, sizeInBytes: 16, ), + WebGpuBlockMember( + name: 'projection', + offsetInBytes: 48, + sizeInBytes: 16, + ), ], ), WebGpuBlock( name: 'FragInfo', group: 1, binding: 1, - sizeInBytes: 896, + sizeInBytes: 912, members: [ WebGpuBlockMember( name: 'light_position', @@ -5031,6 +6097,11 @@ fn main(@location(6) v_world_position: vec3, @location(4) v_texcoord: vec2< offsetInBytes: 880, sizeInBytes: 16, ), + WebGpuBlockMember( + name: 'debug_view', + offsetInBytes: 896, + sizeInBytes: 16, + ), ], ), ], @@ -5044,139 +6115,427 @@ fn main(@location(6) v_world_position: vec3, @location(4) v_texcoord: vec2< ), ], ), - 'Lambert': WebGpuStage( + 'PlanarReflection': WebGpuStage( wgsl: r''' -struct Surface { - albedo: vec3, - alpha: f32, - n: vec3, - v: vec3, - n_dot_v: f32, - metallic: f32, - roughness: f32, - occlusion: f32, - emissive: vec3, - ambient: vec3, - exposure: f32, -} - -struct LightSample { - l: vec3, - h: vec3, - radiance: vec3, - n_dot_l: f32, - n_dot_h: f32, - v_dot_h: f32, - integrated: f32, - ltc: vec3, -} - struct FogInfo { fog: vec4, eye: vec4, forward: vec4, + projection: vec4, } -struct LightListInfo { - list: vec4, - indices: array, 6>, - scales: array, 6>, - cluster_view_projection: mat4x4, - cluster_grid: vec4, - cluster_depth: vec4, - slot_rows: array, 2>, -} - -struct FragInfo { - light_position: array, 8>, - light_color: array, 8>, - light_direction: array, 8>, - light_cone: array, 8>, - base_color: vec4, - emissive: vec4, - camera_position: vec4, - material: vec4, - material2_: vec4, - frame_params: vec4, - shadow_params: vec4, - shadow_matrix: mat4x4, - shadow_matrix_far: mat4x4, - shadow_matrix_farthest: mat4x4, - shadow_cascades: vec4, - ambient_sky: vec4, - ambient_ground: vec4, - shadow_bias: vec4, - target_origin: vec4, -} - -struct PointShadow { - faces: array, 36>, - lights: array, 6>, - slots: array, 8>, +struct PlanarReflectionInfo { + view: vec4, params: vec4, - params2_: vec4, - params3_: vec4, -} - -struct IrradianceInfo { - origin: vec4, - spacing: vec4, - counts: vec4, - tiles: vec4, - atlas: vec4, -} - -struct FragmentOutput { - @location(2) member: vec4, - @location(1) member_1: vec4, - @location(0) member_2: vec4, + tint: vec4, } var g_albedo: vec3; var g_debug_surface: vec3; var g_debug_surface_on: bool; var g_premultiply: bool; -var g_cluster_offset: f32; -var g_cluster_count: f32; -var v_world_position_1: vec3; +var g_pass_through: f32; @group(1) @binding(0) var fog_info: FogInfo; -var frag_albedo: vec4; -var frag_surface: vec4; +var v_world_position_1: vec3; +var gl_FragCoord_1: vec4; +@group(1) @binding(1) +var planar_info: PlanarReflectionInfo; +@group(1) @binding(2) +var reflection_texture_tex: texture_2d; +@group(1) @binding(3) +var reflection_texture_smp: sampler; var v_normal_1: vec3; var gl_FrontFacing_1: bool; var frag_color: vec4; -var gl_FragCoord_1: vec4; -@group(1) @binding(3) -var light_list_info: LightListInfo; -@group(1) @binding(11) -var light_list_texture_tex: texture_2d; -@group(1) @binding(12) -var light_list_texture_smp: sampler; -@group(1) @binding(1) -var frag_info: FragInfo; -@group(1) @binding(5) -var base_color_texture_tex: texture_2d; -@group(1) @binding(6) -var base_color_texture_smp: sampler; var v_texcoord_1: vec2; +var v_tangent_1: vec4; var v_color_1: vec4; -@group(1) @binding(4) -var point_shadow: PointShadow; -@group(1) @binding(23) -var point_shadow_texture_tex: texture_2d; -@group(1) @binding(24) -var point_shadow_texture_smp: sampler; -@group(1) @binding(21) -var point_shadow_static_texture_tex: texture_2d; -@group(1) @binding(22) -var point_shadow_static_texture_smp: sampler; -@group(1) @binding(2) -var irradiance_info: IrradianceInfo; -@group(1) @binding(9) -var irradiance_texture_tex: texture_2d; -@group(1) @binding(10) -var irradiance_texture_smp: sampler; +var v_lightmap_uv_1: vec2; + +fn ViewDepth_u0028_() -> f32 { + let _e33 = v_world_position_1; + let _e35 = fog_info.eye; + let _e39 = fog_info.forward; + return dot((_e33 - _e35.xyz), _e39.xyz); +} + +fn Orthographic_u0028_() -> bool { + let _e35 = fog_info.projection[0u]; + return (_e35 > 0.5f); +} + +fn EyeDistance_u0028_() -> f32 { + var local: f32; + + let _e34 = Orthographic_u0028_(); + if _e34 { + let _e35 = ViewDepth_u0028_(); + local = max(_e35, 0f); + } else { + let _e37 = v_world_position_1; + let _e39 = fog_info.eye; + local = distance(_e37, _e39.xyz); + } + let _e42 = local; + return _e42; +} + +fn ApplyFog_u0028_vf3_u003b(color: ptr>) -> vec3 { + var density: f32; + var d: f32; + var falloff: f32; + var k: f32; + var local_1: f32; + + let _e41 = fog_info.fog[3u]; + density = _e41; + let _e42 = density; + if (_e42 <= 0f) { + let _e44 = (*color); + return _e44; + } + let _e45 = EyeDistance_u0028_(); + d = _e45; + let _e48 = fog_info.eye[3u]; + falloff = _e48; + let _e49 = falloff; + if (_e49 > 0f) { + let _e51 = falloff; + let _e53 = v_world_position_1[1u]; + let _e56 = fog_info.eye[1u]; + k = (_e51 * (_e53 - _e56)); + let _e59 = k; + if (abs(_e59) > 0.001f) { + let _e62 = k; + let _e66 = k; + local_1 = ((1f - exp(-(_e62))) / _e66); + } else { + let _e68 = k; + local_1 = (1f - (0.5f * _e68)); + } + let _e71 = local_1; + let _e72 = density; + density = (_e72 * _e71); + } + let _e75 = fog_info.fog; + let _e77 = (*color); + let _e78 = density; + let _e80 = d; + return mix(_e75.xyz, _e77, vec3(clamp(exp((-(_e78) * _e80)), 0f, 1f))); +} + +fn TowardsEye_u0028_() -> vec3 { + var local_2: vec3; + + let _e34 = Orthographic_u0028_(); + if _e34 { + let _e36 = fog_info.forward; + local_2 = -(_e36.xyz); + } else { + let _e40 = fog_info.eye; + let _e42 = v_world_position_1; + local_2 = normalize((_e40.xyz - _e42)); + } + let _e45 = local_2; + return _e45; +} + +fn FragCoordFromTop_u0028_f1_u003b(rows: ptr) -> vec2 { + var local_3: vec2; + + let _e35 = (*rows); + if (_e35 > 0f) { + let _e38 = gl_FragCoord_1[0u]; + let _e39 = (*rows); + let _e41 = gl_FragCoord_1[1u]; + local_3 = vec2(_e38, (_e39 - _e41)); + } else { + let _e44 = gl_FragCoord_1; + local_3 = _e44.xy; + } + let _e46 = local_3; + return _e46; +} + +fn main_1() { + var rows_1: f32; + var pixel: vec2; + var param: f32; + var uv: vec2; + var reflected: vec3; + var n: vec3; + var v: vec3; + var cosine: f32; + var f0_: f32; + var grazing: f32; + var grazing2_: f32; + var fresnel: f32; + var alpha: f32; + var param_1: vec3; + + g_albedo = vec3(0f, 0f, 0f); + g_debug_surface = vec3(0f, 0f, 0f); + g_debug_surface_on = false; + g_premultiply = false; + g_pass_through = 0f; + let _e49 = planar_info.params[2u]; + rows_1 = _e49; + let _e50 = rows_1; + param = _e50; + let _e51 = FragCoordFromTop_u0028_f1_u003b((¶m)); + pixel = _e51; + let _e52 = pixel; + let _e54 = planar_info.view; + let _e58 = planar_info.view; + uv = ((_e52 - _e54.xy) / _e58.zw); + let _e61 = rows_1; + if (_e61 > 0f) { + let _e64 = uv[1u]; + uv[1u] = (1f - _e64); + } + let _e67 = uv; + let _e68 = textureSampleLevel(reflection_texture_tex, reflection_texture_smp, _e67, 0f); + let _e71 = planar_info.tint; + reflected = (_e68.xyz * _e71.xyz); + let _e74 = v_normal_1; + n = normalize(_e74); + let _e76 = gl_FrontFacing_1; + if !(_e76) { + let _e78 = n; + n = -(_e78); + } + let _e80 = TowardsEye_u0028_(); + v = _e80; + let _e81 = n; + let _e82 = v; + cosine = clamp(dot(_e81, _e82), 0f, 1f); + let _e87 = planar_info.params[0u]; + f0_ = _e87; + let _e88 = cosine; + grazing = (1f - _e88); + let _e90 = grazing; + let _e91 = grazing; + grazing2_ = (_e90 * _e91); + let _e93 = f0_; + let _e94 = f0_; + let _e96 = grazing2_; + let _e98 = grazing2_; + let _e100 = grazing; + fresnel = (_e93 + ((((1f - _e94) * _e96) * _e98) * _e100)); + let _e103 = fresnel; + let _e106 = planar_info.params[1u]; + alpha = clamp((_e103 * _e106), 0f, 1f); + let _e109 = reflected; + param_1 = _e109; + let _e110 = ApplyFog_u0028_vf3_u003b((¶m_1)); + let _e111 = alpha; + let _e112 = (_e110 * _e111); + let _e113 = alpha; + frag_color = vec4(_e112.x, _e112.y, _e112.z, _e113); + return; +} + +@fragment +fn main(@location(7) v_world_position: vec3, @builtin(position) gl_FragCoord: vec4, @location(3) v_normal: vec3, @builtin(front_facing) gl_FrontFacing: bool, @location(5) v_texcoord: vec2, @location(4) v_tangent: vec4, @location(0) v_color: vec4, @location(2) v_lightmap_uv: vec2) -> @location(0) vec4 { + v_world_position_1 = v_world_position; + gl_FragCoord_1 = gl_FragCoord; + v_normal_1 = v_normal; + gl_FrontFacing_1 = gl_FrontFacing; + v_texcoord_1 = v_texcoord; + v_tangent_1 = v_tangent; + v_color_1 = v_color; + v_lightmap_uv_1 = v_lightmap_uv; + main_1(); + let _e17 = frag_color; + return _e17; +} +''', + attributes: [], + blocks: [ + WebGpuBlock( + name: 'FogInfo', + group: 1, + binding: 0, + sizeInBytes: 64, + members: [ + WebGpuBlockMember(name: 'fog', offsetInBytes: 0, sizeInBytes: 16), + WebGpuBlockMember(name: 'eye', offsetInBytes: 16, sizeInBytes: 16), + WebGpuBlockMember( + name: 'forward', + offsetInBytes: 32, + sizeInBytes: 16, + ), + WebGpuBlockMember( + name: 'projection', + offsetInBytes: 48, + sizeInBytes: 16, + ), + ], + ), + WebGpuBlock( + name: 'PlanarReflectionInfo', + group: 1, + binding: 1, + sizeInBytes: 48, + members: [ + WebGpuBlockMember(name: 'view', offsetInBytes: 0, sizeInBytes: 16), + WebGpuBlockMember( + name: 'params', + offsetInBytes: 16, + sizeInBytes: 16, + ), + WebGpuBlockMember(name: 'tint', offsetInBytes: 32, sizeInBytes: 16), + ], + ), + ], + samplers: [ + WebGpuSampler( + name: 'reflection_texture', + group: 1, + textureBinding: 2, + samplerBinding: 3, + dimension: WebGpuTextureDimension.twoDimensional, + ), + ], + ), + 'Lambert': WebGpuStage( + wgsl: r''' +struct Surface { + albedo: vec3, + alpha: f32, + n: vec3, + v: vec3, + n_dot_v: f32, + metallic: f32, + roughness: f32, + occlusion: f32, + emissive: vec3, + ambient: vec3, + exposure: f32, +} + +struct LightSample { + l: vec3, + h: vec3, + radiance: vec3, + n_dot_l: f32, + n_dot_h: f32, + v_dot_h: f32, + integrated: f32, + ltc: vec3, +} + +struct FogInfo { + fog: vec4, + eye: vec4, + forward: vec4, + projection: vec4, +} + +struct LightListInfo { + list: vec4, + indices: array, 6>, + scales: array, 6>, + cluster_view_projection: mat4x4, + cluster_grid: vec4, + cluster_depth: vec4, + slot_rows: array, 2>, +} + +struct FragInfo { + light_position: array, 8>, + light_color: array, 8>, + light_direction: array, 8>, + light_cone: array, 8>, + base_color: vec4, + emissive: vec4, + camera_position: vec4, + material: vec4, + material2_: vec4, + frame_params: vec4, + shadow_params: vec4, + shadow_matrix: mat4x4, + shadow_matrix_far: mat4x4, + shadow_matrix_farthest: mat4x4, + shadow_cascades: vec4, + ambient_sky: vec4, + ambient_ground: vec4, + shadow_bias: vec4, + target_origin: vec4, + debug_view: vec4, +} + +struct PointShadow { + faces: array, 36>, + lights: array, 6>, + slots: array, 8>, + params: vec4, + params2_: vec4, + params3_: vec4, +} + +struct IrradianceInfo { + origin: vec4, + spacing: vec4, + counts: vec4, + tiles: vec4, + atlas: vec4, +} + +struct FragmentOutput { + @location(2) member: vec4, + @location(1) member_1: vec4, + @location(0) member_2: vec4, +} + +var g_albedo: vec3; +var g_debug_surface: vec3; +var g_debug_surface_on: bool; +var g_premultiply: bool; +var g_pass_through: f32; +var g_cluster_offset: f32; +var g_cluster_count: f32; +var light_transmittance: vec3; +@group(1) @binding(0) +var fog_info: FogInfo; +var v_world_position_1: vec3; +var frag_albedo: vec4; +var frag_surface: vec4; +var v_normal_1: vec3; +var gl_FrontFacing_1: bool; +var frag_color: vec4; +var gl_FragCoord_1: vec4; +@group(1) @binding(3) +var light_list_info: LightListInfo; +@group(1) @binding(11) +var light_list_texture_tex: texture_2d; +@group(1) @binding(12) +var light_list_texture_smp: sampler; +@group(1) @binding(1) +var frag_info: FragInfo; +@group(1) @binding(5) +var base_color_texture_tex: texture_2d; +@group(1) @binding(6) +var base_color_texture_smp: sampler; +var v_texcoord_1: vec2; +var v_color_1: vec4; +@group(1) @binding(4) +var point_shadow: PointShadow; +@group(1) @binding(23) +var point_shadow_texture_tex: texture_2d; +@group(1) @binding(24) +var point_shadow_texture_smp: sampler; +@group(1) @binding(21) +var point_shadow_static_texture_tex: texture_2d; +@group(1) @binding(22) +var point_shadow_static_texture_smp: sampler; +@group(1) @binding(2) +var irradiance_info: IrradianceInfo; +@group(1) @binding(9) +var irradiance_texture_tex: texture_2d; +@group(1) @binding(10) +var irradiance_texture_smp: sampler; @group(1) @binding(13) var lightmap_texture_tex: texture_2d; @group(1) @binding(14) @@ -5205,10 +6564,10 @@ var metallic_roughness_texture_tex: texture_2d; var metallic_roughness_texture_smp: sampler; fn ViewDepth_u0028_() -> f32 { - let _e175 = v_world_position_1; - let _e177 = fog_info.eye; - let _e181 = fog_info.forward; - return dot((_e175 - _e177.xyz), _e181.xyz); + let _e186 = v_world_position_1; + let _e188 = fog_info.eye; + let _e192 = fog_info.forward; + return dot((_e186 - _e188.xyz), _e192.xyz); } fn WeightedBlendedWeight_u0028_f1_u003b(alpha: ptr) -> f32 { @@ -5217,22 +6576,22 @@ fn WeightedBlendedWeight_u0028_f1_u003b(alpha: ptr) -> f32 { var far: f32; var far3_: f32; - let _e180 = ViewDepth_u0028_(); - z = abs(_e180); - let _e182 = z; - near = (_e182 / 5f); - let _e184 = z; - far = (_e184 / 200f); - let _e186 = far; - let _e187 = far; - let _e189 = far; - far3_ = ((_e186 * _e187) * _e189); - let _e191 = (*alpha); - let _e192 = near; - let _e193 = near; - let _e196 = far3_; - let _e197 = far3_; - return (_e191 * clamp((10f / ((0.00001f + (_e192 * _e193)) + (_e196 * _e197))), 0.01f, 3000f)); + let _e191 = ViewDepth_u0028_(); + z = abs(_e191); + let _e193 = z; + near = (_e193 / 5f); + let _e195 = z; + far = (_e195 / 200f); + let _e197 = far; + let _e198 = far; + let _e200 = far; + far3_ = ((_e197 * _e198) * _e200); + let _e202 = (*alpha); + let _e203 = near; + let _e204 = near; + let _e207 = far3_; + let _e208 = far3_; + return (_e202 * clamp((10f / ((0.00001f + (_e203 * _e204)) + (_e207 * _e208))), 0.01f, 3000f)); } fn WriteWeightedBlended_u0028_() { @@ -5244,39 +6603,39 @@ fn WriteWeightedBlended_u0028_() { var revealage: bool; var local: vec4; - let _e184 = fog_info.forward[3u]; - mode = _e184; - let _e185 = mode; - if (_e185 > 0.5f) { - let _e188 = frag_color[3u]; - alpha_1 = _e188; - let _e189 = alpha_1; - param = _e189; - let _e190 = WeightedBlendedWeight_u0028_f1_u003b((¶m)); - weight = _e190; - let _e191 = frag_color; - let _e193 = weight; - let _e194 = (_e191.xyz * _e193); - let _e195 = alpha_1; - let _e196 = weight; - accumulate = vec4(_e194.x, _e194.y, _e194.z, (_e195 * _e196)); - let _e202 = mode; - let _e204 = mode; - revealage = ((_e202 > 1.5f) && (_e204 < 2.5f)); - let _e207 = revealage; - if _e207 { - let _e208 = alpha_1; - local = vec4(_e208); + let _e195 = fog_info.forward[3u]; + mode = _e195; + let _e196 = mode; + if (_e196 > 0.5f) { + let _e199 = frag_color[3u]; + alpha_1 = _e199; + let _e200 = alpha_1; + param = _e200; + let _e201 = WeightedBlendedWeight_u0028_f1_u003b((¶m)); + weight = _e201; + let _e202 = frag_color; + let _e204 = weight; + let _e205 = (_e202.xyz * _e204); + let _e206 = alpha_1; + let _e207 = weight; + accumulate = vec4(_e205.x, _e205.y, _e205.z, (_e206 * _e207)); + let _e213 = mode; + let _e215 = mode; + revealage = ((_e213 > 1.5f) && (_e215 < 2.5f)); + let _e218 = revealage; + if _e218 { + let _e219 = alpha_1; + local = vec4(_e219); } else { - let _e210 = accumulate; - local = _e210; + let _e221 = accumulate; + local = _e221; } - let _e211 = local; - frag_color = _e211; - let _e212 = mode; - if (_e212 > 2.5f) { - let _e214 = alpha_1; - frag_surface = vec4(_e214); + let _e222 = local; + frag_color = _e222; + let _e223 = mode; + if (_e223 > 2.5f) { + let _e225 = alpha_1; + frag_surface = vec4(_e225); } } return; @@ -5285,29 +6644,29 @@ fn WriteWeightedBlended_u0028_() { fn EncodeOctahedral_u0028_vf3_u003b(n: ptr>) -> vec2 { var e: vec2; - let _e178 = (*n)[0u]; - let _e181 = (*n)[1u]; - let _e185 = (*n)[2u]; - let _e188 = (*n); - (*n) = (_e188 / vec3(((abs(_e178) + abs(_e181)) + abs(_e185)))); - let _e191 = (*n); - e = _e191.xy; - let _e194 = (*n)[2u]; - if (_e194 < 0f) { - let _e196 = (*n); - let _e202 = (*n)[0u]; - let _e206 = (*n)[1u]; - e = ((vec2(1f) - abs(_e196.yx)) * vec2(select(-1f, 1f, (_e202 >= 0f)), select(-1f, 1f, (_e206 >= 0f)))); + let _e189 = (*n)[0u]; + let _e192 = (*n)[1u]; + let _e196 = (*n)[2u]; + let _e199 = (*n); + (*n) = (_e199 / vec3(((abs(_e189) + abs(_e192)) + abs(_e196)))); + let _e202 = (*n); + e = _e202.xy; + let _e205 = (*n)[2u]; + if (_e205 < 0f) { + let _e207 = (*n); + let _e213 = (*n)[0u]; + let _e217 = (*n)[1u]; + e = ((vec2(1f) - abs(_e207.yx)) * vec2(select(-1f, 1f, (_e213 >= 0f)), select(-1f, 1f, (_e217 >= 0f)))); } - let _e211 = e; - return ((_e211 * 0.5f) + vec2(0.5f)); + let _e222 = e; + return ((_e222 * 0.5f) + vec2(0.5f)); } fn LinearToSrgb_u0028_vf3_u003b(linear: ptr>) -> vec3 { - let _e176 = (*linear); - let _e178 = (*linear); - let _e182 = (*linear); - return mix((_e176 * 12.92f), ((pow(_e178, vec3(0.41666666f, 0.41666666f, 0.41666666f)) * 1.055f) - vec3(0.055f, 0.055f, 0.055f)), step(vec3(0.0031308f, 0.0031308f, 0.0031308f), _e182)); + let _e187 = (*linear); + let _e189 = (*linear); + let _e193 = (*linear); + return mix((_e187 * 12.92f), ((pow(_e189, vec3(0.41666666f, 0.41666666f, 0.41666666f)) * 1.055f) - vec3(0.055f, 0.055f, 0.055f)), step(vec3(0.0031308f, 0.0031308f, 0.0031308f), _e193)); } fn WriteSurfaceGeometry_u0028_f1_u003b(roughness: ptr) { @@ -5315,57 +6674,96 @@ fn WriteSurfaceGeometry_u0028_f1_u003b(roughness: ptr) { var geometric: vec3; var param_2: vec3; - let _e179 = g_albedo; - param_1 = clamp(_e179, vec3(0f, 0f, 0f), vec3(1f, 1f, 1f)); - let _e181 = LinearToSrgb_u0028_vf3_u003b((¶m_1)); - frag_albedo = vec4(_e181.x, _e181.y, _e181.z, 1f); - let _e186 = g_debug_surface_on; - if _e186 { - let _e187 = g_debug_surface; - let _e188 = ViewDepth_u0028_(); - frag_surface = vec4(_e187.x, _e187.y, _e187.z, _e188); + let _e190 = g_albedo; + param_1 = clamp(_e190, vec3(0f, 0f, 0f), vec3(1f, 1f, 1f)); + let _e192 = LinearToSrgb_u0028_vf3_u003b((¶m_1)); + frag_albedo = vec4(_e192.x, _e192.y, _e192.z, 1f); + let _e197 = g_debug_surface_on; + if _e197 { + let _e198 = g_debug_surface; + let _e199 = ViewDepth_u0028_(); + frag_surface = vec4(_e198.x, _e198.y, _e198.z, _e199); return; } - let _e193 = v_normal_1; - geometric = normalize(_e193); - let _e195 = gl_FrontFacing_1; - if !(_e195) { - let _e197 = geometric; - geometric = -(_e197); - } - let _e199 = geometric; - param_2 = _e199; - let _e200 = EncodeOctahedral_u0028_vf3_u003b((¶m_2)); - let _e201 = (*roughness); - let _e203 = ViewDepth_u0028_(); - frag_surface = vec4(_e200.x, _e200.y, clamp(_e201, 0f, 1f), _e203); + let _e204 = v_normal_1; + geometric = normalize(_e204); + let _e206 = gl_FrontFacing_1; + if !(_e206) { + let _e208 = geometric; + geometric = -(_e208); + } + let _e210 = geometric; + param_2 = _e210; + let _e211 = EncodeOctahedral_u0028_vf3_u003b((¶m_2)); + let _e212 = (*roughness); + let _e214 = ViewDepth_u0028_(); + frag_surface = vec4(_e211.x, _e211.y, clamp(_e212, 0f, 1f), _e214); return; } +fn Orthographic_u0028_() -> bool { + let _e188 = fog_info.projection[0u]; + return (_e188 > 0.5f); +} + fn EyeDistance_u0028_() -> f32 { - let _e175 = v_world_position_1; - let _e177 = fog_info.eye; - return distance(_e175, _e177.xyz); + var local_1: f32; + + let _e187 = Orthographic_u0028_(); + if _e187 { + let _e188 = ViewDepth_u0028_(); + local_1 = max(_e188, 0f); + } else { + let _e190 = v_world_position_1; + let _e192 = fog_info.eye; + local_1 = distance(_e190, _e192.xyz); + } + let _e195 = local_1; + return _e195; } fn ApplyFog_u0028_vf3_u003b(color: ptr>) -> vec3 { var density: f32; var d: f32; + var falloff: f32; + var k: f32; + var local_2: f32; - let _e180 = fog_info.fog[3u]; - density = _e180; - let _e181 = density; - if (_e181 <= 0f) { - let _e183 = (*color); - return _e183; + let _e194 = fog_info.fog[3u]; + density = _e194; + let _e195 = density; + if (_e195 <= 0f) { + let _e197 = (*color); + return _e197; + } + let _e198 = EyeDistance_u0028_(); + d = _e198; + let _e201 = fog_info.eye[3u]; + falloff = _e201; + let _e202 = falloff; + if (_e202 > 0f) { + let _e204 = falloff; + let _e206 = v_world_position_1[1u]; + let _e209 = fog_info.eye[1u]; + k = (_e204 * (_e206 - _e209)); + let _e212 = k; + if (abs(_e212) > 0.001f) { + let _e215 = k; + let _e219 = k; + local_2 = ((1f - exp(-(_e215))) / _e219); + } else { + let _e221 = k; + local_2 = (1f - (0.5f * _e221)); + } + let _e224 = local_2; + let _e225 = density; + density = (_e225 * _e224); } - let _e184 = EyeDistance_u0028_(); - d = _e184; - let _e186 = fog_info.fog; - let _e188 = (*color); - let _e189 = density; - let _e191 = d; - return mix(_e186.xyz, _e188, vec3(clamp(exp((-(_e189) * _e191)), 0f, 1f))); + let _e228 = fog_info.fog; + let _e230 = (*color); + let _e231 = density; + let _e233 = d; + return mix(_e228.xyz, _e230, vec3(clamp(exp((-(_e231) * _e233)), 0f, 1f))); } fn WriteSurface_u0028_vf3_u003b_f1_u003b_f1_u003b(linearColor: ptr>, alpha_2: ptr, roughness_1: ptr) { @@ -5373,55 +6771,196 @@ fn WriteSurface_u0028_vf3_u003b_f1_u003b_f1_u003b(linearColor: ptr; var param_4: f32; - let _e181 = g_premultiply; - let _e182 = (*alpha_2); - weight_1 = select(1f, _e182, _e181); - let _e184 = (*linearColor); - param_3 = _e184; - let _e185 = ApplyFog_u0028_vf3_u003b((¶m_3)); - let _e186 = weight_1; - let _e187 = (_e185 * _e186); - let _e188 = (*alpha_2); - frag_color = vec4(_e187.x, _e187.y, _e187.z, _e188); - let _e193 = (*roughness_1); - param_4 = _e193; + let _e192 = g_premultiply; + let _e193 = (*alpha_2); + weight_1 = select(1f, _e193, _e192); + let _e195 = (*linearColor); + param_3 = _e195; + let _e196 = ApplyFog_u0028_vf3_u003b((¶m_3)); + let _e197 = weight_1; + let _e198 = (_e196 * _e197); + let _e199 = (*alpha_2); + let _e200 = g_pass_through; + frag_color = vec4(_e198.x, _e198.y, _e198.z, (_e199 * (1f - _e200))); + let _e207 = (*roughness_1); + param_4 = _e207; WriteSurfaceGeometry_u0028_f1_u003b((¶m_4)); WriteWeightedBlended_u0028_(); return; } -fn ShadeLight_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b_struct_u002d_LightSample_u002d_vf3_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf31_u003b(s: ptr, light: ptr) -> vec3 { - let _e178 = (*s).albedo; - return _e178; +fn SrgbToLinear_u0028_vf3_u003b(srgb: ptr>) -> vec3 { + let _e187 = (*srgb); + let _e190 = (*srgb); + let _e195 = (*srgb); + return mix((_e187 / vec3(12.92f)), pow(((_e190 + vec3(0.055f, 0.055f, 0.055f)) / vec3(1.055f)), vec3(2.4f, 2.4f, 2.4f)), step(vec3(0.04045f, 0.04045f, 0.04045f), _e195)); +} + +fn NonFinite_u0028_vf3_u003b(c: ptr>) -> bool { + var phi_2559_: bool; + + let _e187 = (*c); + let _e188 = (*c); + let _e190 = any((_e187 != _e188)); + phi_2559_ = _e190; + if !(_e190) { + let _e192 = (*c); + phi_2559_ = any((abs(_e192) > vec3(300000000000000000000000000000000000000f, 300000000000000000000000000000000000000f, 300000000000000000000000000000000000000f))); + } + let _e197 = phi_2559_; + return _e197; +} + +fn WriteDebugView_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b_vf3_u003b(s: ptr, lit: ptr>) -> bool { + var view: f32; + var code: i32; + var shown: vec3; + var param_5: vec3; + var param_6: vec3; + var grey: f32; + var param_7: vec3; + var param_8: vec3; + var local_3: vec3; + var weight_2: f32; + var local_4: f32; + var param_9: vec3; + var param_10: f32; + + let _e203 = frag_info.debug_view[0u]; + view = _e203; + let _e204 = view; + if (_e204 < 0.5f) { + return false; + } + let _e207 = gl_FragCoord_1[0u]; + let _e210 = frag_info.debug_view[1u]; + let _e213 = frag_info.debug_view[2u]; + if (_e207 < (_e210 * _e213)) { + return false; + } + let _e216 = view; + code = i32((_e216 + 0.5f)); + shown = vec3(0f, 0f, 0f); + let _e219 = code; + if (_e219 == 1i) { + let _e222 = (*s).albedo; + param_5 = clamp(_e222, vec3(0f, 0f, 0f), vec3(1f, 1f, 1f)); + let _e224 = LinearToSrgb_u0028_vf3_u003b((¶m_5)); + shown = _e224; + } else { + let _e225 = code; + if (_e225 == 2i) { + let _e228 = (*s).n; + shown = ((_e228 * 0.5f) + vec3(0.5f, 0.5f, 0.5f)); + } else { + let _e231 = code; + if (_e231 == 3i) { + let _e234 = (*s).roughness; + shown = vec3(clamp(_e234, 0f, 1f)); + } else { + let _e237 = code; + if (_e237 == 4i) { + let _e240 = (*s).metallic; + shown = vec3(clamp(_e240, 0f, 1f)); + } else { + let _e243 = code; + if (_e243 == 5i) { + let _e246 = (*s).occlusion; + shown = vec3(clamp(_e246, 0f, 1f)); + } else { + let _e249 = code; + if (_e249 == 6i) { + let _e252 = (*s).emissive; + param_6 = clamp(_e252, vec3(0f, 0f, 0f), vec3(1f, 1f, 1f)); + let _e254 = LinearToSrgb_u0028_vf3_u003b((¶m_6)); + shown = _e254; + } else { + let _e255 = code; + if (_e255 == 7i) { + let _e257 = v_texcoord_1; + let _e258 = fract(_e257); + shown = vec3(_e258.x, _e258.y, 0f); + } else { + let _e262 = code; + if (_e262 == 8i) { + let _e264 = (*lit); + param_7 = clamp(_e264, vec3(0f, 0f, 0f), vec3(1f, 1f, 1f)); + let _e266 = LinearToSrgb_u0028_vf3_u003b((¶m_7)); + grey = dot(_e266, vec3(0.2126f, 0.7152f, 0.0722f)); + let _e268 = (*lit); + param_8 = _e268; + let _e269 = NonFinite_u0028_vf3_u003b((¶m_8)); + if _e269 { + local_3 = vec3(1f, 0f, 1f); + } else { + let _e270 = grey; + local_3 = vec3((_e270 * 0.5f)); + } + let _e273 = local_3; + shown = _e273; + } + } + } + } + } + } + } + } + let _e274 = g_premultiply; + if _e274 { + let _e276 = (*s).alpha; + local_4 = _e276; + } else { + local_4 = 1f; + } + let _e277 = local_4; + weight_2 = _e277; + let _e278 = shown; + param_9 = _e278; + let _e279 = SrgbToLinear_u0028_vf3_u003b((¶m_9)); + let _e280 = weight_2; + let _e281 = (_e279 * _e280); + let _e283 = (*s).alpha; + frag_color = vec4(_e281.x, _e281.y, _e281.z, _e283); + let _e289 = (*s).roughness; + param_10 = _e289; + WriteSurfaceGeometry_u0028_f1_u003b((¶m_10)); + WriteWeightedBlended_u0028_(); + return true; +} + +fn ShadeLight_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b_struct_u002d_LightSample_u002d_vf3_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf31_u003b(s_1: ptr, light: ptr) -> vec3 { + let _e189 = (*s_1).albedo; + return _e189; } fn PointShadowDiskTap_u0028_i1_u003b(i: ptr) -> vec2 { - let _e176 = (*i); - if (_e176 == 0i) { + let _e187 = (*i); + if (_e187 == 0i) { return vec2(-0.94201624f, -0.39906216f); } - let _e178 = (*i); - if (_e178 == 1i) { + let _e189 = (*i); + if (_e189 == 1i) { return vec2(0.9455861f, -0.76890725f); } - let _e180 = (*i); - if (_e180 == 2i) { + let _e191 = (*i); + if (_e191 == 2i) { return vec2(-0.0941841f, -0.9293887f); } - let _e182 = (*i); - if (_e182 == 3i) { + let _e193 = (*i); + if (_e193 == 3i) { return vec2(0.34495938f, 0.2938776f); } - let _e184 = (*i); - if (_e184 == 4i) { + let _e195 = (*i); + if (_e195 == 4i) { return vec2(-0.9158858f, 0.45771432f); } - let _e186 = (*i); - if (_e186 == 5i) { + let _e197 = (*i); + if (_e197 == 5i) { return vec2(-0.8154423f, -0.87912464f); } - let _e188 = (*i); - if (_e188 == 6i) { + let _e199 = (*i); + if (_e199 == 6i) { return vec2(-0.38277543f, 0.27676845f); } return vec2(0.974844f, 0.7564838f); @@ -5429,208 +6968,208 @@ fn PointShadowDiskTap_u0028_i1_u003b(i: ptr) -> vec2 { fn PointShadowOffset_u0028_i1_u003b_f1_u003b_f1_u003b_f1_u003b(i_1: ptr, ca: ptr, sa: ptr, radius: ptr) -> vec2 { var p: vec2; - var param_5: i32; - - let _e181 = (*i_1); - param_5 = _e181; - let _e182 = PointShadowDiskTap_u0028_i1_u003b((¶m_5)); - p = _e182; - let _e184 = p[0u]; - let _e185 = (*ca); - let _e188 = p[1u]; - let _e189 = (*sa); - let _e193 = p[0u]; - let _e194 = (*sa); - let _e197 = p[1u]; - let _e198 = (*ca); - let _e202 = (*radius); - return (vec2(((_e184 * _e185) - (_e188 * _e189)), ((_e193 * _e194) + (_e197 * _e198))) * _e202); + var param_11: i32; + + let _e192 = (*i_1); + param_11 = _e192; + let _e193 = PointShadowDiskTap_u0028_i1_u003b((¶m_11)); + p = _e193; + let _e195 = p[0u]; + let _e196 = (*ca); + let _e199 = p[1u]; + let _e200 = (*sa); + let _e204 = p[0u]; + let _e205 = (*sa); + let _e208 = p[1u]; + let _e209 = (*ca); + let _e213 = (*radius); + return (vec2(((_e195 * _e196) - (_e199 * _e200)), ((_e204 * _e205) + (_e208 * _e209))) * _e213); } fn PointShadowDistance_u0028_vf2_u003b_vf2_u003b_vf2_u003b_f1_u003b(uv: ptr>, offset: ptr>, tile: ptr>, range: ptr) -> f32 { var inset: f32; - var local_1: vec2; + var local_5: vec2; var atlas: vec2; - let _e184 = point_shadow.params[0u]; - inset = _e184; - let _e185 = (*uv); - let _e186 = (*offset); - let _e188 = inset; - let _e189 = inset; - local_1 = clamp((_e185 + _e186), vec2(_e188), vec2((1f - _e189))); - let _e194 = local_1; - let _e195 = (*tile); - atlas = ((_e194 + _e195) * vec2(0.16666667f, 0.16666667f)); - let _e200 = point_shadow.params3_[0u]; - if (_e200 > 0.5f) { - let _e203 = atlas[1u]; - atlas[1u] = (1f - _e203); - } - let _e206 = atlas; - let _e207 = textureSampleLevel(point_shadow_texture_tex, point_shadow_texture_smp, _e206, 0f); - let _e209 = atlas; - let _e210 = textureSampleLevel(point_shadow_static_texture_tex, point_shadow_static_texture_smp, _e209, 0f); - let _e213 = (*range); - return (min(_e207.x, _e210.x) * _e213); + let _e195 = point_shadow.params[0u]; + inset = _e195; + let _e196 = (*uv); + let _e197 = (*offset); + let _e199 = inset; + let _e200 = inset; + local_5 = clamp((_e196 + _e197), vec2(_e199), vec2((1f - _e200))); + let _e205 = local_5; + let _e206 = (*tile); + atlas = ((_e205 + _e206) * vec2(0.16666667f, 0.16666667f)); + let _e211 = point_shadow.params3_[0u]; + if (_e211 > 0.5f) { + let _e214 = atlas[1u]; + atlas[1u] = (1f - _e214); + } + let _e217 = atlas; + let _e218 = textureSampleLevel(point_shadow_texture_tex, point_shadow_texture_smp, _e217, 0f); + let _e220 = atlas; + let _e221 = textureSampleLevel(point_shadow_static_texture_tex, point_shadow_static_texture_smp, _e220, 0f); + let _e224 = (*range); + return (min(_e218.x, _e221.x) * _e224); } fn PointShadowTap_u0028_vf2_u003b_vf2_u003b_vf2_u003b_f1_u003b_f1_u003b(uv_1: ptr>, offset_1: ptr>, tile_1: ptr>, range_1: ptr, receiver: ptr) -> f32 { var stored: f32; - var param_6: vec2; - var param_7: vec2; - var param_8: vec2; - var param_9: f32; + var param_12: vec2; + var param_13: vec2; + var param_14: vec2; + var param_15: f32; - let _e185 = (*uv_1); - param_6 = _e185; - let _e186 = (*offset_1); - param_7 = _e186; - let _e187 = (*tile_1); - param_8 = _e187; - let _e188 = (*range_1); - param_9 = _e188; - let _e189 = PointShadowDistance_u0028_vf2_u003b_vf2_u003b_vf2_u003b_f1_u003b((¶m_6), (¶m_7), (¶m_8), (¶m_9)); - stored = _e189; - let _e190 = stored; - let _e191 = (*range_1); - if (_e190 >= (_e191 * 0.999f)) { + let _e196 = (*uv_1); + param_12 = _e196; + let _e197 = (*offset_1); + param_13 = _e197; + let _e198 = (*tile_1); + param_14 = _e198; + let _e199 = (*range_1); + param_15 = _e199; + let _e200 = PointShadowDistance_u0028_vf2_u003b_vf2_u003b_vf2_u003b_f1_u003b((¶m_12), (¶m_13), (¶m_14), (¶m_15)); + stored = _e200; + let _e201 = stored; + let _e202 = (*range_1); + if (_e201 >= (_e202 * 0.999f)) { return 1f; } - let _e194 = (*receiver); - let _e195 = stored; - return select(1f, 0f, (_e194 > _e195)); + let _e205 = (*receiver); + let _e206 = stored; + return select(1f, 0f, (_e205 > _e206)); } fn PointShadowPenumbra_u0028_vf2_u003b_vf2_u003b_f1_u003b_f1_u003b_f1_u003b_f1_u003b_f1_u003b_f1_u003b(uv_2: ptr>, tile_2: ptr>, range_2: ptr, receiver_1: ptr, ca_1: ptr, sa_1: ptr, tanHalf: ptr, blockerOut: ptr) -> f32 { var lightRadius: f32; var minRadius: f32; var maxRadius: f32; - var param_10: vec2; - var param_11: vec2; - var param_12: vec2; - var param_13: f32; + var param_16: vec2; + var param_17: vec2; + var param_18: vec2; + var param_19: f32; var sum: f32; var count: f32; var i_2: i32; var stored_1: f32; - var param_14: i32; - var param_15: f32; - var param_16: f32; - var param_17: f32; - var param_18: vec2; - var param_19: vec2; - var param_20: vec2; + var param_20: i32; var param_21: f32; + var param_22: f32; + var param_23: f32; + var param_24: vec2; + var param_25: vec2; + var param_26: vec2; + var param_27: f32; var blocker: f32; var world: f32; (*blockerOut) = -1f; - let _e206 = point_shadow.params2_[1u]; - lightRadius = _e206; - let _e209 = point_shadow.params2_[0u]; - minRadius = _e209; - let _e212 = point_shadow.params2_[2u]; - maxRadius = _e212; - let _e213 = lightRadius; - if (_e213 <= 0f) { - let _e215 = (*uv_2); - param_10 = _e215; - param_11 = vec2(0f, 0f); - let _e216 = (*tile_2); - param_12 = _e216; - let _e217 = (*range_2); - param_13 = _e217; - let _e218 = PointShadowDistance_u0028_vf2_u003b_vf2_u003b_vf2_u003b_f1_u003b((¶m_10), (¶m_11), (¶m_12), (¶m_13)); - (*blockerOut) = _e218; - let _e219 = minRadius; - return _e219; + let _e217 = point_shadow.params2_[1u]; + lightRadius = _e217; + let _e220 = point_shadow.params2_[0u]; + minRadius = _e220; + let _e223 = point_shadow.params2_[2u]; + maxRadius = _e223; + let _e224 = lightRadius; + if (_e224 <= 0f) { + let _e226 = (*uv_2); + param_16 = _e226; + param_17 = vec2(0f, 0f); + let _e227 = (*tile_2); + param_18 = _e227; + let _e228 = (*range_2); + param_19 = _e228; + let _e229 = PointShadowDistance_u0028_vf2_u003b_vf2_u003b_vf2_u003b_f1_u003b((¶m_16), (¶m_17), (¶m_18), (¶m_19)); + (*blockerOut) = _e229; + let _e230 = minRadius; + return _e230; } sum = 0f; count = 0f; i_2 = 0i; loop { - let _e220 = i_2; - if (_e220 < 8i) { - let _e222 = i_2; - param_14 = _e222; - let _e223 = (*ca_1); - param_15 = _e223; - let _e224 = (*sa_1); - param_16 = _e224; - let _e225 = maxRadius; - param_17 = _e225; - let _e226 = PointShadowOffset_u0028_i1_u003b_f1_u003b_f1_u003b_f1_u003b((¶m_14), (¶m_15), (¶m_16), (¶m_17)); - let _e227 = (*uv_2); - param_18 = _e227; - param_19 = _e226; - let _e228 = (*tile_2); - param_20 = _e228; - let _e229 = (*range_2); - param_21 = _e229; - let _e230 = PointShadowDistance_u0028_vf2_u003b_vf2_u003b_vf2_u003b_f1_u003b((¶m_18), (¶m_19), (¶m_20), (¶m_21)); - stored_1 = _e230; - let _e231 = stored_1; - let _e232 = (*range_2); - if (_e231 >= (_e232 * 0.999f)) { + let _e231 = i_2; + if (_e231 < 8i) { + let _e233 = i_2; + param_20 = _e233; + let _e234 = (*ca_1); + param_21 = _e234; + let _e235 = (*sa_1); + param_22 = _e235; + let _e236 = maxRadius; + param_23 = _e236; + let _e237 = PointShadowOffset_u0028_i1_u003b_f1_u003b_f1_u003b_f1_u003b((¶m_20), (¶m_21), (¶m_22), (¶m_23)); + let _e238 = (*uv_2); + param_24 = _e238; + param_25 = _e237; + let _e239 = (*tile_2); + param_26 = _e239; + let _e240 = (*range_2); + param_27 = _e240; + let _e241 = PointShadowDistance_u0028_vf2_u003b_vf2_u003b_vf2_u003b_f1_u003b((¶m_24), (¶m_25), (¶m_26), (¶m_27)); + stored_1 = _e241; + let _e242 = stored_1; + let _e243 = (*range_2); + if (_e242 >= (_e243 * 0.999f)) { continue; } - let _e235 = stored_1; - let _e236 = (*receiver_1); - if (_e235 >= _e236) { + let _e246 = stored_1; + let _e247 = (*receiver_1); + if (_e246 >= _e247) { continue; } - let _e238 = stored_1; - let _e239 = sum; - sum = (_e239 + _e238); - let _e241 = count; - count = (_e241 + 1f); + let _e249 = stored_1; + let _e250 = sum; + sum = (_e250 + _e249); + let _e252 = count; + count = (_e252 + 1f); continue; } else { break; } continuing { - let _e243 = i_2; - i_2 = (_e243 + 1i); + let _e254 = i_2; + i_2 = (_e254 + 1i); } } - let _e245 = count; - if (_e245 < 0.5f) { + let _e256 = count; + if (_e256 < 0.5f) { return -1f; } - let _e247 = sum; - let _e248 = count; - blocker = max((_e247 / _e248), 0.0001f); - let _e251 = blocker; - (*blockerOut) = _e251; - let _e252 = lightRadius; - let _e253 = (*receiver_1); - let _e254 = blocker; - let _e258 = blocker; - world = ((_e252 * max((_e253 - _e254), 0f)) / _e258); - let _e260 = world; - let _e261 = (*receiver_1); - let _e263 = (*tanHalf); - let _e266 = minRadius; - let _e267 = maxRadius; - return clamp((_e260 / ((2f * _e261) * _e263)), _e266, _e267); + let _e258 = sum; + let _e259 = count; + blocker = max((_e258 / _e259), 0.0001f); + let _e262 = blocker; + (*blockerOut) = _e262; + let _e263 = lightRadius; + let _e264 = (*receiver_1); + let _e265 = blocker; + let _e269 = blocker; + world = ((_e263 * max((_e264 - _e265), 0f)) / _e269); + let _e271 = world; + let _e272 = (*receiver_1); + let _e274 = (*tanHalf); + let _e277 = minRadius; + let _e278 = maxRadius; + return clamp((_e271 / ((2f * _e272) * _e274)), _e277, _e278); } fn FragCoordFromTop_u0028_f1_u003b(rows: ptr) -> vec2 { - var local_2: vec2; + var local_6: vec2; - let _e177 = (*rows); - if (_e177 > 0f) { - let _e180 = gl_FragCoord_1[0u]; - let _e181 = (*rows); - let _e183 = gl_FragCoord_1[1u]; - local_2 = vec2(_e180, (_e181 - _e183)); + let _e188 = (*rows); + if (_e188 > 0f) { + let _e191 = gl_FragCoord_1[0u]; + let _e192 = (*rows); + let _e194 = gl_FragCoord_1[1u]; + local_6 = vec2(_e191, (_e192 - _e194)); } else { - let _e186 = gl_FragCoord_1; - local_2 = _e186.xy; + let _e197 = gl_FragCoord_1; + local_6 = _e197.xy; } - let _e188 = local_2; - return _e188; + let _e199 = local_6; + return _e199; } fn PointShadowFactor_u0028_vf3_u003b_vf3_u003b_i1_u003b(world_1: ptr>, normal: ptr>, lightIndex: ptr) -> f32 { @@ -5653,292 +7192,292 @@ fn PointShadowFactor_u0028_vf3_u003b_vf3_u003b_i1_u003b(world_1: ptr; var receiver_2: f32; var noise: f32; - var param_22: f32; + var param_28: f32; var angle: f32; var ca_2: f32; var sa_2: f32; var tanHalf_1: f32; var blocker_1: f32; var radius_1: f32; - var param_23: vec2; - var param_24: vec2; - var param_25: f32; - var param_26: f32; - var param_27: f32; - var param_28: f32; - var param_29: f32; - var param_30: f32; - var local_3: vec3; - var lit: f32; - var param_31: vec2; - var param_32: vec2; - var param_33: vec2; + var param_29: vec2; + var param_30: vec2; + var param_31: f32; + var param_32: f32; + var param_33: f32; var param_34: f32; var param_35: f32; + var param_36: f32; + var local_7: vec3; + var lit_1: f32; + var param_37: vec2; + var param_38: vec2; + var param_39: vec2; + var param_40: f32; + var param_41: f32; var i_3: i32; - var param_36: i32; - var param_37: f32; - var param_38: f32; - var param_39: f32; - var param_40: vec2; - var param_41: vec2; - var param_42: vec2; + var param_42: i32; var param_43: f32; var param_44: f32; - var phi_2146_: bool; - var phi_2207_: bool; - - let _e229 = (*lightIndex); - let _e233 = point_shadow.slots[_e229][0u]; - slot = i32((_e233 + 0.5f)); - let _e236 = (*lightIndex); - let _e240 = point_shadow.slots[_e236][0u]; - if (_e240 < 0f) { + var param_45: f32; + var param_46: vec2; + var param_47: vec2; + var param_48: vec2; + var param_49: f32; + var param_50: f32; + var phi_2246_: bool; + var phi_2307_: bool; + + let _e240 = (*lightIndex); + let _e244 = point_shadow.slots[_e240][0u]; + slot = i32((_e244 + 0.5f)); + let _e247 = (*lightIndex); + let _e251 = point_shadow.slots[_e247][0u]; + if (_e251 < 0f) { return 1f; } - let _e244 = point_shadow.params[2u]; - strength = _e244; - let _e245 = strength; - if (_e245 <= 0f) { + let _e255 = point_shadow.params[2u]; + strength = _e255; + let _e256 = strength; + if (_e256 <= 0f) { return 1f; } - let _e247 = slot; - let _e250 = point_shadow.lights[_e247]; - let _e252 = (*world_1); - toLight = (_e250.xyz - _e252); - let _e254 = toLight; - toLightLength = max(length(_e254), 0.000001f); - let _e257 = (*normal); - let _e258 = toLight; - let _e259 = toLightLength; - nDotL = max(dot(_e257, (_e258 / vec3(_e259))), 0.15f); - let _e264 = nDotL; - let _e265 = nDotL; - let _e270 = nDotL; - let _e271 = nDotL; - slope = min((sqrt(max((1f - (_e264 * _e265)), 0f)) / (_e270 * _e271)), 8f); - let _e275 = toLightLength; - let _e277 = (*lightIndex); - let _e281 = point_shadow.slots[_e277][2u]; - let _e286 = point_shadow.params3_[1u]; - texel = (((2f * _e275) * max(_e281, 0.0001f)) * _e286); - let _e288 = (*world_1); - let _e289 = (*normal); - let _e290 = texel; - let _e294 = point_shadow.params[3u]; - let _e296 = slope; - origin = (_e288 + (((_e289 * _e290) * _e294) * (1f + _e296))); - let _e300 = origin; - let _e301 = slot; - let _e304 = point_shadow.lights[_e301]; - toFragment = (_e300 - _e304.xyz); - let _e307 = toFragment; - distance_ = length(_e307); - let _e309 = slot; - let _e313 = point_shadow.lights[_e309][3u]; - range_3 = max(_e313, 0.0001f); - let _e315 = distance_; - let _e316 = range_3; - if (_e315 >= _e316) { + let _e258 = slot; + let _e261 = point_shadow.lights[_e258]; + let _e263 = (*world_1); + toLight = (_e261.xyz - _e263); + let _e265 = toLight; + toLightLength = max(length(_e265), 0.000001f); + let _e268 = (*normal); + let _e269 = toLight; + let _e270 = toLightLength; + nDotL = max(dot(_e268, (_e269 / vec3(_e270))), 0.15f); + let _e275 = nDotL; + let _e276 = nDotL; + let _e281 = nDotL; + let _e282 = nDotL; + slope = min((sqrt(max((1f - (_e275 * _e276)), 0f)) / (_e281 * _e282)), 8f); + let _e286 = toLightLength; + let _e288 = (*lightIndex); + let _e292 = point_shadow.slots[_e288][2u]; + let _e297 = point_shadow.params3_[1u]; + texel = (((2f * _e286) * max(_e292, 0.0001f)) * _e297); + let _e299 = (*world_1); + let _e300 = (*normal); + let _e301 = texel; + let _e305 = point_shadow.params[3u]; + let _e307 = slope; + origin = (_e299 + (((_e300 * _e301) * _e305) * (1f + _e307))); + let _e311 = origin; + let _e312 = slot; + let _e315 = point_shadow.lights[_e312]; + toFragment = (_e311 - _e315.xyz); + let _e318 = toFragment; + distance_ = length(_e318); + let _e320 = slot; + let _e324 = point_shadow.lights[_e320][3u]; + range_3 = max(_e324, 0.0001f); + let _e326 = distance_; + let _e327 = range_3; + if (_e326 >= _e327) { return 1f; } face = 0i; - let _e318 = (*lightIndex); - let _e322 = point_shadow.slots[_e318][1u]; - if (_e322 < 0.5f) { - let _e324 = toFragment; - a = abs(_e324); - let _e327 = a[0u]; - let _e329 = a[1u]; - let _e330 = (_e327 >= _e329); - phi_2146_ = _e330; - if _e330 { - let _e332 = a[0u]; - let _e334 = a[2u]; - phi_2146_ = (_e332 >= _e334); - } - let _e337 = phi_2146_; - if _e337 { - let _e339 = toFragment[0u]; - face = select(1i, 0i, (_e339 > 0f)); - } else { - let _e343 = a[1u]; + let _e329 = (*lightIndex); + let _e333 = point_shadow.slots[_e329][1u]; + if (_e333 < 0.5f) { + let _e335 = toFragment; + a = abs(_e335); + let _e338 = a[0u]; + let _e340 = a[1u]; + let _e341 = (_e338 >= _e340); + phi_2246_ = _e341; + if _e341 { + let _e343 = a[0u]; let _e345 = a[2u]; - if (_e343 >= _e345) { - let _e348 = toFragment[1u]; - face = select(3i, 2i, (_e348 > 0f)); + phi_2246_ = (_e343 >= _e345); + } + let _e348 = phi_2246_; + if _e348 { + let _e350 = toFragment[0u]; + face = select(1i, 0i, (_e350 > 0f)); + } else { + let _e354 = a[1u]; + let _e356 = a[2u]; + if (_e354 >= _e356) { + let _e359 = toFragment[1u]; + face = select(3i, 2i, (_e359 > 0f)); } else { - let _e352 = toFragment[2u]; - face = select(5i, 4i, (_e352 > 0f)); + let _e363 = toFragment[2u]; + face = select(5i, 4i, (_e363 > 0f)); } } } - let _e355 = slot; - let _e357 = face; - let _e361 = point_shadow.faces[((_e355 * 6i) + _e357)]; - let _e362 = origin; - clip = (_e361 * vec4(_e362.x, _e362.y, _e362.z, 1f)); - let _e369 = clip[3u]; - if (_e369 <= 0f) { + let _e366 = slot; + let _e368 = face; + let _e372 = point_shadow.faces[((_e366 * 6i) + _e368)]; + let _e373 = origin; + clip = (_e372 * vec4(_e373.x, _e373.y, _e373.z, 1f)); + let _e380 = clip[3u]; + if (_e380 <= 0f) { return 1f; } - let _e371 = clip; - let _e374 = clip[3u]; - ndc = (_e371.xy / vec2(_e374)); - let _e378 = ndc[0u]; - let _e380 = (abs(_e378) > 1f); - phi_2207_ = _e380; - if !(_e380) { - let _e383 = ndc[1u]; - phi_2207_ = (abs(_e383) > 1f); - } - let _e387 = phi_2207_; - if _e387 { + let _e382 = clip; + let _e385 = clip[3u]; + ndc = (_e382.xy / vec2(_e385)); + let _e389 = ndc[0u]; + let _e391 = (abs(_e389) > 1f); + phi_2307_ = _e391; + if !(_e391) { + let _e394 = ndc[1u]; + phi_2307_ = (abs(_e394) > 1f); + } + let _e398 = phi_2307_; + if _e398 { return 1f; } - let _e389 = ndc[0u]; - let _e393 = ndc[1u]; - uv_3 = vec2(((_e389 * 0.5f) + 0.5f), (0.5f - (_e393 * 0.5f))); - let _e397 = face; - let _e399 = slot; - tile_3 = vec2(f32(_e397), f32(_e399)); - let _e402 = distance_; - let _e405 = point_shadow.params[1u]; - receiver_2 = (_e402 - _e405); - let _e409 = frag_info.target_origin[0u]; - param_22 = _e409; - let _e410 = FragCoordFromTop_u0028_f1_u003b((¶m_22)); - noise = fract((52.982918f * fract(dot(_e410, vec2(0.06711056f, 0.00583715f))))); - let _e415 = noise; - angle = (_e415 * 6.2831855f); - let _e417 = angle; - ca_2 = cos(_e417); - let _e419 = angle; - sa_2 = sin(_e419); - let _e421 = (*lightIndex); - let _e425 = point_shadow.slots[_e421][2u]; - tanHalf_1 = max(_e425, 0.0001f); + let _e400 = ndc[0u]; + let _e404 = ndc[1u]; + uv_3 = vec2(((_e400 * 0.5f) + 0.5f), (0.5f - (_e404 * 0.5f))); + let _e408 = face; + let _e410 = slot; + tile_3 = vec2(f32(_e408), f32(_e410)); + let _e413 = distance_; + let _e416 = point_shadow.params[1u]; + receiver_2 = (_e413 - _e416); + let _e420 = frag_info.target_origin[0u]; + param_28 = _e420; + let _e421 = FragCoordFromTop_u0028_f1_u003b((¶m_28)); + noise = fract((52.982918f * fract(dot(_e421, vec2(0.06711056f, 0.00583715f))))); + let _e426 = noise; + angle = (_e426 * 6.2831855f); + let _e428 = angle; + ca_2 = cos(_e428); + let _e430 = angle; + sa_2 = sin(_e430); + let _e432 = (*lightIndex); + let _e436 = point_shadow.slots[_e432][2u]; + tanHalf_1 = max(_e436, 0.0001f); blocker_1 = -1f; - let _e427 = uv_3; - param_23 = _e427; - let _e428 = tile_3; - param_24 = _e428; - let _e429 = range_3; - param_25 = _e429; - let _e430 = receiver_2; - param_26 = _e430; - let _e431 = ca_2; - param_27 = _e431; - let _e432 = sa_2; - param_28 = _e432; - let _e433 = tanHalf_1; - param_29 = _e433; - let _e434 = PointShadowPenumbra_u0028_vf2_u003b_vf2_u003b_f1_u003b_f1_u003b_f1_u003b_f1_u003b_f1_u003b_f1_u003b((¶m_23), (¶m_24), (¶m_25), (¶m_26), (¶m_27), (¶m_28), (¶m_29), (¶m_30)); - let _e435 = param_30; - blocker_1 = _e435; - radius_1 = _e434; - let _e438 = point_shadow.params2_[3u]; - if (_e438 > 0.5f) { + let _e438 = uv_3; + param_29 = _e438; + let _e439 = tile_3; + param_30 = _e439; + let _e440 = range_3; + param_31 = _e440; + let _e441 = receiver_2; + param_32 = _e441; + let _e442 = ca_2; + param_33 = _e442; + let _e443 = sa_2; + param_34 = _e443; + let _e444 = tanHalf_1; + param_35 = _e444; + let _e445 = PointShadowPenumbra_u0028_vf2_u003b_vf2_u003b_f1_u003b_f1_u003b_f1_u003b_f1_u003b_f1_u003b_f1_u003b((¶m_29), (¶m_30), (¶m_31), (¶m_32), (¶m_33), (¶m_34), (¶m_35), (¶m_36)); + let _e446 = param_36; + blocker_1 = _e446; + radius_1 = _e445; + let _e449 = point_shadow.params2_[3u]; + if (_e449 > 0.5f) { g_debug_surface_on = true; - let _e440 = radius_1; - if (_e440 < 0f) { - local_3 = vec3(0f, 0f, 1f); + let _e451 = radius_1; + if (_e451 < 0f) { + local_7 = vec3(0f, 0f, 1f); } else { - let _e442 = radius_1; - let _e445 = point_shadow.params2_[2u]; - let _e449 = blocker_1; - let _e450 = range_3; - local_3 = vec3(clamp((_e442 / max(_e445, 0.000001f)), 0f, 1f), clamp((_e449 / _e450), 0f, 1f), 0f); + let _e453 = radius_1; + let _e456 = point_shadow.params2_[2u]; + let _e460 = blocker_1; + let _e461 = range_3; + local_7 = vec3(clamp((_e453 / max(_e456, 0.000001f)), 0f, 1f), clamp((_e460 / _e461), 0f, 1f), 0f); } - let _e454 = local_3; - g_debug_surface = _e454; + let _e465 = local_7; + g_debug_surface = _e465; } - let _e455 = radius_1; - if (_e455 < 0f) { + let _e466 = radius_1; + if (_e466 < 0f) { return 1f; } - let _e457 = uv_3; - param_31 = _e457; - param_32 = vec2(0f, 0f); - let _e458 = tile_3; - param_33 = _e458; - let _e459 = range_3; - param_34 = _e459; - let _e460 = receiver_2; - param_35 = _e460; - let _e461 = PointShadowTap_u0028_vf2_u003b_vf2_u003b_vf2_u003b_f1_u003b_f1_u003b((¶m_31), (¶m_32), (¶m_33), (¶m_34), (¶m_35)); - lit = _e461; - let _e462 = radius_1; - if (_e462 > 0f) { + let _e468 = uv_3; + param_37 = _e468; + param_38 = vec2(0f, 0f); + let _e469 = tile_3; + param_39 = _e469; + let _e470 = range_3; + param_40 = _e470; + let _e471 = receiver_2; + param_41 = _e471; + let _e472 = PointShadowTap_u0028_vf2_u003b_vf2_u003b_vf2_u003b_f1_u003b_f1_u003b((¶m_37), (¶m_38), (¶m_39), (¶m_40), (¶m_41)); + lit_1 = _e472; + let _e473 = radius_1; + if (_e473 > 0f) { i_3 = 0i; loop { - let _e464 = i_3; - if (_e464 < 8i) { - let _e466 = i_3; - param_36 = _e466; - let _e467 = ca_2; - param_37 = _e467; - let _e468 = sa_2; - param_38 = _e468; - let _e469 = radius_1; - param_39 = _e469; - let _e470 = PointShadowOffset_u0028_i1_u003b_f1_u003b_f1_u003b_f1_u003b((¶m_36), (¶m_37), (¶m_38), (¶m_39)); - let _e471 = uv_3; - param_40 = _e471; - param_41 = _e470; - let _e472 = tile_3; - param_42 = _e472; - let _e473 = range_3; - param_43 = _e473; - let _e474 = receiver_2; - param_44 = _e474; - let _e475 = PointShadowTap_u0028_vf2_u003b_vf2_u003b_vf2_u003b_f1_u003b_f1_u003b((¶m_40), (¶m_41), (¶m_42), (¶m_43), (¶m_44)); - let _e476 = lit; - lit = (_e476 + _e475); + let _e475 = i_3; + if (_e475 < 8i) { + let _e477 = i_3; + param_42 = _e477; + let _e478 = ca_2; + param_43 = _e478; + let _e479 = sa_2; + param_44 = _e479; + let _e480 = radius_1; + param_45 = _e480; + let _e481 = PointShadowOffset_u0028_i1_u003b_f1_u003b_f1_u003b_f1_u003b((¶m_42), (¶m_43), (¶m_44), (¶m_45)); + let _e482 = uv_3; + param_46 = _e482; + param_47 = _e481; + let _e483 = tile_3; + param_48 = _e483; + let _e484 = range_3; + param_49 = _e484; + let _e485 = receiver_2; + param_50 = _e485; + let _e486 = PointShadowTap_u0028_vf2_u003b_vf2_u003b_vf2_u003b_f1_u003b_f1_u003b((¶m_46), (¶m_47), (¶m_48), (¶m_49), (¶m_50)); + let _e487 = lit_1; + lit_1 = (_e487 + _e486); continue; } else { break; } continuing { - let _e478 = i_3; - i_3 = (_e478 + 1i); + let _e489 = i_3; + i_3 = (_e489 + 1i); } } - let _e480 = lit; - lit = (_e480 * 0.11111111f); + let _e491 = lit_1; + lit_1 = (_e491 * 0.11111111f); } - let _e482 = lit; - let _e483 = strength; - return mix(1f, _e482, clamp(_e483, 0f, 1f)); + let _e493 = lit_1; + let _e494 = strength; + return mix(1f, _e493, clamp(_e494, 0f, 1f)); } fn VogelDisc_u0028_i1_u003b_i1_u003b_f1_u003b(i_4: ptr, n_1: ptr, turn: ptr) -> vec2 { var r: f32; var theta: f32; - let _e180 = (*i_4); - let _e183 = (*n_1); - r = sqrt(((f32(_e180) + 0.5f) / f32(_e183))); - let _e187 = (*i_4); - let _e190 = (*turn); - theta = ((f32(_e187) * 2.3999631f) + _e190); - let _e192 = r; - let _e193 = theta; - let _e195 = theta; - return (vec2(cos(_e193), sin(_e195)) * _e192); + let _e191 = (*i_4); + let _e194 = (*n_1); + r = sqrt(((f32(_e191) + 0.5f) / f32(_e194))); + let _e198 = (*i_4); + let _e201 = (*turn); + theta = ((f32(_e198) * 2.3999631f) + _e201); + let _e203 = r; + let _e204 = theta; + let _e206 = theta; + return (vec2(cos(_e204), sin(_e206)) * _e203); } fn ShadowNoise_u0028_() -> f32 { var at: vec2; - var param_45: f32; + var param_51: f32; - let _e179 = frag_info.target_origin[0u]; - param_45 = _e179; - let _e180 = FragCoordFromTop_u0028_f1_u003b((¶m_45)); - let _e183 = frag_info.target_origin[3u]; - at = (_e180 + vec2((5.588238f * max(_e183, 0f)))); - let _e188 = at; - return fract((52.982918f * fract(dot(_e188, vec2(0.06711056f, 0.00583715f))))); + let _e190 = frag_info.target_origin[0u]; + param_51 = _e190; + let _e191 = FragCoordFromTop_u0028_f1_u003b((¶m_51)); + let _e194 = frag_info.target_origin[3u]; + at = (_e191 + vec2((5.588238f * max(_e194, 0f)))); + let _e199 = at; + return fract((52.982918f * fract(dot(_e199, vec2(0.06711056f, 0.00583715f))))); } fn EvsmChebyshev_u0028_vf2_u003b_f1_u003b_f1_u003b_f1_u003b(moments: ptr>, t: ptr, minVariance: ptr, bleed: ptr) -> f32 { @@ -5947,91 +7486,92 @@ fn EvsmChebyshev_u0028_vf2_u003b_f1_u003b_f1_u003b_f1_u003b(moments: ptr) -> vec2 { var d_2: f32; - let _e177 = (*depth); - d_2 = ((2f * clamp(_e177, 0f, 1f)) - 1f); - let _e181 = d_2; - let _e184 = d_2; - return vec2(exp((40f * _e181)), -(exp((-5f * _e184)))); + let _e188 = (*depth); + d_2 = ((2f * clamp(_e188, 0f, 1f)) - 1f); + let _e192 = d_2; + let _e195 = d_2; + return vec2(exp((40f * _e192)), -(exp((-5f * _e195)))); } fn EvsmVisibility_u0028_vf4_u003b_f1_u003b_f1_u003b(moments_1: ptr>, depth_1: ptr, bleed_1: ptr) -> f32 { var warped: vec2; - var param_46: f32; + var param_52: f32; var scale: vec2; var positive: f32; - var param_47: vec2; - var param_48: f32; - var param_49: f32; - var param_50: f32; - var negative: f32; - var param_51: vec2; - var param_52: f32; - var param_53: f32; + var param_53: vec2; var param_54: f32; + var param_55: f32; + var param_56: f32; + var negative: f32; + var param_57: vec2; + var param_58: f32; + var param_59: f32; + var param_60: f32; - let _e191 = (*depth_1); - param_46 = _e191; - let _e192 = EvsmWarp_u0028_f1_u003b((¶m_46)); - warped = _e192; - let _e193 = warped; - scale = (vec2(0.004f, 0.0005f) * _e193); - let _e196 = scale[0u]; - let _e198 = scale[0u]; - let _e200 = (*moments_1); - param_47 = _e200.xy; - let _e203 = warped[0u]; - param_48 = _e203; - param_49 = (_e196 * _e198); - let _e204 = (*bleed_1); - param_50 = _e204; - let _e205 = EvsmChebyshev_u0028_vf2_u003b_f1_u003b_f1_u003b_f1_u003b((¶m_47), (¶m_48), (¶m_49), (¶m_50)); - positive = _e205; - let _e207 = scale[1u]; - let _e209 = scale[1u]; + let _e202 = (*depth_1); + param_52 = _e202; + let _e203 = EvsmWarp_u0028_f1_u003b((¶m_52)); + warped = _e203; + let _e204 = warped; + scale = (vec2(0.004f, 0.0005f) * _e204); + let _e207 = scale[0u]; + let _e209 = scale[0u]; let _e211 = (*moments_1); - param_51 = _e211.zw; - let _e214 = warped[1u]; - param_52 = _e214; - param_53 = (_e207 * _e209); + param_53 = _e211.xy; + let _e214 = warped[0u]; + param_54 = _e214; + param_55 = (_e207 * _e209); let _e215 = (*bleed_1); - param_54 = _e215; - let _e216 = EvsmChebyshev_u0028_vf2_u003b_f1_u003b_f1_u003b_f1_u003b((¶m_51), (¶m_52), (¶m_53), (¶m_54)); - negative = _e216; - let _e217 = positive; - let _e218 = negative; - return min(_e217, _e218); -} - -fn ShadowFactor_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b_struct_u002d_LightSample_u002d_vf3_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf31_u003b_i1_u003b(s_1: ptr, light_1: ptr, lightIndex_1: ptr) -> f32 { + param_56 = _e215; + let _e216 = EvsmChebyshev_u0028_vf2_u003b_f1_u003b_f1_u003b_f1_u003b((¶m_53), (¶m_54), (¶m_55), (¶m_56)); + positive = _e216; + let _e218 = scale[1u]; + let _e220 = scale[1u]; + let _e222 = (*moments_1); + param_57 = _e222.zw; + let _e225 = warped[1u]; + param_58 = _e225; + param_59 = (_e218 * _e220); + let _e226 = (*bleed_1); + param_60 = _e226; + let _e227 = EvsmChebyshev_u0028_vf2_u003b_f1_u003b_f1_u003b_f1_u003b((¶m_57), (¶m_58), (¶m_59), (¶m_60)); + negative = _e227; + let _e228 = positive; + let _e229 = negative; + return min(_e228, _e229); +} + +fn ShadowFactor_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b_struct_u002d_LightSample_u002d_vf3_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf31_u003b_i1_u003b(s_2: ptr, light_1: ptr, lightIndex_1: ptr) -> f32 { var strength_1: f32; var cascadeCount: i32; var viewDistance: f32; + var local_8: f32; var cascade: i32; var uv_4: vec2; var projected: vec3; @@ -6042,8 +7582,8 @@ fn ShadowFactor_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf var attempt: i32; var which: i32; var matrix: mat4x4; - var local_4: mat4x4; - var local_5: mat4x4; + var local_9: mat4x4; + var local_10: mat4x4; var axisX: vec3; var axisY: vec3; var rowX: f32; @@ -6057,17 +7597,19 @@ fn ShadowFactor_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf var inTile: vec2; var cosSlope: f32; var bias: f32; - var local_6: f32; - var local_7: f32; + var local_11: f32; + var local_12: f32; var texel_1: vec2; var tileLo: vec2; var tileHi: vec2; + var stored_2: vec4; + var through: vec3; var softness: f32; - var lit_1: f32; + var lit_2: f32; var moments_2: vec4; - var param_55: vec4; - var param_56: f32; - var param_57: f32; + var param_61: vec4; + var param_62: f32; + var param_63: f32; var y: i32; var x: i32; var occluder: f32; @@ -6078,65 +7620,73 @@ fn ShadowFactor_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf var blockerCount: f32; var i_5: i32; var disc: vec2; - var param_58: i32; - var param_59: i32; - var param_60: f32; + var param_64: i32; + var param_65: i32; + var param_66: f32; var tapBias: f32; var occluder_1: f32; var gap: f32; var radius_2: f32; var i_6: i32; var disc_1: vec2; - var param_61: i32; - var param_62: i32; - var param_63: f32; + var param_67: i32; + var param_68: i32; + var param_69: f32; var tapBias_1: f32; var occluder_2: f32; - var phi_3242_: bool; - var phi_3253_: bool; - var phi_3414_: bool; - var phi_3421_: bool; - var phi_3428_: bool; - - let _e243 = frag_info.shadow_params[3u]; - strength_1 = _e243; - let _e244 = strength_1; - if (_e244 <= 0f) { + var phi_3511_: bool; + var phi_3522_: bool; + var phi_3683_: bool; + var phi_3690_: bool; + var phi_3697_: bool; + + let _e257 = frag_info.shadow_params[3u]; + strength_1 = _e257; + let _e258 = strength_1; + if (_e258 <= 0f) { return 1f; } - let _e246 = (*lightIndex_1); - let _e249 = frag_info.frame_params[2u]; - if (_e246 != i32((_e249 + 0.5f))) { + let _e260 = (*lightIndex_1); + let _e263 = frag_info.frame_params[2u]; + if (_e260 != i32((_e263 + 0.5f))) { return 1f; } - let _e255 = frag_info.shadow_cascades[2u]; - cascadeCount = i32((_e255 + 0.5f)); - let _e258 = v_world_position_1; - let _e260 = frag_info.camera_position; - viewDistance = length((_e258 - _e260.xyz)); - cascade = 0i; - let _e264 = cascadeCount; - let _e265 = (_e264 > 1i); - phi_3242_ = _e265; - if _e265 { - let _e266 = viewDistance; - let _e269 = frag_info.shadow_cascades[0u]; - phi_3242_ = (_e266 > _e269); - } - let _e272 = phi_3242_; + let _e269 = frag_info.shadow_cascades[2u]; + cascadeCount = i32((_e269 + 0.5f)); + let _e272 = Orthographic_u0028_(); if _e272 { + let _e273 = ViewDepth_u0028_(); + local_8 = _e273; + } else { + let _e274 = v_world_position_1; + let _e276 = frag_info.camera_position; + local_8 = length((_e274 - _e276.xyz)); + } + let _e280 = local_8; + viewDistance = _e280; + cascade = 0i; + let _e281 = cascadeCount; + let _e282 = (_e281 > 1i); + phi_3511_ = _e282; + if _e282 { + let _e283 = viewDistance; + let _e286 = frag_info.shadow_cascades[0u]; + phi_3511_ = (_e283 > _e286); + } + let _e289 = phi_3511_; + if _e289 { cascade = 1i; } - let _e273 = cascadeCount; - let _e274 = (_e273 > 2i); - phi_3253_ = _e274; - if _e274 { - let _e275 = viewDistance; - let _e278 = frag_info.shadow_cascades[1u]; - phi_3253_ = (_e275 > _e278); + let _e290 = cascadeCount; + let _e291 = (_e290 > 2i); + phi_3522_ = _e291; + if _e291 { + let _e292 = viewDistance; + let _e295 = frag_info.shadow_cascades[1u]; + phi_3522_ = (_e292 > _e295); } - let _e281 = phi_3253_; - if _e281 { + let _e298 = phi_3522_; + if _e298 { cascade = 2i; } uv_4 = vec2(0f, 0f); @@ -6147,232 +7697,247 @@ fn ShadowFactor_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf receiverSlope = 0f; attempt = 0i; loop { - let _e282 = attempt; - if (_e282 < 3i) { - let _e284 = cascade; - let _e285 = attempt; - which = (_e284 + _e285); - let _e287 = which; - let _e288 = cascadeCount; - if (_e287 >= _e288) { + let _e299 = attempt; + if (_e299 < 3i) { + let _e301 = cascade; + let _e302 = attempt; + which = (_e301 + _e302); + let _e304 = which; + let _e305 = cascadeCount; + if (_e304 >= _e305) { break; } - let _e290 = which; - if (_e290 == 0i) { - let _e293 = frag_info.shadow_matrix; - local_4 = _e293; + let _e307 = which; + if (_e307 == 0i) { + let _e310 = frag_info.shadow_matrix; + local_9 = _e310; } else { - let _e294 = which; - if (_e294 == 1i) { - let _e297 = frag_info.shadow_matrix_far; - local_5 = _e297; + let _e311 = which; + if (_e311 == 1i) { + let _e314 = frag_info.shadow_matrix_far; + local_10 = _e314; } else { - let _e299 = frag_info.shadow_matrix_farthest; - local_5 = _e299; + let _e316 = frag_info.shadow_matrix_farthest; + local_10 = _e316; } - let _e300 = local_5; - local_4 = _e300; - } - let _e301 = local_4; - matrix = _e301; - let _e304 = matrix[0][0u]; - let _e307 = matrix[1][0u]; - let _e310 = matrix[2][0u]; - axisX = vec3(_e304, _e307, _e310); - let _e314 = matrix[0][1u]; - let _e317 = matrix[1][1u]; - let _e320 = matrix[2][1u]; - axisY = vec3(_e314, _e317, _e320); - let _e322 = axisX; - rowX = max(length(_e322), 0.000001f); - let _e325 = axisY; - rowY = max(length(_e325), 0.000001f); - let _e330 = frag_info.shadow_cascades[3u]; - let _e332 = rowX; - texelMetres = ((2f * _e330) / _e332); - let _e336 = frag_info.shadow_cascades[3u]; - let _e339 = (*s_1).n; - let _e340 = axisX; - let _e343 = rowX; - let _e344 = rowX; - let _e348 = (*s_1).n; - let _e349 = axisY; - let _e352 = rowY; - let _e353 = rowY; - reach = ((3f * _e336) * ((abs(dot(_e339, _e340)) / (_e343 * _e344)) + (abs(dot(_e348, _e349)) / (_e352 * _e353)))); - let _e360 = frag_info.shadow_params[2u]; - let _e361 = texelMetres; - flatOffset = min(_e360, _e361); - let _e363 = v_world_position_1; - let _e365 = (*s_1).n; - let _e366 = flatOffset; - let _e367 = reach; - origin_1 = (_e363 + (_e365 * (_e366 + _e367))); - let _e371 = matrix; - let _e372 = origin_1; - lightSpace = (_e371 * vec4(_e372.x, _e372.y, _e372.z, 1f)); - let _e379 = lightSpace[3u]; - if (_e379 <= 0f) { + let _e317 = local_10; + local_9 = _e317; + } + let _e318 = local_9; + matrix = _e318; + let _e321 = matrix[0][0u]; + let _e324 = matrix[1][0u]; + let _e327 = matrix[2][0u]; + axisX = vec3(_e321, _e324, _e327); + let _e331 = matrix[0][1u]; + let _e334 = matrix[1][1u]; + let _e337 = matrix[2][1u]; + axisY = vec3(_e331, _e334, _e337); + let _e339 = axisX; + rowX = max(length(_e339), 0.000001f); + let _e342 = axisY; + rowY = max(length(_e342), 0.000001f); + let _e347 = frag_info.shadow_cascades[3u]; + let _e349 = rowX; + texelMetres = ((2f * _e347) / _e349); + let _e353 = frag_info.shadow_cascades[3u]; + let _e356 = (*s_2).n; + let _e357 = axisX; + let _e360 = rowX; + let _e361 = rowX; + let _e365 = (*s_2).n; + let _e366 = axisY; + let _e369 = rowY; + let _e370 = rowY; + reach = ((3f * _e353) * ((abs(dot(_e356, _e357)) / (_e360 * _e361)) + (abs(dot(_e365, _e366)) / (_e369 * _e370)))); + let _e377 = frag_info.shadow_params[2u]; + let _e378 = texelMetres; + flatOffset = min(_e377, _e378); + let _e380 = v_world_position_1; + let _e382 = (*s_2).n; + let _e383 = flatOffset; + let _e384 = reach; + origin_1 = (_e380 + (_e382 * (_e383 + _e384))); + let _e388 = matrix; + let _e389 = origin_1; + lightSpace = (_e388 * vec4(_e389.x, _e389.y, _e389.z, 1f)); + let _e396 = lightSpace[3u]; + if (_e396 <= 0f) { continue; } - let _e381 = lightSpace; - let _e384 = lightSpace[3u]; - candidate = (_e381.xyz / vec3(_e384)); - let _e388 = candidate[0u]; - let _e392 = candidate[1u]; - inTile = vec2(((_e388 * 0.5f) + 0.5f), (0.5f - (_e392 * 0.5f))); - let _e397 = inTile[0u]; - let _e398 = (_e397 < 0f); - phi_3414_ = _e398; - if !(_e398) { - let _e401 = inTile[0u]; - phi_3414_ = (_e401 > 1f); - } - let _e404 = phi_3414_; - phi_3421_ = _e404; - if !(_e404) { - let _e407 = inTile[1u]; - phi_3421_ = (_e407 < 0f); - } - let _e410 = phi_3421_; - phi_3428_ = _e410; - if !(_e410) { - let _e413 = inTile[1u]; - phi_3428_ = (_e413 > 1f); - } - let _e416 = phi_3428_; - if _e416 { + let _e398 = lightSpace; + let _e401 = lightSpace[3u]; + candidate = (_e398.xyz / vec3(_e401)); + let _e405 = candidate[0u]; + let _e409 = candidate[1u]; + inTile = vec2(((_e405 * 0.5f) + 0.5f), (0.5f - (_e409 * 0.5f))); + let _e414 = inTile[0u]; + let _e415 = (_e414 < 0f); + phi_3683_ = _e415; + if !(_e415) { + let _e418 = inTile[0u]; + phi_3683_ = (_e418 > 1f); + } + let _e421 = phi_3683_; + phi_3690_ = _e421; + if !(_e421) { + let _e424 = inTile[1u]; + phi_3690_ = (_e424 < 0f); + } + let _e427 = phi_3690_; + phi_3697_ = _e427; + if !(_e427) { + let _e430 = inTile[1u]; + phi_3697_ = (_e430 > 1f); + } + let _e433 = phi_3697_; + if _e433 { continue; } - let _e418 = candidate[2u]; - if (_e418 > 1f) { - let _e420 = which; - let _e421 = cascadeCount; - if (_e420 < (_e421 - 1i)) { + let _e435 = candidate[2u]; + if (_e435 > 1f) { + let _e437 = which; + let _e438 = cascadeCount; + if (_e437 < (_e438 - 1i)) { continue; } candidate[2u] = 1f; } - let _e426 = inTile[0u]; - let _e427 = which; - let _e430 = cascadeCount; - let _e434 = inTile[1u]; - uv_4 = vec2(((_e426 + f32(_e427)) / f32(_e430)), _e434); - let _e436 = candidate; - projected = _e436; - let _e437 = which; - cascade = _e437; - let _e438 = texelMetres; - cascadeTexel = _e438; - let _e441 = matrix[0][2u]; - let _e444 = matrix[1][2u]; - let _e447 = matrix[2][2u]; - cascadeDepth = (1f / max(length(vec3(_e441, _e444, _e447)), 0.000001f)); - let _e453 = (*s_1).n; - let _e456 = matrix[0][2u]; - let _e459 = matrix[1][2u]; - let _e462 = matrix[2][2u]; - let _e466 = cascadeDepth; - cosSlope = clamp((abs(dot(_e453, vec3(_e456, _e459, _e462))) * _e466), 0.1f, 1f); - let _e469 = texelMetres; - let _e470 = cosSlope; - let _e471 = cosSlope; - let _e477 = cosSlope; - let _e479 = cascadeDepth; - receiverSlope = (((_e469 * sqrt(max((1f - (_e470 * _e471)), 0f))) / _e477) / _e479); + let _e443 = inTile[0u]; + let _e444 = which; + let _e447 = cascadeCount; + let _e451 = inTile[1u]; + uv_4 = vec2(((_e443 + f32(_e444)) / f32(_e447)), _e451); + let _e453 = candidate; + projected = _e453; + let _e454 = which; + cascade = _e454; + let _e455 = texelMetres; + cascadeTexel = _e455; + let _e458 = matrix[0][2u]; + let _e461 = matrix[1][2u]; + let _e464 = matrix[2][2u]; + cascadeDepth = (1f / max(length(vec3(_e458, _e461, _e464)), 0.000001f)); + let _e470 = (*s_2).n; + let _e473 = matrix[0][2u]; + let _e476 = matrix[1][2u]; + let _e479 = matrix[2][2u]; + let _e483 = cascadeDepth; + cosSlope = clamp((abs(dot(_e470, vec3(_e473, _e476, _e479))) * _e483), 0.1f, 1f); + let _e486 = texelMetres; + let _e487 = cosSlope; + let _e488 = cosSlope; + let _e494 = cosSlope; + let _e496 = cascadeDepth; + receiverSlope = (((_e486 * sqrt(max((1f - (_e487 * _e488)), 0f))) / _e494) / _e496); found = true; break; } else { break; } continuing { - let _e481 = attempt; - attempt = (_e481 + 1i); + let _e498 = attempt; + attempt = (_e498 + 1i); } } - let _e483 = found; - if !(_e483) { + let _e500 = found; + if !(_e500) { return 1f; } - let _e485 = cascade; - if (_e485 == 0i) { - let _e489 = frag_info.shadow_bias[0u]; - local_6 = _e489; + let _e502 = cascade; + if (_e502 == 0i) { + let _e506 = frag_info.shadow_bias[0u]; + local_11 = _e506; } else { - let _e490 = cascade; - if (_e490 == 1i) { - let _e494 = frag_info.shadow_bias[1u]; - local_7 = _e494; + let _e507 = cascade; + if (_e507 == 1i) { + let _e511 = frag_info.shadow_bias[1u]; + local_12 = _e511; } else { - let _e497 = frag_info.shadow_bias[2u]; - local_7 = _e497; - } - let _e498 = local_7; - local_6 = _e498; - } - let _e499 = local_6; - bias = _e499; - let _e502 = frag_info.shadow_params[0u]; - let _e505 = frag_info.shadow_cascades[3u]; - texel_1 = vec2(_e502, _e505); - let _e507 = cascade; - let _e509 = cascadeCount; - let _e513 = texel_1; - tileLo = (vec2((f32(_e507) / f32(_e509)), 0f) + (_e513 * 0.5f)); - let _e516 = cascade; - let _e519 = cascadeCount; - let _e523 = texel_1; - tileHi = (vec2((f32((_e516 + 1i)) / f32(_e519)), 1f) - (_e523 * 0.5f)); - let _e528 = frag_info.ambient_ground[3u]; - softness = _e528; - lit_1 = 0f; - let _e529 = softness; - if (_e529 < 0f) { - let _e531 = uv_4; - let _e532 = tileLo; - let _e533 = tileHi; - let _e535 = textureSampleLevel(shadow_texture_tex, shadow_texture_smp, clamp(_e531, _e532, _e533), 0f); - moments_2 = _e535; - let _e537 = projected[2u]; - let _e538 = bias; - let _e540 = softness; - let _e544 = moments_2; - param_55 = _e544; - param_56 = (_e537 - _e538); - param_57 = clamp((-(_e540) - 1f), 0f, 0.95f); - let _e545 = EvsmVisibility_u0028_vf4_u003b_f1_u003b_f1_u003b((¶m_55), (¶m_56), (¶m_57)); - lit_1 = _e545; - } else { - let _e546 = softness; - if (_e546 <= 0f) { + let _e514 = frag_info.shadow_bias[2u]; + local_12 = _e514; + } + let _e515 = local_12; + local_11 = _e515; + } + let _e516 = local_11; + bias = _e516; + let _e519 = frag_info.shadow_params[0u]; + let _e522 = frag_info.shadow_cascades[3u]; + texel_1 = vec2(_e519, _e522); + let _e524 = cascade; + let _e526 = cascadeCount; + let _e530 = texel_1; + tileLo = (vec2((f32(_e524) / f32(_e526)), 0f) + (_e530 * 0.5f)); + let _e533 = cascade; + let _e536 = cascadeCount; + let _e540 = texel_1; + tileHi = (vec2((f32((_e533 + 1i)) / f32(_e536)), 1f) - (_e540 * 0.5f)); + let _e545 = frag_info.shadow_bias[3u]; + if (_e545 > 0.5f) { + let _e547 = uv_4; + let _e548 = tileLo; + let _e549 = tileHi; + let _e551 = textureSampleLevel(shadow_texture_tex, shadow_texture_smp, clamp(_e547, _e548, _e549), 0f); + stored_2 = _e551; + let _e553 = stored_2[1u]; + let _e556 = stored_2[2u]; + let _e559 = stored_2[3u]; + through = clamp(vec3((1f - _e553), (1f - _e556), _e559), vec3(0f), vec3(4f)); + let _e564 = through; + let _e565 = strength_1; + light_transmittance = mix(vec3(1f, 1f, 1f), _e564, vec3(clamp(_e565, 0f, 1f))); + } + let _e571 = frag_info.ambient_ground[3u]; + softness = _e571; + lit_2 = 0f; + let _e572 = softness; + if (_e572 < 0f) { + let _e574 = uv_4; + let _e575 = tileLo; + let _e576 = tileHi; + let _e578 = textureSampleLevel(shadow_texture_tex, shadow_texture_smp, clamp(_e574, _e575, _e576), 0f); + moments_2 = _e578; + let _e580 = projected[2u]; + let _e581 = bias; + let _e583 = softness; + let _e587 = moments_2; + param_61 = _e587; + param_62 = (_e580 - _e581); + param_63 = clamp((-(_e583) - 1f), 0f, 0.95f); + let _e588 = EvsmVisibility_u0028_vf4_u003b_f1_u003b_f1_u003b((¶m_61), (¶m_62), (¶m_63)); + lit_2 = _e588; + } else { + let _e589 = softness; + if (_e589 <= 0f) { y = -1i; loop { - let _e548 = y; - if (_e548 <= 1i) { + let _e591 = y; + if (_e591 <= 1i) { x = -1i; loop { - let _e550 = x; - if (_e550 <= 1i) { - let _e552 = uv_4; - let _e553 = x; - let _e555 = y; - let _e558 = texel_1; - let _e561 = tileLo; - let _e562 = tileHi; - let _e564 = textureSampleLevel(shadow_texture_tex, shadow_texture_smp, clamp((_e552 + (vec2(f32(_e553), f32(_e555)) * _e558)), _e561, _e562), 0f); - occluder = _e564.x; - let _e567 = projected[2u]; - let _e568 = bias; - let _e570 = occluder; - let _e573 = lit_1; - lit_1 = (_e573 + select(1f, 0f, ((_e567 - _e568) > _e570))); + let _e593 = x; + if (_e593 <= 1i) { + let _e595 = uv_4; + let _e596 = x; + let _e598 = y; + let _e601 = texel_1; + let _e604 = tileLo; + let _e605 = tileHi; + let _e607 = textureSampleLevel(shadow_texture_tex, shadow_texture_smp, clamp((_e595 + (vec2(f32(_e596), f32(_e598)) * _e601)), _e604, _e605), 0f); + occluder = _e607.x; + let _e610 = projected[2u]; + let _e611 = bias; + let _e613 = occluder; + let _e616 = lit_2; + lit_2 = (_e616 + select(1f, 0f, ((_e610 - _e611) > _e613))); continue; } else { break; } continuing { - let _e575 = x; - x = (_e575 + 1i); + let _e618 = x; + x = (_e618 + 1i); } } continue; @@ -6380,145 +7945,145 @@ fn ShadowFactor_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf break; } continuing { - let _e577 = y; - y = (_e577 + 1i); + let _e620 = y; + y = (_e620 + 1i); } } - let _e579 = lit_1; - lit_1 = (_e579 * 0.11111111f); + let _e622 = lit_2; + lit_2 = (_e622 * 0.11111111f); } else { - let _e581 = softness; - spread = tan(min(_e581, 0.5f)); - let _e584 = ShadowNoise_u0028_(); - turn_1 = (_e584 * 6.2831855f); - let _e586 = spread; - let _e588 = projected[2u]; - let _e590 = cascadeDepth; - let _e592 = cascadeTexel; - searchRadius = clamp((((_e586 * _e588) * _e590) / _e592), 1f, 16f); + let _e624 = softness; + spread = tan(min(_e624, 0.5f)); + let _e627 = ShadowNoise_u0028_(); + turn_1 = (_e627 * 6.2831855f); + let _e629 = spread; + let _e631 = projected[2u]; + let _e633 = cascadeDepth; + let _e635 = cascadeTexel; + searchRadius = clamp((((_e629 * _e631) * _e633) / _e635), 1f, 16f); blockerSum = 0f; blockerCount = 0f; i_5 = 0i; loop { - let _e595 = i_5; - if (_e595 < 16i) { - let _e597 = i_5; - param_58 = _e597; - param_59 = 16i; - let _e598 = turn_1; - param_60 = _e598; - let _e599 = VogelDisc_u0028_i1_u003b_i1_u003b_f1_u003b((¶m_58), (¶m_59), (¶m_60)); - disc = _e599; - let _e600 = bias; - let _e601 = disc; - let _e603 = searchRadius; - let _e607 = receiverSlope; - tapBias = (_e600 + (max(((length(_e601) * _e603) - 1.5f), 0f) * _e607)); - let _e610 = uv_4; - let _e611 = disc; - let _e612 = texel_1; - let _e614 = searchRadius; - let _e617 = tileLo; - let _e618 = tileHi; - let _e620 = textureSampleLevel(shadow_texture_tex, shadow_texture_smp, clamp((_e610 + ((_e611 * _e612) * _e614)), _e617, _e618), 0f); - occluder_1 = _e620.x; - let _e623 = projected[2u]; - let _e624 = tapBias; - let _e626 = occluder_1; - if ((_e623 - _e624) > _e626) { - let _e628 = occluder_1; - let _e629 = blockerSum; - blockerSum = (_e629 + _e628); - let _e631 = blockerCount; - blockerCount = (_e631 + 1f); + let _e638 = i_5; + if (_e638 < 16i) { + let _e640 = i_5; + param_64 = _e640; + param_65 = 16i; + let _e641 = turn_1; + param_66 = _e641; + let _e642 = VogelDisc_u0028_i1_u003b_i1_u003b_f1_u003b((¶m_64), (¶m_65), (¶m_66)); + disc = _e642; + let _e643 = bias; + let _e644 = disc; + let _e646 = searchRadius; + let _e650 = receiverSlope; + tapBias = (_e643 + (max(((length(_e644) * _e646) - 1.5f), 0f) * _e650)); + let _e653 = uv_4; + let _e654 = disc; + let _e655 = texel_1; + let _e657 = searchRadius; + let _e660 = tileLo; + let _e661 = tileHi; + let _e663 = textureSampleLevel(shadow_texture_tex, shadow_texture_smp, clamp((_e653 + ((_e654 * _e655) * _e657)), _e660, _e661), 0f); + occluder_1 = _e663.x; + let _e666 = projected[2u]; + let _e667 = tapBias; + let _e669 = occluder_1; + if ((_e666 - _e667) > _e669) { + let _e671 = occluder_1; + let _e672 = blockerSum; + blockerSum = (_e672 + _e671); + let _e674 = blockerCount; + blockerCount = (_e674 + 1f); } continue; } else { break; } continuing { - let _e633 = i_5; - i_5 = (_e633 + 1i); + let _e676 = i_5; + i_5 = (_e676 + 1i); } } - let _e635 = blockerCount; - if (_e635 <= 0f) { + let _e678 = blockerCount; + if (_e678 <= 0f) { return 1f; } - let _e638 = projected[2u]; - let _e639 = blockerSum; - let _e640 = blockerCount; - let _e644 = cascadeDepth; - gap = (max((_e638 - (_e639 / _e640)), 0f) * _e644); - let _e646 = spread; - let _e647 = gap; - let _e649 = cascadeTexel; - radius_2 = clamp(((_e646 * _e647) / _e649), 1f, 16f); + let _e681 = projected[2u]; + let _e682 = blockerSum; + let _e683 = blockerCount; + let _e687 = cascadeDepth; + gap = (max((_e681 - (_e682 / _e683)), 0f) * _e687); + let _e689 = spread; + let _e690 = gap; + let _e692 = cascadeTexel; + radius_2 = clamp(((_e689 * _e690) / _e692), 1f, 16f); i_6 = 0i; loop { - let _e652 = i_6; - if (_e652 < 16i) { - let _e654 = turn_1; - let _e656 = i_6; - param_61 = _e656; - param_62 = 16i; - param_63 = (_e654 + 1f); - let _e657 = VogelDisc_u0028_i1_u003b_i1_u003b_f1_u003b((¶m_61), (¶m_62), (¶m_63)); - disc_1 = _e657; - let _e658 = bias; - let _e659 = disc_1; - let _e661 = radius_2; - let _e665 = receiverSlope; - tapBias_1 = (_e658 + (max(((length(_e659) * _e661) - 1.5f), 0f) * _e665)); - let _e668 = uv_4; - let _e669 = disc_1; - let _e670 = texel_1; - let _e672 = radius_2; - let _e675 = tileLo; - let _e676 = tileHi; - let _e678 = textureSampleLevel(shadow_texture_tex, shadow_texture_smp, clamp((_e668 + ((_e669 * _e670) * _e672)), _e675, _e676), 0f); - occluder_2 = _e678.x; - let _e681 = projected[2u]; - let _e682 = tapBias_1; - let _e684 = occluder_2; - let _e687 = lit_1; - lit_1 = (_e687 + select(1f, 0f, ((_e681 - _e682) > _e684))); + let _e695 = i_6; + if (_e695 < 16i) { + let _e697 = turn_1; + let _e699 = i_6; + param_67 = _e699; + param_68 = 16i; + param_69 = (_e697 + 1f); + let _e700 = VogelDisc_u0028_i1_u003b_i1_u003b_f1_u003b((¶m_67), (¶m_68), (¶m_69)); + disc_1 = _e700; + let _e701 = bias; + let _e702 = disc_1; + let _e704 = radius_2; + let _e708 = receiverSlope; + tapBias_1 = (_e701 + (max(((length(_e702) * _e704) - 1.5f), 0f) * _e708)); + let _e711 = uv_4; + let _e712 = disc_1; + let _e713 = texel_1; + let _e715 = radius_2; + let _e718 = tileLo; + let _e719 = tileHi; + let _e721 = textureSampleLevel(shadow_texture_tex, shadow_texture_smp, clamp((_e711 + ((_e712 * _e713) * _e715)), _e718, _e719), 0f); + occluder_2 = _e721.x; + let _e724 = projected[2u]; + let _e725 = tapBias_1; + let _e727 = occluder_2; + let _e730 = lit_2; + lit_2 = (_e730 + select(1f, 0f, ((_e724 - _e725) > _e727))); continue; } else { break; } continuing { - let _e689 = i_6; - i_6 = (_e689 + 1i); + let _e732 = i_6; + i_6 = (_e732 + 1i); } } - let _e691 = lit_1; - lit_1 = (_e691 * 0.0625f); + let _e734 = lit_2; + lit_2 = (_e734 * 0.0625f); } } - let _e693 = lit_1; - let _e694 = strength_1; - return mix(1f, _e693, clamp(_e694, 0f, 1f)); + let _e736 = lit_2; + let _e737 = strength_1; + return mix(1f, _e736, clamp(_e737, 0f, 1f)); } -fn LightVisibility_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b_struct_u002d_LightSample_u002d_vf3_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf31_u003b_i1_u003b(s_2: ptr, light_2: ptr, index: ptr) -> f32 { - var param_64: Surface; - var param_65: LightSample; - var param_66: i32; +fn LightVisibility_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b_struct_u002d_LightSample_u002d_vf3_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf31_u003b_i1_u003b(s_3: ptr, light_2: ptr, index: ptr) -> f32 { + var param_70: Surface; + var param_71: LightSample; + var param_72: i32; - let _e181 = (*s_2); - param_64 = _e181; - let _e182 = (*light_2); - param_65 = _e182; - let _e183 = (*index); - param_66 = _e183; - let _e184 = ShadowFactor_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b_struct_u002d_LightSample_u002d_vf3_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf31_u003b_i1_u003b((¶m_64), (¶m_65), (¶m_66)); - return _e184; + let _e192 = (*s_3); + param_70 = _e192; + let _e193 = (*light_2); + param_71 = _e193; + let _e194 = (*index); + param_72 = _e194; + let _e195 = ShadowFactor_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b_struct_u002d_LightSample_u002d_vf3_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf31_u003b_i1_u003b((¶m_70), (¶m_71), (¶m_72)); + return _e195; } fn LightHasShadow_u0028_i1_u003b(index_1: ptr) -> bool { - let _e176 = (*index_1); - return (_e176 < 8i); + let _e187 = (*index_1); + return (_e187 < 8i); } fn PunctualAttenuation_u0028_f1_u003b_f1_u003b(distance_1: ptr, range_4: ptr) -> f32 { @@ -6526,26 +8091,26 @@ fn PunctualAttenuation_u0028_f1_u003b_f1_u003b(distance_1: ptr, r var ratio: f32; var window: f32; - let _e180 = (*distance_1); - let _e181 = (*distance_1); - attenuation = (1f / max((_e180 * _e181), 0.0001f)); - let _e185 = (*range_4); - if (_e185 > 0f) { - let _e187 = (*distance_1); - let _e188 = (*range_4); - ratio = (_e187 / _e188); - let _e190 = ratio; - let _e191 = ratio; - let _e193 = ratio; - let _e195 = ratio; - window = clamp((1f - (((_e190 * _e191) * _e193) * _e195)), 0f, 1f); - let _e199 = window; - let _e200 = window; - let _e202 = attenuation; - attenuation = (_e202 * (_e199 * _e200)); - } - let _e204 = attenuation; - return _e204; + let _e191 = (*distance_1); + let _e192 = (*distance_1); + attenuation = (1f / max((_e191 * _e192), 0.0001f)); + let _e196 = (*range_4); + if (_e196 > 0f) { + let _e198 = (*distance_1); + let _e199 = (*range_4); + ratio = (_e198 / _e199); + let _e201 = ratio; + let _e202 = ratio; + let _e204 = ratio; + let _e206 = ratio; + window = clamp((1f - (((_e201 * _e202) * _e204) * _e206)), 0f, 1f); + let _e210 = window; + let _e211 = window; + let _e213 = attenuation; + attenuation = (_e213 * (_e210 * _e211)); + } + let _e215 = attenuation; + return _e215; } fn RectangleClosestPoint_u0028_vf3_u003b_vf3_u003b_vf3_u003b_vf3_u003b_vf3_u003b(centre: ptr>, halfWidth: ptr>, halfHeight: ptr>, world_2: ptr>, mirror: ptr>) -> vec3 { @@ -6555,82 +8120,82 @@ fn RectangleClosestPoint_u0028_vf3_u003b_vf3_u003b_vf3_u003b_vf3_u003b_vf3_u003b var denom: f32; var onPlane: vec3; var t_1: f32; - var local_8: vec3; + var local_13: vec3; var offset_2: vec3; var wLen2_: f32; var hLen2_: f32; var u: f32; var v: f32; - let _e192 = (*halfWidth); - let _e193 = (*halfHeight); - n_2 = cross(_e192, _e193); - let _e195 = n_2; - nLen = length(_e195); - let _e197 = nLen; - if (_e197 < 0.000000000001f) { - let _e199 = (*centre); - return _e199; + let _e203 = (*halfWidth); + let _e204 = (*halfHeight); + n_2 = cross(_e203, _e204); + let _e206 = n_2; + nLen = length(_e206); + let _e208 = nLen; + if (_e208 < 0.000000000001f) { + let _e210 = (*centre); + return _e210; } - let _e200 = nLen; - let _e201 = n_2; - n_2 = (_e201 / vec3(_e200)); - let _e204 = (*centre); - let _e205 = (*world_2); - toPlane = (_e204 - _e205); - let _e207 = (*mirror); - let _e208 = n_2; - denom = dot(_e207, _e208); - let _e210 = denom; - if (abs(_e210) < 0.00001f) { - let _e213 = toPlane; - let _e214 = n_2; - let _e215 = toPlane; - let _e216 = n_2; - onPlane = (_e213 - (_e214 * dot(_e215, _e216))); - } else { - let _e220 = toPlane; - let _e221 = n_2; - let _e223 = denom; - t_1 = (dot(_e220, _e221) / _e223); - let _e225 = t_1; - if (_e225 > 0f) { - let _e227 = (*mirror); - let _e228 = t_1; - local_8 = (_e227 * _e228); + let _e211 = nLen; + let _e212 = n_2; + n_2 = (_e212 / vec3(_e211)); + let _e215 = (*centre); + let _e216 = (*world_2); + toPlane = (_e215 - _e216); + let _e218 = (*mirror); + let _e219 = n_2; + denom = dot(_e218, _e219); + let _e221 = denom; + if (abs(_e221) < 0.00001f) { + let _e224 = toPlane; + let _e225 = n_2; + let _e226 = toPlane; + let _e227 = n_2; + onPlane = (_e224 - (_e225 * dot(_e226, _e227))); + } else { + let _e231 = toPlane; + let _e232 = n_2; + let _e234 = denom; + t_1 = (dot(_e231, _e232) / _e234); + let _e236 = t_1; + if (_e236 > 0f) { + let _e238 = (*mirror); + let _e239 = t_1; + local_13 = (_e238 * _e239); } else { - let _e230 = toPlane; - let _e231 = n_2; - let _e232 = toPlane; - let _e233 = n_2; - local_8 = (_e230 - (_e231 * dot(_e232, _e233))); - } - let _e237 = local_8; - onPlane = _e237; - } - let _e238 = onPlane; - let _e239 = toPlane; - offset_2 = (_e238 - _e239); - let _e241 = (*halfWidth); - let _e242 = (*halfWidth); - wLen2_ = max(dot(_e241, _e242), 0.000000000001f); - let _e245 = (*halfHeight); - let _e246 = (*halfHeight); - hLen2_ = max(dot(_e245, _e246), 0.000000000001f); - let _e249 = offset_2; - let _e250 = (*halfWidth); - let _e252 = wLen2_; - u = clamp((dot(_e249, _e250) / _e252), -1f, 1f); - let _e255 = offset_2; + let _e241 = toPlane; + let _e242 = n_2; + let _e243 = toPlane; + let _e244 = n_2; + local_13 = (_e241 - (_e242 * dot(_e243, _e244))); + } + let _e248 = local_13; + onPlane = _e248; + } + let _e249 = onPlane; + let _e250 = toPlane; + offset_2 = (_e249 - _e250); + let _e252 = (*halfWidth); + let _e253 = (*halfWidth); + wLen2_ = max(dot(_e252, _e253), 0.000000000001f); let _e256 = (*halfHeight); - let _e258 = hLen2_; - v = clamp((dot(_e255, _e256) / _e258), -1f, 1f); - let _e261 = (*centre); - let _e262 = (*halfWidth); - let _e263 = u; - let _e266 = (*halfHeight); - let _e267 = v; - return ((_e261 + (_e262 * _e263)) + (_e266 * _e267)); + let _e257 = (*halfHeight); + hLen2_ = max(dot(_e256, _e257), 0.000000000001f); + let _e260 = offset_2; + let _e261 = (*halfWidth); + let _e263 = wLen2_; + u = clamp((dot(_e260, _e261) / _e263), -1f, 1f); + let _e266 = offset_2; + let _e267 = (*halfHeight); + let _e269 = hLen2_; + v = clamp((dot(_e266, _e267) / _e269), -1f, 1f); + let _e272 = (*centre); + let _e273 = (*halfWidth); + let _e274 = u; + let _e277 = (*halfHeight); + let _e278 = v; + return ((_e272 + (_e273 * _e274)) + (_e277 * _e278)); } fn LambertEdge_u0028_vf3_u003b_vf3_u003b_vf3_u003b(a_1: ptr>, b: ptr>, n_3: ptr>) -> f32 { @@ -6639,34 +8204,34 @@ fn LambertEdge_u0028_vf3_u003b_vf3_u003b_vf3_u003b(a_1: ptr> var angle_1: f32; var axis: vec3; var len: f32; - var local_9: f32; + var local_14: f32; - let _e184 = (*a_1); - let _e185 = (*a_1); - ua = (_e184 / vec3(max(length(_e185), 0.000000000001f))); - let _e190 = (*b); - let _e191 = (*b); - ub = (_e190 / vec3(max(length(_e191), 0.000000000001f))); - let _e196 = ua; - let _e197 = ub; - angle_1 = acos(clamp(dot(_e196, _e197), -1f, 1f)); - let _e201 = ua; - let _e202 = ub; - axis = cross(_e201, _e202); - let _e204 = axis; - len = length(_e204); - let _e206 = len; - if (_e206 > 0.000001f) { - let _e208 = angle_1; - let _e209 = axis; - let _e210 = (*n_3); - let _e213 = len; - local_9 = ((_e208 * dot(_e209, _e210)) / _e213); - } else { - local_9 = 0f; - } - let _e215 = local_9; - return _e215; + let _e195 = (*a_1); + let _e196 = (*a_1); + ua = (_e195 / vec3(max(length(_e196), 0.000000000001f))); + let _e201 = (*b); + let _e202 = (*b); + ub = (_e201 / vec3(max(length(_e202), 0.000000000001f))); + let _e207 = ua; + let _e208 = ub; + angle_1 = acos(clamp(dot(_e207, _e208), -1f, 1f)); + let _e212 = ua; + let _e213 = ub; + axis = cross(_e212, _e213); + let _e215 = axis; + len = length(_e215); + let _e217 = len; + if (_e217 > 0.000001f) { + let _e219 = angle_1; + let _e220 = axis; + let _e221 = (*n_3); + let _e224 = len; + local_14 = ((_e219 * dot(_e220, _e221)) / _e224); + } else { + local_14 = 0f; + } + let _e226 = local_14; + return _e226; } fn RectangleFormFactor_u0028_vf3_u005b_4_u005d_u003b_vf3_u003b(corners: ptr, 4>>, n_4: ptr>) -> f32 { @@ -6676,212 +8241,212 @@ fn RectangleFormFactor_u0028_vf3_u005b_4_u005d_u003b_vf3_u003b(corners: ptr; var b_1: vec3; - var local_10: i32; + var local_15: i32; var ha: f32; var hb: f32; var d_3: f32; var q: vec3; - var local_11: f32; + var local_16: f32; var aAbove: bool; var bAbove: bool; - var local_12: f32; - var param_67: vec3; - var param_68: vec3; - var param_69: vec3; - var param_70: vec3; - var param_71: vec3; - var param_72: vec3; + var local_17: f32; + var param_73: vec3; + var param_74: vec3; + var param_75: vec3; + var param_76: vec3; + var param_77: vec3; + var param_78: vec3; total = 0f; exit = vec3(0f, 0f, 0f); entry = vec3(0f, 0f, 0f); i_7 = 0i; loop { - let _e198 = i_7; - if (_e198 < 4i) { - let _e200 = i_7; - let _e202 = (*corners)[_e200]; - a_2 = _e202; - let _e203 = i_7; - if (_e203 == 3i) { - local_10 = 0i; + let _e209 = i_7; + if (_e209 < 4i) { + let _e211 = i_7; + let _e213 = (*corners)[_e211]; + a_2 = _e213; + let _e214 = i_7; + if (_e214 == 3i) { + local_15 = 0i; } else { - let _e205 = i_7; - local_10 = (_e205 + 1i); - } - let _e207 = local_10; - let _e209 = (*corners)[_e207]; - b_1 = _e209; - let _e210 = a_2; - let _e211 = (*n_4); - ha = dot(_e210, _e211); - let _e213 = b_1; - let _e214 = (*n_4); - hb = dot(_e213, _e214); - let _e216 = ha; - let _e217 = hb; - d_3 = (_e216 - _e217); - let _e219 = a_2; - let _e220 = b_1; + let _e216 = i_7; + local_15 = (_e216 + 1i); + } + let _e218 = local_15; + let _e220 = (*corners)[_e218]; + b_1 = _e220; let _e221 = a_2; - let _e223 = d_3; - if (abs(_e223) > 0.000000000001f) { - let _e226 = ha; - let _e227 = d_3; - local_11 = (_e226 / _e227); + let _e222 = (*n_4); + ha = dot(_e221, _e222); + let _e224 = b_1; + let _e225 = (*n_4); + hb = dot(_e224, _e225); + let _e227 = ha; + let _e228 = hb; + d_3 = (_e227 - _e228); + let _e230 = a_2; + let _e231 = b_1; + let _e232 = a_2; + let _e234 = d_3; + if (abs(_e234) > 0.000000000001f) { + let _e237 = ha; + let _e238 = d_3; + local_16 = (_e237 / _e238); } else { - local_11 = 0f; - } - let _e229 = local_11; - q = (_e219 + ((_e220 - _e221) * _e229)); - let _e232 = ha; - aAbove = (_e232 > 0f); - let _e234 = hb; - bAbove = (_e234 > 0f); - let _e236 = aAbove; - let _e237 = bAbove; - if (_e236 || _e237) { - let _e239 = aAbove; - let _e240 = a_2; - let _e241 = q; - let _e244 = bAbove; - let _e245 = b_1; - let _e246 = q; - param_67 = select(_e241, _e240, vec3(_e239)); - param_68 = select(_e246, _e245, vec3(_e244)); - let _e249 = (*n_4); - param_69 = _e249; - let _e250 = LambertEdge_u0028_vf3_u003b_vf3_u003b_vf3_u003b((¶m_67), (¶m_68), (¶m_69)); - local_12 = _e250; + local_16 = 0f; + } + let _e240 = local_16; + q = (_e230 + ((_e231 - _e232) * _e240)); + let _e243 = ha; + aAbove = (_e243 > 0f); + let _e245 = hb; + bAbove = (_e245 > 0f); + let _e247 = aAbove; + let _e248 = bAbove; + if (_e247 || _e248) { + let _e250 = aAbove; + let _e251 = a_2; + let _e252 = q; + let _e255 = bAbove; + let _e256 = b_1; + let _e257 = q; + param_73 = select(_e252, _e251, vec3(_e250)); + param_74 = select(_e257, _e256, vec3(_e255)); + let _e260 = (*n_4); + param_75 = _e260; + let _e261 = LambertEdge_u0028_vf3_u003b_vf3_u003b_vf3_u003b((¶m_73), (¶m_74), (¶m_75)); + local_17 = _e261; } else { - local_12 = 0f; - } - let _e251 = local_12; - let _e252 = total; - total = (_e252 + _e251); - let _e254 = aAbove; - let _e255 = bAbove; - let _e258 = q; - let _e259 = exit; - exit = select(_e259, _e258, vec3((_e254 && !(_e255)))); - let _e262 = aAbove; - let _e264 = bAbove; - let _e266 = q; - let _e267 = entry; - entry = select(_e267, _e266, vec3((!(_e262) && _e264))); + local_17 = 0f; + } + let _e262 = local_17; + let _e263 = total; + total = (_e263 + _e262); + let _e265 = aAbove; + let _e266 = bAbove; + let _e269 = q; + let _e270 = exit; + exit = select(_e270, _e269, vec3((_e265 && !(_e266)))); + let _e273 = aAbove; + let _e275 = bAbove; + let _e277 = q; + let _e278 = entry; + entry = select(_e278, _e277, vec3((!(_e273) && _e275))); continue; } else { break; } continuing { - let _e270 = i_7; - i_7 = (_e270 + 1i); + let _e281 = i_7; + i_7 = (_e281 + 1i); } } - let _e272 = exit; - param_70 = _e272; - let _e273 = entry; - param_71 = _e273; - let _e274 = (*n_4); - param_72 = _e274; - let _e275 = LambertEdge_u0028_vf3_u003b_vf3_u003b_vf3_u003b((¶m_70), (¶m_71), (¶m_72)); - let _e276 = total; - total = (_e276 + _e275); - let _e278 = total; - return max((-(_e278) * 0.5f), 0f); + let _e283 = exit; + param_76 = _e283; + let _e284 = entry; + param_77 = _e284; + let _e285 = (*n_4); + param_78 = _e285; + let _e286 = LambertEdge_u0028_vf3_u003b_vf3_u003b_vf3_u003b((¶m_76), (¶m_77), (¶m_78)); + let _e287 = total; + total = (_e287 + _e286); + let _e289 = total; + return max((-(_e289) * 0.5f), 0f); } fn LightListLane_u0028_vf4_u003b_i1_u003b(four: ptr>, slot_1: ptr) -> f32 { var lane: i32; - var local_13: f32; - var local_14: f32; - var local_15: f32; + var local_18: f32; + var local_19: f32; + var local_20: f32; - let _e181 = (*slot_1); - let _e182 = (*slot_1); - lane = (_e181 - ((_e182 / 4i) * 4i)); - let _e186 = lane; - if (_e186 == 0i) { - let _e189 = (*four)[0u]; - local_13 = _e189; + let _e192 = (*slot_1); + let _e193 = (*slot_1); + lane = (_e192 - ((_e193 / 4i) * 4i)); + let _e197 = lane; + if (_e197 == 0i) { + let _e200 = (*four)[0u]; + local_18 = _e200; } else { - let _e190 = lane; - if (_e190 == 1i) { - let _e193 = (*four)[1u]; - local_14 = _e193; + let _e201 = lane; + if (_e201 == 1i) { + let _e204 = (*four)[1u]; + local_19 = _e204; } else { - let _e194 = lane; - if (_e194 == 2i) { - let _e197 = (*four)[2u]; - local_15 = _e197; + let _e205 = lane; + if (_e205 == 2i) { + let _e208 = (*four)[2u]; + local_20 = _e208; } else { - let _e199 = (*four)[3u]; - local_15 = _e199; + let _e210 = (*four)[3u]; + local_20 = _e210; } - let _e200 = local_15; - local_14 = _e200; + let _e211 = local_20; + local_19 = _e211; } - let _e201 = local_14; - local_13 = _e201; + let _e212 = local_19; + local_18 = _e212; } - let _e202 = local_13; - return _e202; + let _e213 = local_18; + return _e213; } fn LightListScale_u0028_i1_u003b(slot_2: ptr) -> f32 { - var param_73: vec4; - var param_74: i32; + var param_79: vec4; + var param_80: i32; - let _e178 = (*slot_2); - let _e182 = light_list_info.scales[(_e178 / 4i)]; - param_73 = _e182; - let _e183 = (*slot_2); - param_74 = _e183; - let _e184 = LightListLane_u0028_vf4_u003b_i1_u003b((¶m_73), (¶m_74)); - return _e184; + let _e189 = (*slot_2); + let _e193 = light_list_info.scales[(_e189 / 4i)]; + param_79 = _e193; + let _e194 = (*slot_2); + param_80 = _e194; + let _e195 = LightListLane_u0028_vf4_u003b_i1_u003b((¶m_79), (¶m_80)); + return _e195; } fn InSlots_u0028_f1_u003b(row: ptr) -> bool { var a_3: vec4; var b_2: vec4; - let _e180 = light_list_info.slot_rows[0i]; - let _e181 = (*row); - a_3 = abs((_e180 - vec4(_e181))); - let _e187 = light_list_info.slot_rows[1i]; - let _e188 = (*row); - b_2 = abs((_e187 - vec4(_e188))); - let _e193 = a_3[0u]; - let _e195 = a_3[1u]; - let _e198 = a_3[2u]; - let _e200 = a_3[3u]; - let _e204 = b_2[0u]; - let _e206 = b_2[1u]; - let _e209 = b_2[2u]; - let _e211 = b_2[3u]; - return (min(min(min(_e193, _e195), min(_e198, _e200)), min(min(_e204, _e206), min(_e209, _e211))) < 0.5f); + let _e191 = light_list_info.slot_rows[0i]; + let _e192 = (*row); + a_3 = abs((_e191 - vec4(_e192))); + let _e198 = light_list_info.slot_rows[1i]; + let _e199 = (*row); + b_2 = abs((_e198 - vec4(_e199))); + let _e204 = a_3[0u]; + let _e206 = a_3[1u]; + let _e209 = a_3[2u]; + let _e211 = a_3[3u]; + let _e215 = b_2[0u]; + let _e217 = b_2[1u]; + let _e220 = b_2[2u]; + let _e222 = b_2[3u]; + return (min(min(min(_e204, _e206), min(_e209, _e211)), min(min(_e215, _e217), min(_e220, _e222))) < 0.5f); } fn LightListRow_u0028_i1_u003b(slot_3: ptr) -> f32 { - var param_75: vec4; - var param_76: i32; + var param_81: vec4; + var param_82: i32; - let _e178 = (*slot_3); - let _e182 = light_list_info.indices[(_e178 / 4i)]; - param_75 = _e182; - let _e183 = (*slot_3); - param_76 = _e183; - let _e184 = LightListLane_u0028_vf4_u003b_i1_u003b((¶m_75), (¶m_76)); - return _e184; + let _e189 = (*slot_3); + let _e193 = light_list_info.indices[(_e189 / 4i)]; + param_81 = _e193; + let _e194 = (*slot_3); + param_82 = _e194; + let _e195 = LightListLane_u0028_vf4_u003b_i1_u003b((¶m_81), (¶m_82)); + return _e195; } fn LightListTexel_u0028_f1_u003b_f1_u003b(texel_2: ptr, row_1: ptr) -> vec4 { - let _e177 = (*texel_2); - let _e181 = light_list_info.list[1u]; - let _e183 = (*row_1); - let _e187 = light_list_info.list[2u]; - let _e190 = textureSampleLevel(light_list_texture_tex, light_list_texture_smp, vec2(((_e177 + 0.5f) * _e181), ((_e183 + 0.5f) * _e187)), 0f); - return _e190; + let _e188 = (*texel_2); + let _e192 = light_list_info.list[1u]; + let _e194 = (*row_1); + let _e198 = light_list_info.list[2u]; + let _e201 = textureSampleLevel(light_list_texture_tex, light_list_texture_smp, vec2(((_e188 + 0.5f) * _e192), ((_e194 + 0.5f) * _e198)), 0f); + return _e201; } fn ClusterRow_u0028_i1_u003b(slot_4: ptr) -> f32 { @@ -6890,43 +8455,43 @@ fn ClusterRow_u0028_i1_u003b(slot_4: ptr) -> f32 { var within: f32; var texel_3: f32; var four_1: vec4; - var param_77: f32; - var param_78: f32; - var param_79: vec4; - var param_80: i32; + var param_83: f32; + var param_84: f32; + var param_85: vec4; + var param_86: i32; - let _e185 = g_cluster_offset; - let _e186 = (*slot_4); - entry_1 = (_e185 + f32(_e186)); - let _e189 = entry_1; - row_2 = floor((_e189 / 16f)); - let _e192 = entry_1; - let _e193 = row_2; - within = (_e192 - (_e193 * 16f)); - let _e196 = within; - texel_3 = floor((_e196 / 4f)); - let _e201 = light_list_info.cluster_depth[3u]; - let _e202 = row_2; - let _e204 = texel_3; - param_77 = _e204; - param_78 = (_e201 + _e202); - let _e205 = LightListTexel_u0028_f1_u003b_f1_u003b((¶m_77), (¶m_78)); - four_1 = _e205; - let _e206 = within; - let _e207 = texel_3; - let _e212 = four_1; - param_79 = _e212; - param_80 = i32(((_e206 - (_e207 * 4f)) + 0.5f)); - let _e213 = LightListLane_u0028_vf4_u003b_i1_u003b((¶m_79), (¶m_80)); - return _e213; + let _e196 = g_cluster_offset; + let _e197 = (*slot_4); + entry_1 = (_e196 + f32(_e197)); + let _e200 = entry_1; + row_2 = floor((_e200 / 16f)); + let _e203 = entry_1; + let _e204 = row_2; + within = (_e203 - (_e204 * 16f)); + let _e207 = within; + texel_3 = floor((_e207 / 4f)); + let _e212 = light_list_info.cluster_depth[3u]; + let _e213 = row_2; + let _e215 = texel_3; + param_83 = _e215; + param_84 = (_e212 + _e213); + let _e216 = LightListTexel_u0028_f1_u003b_f1_u003b((¶m_83), (¶m_84)); + four_1 = _e216; + let _e217 = within; + let _e218 = texel_3; + let _e223 = four_1; + param_85 = _e223; + param_86 = i32(((_e217 - (_e218 * 4f)) + 0.5f)); + let _e224 = LightListLane_u0028_vf4_u003b_i1_u003b((¶m_85), (¶m_86)); + return _e224; } fn Clustered_u0028_() -> bool { - let _e177 = light_list_info.cluster_grid[3u]; - return (_e177 > 0.5f); + let _e188 = light_list_info.cluster_grid[3u]; + return (_e188 > 0.5f); } -fn SampleLight_u0028_i1_u003b_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b(index_2: ptr, s_3: ptr) -> LightSample { +fn SampleLight_u0028_i1_u003b_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b(index_2: ptr, s_4: ptr) -> LightSample { var light_3: LightSample; var position: vec4; var color_1: vec4; @@ -6935,14 +8500,14 @@ fn SampleLight_u0028_i1_u003b_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_ var slot_5: i32; var clustered: bool; var listRow: f32; - var local_16: f32; - var param_81: i32; - var param_82: i32; + var local_21: f32; + var param_87: i32; + var param_88: i32; var v_1: f32; var u_1: f32; - var local_17: f32; - var param_83: f32; - var param_84: i32; + var local_22: f32; + var param_89: f32; + var param_90: i32; var type_39: f32; var halfWidth_1: vec3; var halfHeight_1: vec3; @@ -6950,293 +8515,293 @@ fn SampleLight_u0028_i1_u003b_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_ var corners_1: array, 4>; var behind: bool; var formFactor: f32; - var local_18: f32; - var param_85: array, 4>; - var param_86: vec3; + var local_23: f32; + var param_91: array, 4>; + var param_92: vec3; var area: f32; var radiance: f32; - var local_19: f32; + var local_24: f32; var distance_2: f32; var ratio_1: f32; var window_1: f32; var mirror_1: vec3; var representative: vec3; - var param_87: vec3; - var param_88: vec3; - var param_89: vec3; - var param_90: vec3; - var param_91: vec3; + var param_93: vec3; + var param_94: vec3; + var param_95: vec3; + var param_96: vec3; + var param_97: vec3; var toPoint: vec3; var pointDistance: f32; - var local_20: vec3; + var local_25: vec3; var aim: vec3; var attenuation_1: f32; var toLight_1: vec3; var distance_3: f32; - var param_92: f32; - var param_93: f32; + var param_98: f32; + var param_99: f32; var cosAngle: f32; light_3.integrated = 0f; light_3.ltc = vec3(0f, 0f, 0f); - let _e228 = (*index_2); - if (_e228 < 8i) { - let _e230 = (*index_2); - let _e233 = frag_info.light_position[_e230]; - position = _e233; - let _e234 = (*index_2); - let _e237 = frag_info.light_color[_e234]; - color_1 = _e237; - let _e238 = (*index_2); - let _e241 = frag_info.light_direction[_e238]; - direction = _e241; - let _e242 = (*index_2); - let _e245 = frag_info.light_cone[_e242]; - cone = _e245; + let _e239 = (*index_2); + if (_e239 < 8i) { + let _e241 = (*index_2); + let _e244 = frag_info.light_position[_e241]; + position = _e244; + let _e245 = (*index_2); + let _e248 = frag_info.light_color[_e245]; + color_1 = _e248; + let _e249 = (*index_2); + let _e252 = frag_info.light_direction[_e249]; + direction = _e252; + let _e253 = (*index_2); + let _e256 = frag_info.light_cone[_e253]; + cone = _e256; } else { - let _e246 = (*index_2); - slot_5 = (_e246 - 8i); - let _e248 = Clustered_u0028_(); - clustered = _e248; - let _e249 = clustered; - if _e249 { - let _e250 = slot_5; - param_81 = _e250; - let _e251 = ClusterRow_u0028_i1_u003b((¶m_81)); - local_16 = _e251; + let _e257 = (*index_2); + slot_5 = (_e257 - 8i); + let _e259 = Clustered_u0028_(); + clustered = _e259; + let _e260 = clustered; + if _e260 { + let _e261 = slot_5; + param_87 = _e261; + let _e262 = ClusterRow_u0028_i1_u003b((¶m_87)); + local_21 = _e262; } else { - let _e252 = slot_5; - param_82 = _e252; - let _e253 = LightListRow_u0028_i1_u003b((¶m_82)); - local_16 = _e253; - } - let _e254 = local_16; - listRow = _e254; - let _e255 = listRow; - let _e259 = light_list_info.list[2u]; - v_1 = ((_e255 + 0.5f) * _e259); - let _e263 = light_list_info.list[1u]; - u_1 = _e263; - let _e264 = u_1; - let _e266 = v_1; - let _e268 = textureSampleLevel(light_list_texture_tex, light_list_texture_smp, vec2((0.5f * _e264), _e266), 0f); - position = _e268; - let _e269 = u_1; - let _e271 = v_1; - let _e273 = textureSampleLevel(light_list_texture_tex, light_list_texture_smp, vec2((1.5f * _e269), _e271), 0f); - color_1 = _e273; - let _e274 = u_1; - let _e276 = v_1; - let _e278 = textureSampleLevel(light_list_texture_tex, light_list_texture_smp, vec2((2.5f * _e274), _e276), 0f); - direction = _e278; - let _e279 = u_1; - let _e281 = v_1; - let _e283 = textureSampleLevel(light_list_texture_tex, light_list_texture_smp, vec2((3.5f * _e279), _e281), 0f); - cone = _e283; - let _e284 = clustered; - if _e284 { - let _e285 = listRow; - param_83 = _e285; - let _e286 = InSlots_u0028_f1_u003b((¶m_83)); - local_17 = select(1f, 0f, _e286); + let _e263 = slot_5; + param_88 = _e263; + let _e264 = LightListRow_u0028_i1_u003b((¶m_88)); + local_21 = _e264; + } + let _e265 = local_21; + listRow = _e265; + let _e266 = listRow; + let _e270 = light_list_info.list[2u]; + v_1 = ((_e266 + 0.5f) * _e270); + let _e274 = light_list_info.list[1u]; + u_1 = _e274; + let _e275 = u_1; + let _e277 = v_1; + let _e279 = textureSampleLevel(light_list_texture_tex, light_list_texture_smp, vec2((0.5f * _e275), _e277), 0f); + position = _e279; + let _e280 = u_1; + let _e282 = v_1; + let _e284 = textureSampleLevel(light_list_texture_tex, light_list_texture_smp, vec2((1.5f * _e280), _e282), 0f); + color_1 = _e284; + let _e285 = u_1; + let _e287 = v_1; + let _e289 = textureSampleLevel(light_list_texture_tex, light_list_texture_smp, vec2((2.5f * _e285), _e287), 0f); + direction = _e289; + let _e290 = u_1; + let _e292 = v_1; + let _e294 = textureSampleLevel(light_list_texture_tex, light_list_texture_smp, vec2((3.5f * _e290), _e292), 0f); + cone = _e294; + let _e295 = clustered; + if _e295 { + let _e296 = listRow; + param_89 = _e296; + let _e297 = InSlots_u0028_f1_u003b((¶m_89)); + local_22 = select(1f, 0f, _e297); } else { - let _e288 = slot_5; - param_84 = _e288; - let _e289 = LightListScale_u0028_i1_u003b((¶m_84)); - local_17 = _e289; - } - let _e290 = local_17; - let _e292 = color_1[3u]; - color_1[3u] = (_e292 * _e290); - } - let _e296 = position[3u]; - type_39 = _e296; - let _e297 = type_39; - if (_e297 > 2.5f) { - let _e299 = direction; - halfWidth_1 = _e299.xyz; - let _e301 = cone; - halfHeight_1 = _e301.xyz; - let _e303 = position; - let _e305 = v_world_position_1; - toCentre = (_e303.xyz - _e305); - let _e307 = toCentre; - let _e308 = halfWidth_1; - let _e310 = halfHeight_1; - corners_1[0i] = ((_e307 - _e308) - _e310); - let _e313 = toCentre; - let _e314 = halfWidth_1; - let _e316 = halfHeight_1; - corners_1[1i] = ((_e313 + _e314) - _e316); - let _e319 = toCentre; - let _e320 = halfWidth_1; - let _e322 = halfHeight_1; - corners_1[2i] = ((_e319 + _e320) + _e322); - let _e325 = toCentre; - let _e326 = halfWidth_1; - let _e328 = halfHeight_1; - corners_1[3i] = ((_e325 - _e326) + _e328); - let _e331 = toCentre; - let _e332 = halfWidth_1; + let _e299 = slot_5; + param_90 = _e299; + let _e300 = LightListScale_u0028_i1_u003b((¶m_90)); + local_22 = _e300; + } + let _e301 = local_22; + let _e303 = color_1[3u]; + color_1[3u] = (_e303 * _e301); + } + let _e307 = position[3u]; + type_39 = _e307; + let _e308 = type_39; + if (_e308 > 2.5f) { + let _e310 = direction; + halfWidth_1 = _e310.xyz; + let _e312 = cone; + halfHeight_1 = _e312.xyz; + let _e314 = position; + let _e316 = v_world_position_1; + toCentre = (_e314.xyz - _e316); + let _e318 = toCentre; + let _e319 = halfWidth_1; + let _e321 = halfHeight_1; + corners_1[0i] = ((_e318 - _e319) - _e321); + let _e324 = toCentre; + let _e325 = halfWidth_1; + let _e327 = halfHeight_1; + corners_1[1i] = ((_e324 + _e325) - _e327); + let _e330 = toCentre; + let _e331 = halfWidth_1; let _e333 = halfHeight_1; - behind = (dot(_e331, cross(_e332, _e333)) >= 0f); - let _e337 = behind; - if _e337 { - local_18 = 0f; - } else { - let _e338 = corners_1; - param_85 = _e338; - let _e340 = (*s_3).n; - param_86 = _e340; - let _e341 = RectangleFormFactor_u0028_vf3_u005b_4_u005d_u003b_vf3_u003b((¶m_85), (¶m_86)); - local_18 = _e341; - } - let _e342 = local_18; - formFactor = _e342; + corners_1[2i] = ((_e330 + _e331) + _e333); + let _e336 = toCentre; + let _e337 = halfWidth_1; + let _e339 = halfHeight_1; + corners_1[3i] = ((_e336 - _e337) + _e339); + let _e342 = toCentre; let _e343 = halfWidth_1; let _e344 = halfHeight_1; - area = (length(cross(_e343, _e344)) * 4f); - let _e348 = area; - if (_e348 > 0.000000001f) { - let _e350 = area; - local_19 = (1f / _e350); + behind = (dot(_e342, cross(_e343, _e344)) >= 0f); + let _e348 = behind; + if _e348 { + local_23 = 0f; } else { - local_19 = 0f; - } - let _e352 = local_19; - radiance = _e352; - let _e353 = toCentre; - distance_2 = length(_e353); - let _e356 = direction[3u]; - if (_e356 > 0f) { - let _e358 = distance_2; - let _e360 = direction[3u]; - ratio_1 = (_e358 / _e360); - let _e362 = ratio_1; - let _e363 = ratio_1; - let _e365 = ratio_1; - let _e367 = ratio_1; - window_1 = clamp((1f - (((_e362 * _e363) * _e365) * _e367)), 0f, 1f); - let _e371 = window_1; - let _e372 = window_1; - let _e374 = radiance; - radiance = (_e374 * (_e371 * _e372)); - } - let _e377 = (*s_3).v; - let _e380 = (*s_3).n; - mirror_1 = reflect(-(_e377), _e380); - let _e382 = position; - param_87 = _e382.xyz; - let _e384 = halfWidth_1; - param_88 = _e384; - let _e385 = halfHeight_1; - param_89 = _e385; - let _e386 = v_world_position_1; - param_90 = _e386; - let _e387 = mirror_1; - param_91 = _e387; - let _e388 = RectangleClosestPoint_u0028_vf3_u003b_vf3_u003b_vf3_u003b_vf3_u003b_vf3_u003b((¶m_87), (¶m_88), (¶m_89), (¶m_90), (¶m_91)); - representative = _e388; - let _e389 = representative; - let _e390 = v_world_position_1; - toPoint = (_e389 - _e390); - let _e392 = toPoint; - pointDistance = length(_e392); - let _e394 = pointDistance; - if (_e394 > 0.000001f) { - let _e396 = toPoint; - let _e397 = pointDistance; - local_20 = (_e396 / vec3(_e397)); + let _e349 = corners_1; + param_91 = _e349; + let _e351 = (*s_4).n; + param_92 = _e351; + let _e352 = RectangleFormFactor_u0028_vf3_u005b_4_u005d_u003b_vf3_u003b((¶m_91), (¶m_92)); + local_23 = _e352; + } + let _e353 = local_23; + formFactor = _e353; + let _e354 = halfWidth_1; + let _e355 = halfHeight_1; + area = (length(cross(_e354, _e355)) * 4f); + let _e359 = area; + if (_e359 > 0.000000001f) { + let _e361 = area; + local_24 = (1f / _e361); + } else { + local_24 = 0f; + } + let _e363 = local_24; + radiance = _e363; + let _e364 = toCentre; + distance_2 = length(_e364); + let _e367 = direction[3u]; + if (_e367 > 0f) { + let _e369 = distance_2; + let _e371 = direction[3u]; + ratio_1 = (_e369 / _e371); + let _e373 = ratio_1; + let _e374 = ratio_1; + let _e376 = ratio_1; + let _e378 = ratio_1; + window_1 = clamp((1f - (((_e373 * _e374) * _e376) * _e378)), 0f, 1f); + let _e382 = window_1; + let _e383 = window_1; + let _e385 = radiance; + radiance = (_e385 * (_e382 * _e383)); + } + let _e388 = (*s_4).v; + let _e391 = (*s_4).n; + mirror_1 = reflect(-(_e388), _e391); + let _e393 = position; + param_93 = _e393.xyz; + let _e395 = halfWidth_1; + param_94 = _e395; + let _e396 = halfHeight_1; + param_95 = _e396; + let _e397 = v_world_position_1; + param_96 = _e397; + let _e398 = mirror_1; + param_97 = _e398; + let _e399 = RectangleClosestPoint_u0028_vf3_u003b_vf3_u003b_vf3_u003b_vf3_u003b_vf3_u003b((¶m_93), (¶m_94), (¶m_95), (¶m_96), (¶m_97)); + representative = _e399; + let _e400 = representative; + let _e401 = v_world_position_1; + toPoint = (_e400 - _e401); + let _e403 = toPoint; + pointDistance = length(_e403); + let _e405 = pointDistance; + if (_e405 > 0.000001f) { + let _e407 = toPoint; + let _e408 = pointDistance; + local_25 = (_e407 / vec3(_e408)); } else { - let _e401 = (*s_3).n; - local_20 = _e401; - } - let _e402 = local_20; - light_3.l = _e402; - let _e405 = light_3.l; - let _e407 = (*s_3).v; - light_3.h = normalize((_e405 + _e407)); - let _e411 = formFactor; - light_3.n_dot_l = _e411; - let _e414 = (*s_3).n; - let _e416 = light_3.h; - light_3.n_dot_h = max(dot(_e414, _e416), 0f); - let _e421 = (*s_3).v; - let _e423 = light_3.h; - light_3.v_dot_h = max(dot(_e421, _e423), 0f); - let _e427 = color_1; - let _e430 = color_1[3u]; - let _e432 = radiance; - light_3.radiance = ((_e427.xyz * _e430) * _e432); - let _e435 = light_3; - return _e435; - } - let _e436 = direction; - aim = normalize(_e436.xyz); + let _e412 = (*s_4).n; + local_25 = _e412; + } + let _e413 = local_25; + light_3.l = _e413; + let _e416 = light_3.l; + let _e418 = (*s_4).v; + light_3.h = normalize((_e416 + _e418)); + let _e422 = formFactor; + light_3.n_dot_l = _e422; + let _e425 = (*s_4).n; + let _e427 = light_3.h; + light_3.n_dot_h = max(dot(_e425, _e427), 0f); + let _e432 = (*s_4).v; + let _e434 = light_3.h; + light_3.v_dot_h = max(dot(_e432, _e434), 0f); + let _e438 = color_1; + let _e441 = color_1[3u]; + let _e443 = radiance; + light_3.radiance = ((_e438.xyz * _e441) * _e443); + let _e446 = light_3; + return _e446; + } + let _e447 = direction; + aim = normalize(_e447.xyz); attenuation_1 = 1f; - let _e439 = type_39; - if (_e439 < 0.5f) { - let _e441 = aim; - light_3.l = -(_e441); - } else { - let _e444 = position; - let _e446 = v_world_position_1; - toLight_1 = (_e444.xyz - _e446); - let _e448 = toLight_1; - distance_3 = length(_e448); - let _e450 = distance_3; - if (_e450 < 0.000001f) { - let _e453 = (*s_3).n; - light_3.l = _e453; - let _e456 = (*s_3).n; - light_3.h = _e456; + let _e450 = type_39; + if (_e450 < 0.5f) { + let _e452 = aim; + light_3.l = -(_e452); + } else { + let _e455 = position; + let _e457 = v_world_position_1; + toLight_1 = (_e455.xyz - _e457); + let _e459 = toLight_1; + distance_3 = length(_e459); + let _e461 = distance_3; + if (_e461 < 0.000001f) { + let _e464 = (*s_4).n; + light_3.l = _e464; + let _e467 = (*s_4).n; + light_3.h = _e467; light_3.radiance = vec3(0f, 0f, 0f); light_3.n_dot_l = 0f; light_3.n_dot_h = 0f; light_3.v_dot_h = 0f; - let _e462 = light_3; - return _e462; - } - let _e463 = toLight_1; - let _e464 = distance_3; - light_3.l = (_e463 / vec3(_e464)); - let _e468 = distance_3; - param_92 = _e468; - let _e470 = direction[3u]; - param_93 = _e470; - let _e471 = PunctualAttenuation_u0028_f1_u003b_f1_u003b((¶m_92), (¶m_93)); - attenuation_1 = _e471; - let _e472 = type_39; - if (_e472 > 1.5f) { - let _e474 = aim; - let _e476 = light_3.l; - cosAngle = dot(_e474, -(_e476)); - let _e479 = cosAngle; - let _e481 = cone[1u]; - let _e484 = cone[0u]; - let _e486 = cone[1u]; - let _e490 = attenuation_1; - attenuation_1 = (_e490 * clamp(((_e479 - _e481) / (_e484 - _e486)), 0f, 1f)); - } - } - let _e493 = light_3.l; - let _e495 = (*s_3).v; - light_3.h = normalize((_e493 + _e495)); - let _e500 = (*s_3).n; - let _e502 = light_3.l; - light_3.n_dot_l = max(dot(_e500, _e502), 0f); - let _e507 = (*s_3).n; - let _e509 = light_3.h; - light_3.n_dot_h = max(dot(_e507, _e509), 0f); - let _e514 = (*s_3).v; - let _e516 = light_3.h; - light_3.v_dot_h = max(dot(_e514, _e516), 0f); - let _e520 = color_1; - let _e523 = color_1[3u]; - let _e525 = attenuation_1; - light_3.radiance = ((_e520.xyz * _e523) * _e525); - let _e528 = light_3; - return _e528; + let _e473 = light_3; + return _e473; + } + let _e474 = toLight_1; + let _e475 = distance_3; + light_3.l = (_e474 / vec3(_e475)); + let _e479 = distance_3; + param_98 = _e479; + let _e481 = direction[3u]; + param_99 = _e481; + let _e482 = PunctualAttenuation_u0028_f1_u003b_f1_u003b((¶m_98), (¶m_99)); + attenuation_1 = _e482; + let _e483 = type_39; + if (_e483 > 1.5f) { + let _e485 = aim; + let _e487 = light_3.l; + cosAngle = dot(_e485, -(_e487)); + let _e490 = cosAngle; + let _e492 = cone[1u]; + let _e495 = cone[0u]; + let _e497 = cone[1u]; + let _e501 = attenuation_1; + attenuation_1 = (_e501 * clamp(((_e490 - _e492) / (_e495 - _e497)), 0f, 1f)); + } + } + let _e504 = light_3.l; + let _e506 = (*s_4).v; + light_3.h = normalize((_e504 + _e506)); + let _e511 = (*s_4).n; + let _e513 = light_3.l; + light_3.n_dot_l = max(dot(_e511, _e513), 0f); + let _e518 = (*s_4).n; + let _e520 = light_3.h; + light_3.n_dot_h = max(dot(_e518, _e520), 0f); + let _e525 = (*s_4).v; + let _e527 = light_3.h; + light_3.v_dot_h = max(dot(_e525, _e527), 0f); + let _e531 = color_1; + let _e534 = color_1[3u]; + let _e536 = attenuation_1; + light_3.radiance = ((_e531.xyz * _e534) * _e536); + let _e539 = light_3; + return _e539; } fn FindCluster_u0028_vf3_u003b(world_3: ptr>) { @@ -7247,296 +8812,291 @@ fn FindCluster_u0028_vf3_u003b(world_3: ptr>) { var tx: f32; var ty: f32; var tz: f32; - var local_21: f32; + var local_26: f32; var cell: f32; var row_3: f32; var header: vec4; - var param_94: f32; - var param_95: f32; + var param_100: f32; + var param_101: f32; - let _e190 = light_list_info.cluster_view_projection; - let _e191 = (*world_3); - clip_1 = (_e190 * vec4(_e191.x, _e191.y, _e191.z, 1f)); - let _e197 = clip_1; - let _e200 = clip_1[3u]; - ndc_1 = (_e197.xy / vec2(max(_e200, 0.000001f))); - let _e205 = light_list_info.cluster_grid; - grid = _e205.xyz; - let _e209 = light_list_info.cluster_depth[0u]; - near_1 = _e209; - let _e211 = ndc_1[0u]; - let _e215 = grid[0u]; - let _e219 = grid[0u]; - tx = clamp(floor((((_e211 * 0.5f) + 0.5f) * _e215)), 0f, (_e219 - 1f)); - let _e223 = ndc_1[1u]; - let _e227 = grid[1u]; - let _e231 = grid[1u]; - ty = clamp(floor((((_e223 * 0.5f) + 0.5f) * _e227)), 0f, (_e231 - 1f)); - let _e235 = clip_1[3u]; - let _e236 = near_1; - if (_e235 <= _e236) { - local_21 = 0f; - } else { - let _e239 = clip_1[3u]; - let _e240 = near_1; - let _e245 = light_list_info.cluster_depth[1u]; - let _e249 = grid[2u]; - local_21 = clamp(floor((log((_e239 / _e240)) * _e245)), 0f, (_e249 - 1f)); - } - let _e252 = local_21; - tz = _e252; - let _e253 = tx; - let _e254 = ty; - let _e256 = grid[0u]; - let _e259 = tz; - let _e261 = grid[0u]; - let _e264 = grid[1u]; - cell = ((_e253 + (_e254 * _e256)) + ((_e259 * _e261) * _e264)); - let _e267 = cell; - row_3 = floor((_e267 / 4f)); - let _e270 = cell; - let _e271 = row_3; - let _e276 = light_list_info.cluster_depth[2u]; - let _e277 = row_3; - param_94 = (_e270 - (_e271 * 4f)); - param_95 = (_e276 + _e277); - let _e279 = LightListTexel_u0028_f1_u003b_f1_u003b((¶m_94), (¶m_95)); - header = _e279; - let _e281 = header[0u]; - g_cluster_offset = _e281; - let _e283 = header[1u]; - g_cluster_count = _e283; + let _e201 = light_list_info.cluster_view_projection; + let _e202 = (*world_3); + clip_1 = (_e201 * vec4(_e202.x, _e202.y, _e202.z, 1f)); + let _e208 = clip_1; + let _e211 = clip_1[3u]; + ndc_1 = (_e208.xy / vec2(max(_e211, 0.000001f))); + let _e216 = light_list_info.cluster_grid; + grid = _e216.xyz; + let _e220 = light_list_info.cluster_depth[0u]; + near_1 = _e220; + let _e222 = ndc_1[0u]; + let _e226 = grid[0u]; + let _e230 = grid[0u]; + tx = clamp(floor((((_e222 * 0.5f) + 0.5f) * _e226)), 0f, (_e230 - 1f)); + let _e234 = ndc_1[1u]; + let _e238 = grid[1u]; + let _e242 = grid[1u]; + ty = clamp(floor((((_e234 * 0.5f) + 0.5f) * _e238)), 0f, (_e242 - 1f)); + let _e246 = clip_1[3u]; + let _e247 = near_1; + if (_e246 <= _e247) { + local_26 = 0f; + } else { + let _e250 = clip_1[3u]; + let _e251 = near_1; + let _e256 = light_list_info.cluster_depth[1u]; + let _e260 = grid[2u]; + local_26 = clamp(floor((log((_e250 / _e251)) * _e256)), 0f, (_e260 - 1f)); + } + let _e263 = local_26; + tz = _e263; + let _e264 = tx; + let _e265 = ty; + let _e267 = grid[0u]; + let _e270 = tz; + let _e272 = grid[0u]; + let _e275 = grid[1u]; + cell = ((_e264 + (_e265 * _e267)) + ((_e270 * _e272) * _e275)); + let _e278 = cell; + row_3 = floor((_e278 / 4f)); + let _e281 = cell; + let _e282 = row_3; + let _e287 = light_list_info.cluster_depth[2u]; + let _e288 = row_3; + param_100 = (_e281 - (_e282 * 4f)); + param_101 = (_e287 + _e288); + let _e290 = LightListTexel_u0028_f1_u003b_f1_u003b((¶m_100), (¶m_101)); + header = _e290; + let _e292 = header[0u]; + g_cluster_offset = _e292; + let _e294 = header[1u]; + g_cluster_count = _e294; return; } fn LightCount_u0028_() -> i32 { var tail: f32; - var param_96: vec3; + var param_102: vec3; - let _e179 = light_list_info.list[0u]; - tail = _e179; - let _e180 = Clustered_u0028_(); - if _e180 { - let _e181 = v_world_position_1; - param_96 = _e181; - FindCluster_u0028_vf3_u003b((¶m_96)); - let _e182 = g_cluster_count; - tail = _e182; + let _e190 = light_list_info.list[0u]; + tail = _e190; + let _e191 = Clustered_u0028_(); + if _e191 { + let _e192 = v_world_position_1; + param_102 = _e192; + FindCluster_u0028_vf3_u003b((¶m_102)); + let _e193 = g_cluster_count; + tail = _e193; } - let _e185 = frag_info.frame_params[1u]; - let _e189 = tail; - return (clamp(i32((_e185 + 0.5f)), 0i, 8i) + clamp(i32((_e189 + 0.5f)), 0i, 24i)); + let _e196 = frag_info.frame_params[1u]; + let _e200 = tail; + return (clamp(i32((_e196 + 0.5f)), 0i, 8i) + clamp(i32((_e200 + 0.5f)), 0i, 24i)); } -fn AccumulateLights_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b(s_4: ptr) -> vec3 { +fn AccumulateLights_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b(s_5: ptr) -> vec3 { var total_1: vec3; var count_1: i32; var i_8: i32; var light_4: LightSample; - var param_97: i32; - var param_98: Surface; + var param_103: i32; + var param_104: Surface; var visibility: f32; - var param_99: i32; - var local_22: f32; - var param_100: Surface; - var param_101: LightSample; - var param_102: i32; - var param_103: vec3; - var param_104: vec3; var param_105: i32; + var local_27: f32; var param_106: Surface; var param_107: LightSample; + var param_108: i32; + var param_109: vec3; + var param_110: vec3; + var param_111: i32; + var param_112: Surface; + var param_113: LightSample; total_1 = vec3(0f, 0f, 0f); - let _e193 = LightCount_u0028_(); - count_1 = _e193; + let _e204 = LightCount_u0028_(); + count_1 = _e204; i_8 = 0i; loop { - let _e194 = i_8; - if (_e194 < 32i) { - let _e196 = i_8; - let _e197 = count_1; - if (_e196 >= _e197) { + let _e205 = i_8; + if (_e205 < 32i) { + let _e207 = i_8; + let _e208 = count_1; + if (_e207 >= _e208) { break; } - let _e199 = i_8; - param_97 = _e199; - let _e200 = (*s_4); - param_98 = _e200; - let _e201 = SampleLight_u0028_i1_u003b_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b((¶m_97), (¶m_98)); - light_4 = _e201; - let _e203 = light_4.n_dot_l; - if (_e203 <= 0f) { + let _e210 = i_8; + param_103 = _e210; + let _e211 = (*s_5); + param_104 = _e211; + let _e212 = SampleLight_u0028_i1_u003b_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b((¶m_103), (¶m_104)); + light_4 = _e212; + let _e214 = light_4.n_dot_l; + if (_e214 <= 0f) { continue; } - let _e205 = i_8; - param_99 = _e205; - let _e206 = LightHasShadow_u0028_i1_u003b((¶m_99)); - if _e206 { - let _e207 = (*s_4); - param_100 = _e207; - let _e208 = light_4; - param_101 = _e208; - let _e209 = i_8; - param_102 = _e209; - let _e210 = LightVisibility_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b_struct_u002d_LightSample_u002d_vf3_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf31_u003b_i1_u003b((¶m_100), (¶m_101), (¶m_102)); - let _e211 = v_world_position_1; - param_103 = _e211; - let _e213 = (*s_4).n; - param_104 = _e213; - let _e214 = i_8; - param_105 = _e214; - let _e215 = PointShadowFactor_u0028_vf3_u003b_vf3_u003b_i1_u003b((¶m_103), (¶m_104), (¶m_105)); - local_22 = (_e210 * _e215); + light_transmittance = vec3(1f, 1f, 1f); + let _e216 = i_8; + param_105 = _e216; + let _e217 = LightHasShadow_u0028_i1_u003b((¶m_105)); + if _e217 { + let _e218 = (*s_5); + param_106 = _e218; + let _e219 = light_4; + param_107 = _e219; + let _e220 = i_8; + param_108 = _e220; + let _e221 = LightVisibility_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b_struct_u002d_LightSample_u002d_vf3_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf31_u003b_i1_u003b((¶m_106), (¶m_107), (¶m_108)); + let _e222 = v_world_position_1; + param_109 = _e222; + let _e224 = (*s_5).n; + param_110 = _e224; + let _e225 = i_8; + param_111 = _e225; + let _e226 = PointShadowFactor_u0028_vf3_u003b_vf3_u003b_i1_u003b((¶m_109), (¶m_110), (¶m_111)); + local_27 = (_e221 * _e226); } else { - local_22 = 1f; + local_27 = 1f; } - let _e217 = local_22; - visibility = _e217; - let _e218 = visibility; - if (_e218 <= 0f) { + let _e228 = local_27; + visibility = _e228; + let _e229 = visibility; + if (_e229 <= 0f) { continue; } - let _e220 = (*s_4); - param_106 = _e220; - let _e221 = light_4; - param_107 = _e221; - let _e222 = ShadeLight_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b_struct_u002d_LightSample_u002d_vf3_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf31_u003b((¶m_106), (¶m_107)); - let _e224 = light_4.radiance; - let _e227 = light_4.n_dot_l; - let _e229 = visibility; - let _e231 = total_1; - total_1 = (_e231 + (((_e222 * _e224) * _e227) * _e229)); + let _e231 = (*s_5); + param_112 = _e231; + let _e232 = light_4; + param_113 = _e232; + let _e233 = ShadeLight_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b_struct_u002d_LightSample_u002d_vf3_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf31_u003b((¶m_112), (¶m_113)); + let _e235 = light_4.radiance; + let _e238 = light_4.n_dot_l; + let _e240 = visibility; + let _e242 = light_transmittance; + let _e244 = total_1; + total_1 = (_e244 + ((((_e233 * _e235) * _e238) * _e240) * _e242)); continue; } else { break; } continuing { - let _e233 = i_8; - i_8 = (_e233 + 1i); + let _e246 = i_8; + i_8 = (_e246 + 1i); } } - let _e235 = total_1; - return _e235; + let _e248 = total_1; + return _e248; } fn SampleLightmap_u0028_() -> vec3 { var texel_4: vec4; - let _e176 = v_lightmap_uv_1; - let _e177 = textureSample(lightmap_texture_tex, lightmap_texture_smp, _e176); - texel_4 = _e177; - let _e178 = texel_4; - let _e181 = texel_4[3u]; - return ((_e178.xyz * _e181) * 8f); -} - -fn SrgbToLinear_u0028_vf3_u003b(srgb: ptr>) -> vec3 { - let _e176 = (*srgb); - let _e179 = (*srgb); - let _e184 = (*srgb); - return mix((_e176 / vec3(12.92f)), pow(((_e179 + vec3(0.055f, 0.055f, 0.055f)) / vec3(1.055f)), vec3(2.4f, 2.4f, 2.4f)), step(vec3(0.04045f, 0.04045f, 0.04045f), _e184)); + let _e187 = v_lightmap_uv_1; + let _e188 = textureSample(lightmap_texture_tex, lightmap_texture_smp, _e187); + texel_4 = _e188; + let _e189 = texel_4; + let _e192 = texel_4[3u]; + return ((_e189.xyz * _e192) * 8f); } fn MaterialLodBias_u0028_() -> f32 { - let _e177 = frag_info.target_origin[1u]; - return _e177; + let _e188 = frag_info.target_origin[1u]; + return _e188; } -fn ApplyEmissiveMap_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b(s_5: ptr) { +fn ApplyEmissiveMap_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b(s_6: ptr) { var emissive: vec3; - var param_108: vec3; + var param_114: vec3; - let _e178 = v_texcoord_1; - let _e179 = MaterialLodBias_u0028_(); - let _e180 = textureSampleBias(emissive_texture_tex, emissive_texture_smp, _e178, _e179); - param_108 = _e180.xyz; - let _e182 = SrgbToLinear_u0028_vf3_u003b((¶m_108)); - emissive = _e182; - let _e183 = emissive; - let _e185 = frag_info.emissive; - let _e190 = frag_info.material2_[3u]; - (*s_5).emissive = ((_e183 * _e185.xyz) * _e190); + let _e189 = v_texcoord_1; + let _e190 = MaterialLodBias_u0028_(); + let _e191 = textureSampleBias(emissive_texture_tex, emissive_texture_smp, _e189, _e190); + param_114 = _e191.xyz; + let _e193 = SrgbToLinear_u0028_vf3_u003b((¶m_114)); + emissive = _e193; + let _e194 = emissive; + let _e196 = frag_info.emissive; + let _e201 = frag_info.material2_[3u]; + (*s_6).emissive = ((_e194 * _e196.xyz) * _e201); return; } -fn ApplyOcclusionMap_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b(s_6: ptr) { +fn ApplyOcclusionMap_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b(s_7: ptr) { var occlusion: f32; - let _e177 = v_texcoord_1; - let _e178 = MaterialLodBias_u0028_(); - let _e179 = textureSampleBias(occlusion_texture_tex, occlusion_texture_smp, _e177, _e178); - occlusion = _e179.x; - let _e181 = occlusion; - let _e184 = frag_info.material2_[2u]; - (*s_6).occlusion = mix(1f, _e181, clamp(_e184, 0f, 1f)); + let _e188 = v_texcoord_1; + let _e189 = MaterialLodBias_u0028_(); + let _e190 = textureSampleBias(occlusion_texture_tex, occlusion_texture_smp, _e188, _e189); + occlusion = _e190.x; + let _e192 = occlusion; + let _e195 = frag_info.material2_[2u]; + (*s_7).occlusion = mix(1f, _e192, clamp(_e195, 0f, 1f)); return; } -fn ApplyNormalMap_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b(s_7: ptr) { +fn ApplyNormalMap_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b(s_8: ptr) { var sampledTexel: vec4; var t_2: vec3; var b_3: vec3; var sampled: vec3; - let _e180 = v_texcoord_1; - let _e181 = MaterialLodBias_u0028_(); - let _e182 = textureSampleBias(normal_texture_tex, normal_texture_smp, _e180, _e181); - sampledTexel = _e182; - let _e183 = v_tangent_1; - t_2 = _e183.xyz; - let _e185 = t_2; - let _e187 = (*s_7).n; - let _e189 = (*s_7).n; - let _e190 = t_2; - t_2 = (_e185 - (_e187 * dot(_e189, _e190))); - let _e194 = t_2; - let _e195 = t_2; - if (dot(_e194, _e195) < 0.000000000001f) { + let _e191 = v_texcoord_1; + let _e192 = MaterialLodBias_u0028_(); + let _e193 = textureSampleBias(normal_texture_tex, normal_texture_smp, _e191, _e192); + sampledTexel = _e193; + let _e194 = v_tangent_1; + t_2 = _e194.xyz; + let _e196 = t_2; + let _e198 = (*s_8).n; + let _e200 = (*s_8).n; + let _e201 = t_2; + t_2 = (_e196 - (_e198 * dot(_e200, _e201))); + let _e205 = t_2; + let _e206 = t_2; + if (dot(_e205, _e206) < 0.000000000001f) { return; } - let _e198 = t_2; - t_2 = normalize(_e198); - let _e201 = (*s_7).n; - let _e202 = t_2; - let _e205 = v_tangent_1[3u]; - b_3 = (cross(_e201, _e202) * _e205); - let _e207 = gl_FrontFacing_1; - if !(_e207) { - let _e209 = t_2; - t_2 = -(_e209); - } - let _e211 = sampledTexel; - sampled = ((_e211.xyz * 2f) - vec3(1f)); - let _e218 = frag_info.emissive[3u]; - if (_e218 > 0.5f) { - let _e220 = sampled; - let _e222 = sampled; - sampled[2u] = sqrt(max((1f - dot(_e220.xy, _e222.xy)), 0f)); - } - let _e231 = frag_info.material2_[1u]; - let _e232 = sampled; - let _e234 = (_e232.xy * _e231); - sampled[0u] = _e234.x; - sampled[1u] = _e234.y; - let _e239 = t_2; - let _e241 = sampled[0u]; - let _e243 = b_3; - let _e245 = sampled[1u]; - let _e249 = (*s_7).n; - let _e251 = sampled[2u]; - (*s_7).n = normalize((((_e239 * _e241) + (_e243 * _e245)) + (_e249 * _e251))); - let _e257 = (*s_7).n; - let _e259 = (*s_7).v; - (*s_7).n_dot_v = max(dot(_e257, _e259), 0.0001f); + let _e209 = t_2; + t_2 = normalize(_e209); + let _e212 = (*s_8).n; + let _e213 = t_2; + let _e216 = v_tangent_1[3u]; + b_3 = (cross(_e212, _e213) * _e216); + let _e218 = gl_FrontFacing_1; + if !(_e218) { + let _e220 = t_2; + t_2 = -(_e220); + } + let _e222 = sampledTexel; + sampled = ((_e222.xyz * 2f) - vec3(1f)); + let _e229 = frag_info.emissive[3u]; + if (_e229 > 0.5f) { + let _e231 = sampled; + let _e233 = sampled; + sampled[2u] = sqrt(max((1f - dot(_e231.xy, _e233.xy)), 0f)); + } + let _e242 = frag_info.material2_[1u]; + let _e243 = sampled; + let _e245 = (_e243.xy * _e242); + sampled[0u] = _e245.x; + sampled[1u] = _e245.y; + let _e250 = t_2; + let _e252 = sampled[0u]; + let _e254 = b_3; + let _e256 = sampled[1u]; + let _e260 = (*s_8).n; + let _e262 = sampled[2u]; + (*s_8).n = normalize((((_e250 * _e252) + (_e254 * _e256)) + (_e260 * _e262))); + let _e268 = (*s_8).n; + let _e270 = (*s_8).v; + (*s_8).n_dot_v = max(dot(_e268, _e270), 0.0001f); return; } fn AtlasTexel_u0028_vf2_u003b(texel_5: ptr>) -> vec4 { - let _e176 = (*texel_5); - let _e180 = irradiance_info.atlas; - let _e183 = textureSampleLevel(irradiance_texture_tex, irradiance_texture_smp, ((_e176 + vec2(0.5f)) * _e180.xy), 0f); - return _e183; + let _e187 = (*texel_5); + let _e191 = irradiance_info.atlas; + let _e194 = textureSampleLevel(irradiance_texture_tex, irradiance_texture_smp, ((_e187 + vec2(0.5f)) * _e191.xy), 0f); + return _e194; } fn TileBilinear_u0028_vf2_u003b_f1_u003b_vf2_u003b(corner: ptr>, interior: ptr, uv_5: ptr>) -> vec4 { @@ -7544,50 +9104,50 @@ fn TileBilinear_u0028_vf2_u003b_f1_u003b_vf2_u003b(corner: ptr; var f: vec2; var a_4: vec4; - var param_109: vec2; + var param_115: vec2; var b_4: vec4; - var param_110: vec2; - var c: vec4; - var param_111: vec2; + var param_116: vec2; + var c_1: vec4; + var param_117: vec2; var d_4: vec4; - var param_112: vec2; + var param_118: vec2; - let _e189 = (*uv_5); - let _e190 = (*interior); - at_1 = ((vec2(1f) + (_e189 * _e190)) - vec2(0.5f)); - let _e196 = at_1; - low = floor(_e196); - let _e198 = at_1; - let _e199 = low; - f = (_e198 - _e199); - let _e201 = (*corner); - let _e202 = low; - param_109 = (_e201 + _e202); - let _e204 = AtlasTexel_u0028_vf2_u003b((¶m_109)); - a_4 = _e204; - let _e205 = (*corner); - let _e206 = low; - param_110 = ((_e205 + _e206) + vec2(1f, 0f)); - let _e209 = AtlasTexel_u0028_vf2_u003b((¶m_110)); - b_4 = _e209; - let _e210 = (*corner); - let _e211 = low; - param_111 = ((_e210 + _e211) + vec2(0f, 1f)); - let _e214 = AtlasTexel_u0028_vf2_u003b((¶m_111)); - c = _e214; - let _e215 = (*corner); - let _e216 = low; - param_112 = ((_e215 + _e216) + vec2(1f, 1f)); - let _e219 = AtlasTexel_u0028_vf2_u003b((¶m_112)); - d_4 = _e219; - let _e220 = a_4; - let _e221 = b_4; - let _e223 = f[0u]; - let _e226 = c; - let _e227 = d_4; - let _e229 = f[0u]; - let _e233 = f[1u]; - return mix(mix(_e220, _e221, vec4(_e223)), mix(_e226, _e227, vec4(_e229)), vec4(_e233)); + let _e200 = (*uv_5); + let _e201 = (*interior); + at_1 = ((vec2(1f) + (_e200 * _e201)) - vec2(0.5f)); + let _e207 = at_1; + low = floor(_e207); + let _e209 = at_1; + let _e210 = low; + f = (_e209 - _e210); + let _e212 = (*corner); + let _e213 = low; + param_115 = (_e212 + _e213); + let _e215 = AtlasTexel_u0028_vf2_u003b((¶m_115)); + a_4 = _e215; + let _e216 = (*corner); + let _e217 = low; + param_116 = ((_e216 + _e217) + vec2(1f, 0f)); + let _e220 = AtlasTexel_u0028_vf2_u003b((¶m_116)); + b_4 = _e220; + let _e221 = (*corner); + let _e222 = low; + param_117 = ((_e221 + _e222) + vec2(0f, 1f)); + let _e225 = AtlasTexel_u0028_vf2_u003b((¶m_117)); + c_1 = _e225; + let _e226 = (*corner); + let _e227 = low; + param_118 = ((_e226 + _e227) + vec2(1f, 1f)); + let _e230 = AtlasTexel_u0028_vf2_u003b((¶m_118)); + d_4 = _e230; + let _e231 = a_4; + let _e232 = b_4; + let _e234 = f[0u]; + let _e237 = c_1; + let _e238 = d_4; + let _e240 = f[0u]; + let _e244 = f[1u]; + return mix(mix(_e231, _e232, vec4(_e234)), mix(_e237, _e238, vec4(_e240)), vec4(_e244)); } fn ProbeOctahedral_u0028_vf3_u003b(direction_1: ptr>) -> vec2 { @@ -7595,32 +9155,32 @@ fn ProbeOctahedral_u0028_vf3_u003b(direction_1: ptr>) -> vec var n_5: vec3; var xy: vec2; - let _e180 = (*direction_1)[0u]; - let _e183 = (*direction_1)[1u]; - let _e187 = (*direction_1)[2u]; - sum_1 = ((abs(_e180) + abs(_e183)) + abs(_e187)); - let _e190 = sum_1; - if (_e190 <= 0f) { + let _e191 = (*direction_1)[0u]; + let _e194 = (*direction_1)[1u]; + let _e198 = (*direction_1)[2u]; + sum_1 = ((abs(_e191) + abs(_e194)) + abs(_e198)); + let _e201 = sum_1; + if (_e201 <= 0f) { return vec2(0.5f, 0.5f); } - let _e192 = (*direction_1); - let _e193 = sum_1; - n_5 = (_e192 / vec3(_e193)); - let _e196 = n_5; - xy = _e196.xy; - let _e199 = n_5[2u]; - if (_e199 < 0f) { - let _e202 = n_5[1u]; - let _e206 = n_5[0u]; - let _e211 = n_5[0u]; - let _e215 = n_5[1u]; - xy = vec2(((1f - abs(_e202)) * select(-1f, 1f, (_e206 >= 0f))), ((1f - abs(_e211)) * select(-1f, 1f, (_e215 >= 0f)))); - } - let _e220 = xy; - return ((_e220 * 0.5f) + vec2(0.5f)); + let _e203 = (*direction_1); + let _e204 = sum_1; + n_5 = (_e203 / vec3(_e204)); + let _e207 = n_5; + xy = _e207.xy; + let _e210 = n_5[2u]; + if (_e210 < 0f) { + let _e213 = n_5[1u]; + let _e217 = n_5[0u]; + let _e222 = n_5[0u]; + let _e226 = n_5[1u]; + xy = vec2(((1f - abs(_e213)) * select(-1f, 1f, (_e217 >= 0f))), ((1f - abs(_e222)) * select(-1f, 1f, (_e226 >= 0f)))); + } + let _e231 = xy; + return ((_e231 * 0.5f) + vec2(0.5f)); } -fn SampleIrradiance_u0028_vf3_u003b_vf3_u003b_vf3_u003b(world_4: ptr>, normal_1: ptr>, view: ptr>) -> vec3 { +fn SampleIrradiance_u0028_vf3_u003b_vf3_u003b_vf3_u003b(world_4: ptr>, normal_1: ptr>, view_1: ptr>) -> vec3 { var origin_2: vec3; var spacing: vec3; var counts: vec3; @@ -7642,9 +9202,9 @@ fn SampleIrradiance_u0028_vf3_u003b_vf3_u003b_vf3_u003b(world_4: ptr; var irradianceCorner: vec2; var momentCorner: vec2; - var param_113: vec2; + var param_119: vec2; var trilinear: vec3; - var weight_2: f32; + var weight_3: f32; var probePosition: vec3; var toProbe: vec3; var distance_4: f32; @@ -7652,370 +9212,413 @@ fn SampleIrradiance_u0028_vf3_u003b_vf3_u003b_vf3_u003b(world_4: ptr; - var param_114: vec3; - var param_115: vec2; - var param_116: f32; - var param_117: vec2; + var param_120: vec3; + var param_121: vec2; + var param_122: f32; + var param_123: vec2; var chebyshev: f32; var variance_1: f32; var difference: f32; - var param_118: vec3; - var param_119: vec2; - var param_120: f32; - var param_121: vec2; - var local_23: vec3; - - let _e222 = irradiance_info.origin; - origin_2 = _e222.xyz; - let _e225 = irradiance_info.spacing; - spacing = _e225.xyz; - let _e228 = irradiance_info.counts; - counts = _e228.xyz; - let _e232 = irradiance_info.tiles[0u]; - irradianceTile = _e232; - let _e235 = irradiance_info.tiles[1u]; - depthTile = _e235; - let _e238 = irradiance_info.tiles[2u]; - columns = _e238; - let _e241 = irradiance_info.tiles[3u]; - momentsTop = _e241; - let _e242 = (*normal_1); - unit = normalize(_e242); - let _e244 = (*world_4); - let _e245 = unit; - let _e248 = irradiance_info.spacing[3u]; - let _e251 = (*view); - let _e254 = irradiance_info.counts[3u]; - biased = ((_e244 + (_e245 * _e248)) + (_e251 * _e254)); - let _e257 = biased; - let _e258 = origin_2; - let _e260 = spacing; - grid_1 = ((_e257 - _e258) / _e260); - let _e262 = grid_1; - let _e264 = counts; - base = clamp(floor(_e262), vec3(0f, 0f, 0f), (_e264 - vec3(2f))); - let _e268 = grid_1; - let _e269 = base; - f_1 = clamp((_e268 - _e269), vec3(0f, 0f, 0f), vec3(1f, 1f, 1f)); + var param_124: vec3; + var param_125: vec2; + var param_126: f32; + var param_127: vec2; + var local_28: vec3; + + let _e233 = irradiance_info.origin; + origin_2 = _e233.xyz; + let _e236 = irradiance_info.spacing; + spacing = _e236.xyz; + let _e239 = irradiance_info.counts; + counts = _e239.xyz; + let _e243 = irradiance_info.tiles[0u]; + irradianceTile = _e243; + let _e246 = irradiance_info.tiles[1u]; + depthTile = _e246; + let _e249 = irradiance_info.tiles[2u]; + columns = _e249; + let _e252 = irradiance_info.tiles[3u]; + momentsTop = _e252; + let _e253 = (*normal_1); + unit = normalize(_e253); + let _e255 = (*world_4); + let _e256 = unit; + let _e259 = irradiance_info.spacing[3u]; + let _e262 = (*view_1); + let _e265 = irradiance_info.counts[3u]; + biased = ((_e255 + (_e256 * _e259)) + (_e262 * _e265)); + let _e268 = biased; + let _e269 = origin_2; + let _e271 = spacing; + grid_1 = ((_e268 - _e269) / _e271); + let _e273 = grid_1; + let _e275 = counts; + base = clamp(floor(_e273), vec3(0f, 0f, 0f), (_e275 - vec3(2f))); + let _e279 = grid_1; + let _e280 = base; + f_1 = clamp((_e279 - _e280), vec3(0f, 0f, 0f), vec3(1f, 1f, 1f)); total_2 = vec3(0f, 0f, 0f); weights = 0f; corner_1 = 0i; loop { - let _e272 = corner_1; - if (_e272 < 8i) { - let _e274 = corner_1; - let _e277 = corner_1; - let _e282 = corner_1; - offset_3 = vec3(f32((_e274 & 1i)), f32(((_e277 >> bitcast(1i)) & 1i)), f32(((_e282 >> bitcast(2i)) & 1i))); - let _e288 = base; - let _e289 = offset_3; - cell_1 = (_e288 + _e289); - let _e292 = cell_1[2u]; - let _e294 = counts[1u]; - let _e297 = cell_1[1u]; - let _e300 = counts[0u]; - let _e303 = cell_1[0u]; - probe = ((((_e292 * _e294) + _e297) * _e300) + _e303); - let _e305 = probe; - let _e306 = columns; - let _e311 = probe; - let _e312 = columns; - tile_4 = vec2((_e305 - (floor((_e305 / _e306)) * _e306)), floor((_e311 / _e312))); - let _e316 = tile_4; - let _e317 = irradianceTile; - irradianceCorner = (_e316 * (_e317 + 2f)); - let _e321 = tile_4[0u]; - let _e322 = depthTile; - let _e325 = momentsTop; - let _e327 = tile_4[1u]; - let _e328 = depthTile; - momentCorner = vec2((_e321 * (_e322 + 2f)), (_e325 + (_e327 * (_e328 + 2f)))); - let _e333 = irradianceCorner; - param_113 = (_e333 + vec2(1f)); - let _e336 = AtlasTexel_u0028_vf2_u003b((¶m_113)); - if (_e336.w < 0.5f) { + let _e283 = corner_1; + if (_e283 < 8i) { + let _e285 = corner_1; + let _e288 = corner_1; + let _e293 = corner_1; + offset_3 = vec3(f32((_e285 & 1i)), f32(((_e288 >> bitcast(1i)) & 1i)), f32(((_e293 >> bitcast(2i)) & 1i))); + let _e299 = base; + let _e300 = offset_3; + cell_1 = (_e299 + _e300); + let _e303 = cell_1[2u]; + let _e305 = counts[1u]; + let _e308 = cell_1[1u]; + let _e311 = counts[0u]; + let _e314 = cell_1[0u]; + probe = ((((_e303 * _e305) + _e308) * _e311) + _e314); + let _e316 = probe; + let _e317 = columns; + let _e322 = probe; + let _e323 = columns; + tile_4 = vec2((_e316 - (floor((_e316 / _e317)) * _e317)), floor((_e322 / _e323))); + let _e327 = tile_4; + let _e328 = irradianceTile; + irradianceCorner = (_e327 * (_e328 + 2f)); + let _e332 = tile_4[0u]; + let _e333 = depthTile; + let _e336 = momentsTop; + let _e338 = tile_4[1u]; + let _e339 = depthTile; + momentCorner = vec2((_e332 * (_e333 + 2f)), (_e336 + (_e338 * (_e339 + 2f)))); + let _e344 = irradianceCorner; + param_119 = (_e344 + vec2(1f)); + let _e347 = AtlasTexel_u0028_vf2_u003b((¶m_119)); + if (_e347.w < 0.5f) { continue; } - let _e339 = f_1; - let _e341 = f_1; - let _e342 = offset_3; - trilinear = mix((vec3(1f, 1f, 1f) - _e339), _e341, _e342); - let _e345 = trilinear[0u]; - let _e347 = trilinear[1u]; - let _e350 = trilinear[2u]; - weight_2 = max(((_e345 * _e347) * _e350), 0.001f); - let _e353 = origin_2; - let _e354 = spacing; - let _e355 = cell_1; - probePosition = (_e353 + (_e354 * _e355)); - let _e358 = probePosition; - let _e359 = biased; - toProbe = (_e358 - _e359); - let _e361 = toProbe; - distance_4 = length(_e361); - let _e363 = distance_4; - if (_e363 > 0.000001f) { - let _e365 = toProbe; - let _e366 = distance_4; - direction_2 = (_e365 / vec3(_e366)); - let _e369 = unit; - let _e370 = probePosition; - let _e371 = (*world_4); - facing = ((dot(_e369, normalize((_e370 - _e371))) * 0.5f) + 0.5f); - let _e377 = facing; - let _e378 = facing; - probeWeight = ((_e377 * _e378) + 0.2f); - let _e381 = direction_2; - param_114 = -(_e381); - let _e383 = ProbeOctahedral_u0028_vf3_u003b((¶m_114)); - let _e384 = momentCorner; - param_115 = _e384; - let _e385 = depthTile; - param_116 = _e385; - param_117 = _e383; - let _e386 = TileBilinear_u0028_vf2_u003b_f1_u003b_vf2_u003b((¶m_115), (¶m_116), (¶m_117)); - moments_3 = _e386.xy; + let _e350 = f_1; + let _e352 = f_1; + let _e353 = offset_3; + trilinear = mix((vec3(1f, 1f, 1f) - _e350), _e352, _e353); + let _e356 = trilinear[0u]; + let _e358 = trilinear[1u]; + let _e361 = trilinear[2u]; + weight_3 = max(((_e356 * _e358) * _e361), 0.001f); + let _e364 = origin_2; + let _e365 = spacing; + let _e366 = cell_1; + probePosition = (_e364 + (_e365 * _e366)); + let _e369 = probePosition; + let _e370 = biased; + toProbe = (_e369 - _e370); + let _e372 = toProbe; + distance_4 = length(_e372); + let _e374 = distance_4; + if (_e374 > 0.000001f) { + let _e376 = toProbe; + let _e377 = distance_4; + direction_2 = (_e376 / vec3(_e377)); + let _e380 = unit; + let _e381 = probePosition; + let _e382 = (*world_4); + facing = ((dot(_e380, normalize((_e381 - _e382))) * 0.5f) + 0.5f); + let _e388 = facing; + let _e389 = facing; + probeWeight = ((_e388 * _e389) + 0.2f); + let _e392 = direction_2; + param_120 = -(_e392); + let _e394 = ProbeOctahedral_u0028_vf3_u003b((¶m_120)); + let _e395 = momentCorner; + param_121 = _e395; + let _e396 = depthTile; + param_122 = _e396; + param_123 = _e394; + let _e397 = TileBilinear_u0028_vf2_u003b_f1_u003b_vf2_u003b((¶m_121), (¶m_122), (¶m_123)); + moments_3 = _e397.xy; chebyshev = 1f; - let _e388 = distance_4; - let _e390 = moments_3[0u]; - if (_e388 > _e390) { - let _e393 = moments_3[1u]; - let _e395 = moments_3[0u]; - let _e397 = moments_3[0u]; - variance_1 = max((_e393 - (_e395 * _e397)), 0.000001f); - let _e401 = distance_4; - let _e403 = moments_3[0u]; - difference = (_e401 - _e403); - let _e405 = variance_1; - let _e406 = variance_1; - let _e407 = difference; - let _e408 = difference; - chebyshev = (_e405 / (_e406 + (_e407 * _e408))); - let _e412 = chebyshev; - let _e413 = chebyshev; - let _e415 = chebyshev; - chebyshev = ((_e412 * _e413) * _e415); + let _e399 = distance_4; + let _e401 = moments_3[0u]; + if (_e399 > _e401) { + let _e404 = moments_3[1u]; + let _e406 = moments_3[0u]; + let _e408 = moments_3[0u]; + variance_1 = max((_e404 - (_e406 * _e408)), 0.000001f); + let _e412 = distance_4; + let _e414 = moments_3[0u]; + difference = (_e412 - _e414); + let _e416 = variance_1; + let _e417 = variance_1; + let _e418 = difference; + let _e419 = difference; + chebyshev = (_e416 / (_e417 + (_e418 * _e419))); + let _e423 = chebyshev; + let _e424 = chebyshev; + let _e426 = chebyshev; + chebyshev = ((_e423 * _e424) * _e426); } - let _e417 = probeWeight; - let _e418 = chebyshev; - probeWeight = max((_e417 * max(_e418, 0.05f)), 0.000001f); - let _e422 = probeWeight; - if (_e422 < 0.2f) { - let _e424 = probeWeight; - let _e425 = probeWeight; - let _e428 = probeWeight; - probeWeight = (_e428 * ((_e424 * _e425) * 25f)); + let _e428 = probeWeight; + let _e429 = chebyshev; + probeWeight = max((_e428 * max(_e429, 0.05f)), 0.000001f); + let _e433 = probeWeight; + if (_e433 < 0.2f) { + let _e435 = probeWeight; + let _e436 = probeWeight; + let _e439 = probeWeight; + probeWeight = (_e439 * ((_e435 * _e436) * 25f)); } - let _e430 = probeWeight; - let _e431 = weight_2; - weight_2 = (_e431 * _e430); - } - let _e433 = unit; - param_118 = _e433; - let _e434 = ProbeOctahedral_u0028_vf3_u003b((¶m_118)); - let _e435 = irradianceCorner; - param_119 = _e435; - let _e436 = irradianceTile; - param_120 = _e436; - param_121 = _e434; - let _e437 = TileBilinear_u0028_vf2_u003b_f1_u003b_vf2_u003b((¶m_119), (¶m_120), (¶m_121)); - let _e439 = weight_2; - let _e441 = total_2; - total_2 = (_e441 + (_e437.xyz * _e439)); - let _e443 = weight_2; - let _e444 = weights; - weights = (_e444 + _e443); + let _e441 = probeWeight; + let _e442 = weight_3; + weight_3 = (_e442 * _e441); + } + let _e444 = unit; + param_124 = _e444; + let _e445 = ProbeOctahedral_u0028_vf3_u003b((¶m_124)); + let _e446 = irradianceCorner; + param_125 = _e446; + let _e447 = irradianceTile; + param_126 = _e447; + param_127 = _e445; + let _e448 = TileBilinear_u0028_vf2_u003b_f1_u003b_vf2_u003b((¶m_125), (¶m_126), (¶m_127)); + let _e450 = weight_3; + let _e452 = total_2; + total_2 = (_e452 + (_e448.xyz * _e450)); + let _e454 = weight_3; + let _e455 = weights; + weights = (_e455 + _e454); continue; } else { break; } continuing { - let _e446 = corner_1; - corner_1 = (_e446 + 1i); + let _e457 = corner_1; + corner_1 = (_e457 + 1i); } } - let _e448 = weights; - if (_e448 > 0f) { - let _e450 = total_2; - let _e451 = weights; - local_23 = (_e450 / vec3(_e451)); + let _e459 = weights; + if (_e459 > 0f) { + let _e461 = total_2; + let _e462 = weights; + local_28 = (_e461 / vec3(_e462)); } else { - local_23 = vec3(0f, 0f, 0f); + local_28 = vec3(0f, 0f, 0f); } - let _e454 = local_23; - return _e454; + let _e465 = local_28; + return _e465; } fn IrradianceEnabled_u0028_() -> bool { - let _e177 = irradiance_info.origin[3u]; - return (_e177 > 0.5f); + let _e188 = irradiance_info.origin[3u]; + return (_e188 > 0.5f); } -fn ApplyCommonMaps_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b(s_8: ptr) { - var param_122: vec3; - var param_123: vec3; - var param_124: vec3; - var param_125: Surface; - var param_126: Surface; - var param_127: Surface; - - let _e182 = IrradianceEnabled_u0028_(); - if _e182 { - let _e183 = v_world_position_1; - param_122 = _e183; - let _e185 = (*s_8).n; - param_123 = _e185; - let _e187 = (*s_8).v; - param_124 = _e187; - let _e188 = SampleIrradiance_u0028_vf3_u003b_vf3_u003b_vf3_u003b((¶m_122), (¶m_123), (¶m_124)); - let _e191 = frag_info.material[2u]; - (*s_8).ambient = (_e188 * _e191); - } - let _e194 = (*s_8); - param_125 = _e194; - ApplyNormalMap_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b((¶m_125)); - let _e195 = param_125; - (*s_8) = _e195; - let _e196 = (*s_8); - param_126 = _e196; - ApplyOcclusionMap_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b((¶m_126)); - let _e197 = param_126; - (*s_8) = _e197; - let _e198 = (*s_8); - param_127 = _e198; - ApplyEmissiveMap_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b((¶m_127)); - let _e199 = param_127; - (*s_8) = _e199; +fn ApplyCommonMaps_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b(s_9: ptr) { + var param_128: vec3; + var param_129: vec3; + var param_130: vec3; + var param_131: Surface; + var param_132: Surface; + var param_133: Surface; + + let _e193 = IrradianceEnabled_u0028_(); + if _e193 { + let _e194 = v_world_position_1; + param_128 = _e194; + let _e196 = (*s_9).n; + param_129 = _e196; + let _e198 = (*s_9).v; + param_130 = _e198; + let _e199 = SampleIrradiance_u0028_vf3_u003b_vf3_u003b_vf3_u003b((¶m_128), (¶m_129), (¶m_130)); + let _e202 = frag_info.material[2u]; + (*s_9).ambient = (_e199 * _e202); + } + let _e205 = (*s_9); + param_131 = _e205; + ApplyNormalMap_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b((¶m_131)); + let _e206 = param_131; + (*s_9) = _e206; + let _e207 = (*s_9); + param_132 = _e207; + ApplyOcclusionMap_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b((¶m_132)); + let _e208 = param_132; + (*s_9) = _e208; + let _e209 = (*s_9); + param_133 = _e209; + ApplyEmissiveMap_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b((¶m_133)); + let _e210 = param_133; + (*s_9) = _e210; return; } +fn TowardsEye_u0028_() -> vec3 { + var local_29: vec3; + + let _e187 = Orthographic_u0028_(); + if _e187 { + let _e189 = fog_info.forward; + local_29 = -(_e189.xyz); + } else { + let _e193 = fog_info.eye; + let _e195 = v_world_position_1; + local_29 = normalize((_e193.xyz - _e195)); + } + let _e198 = local_29; + return _e198; +} + fn ReadSurface_u0028_() -> Surface { var texel_6: vec4; - var s_9: Surface; - var param_128: vec3; - var param_129: vec3; + var s_10: Surface; + var param_134: vec3; + var param_135: vec3; var cutoff: f32; + var edge: f32; var anchored: vec3; var noise_1: f32; - let _e182 = v_texcoord_1; - let _e183 = MaterialLodBias_u0028_(); - let _e184 = textureSampleBias(base_color_texture_tex, base_color_texture_smp, _e182, _e183); - texel_6 = _e184; - let _e185 = texel_6; - param_128 = _e185.xyz; - let _e187 = SrgbToLinear_u0028_vf3_u003b((¶m_128)); - let _e189 = frag_info.base_color; - param_129 = _e189.xyz; - let _e191 = SrgbToLinear_u0028_vf3_u003b((¶m_129)); - let _e193 = v_color_1; - s_9.albedo = ((_e187 * _e191) * _e193.xyz); - let _e198 = texel_6[3u]; - let _e201 = frag_info.base_color[3u]; - let _e204 = v_color_1[3u]; - s_9.alpha = ((_e198 * _e201) * _e204); - let _e208 = s_9.albedo; - g_albedo = _e208; - let _e211 = frag_info.material2_[0u]; - cutoff = _e211; - let _e212 = cutoff; - if (_e212 >= 0f) { - let _e215 = s_9.alpha; - let _e216 = cutoff; - if (_e215 < _e216) { - discard; - } + let _e194 = v_texcoord_1; + let _e195 = MaterialLodBias_u0028_(); + let _e196 = textureSampleBias(base_color_texture_tex, base_color_texture_smp, _e194, _e195); + texel_6 = _e196; + let _e197 = texel_6; + param_134 = _e197.xyz; + let _e199 = SrgbToLinear_u0028_vf3_u003b((¶m_134)); + let _e201 = frag_info.base_color; + param_135 = _e201.xyz; + let _e203 = SrgbToLinear_u0028_vf3_u003b((¶m_135)); + let _e205 = v_color_1; + s_10.albedo = ((_e199 * _e203) * _e205.xyz); + let _e210 = texel_6[3u]; + let _e213 = frag_info.base_color[3u]; + let _e216 = v_color_1[3u]; + s_10.alpha = ((_e210 * _e213) * _e216); + let _e220 = s_10.albedo; + g_albedo = _e220; + let _e223 = frag_info.material2_[0u]; + cutoff = _e223; + let _e224 = cutoff; + if (_e224 > 1f) { + let _e226 = cutoff; + edge = (_e226 - 1f); + let _e229 = s_10.alpha; + let _e230 = edge; + let _e233 = s_10.alpha; + let _e234 = fwidth(_e233); + s_10.alpha = clamp((((_e229 - _e230) / max(_e234, 0.0001f)) + 0.5f), 0f, 1f); } else { - let _e218 = cutoff; - if (_e218 < -1.5f) { - let _e220 = v_world_position_1; - anchored = floor((_e220 * 16f)); - let _e223 = anchored; - noise_1 = fract((sin(dot(_e223, vec3(12.9898f, 78.233f, 37.719f))) * 43758.547f)); - let _e229 = s_9.alpha; - let _e230 = noise_1; - if (_e229 < _e230) { + let _e240 = cutoff; + if (_e240 >= 0f) { + let _e243 = s_10.alpha; + let _e244 = cutoff; + if (_e243 < _e244) { discard; } + s_10.alpha = 1f; + } else { + let _e247 = cutoff; + if (_e247 < -1.5f) { + let _e249 = v_world_position_1; + anchored = floor((_e249 * 16f)); + let _e252 = anchored; + noise_1 = fract((sin(dot(_e252, vec3(12.9898f, 78.233f, 37.719f))) * 43758.547f)); + let _e258 = s_10.alpha; + let _e259 = noise_1; + if (_e258 < _e259) { + discard; + } + } } } - let _e232 = cutoff; - let _e234 = cutoff; - g_premultiply = ((_e232 < 0f) && (_e234 > -0.75f)); - let _e237 = v_normal_1; - s_9.n = normalize(_e237); - let _e240 = gl_FrontFacing_1; - if !(_e240) { - let _e243 = s_9.n; - s_9.n = -(_e243); - } - let _e247 = frag_info.camera_position; - let _e249 = v_world_position_1; - s_9.v = normalize((_e247.xyz - _e249)); - let _e254 = s_9.n; - let _e256 = s_9.v; - s_9.n_dot_v = max(dot(_e254, _e256), 0.0001f); - let _e262 = frag_info.material[0u]; - s_9.metallic = clamp(_e262, 0f, 1f); - let _e267 = frag_info.material[1u]; - s_9.roughness = clamp(_e267, 0.02f, 1f); - let _e271 = frag_info.ambient_ground; - let _e274 = frag_info.ambient_sky; - let _e278 = s_9.n[1u]; - let _e285 = frag_info.material[2u]; - s_9.ambient = (mix(_e271.xyz, _e274.xyz, vec3(((_e278 * 0.5f) + 0.5f))) * _e285); - let _e290 = frag_info.frame_params[0u]; - s_9.exposure = max(_e290, 0f); - s_9.occlusion = 1f; - s_9.emissive = vec3(0f, 0f, 0f); - let _e295 = s_9; - return _e295; + let _e261 = cutoff; + let _e263 = cutoff; + g_premultiply = ((_e261 < 0f) && (_e263 > -0.75f)); + let _e266 = v_normal_1; + s_10.n = normalize(_e266); + let _e269 = gl_FrontFacing_1; + if !(_e269) { + let _e272 = s_10.n; + s_10.n = -(_e272); + } + let _e275 = TowardsEye_u0028_(); + s_10.v = _e275; + let _e278 = s_10.n; + let _e280 = s_10.v; + s_10.n_dot_v = max(dot(_e278, _e280), 0.0001f); + let _e286 = frag_info.material[0u]; + s_10.metallic = clamp(_e286, 0f, 1f); + let _e291 = frag_info.material[1u]; + s_10.roughness = clamp(_e291, 0.02f, 1f); + let _e295 = frag_info.ambient_ground; + let _e298 = frag_info.ambient_sky; + let _e302 = s_10.n[1u]; + let _e309 = frag_info.material[2u]; + s_10.ambient = (mix(_e295.xyz, _e298.xyz, vec3(((_e302 * 0.5f) + 0.5f))) * _e309); + let _e314 = frag_info.frame_params[0u]; + s_10.exposure = max(_e314, 0f); + s_10.occlusion = 1f; + s_10.emissive = vec3(0f, 0f, 0f); + let _e319 = s_10; + return _e319; } fn main_1() { - var s_10: Surface; - var param_130: Surface; + var s_11: Surface; + var param_136: Surface; var ambient: vec3; - var param_131: Surface; - var param_132: vec3; - var param_133: f32; - var param_134: f32; + var lit_3: vec3; + var param_137: Surface; + var param_138: Surface; + var param_139: vec3; + var param_140: vec3; + var param_141: f32; + var param_142: f32; g_albedo = vec3(0f, 0f, 0f); g_debug_surface = vec3(0f, 0f, 0f); g_debug_surface_on = false; g_premultiply = false; + g_pass_through = 0f; g_cluster_offset = 0f; g_cluster_count = 0f; - let _e182 = ReadSurface_u0028_(); - s_10 = _e182; - let _e183 = s_10; - param_130 = _e183; - ApplyCommonMaps_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b((¶m_130)); - let _e184 = param_130; - s_10 = _e184; - let _e186 = s_10.albedo; - let _e188 = s_10.ambient; - let _e189 = SampleLightmap_u0028_(); - let _e193 = s_10.occlusion; - ambient = ((_e186 * (_e188 + _e189)) * _e193); - let _e195 = s_10; - param_131 = _e195; - let _e196 = AccumulateLights_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b((¶m_131)); - let _e198 = s_10.occlusion; - let _e200 = ambient; - let _e203 = s_10.emissive; - param_132 = (((_e196 * _e198) + _e200) + _e203); - let _e206 = s_10.alpha; - param_133 = _e206; - let _e208 = s_10.roughness; - param_134 = _e208; - WriteSurface_u0028_vf3_u003b_f1_u003b_f1_u003b((¶m_132), (¶m_133), (¶m_134)); + light_transmittance = vec3(1f, 1f, 1f); + let _e196 = ReadSurface_u0028_(); + s_11 = _e196; + let _e197 = s_11; + param_136 = _e197; + ApplyCommonMaps_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b((¶m_136)); + let _e198 = param_136; + s_11 = _e198; + let _e200 = s_11.albedo; + let _e202 = s_11.ambient; + let _e203 = SampleLightmap_u0028_(); + let _e207 = s_11.occlusion; + ambient = ((_e200 * (_e202 + _e203)) * _e207); + let _e209 = s_11; + param_137 = _e209; + let _e210 = AccumulateLights_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b((¶m_137)); + let _e212 = s_11.occlusion; + let _e214 = ambient; + let _e217 = s_11.emissive; + lit_3 = (((_e210 * _e212) + _e214) + _e217); + let _e219 = s_11; + param_138 = _e219; + let _e220 = lit_3; + param_139 = _e220; + let _e221 = WriteDebugView_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b_vf3_u003b((¶m_138), (¶m_139)); + if _e221 { + return; + } + let _e222 = lit_3; + param_140 = _e222; + let _e224 = s_11.alpha; + param_141 = _e224; + let _e226 = s_11.roughness; + param_142 = _e226; + WriteSurface_u0028_vf3_u003b_f1_u003b_f1_u003b((¶m_140), (¶m_141), (¶m_142)); return; } @fragment -fn main(@location(6) v_world_position: vec3, @location(2) v_normal: vec3, @builtin(front_facing) gl_FrontFacing: bool, @builtin(position) gl_FragCoord: vec4, @location(4) v_texcoord: vec2, @location(0) v_color: vec4, @location(1) v_lightmap_uv: vec2, @location(3) v_tangent: vec4) -> FragmentOutput { +fn main(@location(7) v_world_position: vec3, @location(3) v_normal: vec3, @builtin(front_facing) gl_FrontFacing: bool, @builtin(position) gl_FragCoord: vec4, @location(5) v_texcoord: vec2, @location(0) v_color: vec4, @location(2) v_lightmap_uv: vec2, @location(4) v_tangent: vec4) -> FragmentOutput { v_world_position_1 = v_world_position; v_normal_1 = v_normal; gl_FrontFacing_1 = gl_FrontFacing; @@ -8037,7 +9640,7 @@ fn main(@location(6) v_world_position: vec3, @location(2) v_normal: vec3[ WebGpuBlockMember(name: 'fog', offsetInBytes: 0, sizeInBytes: 16), WebGpuBlockMember(name: 'eye', offsetInBytes: 16, sizeInBytes: 16), @@ -8046,13 +9649,18 @@ fn main(@location(6) v_world_position: vec3, @location(2) v_normal: vec3[ WebGpuBlockMember( name: 'light_position', @@ -8149,6 +9757,11 @@ fn main(@location(6) v_world_position: vec3, @location(2) v_normal: vec3, eye: vec4, forward: vec4, + projection: vec4, } struct LightListInfo { @@ -8405,6 +10019,7 @@ struct FragInfo { ambient_ground: vec4, shadow_bias: vec4, target_origin: vec4, + debug_view: vec4, } struct PointShadow { @@ -8434,11 +10049,13 @@ var g_albedo: vec3; var g_debug_surface: vec3; var g_debug_surface_on: bool; var g_premultiply: bool; +var g_pass_through: f32; var g_cluster_offset: f32; var g_cluster_count: f32; -var v_world_position_1: vec3; +var light_transmittance: vec3; @group(1) @binding(0) var fog_info: FogInfo; +var v_world_position_1: vec3; var frag_albedo: vec4; var frag_surface: vec4; var v_normal_1: vec3; @@ -8503,10 +10120,10 @@ var shadow_texture_tex: texture_2d; var shadow_texture_smp: sampler; fn ViewDepth_u0028_() -> f32 { - let _e176 = v_world_position_1; - let _e178 = fog_info.eye; - let _e182 = fog_info.forward; - return dot((_e176 - _e178.xyz), _e182.xyz); + let _e187 = v_world_position_1; + let _e189 = fog_info.eye; + let _e193 = fog_info.forward; + return dot((_e187 - _e189.xyz), _e193.xyz); } fn WeightedBlendedWeight_u0028_f1_u003b(alpha: ptr) -> f32 { @@ -8515,22 +10132,22 @@ fn WeightedBlendedWeight_u0028_f1_u003b(alpha: ptr) -> f32 { var far: f32; var far3_: f32; - let _e181 = ViewDepth_u0028_(); - z = abs(_e181); - let _e183 = z; - near = (_e183 / 5f); - let _e185 = z; - far = (_e185 / 200f); - let _e187 = far; - let _e188 = far; - let _e190 = far; - far3_ = ((_e187 * _e188) * _e190); - let _e192 = (*alpha); - let _e193 = near; - let _e194 = near; - let _e197 = far3_; - let _e198 = far3_; - return (_e192 * clamp((10f / ((0.00001f + (_e193 * _e194)) + (_e197 * _e198))), 0.01f, 3000f)); + let _e192 = ViewDepth_u0028_(); + z = abs(_e192); + let _e194 = z; + near = (_e194 / 5f); + let _e196 = z; + far = (_e196 / 200f); + let _e198 = far; + let _e199 = far; + let _e201 = far; + far3_ = ((_e198 * _e199) * _e201); + let _e203 = (*alpha); + let _e204 = near; + let _e205 = near; + let _e208 = far3_; + let _e209 = far3_; + return (_e203 * clamp((10f / ((0.00001f + (_e204 * _e205)) + (_e208 * _e209))), 0.01f, 3000f)); } fn WriteWeightedBlended_u0028_() { @@ -8542,39 +10159,39 @@ fn WriteWeightedBlended_u0028_() { var revealage: bool; var local: vec4; - let _e185 = fog_info.forward[3u]; - mode = _e185; - let _e186 = mode; - if (_e186 > 0.5f) { - let _e189 = frag_color[3u]; - alpha_1 = _e189; - let _e190 = alpha_1; - param = _e190; - let _e191 = WeightedBlendedWeight_u0028_f1_u003b((¶m)); - weight = _e191; - let _e192 = frag_color; - let _e194 = weight; - let _e195 = (_e192.xyz * _e194); - let _e196 = alpha_1; - let _e197 = weight; - accumulate = vec4(_e195.x, _e195.y, _e195.z, (_e196 * _e197)); - let _e203 = mode; - let _e205 = mode; - revealage = ((_e203 > 1.5f) && (_e205 < 2.5f)); - let _e208 = revealage; - if _e208 { - let _e209 = alpha_1; - local = vec4(_e209); + let _e196 = fog_info.forward[3u]; + mode = _e196; + let _e197 = mode; + if (_e197 > 0.5f) { + let _e200 = frag_color[3u]; + alpha_1 = _e200; + let _e201 = alpha_1; + param = _e201; + let _e202 = WeightedBlendedWeight_u0028_f1_u003b((¶m)); + weight = _e202; + let _e203 = frag_color; + let _e205 = weight; + let _e206 = (_e203.xyz * _e205); + let _e207 = alpha_1; + let _e208 = weight; + accumulate = vec4(_e206.x, _e206.y, _e206.z, (_e207 * _e208)); + let _e214 = mode; + let _e216 = mode; + revealage = ((_e214 > 1.5f) && (_e216 < 2.5f)); + let _e219 = revealage; + if _e219 { + let _e220 = alpha_1; + local = vec4(_e220); } else { - let _e211 = accumulate; - local = _e211; + let _e222 = accumulate; + local = _e222; } - let _e212 = local; - frag_color = _e212; - let _e213 = mode; - if (_e213 > 2.5f) { - let _e215 = alpha_1; - frag_surface = vec4(_e215); + let _e223 = local; + frag_color = _e223; + let _e224 = mode; + if (_e224 > 2.5f) { + let _e226 = alpha_1; + frag_surface = vec4(_e226); } } return; @@ -8583,29 +10200,29 @@ fn WriteWeightedBlended_u0028_() { fn EncodeOctahedral_u0028_vf3_u003b(n: ptr>) -> vec2 { var e: vec2; - let _e179 = (*n)[0u]; - let _e182 = (*n)[1u]; - let _e186 = (*n)[2u]; - let _e189 = (*n); - (*n) = (_e189 / vec3(((abs(_e179) + abs(_e182)) + abs(_e186)))); - let _e192 = (*n); - e = _e192.xy; - let _e195 = (*n)[2u]; - if (_e195 < 0f) { - let _e197 = (*n); - let _e203 = (*n)[0u]; - let _e207 = (*n)[1u]; - e = ((vec2(1f) - abs(_e197.yx)) * vec2(select(-1f, 1f, (_e203 >= 0f)), select(-1f, 1f, (_e207 >= 0f)))); + let _e190 = (*n)[0u]; + let _e193 = (*n)[1u]; + let _e197 = (*n)[2u]; + let _e200 = (*n); + (*n) = (_e200 / vec3(((abs(_e190) + abs(_e193)) + abs(_e197)))); + let _e203 = (*n); + e = _e203.xy; + let _e206 = (*n)[2u]; + if (_e206 < 0f) { + let _e208 = (*n); + let _e214 = (*n)[0u]; + let _e218 = (*n)[1u]; + e = ((vec2(1f) - abs(_e208.yx)) * vec2(select(-1f, 1f, (_e214 >= 0f)), select(-1f, 1f, (_e218 >= 0f)))); } - let _e212 = e; - return ((_e212 * 0.5f) + vec2(0.5f)); + let _e223 = e; + return ((_e223 * 0.5f) + vec2(0.5f)); } fn LinearToSrgb_u0028_vf3_u003b(linear: ptr>) -> vec3 { - let _e177 = (*linear); - let _e179 = (*linear); - let _e183 = (*linear); - return mix((_e177 * 12.92f), ((pow(_e179, vec3(0.41666666f, 0.41666666f, 0.41666666f)) * 1.055f) - vec3(0.055f, 0.055f, 0.055f)), step(vec3(0.0031308f, 0.0031308f, 0.0031308f), _e183)); + let _e188 = (*linear); + let _e190 = (*linear); + let _e194 = (*linear); + return mix((_e188 * 12.92f), ((pow(_e190, vec3(0.41666666f, 0.41666666f, 0.41666666f)) * 1.055f) - vec3(0.055f, 0.055f, 0.055f)), step(vec3(0.0031308f, 0.0031308f, 0.0031308f), _e194)); } fn WriteSurfaceGeometry_u0028_f1_u003b(roughness: ptr) { @@ -8613,57 +10230,96 @@ fn WriteSurfaceGeometry_u0028_f1_u003b(roughness: ptr) { var geometric: vec3; var param_2: vec3; - let _e180 = g_albedo; - param_1 = clamp(_e180, vec3(0f, 0f, 0f), vec3(1f, 1f, 1f)); - let _e182 = LinearToSrgb_u0028_vf3_u003b((¶m_1)); - frag_albedo = vec4(_e182.x, _e182.y, _e182.z, 1f); - let _e187 = g_debug_surface_on; - if _e187 { - let _e188 = g_debug_surface; - let _e189 = ViewDepth_u0028_(); - frag_surface = vec4(_e188.x, _e188.y, _e188.z, _e189); + let _e191 = g_albedo; + param_1 = clamp(_e191, vec3(0f, 0f, 0f), vec3(1f, 1f, 1f)); + let _e193 = LinearToSrgb_u0028_vf3_u003b((¶m_1)); + frag_albedo = vec4(_e193.x, _e193.y, _e193.z, 1f); + let _e198 = g_debug_surface_on; + if _e198 { + let _e199 = g_debug_surface; + let _e200 = ViewDepth_u0028_(); + frag_surface = vec4(_e199.x, _e199.y, _e199.z, _e200); return; } - let _e194 = v_normal_1; - geometric = normalize(_e194); - let _e196 = gl_FrontFacing_1; - if !(_e196) { - let _e198 = geometric; - geometric = -(_e198); - } - let _e200 = geometric; - param_2 = _e200; - let _e201 = EncodeOctahedral_u0028_vf3_u003b((¶m_2)); - let _e202 = (*roughness); - let _e204 = ViewDepth_u0028_(); - frag_surface = vec4(_e201.x, _e201.y, clamp(_e202, 0f, 1f), _e204); + let _e205 = v_normal_1; + geometric = normalize(_e205); + let _e207 = gl_FrontFacing_1; + if !(_e207) { + let _e209 = geometric; + geometric = -(_e209); + } + let _e211 = geometric; + param_2 = _e211; + let _e212 = EncodeOctahedral_u0028_vf3_u003b((¶m_2)); + let _e213 = (*roughness); + let _e215 = ViewDepth_u0028_(); + frag_surface = vec4(_e212.x, _e212.y, clamp(_e213, 0f, 1f), _e215); return; } +fn Orthographic_u0028_() -> bool { + let _e189 = fog_info.projection[0u]; + return (_e189 > 0.5f); +} + fn EyeDistance_u0028_() -> f32 { - let _e176 = v_world_position_1; - let _e178 = fog_info.eye; - return distance(_e176, _e178.xyz); + var local_1: f32; + + let _e188 = Orthographic_u0028_(); + if _e188 { + let _e189 = ViewDepth_u0028_(); + local_1 = max(_e189, 0f); + } else { + let _e191 = v_world_position_1; + let _e193 = fog_info.eye; + local_1 = distance(_e191, _e193.xyz); + } + let _e196 = local_1; + return _e196; } fn ApplyFog_u0028_vf3_u003b(color: ptr>) -> vec3 { var density: f32; var d: f32; + var falloff: f32; + var k: f32; + var local_2: f32; - let _e181 = fog_info.fog[3u]; - density = _e181; - let _e182 = density; - if (_e182 <= 0f) { - let _e184 = (*color); - return _e184; + let _e195 = fog_info.fog[3u]; + density = _e195; + let _e196 = density; + if (_e196 <= 0f) { + let _e198 = (*color); + return _e198; + } + let _e199 = EyeDistance_u0028_(); + d = _e199; + let _e202 = fog_info.eye[3u]; + falloff = _e202; + let _e203 = falloff; + if (_e203 > 0f) { + let _e205 = falloff; + let _e207 = v_world_position_1[1u]; + let _e210 = fog_info.eye[1u]; + k = (_e205 * (_e207 - _e210)); + let _e213 = k; + if (abs(_e213) > 0.001f) { + let _e216 = k; + let _e220 = k; + local_2 = ((1f - exp(-(_e216))) / _e220); + } else { + let _e222 = k; + local_2 = (1f - (0.5f * _e222)); + } + let _e225 = local_2; + let _e226 = density; + density = (_e226 * _e225); } - let _e185 = EyeDistance_u0028_(); - d = _e185; - let _e187 = fog_info.fog; - let _e189 = (*color); - let _e190 = density; - let _e192 = d; - return mix(_e187.xyz, _e189, vec3(clamp(exp((-(_e190) * _e192)), 0f, 1f))); + let _e229 = fog_info.fog; + let _e231 = (*color); + let _e232 = density; + let _e234 = d; + return mix(_e229.xyz, _e231, vec3(clamp(exp((-(_e232) * _e234)), 0f, 1f))); } fn WriteSurface_u0028_vf3_u003b_f1_u003b_f1_u003b(linearColor: ptr>, alpha_2: ptr, roughness_1: ptr) { @@ -8671,65 +10327,206 @@ fn WriteSurface_u0028_vf3_u003b_f1_u003b_f1_u003b(linearColor: ptr; var param_4: f32; - let _e182 = g_premultiply; - let _e183 = (*alpha_2); - weight_1 = select(1f, _e183, _e182); - let _e185 = (*linearColor); - param_3 = _e185; - let _e186 = ApplyFog_u0028_vf3_u003b((¶m_3)); - let _e187 = weight_1; - let _e188 = (_e186 * _e187); - let _e189 = (*alpha_2); - frag_color = vec4(_e188.x, _e188.y, _e188.z, _e189); - let _e194 = (*roughness_1); - param_4 = _e194; + let _e193 = g_premultiply; + let _e194 = (*alpha_2); + weight_1 = select(1f, _e194, _e193); + let _e196 = (*linearColor); + param_3 = _e196; + let _e197 = ApplyFog_u0028_vf3_u003b((¶m_3)); + let _e198 = weight_1; + let _e199 = (_e197 * _e198); + let _e200 = (*alpha_2); + let _e201 = g_pass_through; + frag_color = vec4(_e199.x, _e199.y, _e199.z, (_e200 * (1f - _e201))); + let _e208 = (*roughness_1); + param_4 = _e208; WriteSurfaceGeometry_u0028_f1_u003b((¶m_4)); WriteWeightedBlended_u0028_(); return; } -fn ShadeLight_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b_struct_u002d_LightSample_u002d_vf3_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf31_u003b(s: ptr, light: ptr) -> vec3 { - var shininess: f32; - var specular: f32; - - let _e181 = (*s).roughness; - shininess = mix(256f, 4f, _e181); - let _e184 = (*light).n_dot_h; - let _e185 = shininess; - let _e189 = frag_info.material[3u]; - specular = (pow(_e184, _e185) * _e189); - let _e192 = (*s).albedo; - let _e193 = specular; - return (_e192 + vec3(_e193)); +fn SrgbToLinear_u0028_vf3_u003b(srgb: ptr>) -> vec3 { + let _e188 = (*srgb); + let _e191 = (*srgb); + let _e196 = (*srgb); + return mix((_e188 / vec3(12.92f)), pow(((_e191 + vec3(0.055f, 0.055f, 0.055f)) / vec3(1.055f)), vec3(2.4f, 2.4f, 2.4f)), step(vec3(0.04045f, 0.04045f, 0.04045f), _e196)); } -fn PointShadowDiskTap_u0028_i1_u003b(i: ptr) -> vec2 { - let _e177 = (*i); - if (_e177 == 0i) { - return vec2(-0.94201624f, -0.39906216f); - } - let _e179 = (*i); - if (_e179 == 1i) { - return vec2(0.9455861f, -0.76890725f); - } - let _e181 = (*i); - if (_e181 == 2i) { - return vec2(-0.0941841f, -0.9293887f); - } - let _e183 = (*i); - if (_e183 == 3i) { - return vec2(0.34495938f, 0.2938776f); - } - let _e185 = (*i); - if (_e185 == 4i) { - return vec2(-0.9158858f, 0.45771432f); +fn NonFinite_u0028_vf3_u003b(c: ptr>) -> bool { + var phi_2562_: bool; + + let _e188 = (*c); + let _e189 = (*c); + let _e191 = any((_e188 != _e189)); + phi_2562_ = _e191; + if !(_e191) { + let _e193 = (*c); + phi_2562_ = any((abs(_e193) > vec3(300000000000000000000000000000000000000f, 300000000000000000000000000000000000000f, 300000000000000000000000000000000000000f))); } - let _e187 = (*i); - if (_e187 == 5i) { - return vec2(-0.8154423f, -0.87912464f); + let _e198 = phi_2562_; + return _e198; +} + +fn WriteDebugView_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b_vf3_u003b(s: ptr, lit: ptr>) -> bool { + var view: f32; + var code: i32; + var shown: vec3; + var param_5: vec3; + var param_6: vec3; + var grey: f32; + var param_7: vec3; + var param_8: vec3; + var local_3: vec3; + var weight_2: f32; + var local_4: f32; + var param_9: vec3; + var param_10: f32; + + let _e204 = frag_info.debug_view[0u]; + view = _e204; + let _e205 = view; + if (_e205 < 0.5f) { + return false; + } + let _e208 = gl_FragCoord_1[0u]; + let _e211 = frag_info.debug_view[1u]; + let _e214 = frag_info.debug_view[2u]; + if (_e208 < (_e211 * _e214)) { + return false; + } + let _e217 = view; + code = i32((_e217 + 0.5f)); + shown = vec3(0f, 0f, 0f); + let _e220 = code; + if (_e220 == 1i) { + let _e223 = (*s).albedo; + param_5 = clamp(_e223, vec3(0f, 0f, 0f), vec3(1f, 1f, 1f)); + let _e225 = LinearToSrgb_u0028_vf3_u003b((¶m_5)); + shown = _e225; + } else { + let _e226 = code; + if (_e226 == 2i) { + let _e229 = (*s).n; + shown = ((_e229 * 0.5f) + vec3(0.5f, 0.5f, 0.5f)); + } else { + let _e232 = code; + if (_e232 == 3i) { + let _e235 = (*s).roughness; + shown = vec3(clamp(_e235, 0f, 1f)); + } else { + let _e238 = code; + if (_e238 == 4i) { + let _e241 = (*s).metallic; + shown = vec3(clamp(_e241, 0f, 1f)); + } else { + let _e244 = code; + if (_e244 == 5i) { + let _e247 = (*s).occlusion; + shown = vec3(clamp(_e247, 0f, 1f)); + } else { + let _e250 = code; + if (_e250 == 6i) { + let _e253 = (*s).emissive; + param_6 = clamp(_e253, vec3(0f, 0f, 0f), vec3(1f, 1f, 1f)); + let _e255 = LinearToSrgb_u0028_vf3_u003b((¶m_6)); + shown = _e255; + } else { + let _e256 = code; + if (_e256 == 7i) { + let _e258 = v_texcoord_1; + let _e259 = fract(_e258); + shown = vec3(_e259.x, _e259.y, 0f); + } else { + let _e263 = code; + if (_e263 == 8i) { + let _e265 = (*lit); + param_7 = clamp(_e265, vec3(0f, 0f, 0f), vec3(1f, 1f, 1f)); + let _e267 = LinearToSrgb_u0028_vf3_u003b((¶m_7)); + grey = dot(_e267, vec3(0.2126f, 0.7152f, 0.0722f)); + let _e269 = (*lit); + param_8 = _e269; + let _e270 = NonFinite_u0028_vf3_u003b((¶m_8)); + if _e270 { + local_3 = vec3(1f, 0f, 1f); + } else { + let _e271 = grey; + local_3 = vec3((_e271 * 0.5f)); + } + let _e274 = local_3; + shown = _e274; + } + } + } + } + } + } + } } - let _e189 = (*i); - if (_e189 == 6i) { + let _e275 = g_premultiply; + if _e275 { + let _e277 = (*s).alpha; + local_4 = _e277; + } else { + local_4 = 1f; + } + let _e278 = local_4; + weight_2 = _e278; + let _e279 = shown; + param_9 = _e279; + let _e280 = SrgbToLinear_u0028_vf3_u003b((¶m_9)); + let _e281 = weight_2; + let _e282 = (_e280 * _e281); + let _e284 = (*s).alpha; + frag_color = vec4(_e282.x, _e282.y, _e282.z, _e284); + let _e290 = (*s).roughness; + param_10 = _e290; + WriteSurfaceGeometry_u0028_f1_u003b((¶m_10)); + WriteWeightedBlended_u0028_(); + return true; +} + +fn ShadeLight_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b_struct_u002d_LightSample_u002d_vf3_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf31_u003b(s_1: ptr, light: ptr) -> vec3 { + var shininess: f32; + var specular: f32; + + let _e192 = (*s_1).roughness; + shininess = mix(256f, 4f, _e192); + let _e195 = (*light).n_dot_h; + let _e196 = shininess; + let _e200 = frag_info.material[3u]; + specular = (pow(_e195, _e196) * _e200); + let _e203 = (*s_1).albedo; + let _e204 = specular; + return (_e203 + vec3(_e204)); +} + +fn PointShadowDiskTap_u0028_i1_u003b(i: ptr) -> vec2 { + let _e188 = (*i); + if (_e188 == 0i) { + return vec2(-0.94201624f, -0.39906216f); + } + let _e190 = (*i); + if (_e190 == 1i) { + return vec2(0.9455861f, -0.76890725f); + } + let _e192 = (*i); + if (_e192 == 2i) { + return vec2(-0.0941841f, -0.9293887f); + } + let _e194 = (*i); + if (_e194 == 3i) { + return vec2(0.34495938f, 0.2938776f); + } + let _e196 = (*i); + if (_e196 == 4i) { + return vec2(-0.9158858f, 0.45771432f); + } + let _e198 = (*i); + if (_e198 == 5i) { + return vec2(-0.8154423f, -0.87912464f); + } + let _e200 = (*i); + if (_e200 == 6i) { return vec2(-0.38277543f, 0.27676845f); } return vec2(0.974844f, 0.7564838f); @@ -8737,208 +10534,208 @@ fn PointShadowDiskTap_u0028_i1_u003b(i: ptr) -> vec2 { fn PointShadowOffset_u0028_i1_u003b_f1_u003b_f1_u003b_f1_u003b(i_1: ptr, ca: ptr, sa: ptr, radius: ptr) -> vec2 { var p: vec2; - var param_5: i32; - - let _e182 = (*i_1); - param_5 = _e182; - let _e183 = PointShadowDiskTap_u0028_i1_u003b((¶m_5)); - p = _e183; - let _e185 = p[0u]; - let _e186 = (*ca); - let _e189 = p[1u]; - let _e190 = (*sa); - let _e194 = p[0u]; - let _e195 = (*sa); - let _e198 = p[1u]; - let _e199 = (*ca); - let _e203 = (*radius); - return (vec2(((_e185 * _e186) - (_e189 * _e190)), ((_e194 * _e195) + (_e198 * _e199))) * _e203); + var param_11: i32; + + let _e193 = (*i_1); + param_11 = _e193; + let _e194 = PointShadowDiskTap_u0028_i1_u003b((¶m_11)); + p = _e194; + let _e196 = p[0u]; + let _e197 = (*ca); + let _e200 = p[1u]; + let _e201 = (*sa); + let _e205 = p[0u]; + let _e206 = (*sa); + let _e209 = p[1u]; + let _e210 = (*ca); + let _e214 = (*radius); + return (vec2(((_e196 * _e197) - (_e200 * _e201)), ((_e205 * _e206) + (_e209 * _e210))) * _e214); } fn PointShadowDistance_u0028_vf2_u003b_vf2_u003b_vf2_u003b_f1_u003b(uv: ptr>, offset: ptr>, tile: ptr>, range: ptr) -> f32 { var inset: f32; - var local_1: vec2; + var local_5: vec2; var atlas: vec2; - let _e185 = point_shadow.params[0u]; - inset = _e185; - let _e186 = (*uv); - let _e187 = (*offset); - let _e189 = inset; - let _e190 = inset; - local_1 = clamp((_e186 + _e187), vec2(_e189), vec2((1f - _e190))); - let _e195 = local_1; - let _e196 = (*tile); - atlas = ((_e195 + _e196) * vec2(0.16666667f, 0.16666667f)); - let _e201 = point_shadow.params3_[0u]; - if (_e201 > 0.5f) { - let _e204 = atlas[1u]; - atlas[1u] = (1f - _e204); - } - let _e207 = atlas; - let _e208 = textureSampleLevel(point_shadow_texture_tex, point_shadow_texture_smp, _e207, 0f); - let _e210 = atlas; - let _e211 = textureSampleLevel(point_shadow_static_texture_tex, point_shadow_static_texture_smp, _e210, 0f); - let _e214 = (*range); - return (min(_e208.x, _e211.x) * _e214); + let _e196 = point_shadow.params[0u]; + inset = _e196; + let _e197 = (*uv); + let _e198 = (*offset); + let _e200 = inset; + let _e201 = inset; + local_5 = clamp((_e197 + _e198), vec2(_e200), vec2((1f - _e201))); + let _e206 = local_5; + let _e207 = (*tile); + atlas = ((_e206 + _e207) * vec2(0.16666667f, 0.16666667f)); + let _e212 = point_shadow.params3_[0u]; + if (_e212 > 0.5f) { + let _e215 = atlas[1u]; + atlas[1u] = (1f - _e215); + } + let _e218 = atlas; + let _e219 = textureSampleLevel(point_shadow_texture_tex, point_shadow_texture_smp, _e218, 0f); + let _e221 = atlas; + let _e222 = textureSampleLevel(point_shadow_static_texture_tex, point_shadow_static_texture_smp, _e221, 0f); + let _e225 = (*range); + return (min(_e219.x, _e222.x) * _e225); } fn PointShadowTap_u0028_vf2_u003b_vf2_u003b_vf2_u003b_f1_u003b_f1_u003b(uv_1: ptr>, offset_1: ptr>, tile_1: ptr>, range_1: ptr, receiver: ptr) -> f32 { var stored: f32; - var param_6: vec2; - var param_7: vec2; - var param_8: vec2; - var param_9: f32; + var param_12: vec2; + var param_13: vec2; + var param_14: vec2; + var param_15: f32; - let _e186 = (*uv_1); - param_6 = _e186; - let _e187 = (*offset_1); - param_7 = _e187; - let _e188 = (*tile_1); - param_8 = _e188; - let _e189 = (*range_1); - param_9 = _e189; - let _e190 = PointShadowDistance_u0028_vf2_u003b_vf2_u003b_vf2_u003b_f1_u003b((¶m_6), (¶m_7), (¶m_8), (¶m_9)); - stored = _e190; - let _e191 = stored; - let _e192 = (*range_1); - if (_e191 >= (_e192 * 0.999f)) { + let _e197 = (*uv_1); + param_12 = _e197; + let _e198 = (*offset_1); + param_13 = _e198; + let _e199 = (*tile_1); + param_14 = _e199; + let _e200 = (*range_1); + param_15 = _e200; + let _e201 = PointShadowDistance_u0028_vf2_u003b_vf2_u003b_vf2_u003b_f1_u003b((¶m_12), (¶m_13), (¶m_14), (¶m_15)); + stored = _e201; + let _e202 = stored; + let _e203 = (*range_1); + if (_e202 >= (_e203 * 0.999f)) { return 1f; } - let _e195 = (*receiver); - let _e196 = stored; - return select(1f, 0f, (_e195 > _e196)); + let _e206 = (*receiver); + let _e207 = stored; + return select(1f, 0f, (_e206 > _e207)); } fn PointShadowPenumbra_u0028_vf2_u003b_vf2_u003b_f1_u003b_f1_u003b_f1_u003b_f1_u003b_f1_u003b_f1_u003b(uv_2: ptr>, tile_2: ptr>, range_2: ptr, receiver_1: ptr, ca_1: ptr, sa_1: ptr, tanHalf: ptr, blockerOut: ptr) -> f32 { var lightRadius: f32; var minRadius: f32; var maxRadius: f32; - var param_10: vec2; - var param_11: vec2; - var param_12: vec2; - var param_13: f32; + var param_16: vec2; + var param_17: vec2; + var param_18: vec2; + var param_19: f32; var sum: f32; var count: f32; var i_2: i32; var stored_1: f32; - var param_14: i32; - var param_15: f32; - var param_16: f32; - var param_17: f32; - var param_18: vec2; - var param_19: vec2; - var param_20: vec2; + var param_20: i32; var param_21: f32; + var param_22: f32; + var param_23: f32; + var param_24: vec2; + var param_25: vec2; + var param_26: vec2; + var param_27: f32; var blocker: f32; var world: f32; (*blockerOut) = -1f; - let _e207 = point_shadow.params2_[1u]; - lightRadius = _e207; - let _e210 = point_shadow.params2_[0u]; - minRadius = _e210; - let _e213 = point_shadow.params2_[2u]; - maxRadius = _e213; - let _e214 = lightRadius; - if (_e214 <= 0f) { - let _e216 = (*uv_2); - param_10 = _e216; - param_11 = vec2(0f, 0f); - let _e217 = (*tile_2); - param_12 = _e217; - let _e218 = (*range_2); - param_13 = _e218; - let _e219 = PointShadowDistance_u0028_vf2_u003b_vf2_u003b_vf2_u003b_f1_u003b((¶m_10), (¶m_11), (¶m_12), (¶m_13)); - (*blockerOut) = _e219; - let _e220 = minRadius; - return _e220; + let _e218 = point_shadow.params2_[1u]; + lightRadius = _e218; + let _e221 = point_shadow.params2_[0u]; + minRadius = _e221; + let _e224 = point_shadow.params2_[2u]; + maxRadius = _e224; + let _e225 = lightRadius; + if (_e225 <= 0f) { + let _e227 = (*uv_2); + param_16 = _e227; + param_17 = vec2(0f, 0f); + let _e228 = (*tile_2); + param_18 = _e228; + let _e229 = (*range_2); + param_19 = _e229; + let _e230 = PointShadowDistance_u0028_vf2_u003b_vf2_u003b_vf2_u003b_f1_u003b((¶m_16), (¶m_17), (¶m_18), (¶m_19)); + (*blockerOut) = _e230; + let _e231 = minRadius; + return _e231; } sum = 0f; count = 0f; i_2 = 0i; loop { - let _e221 = i_2; - if (_e221 < 8i) { - let _e223 = i_2; - param_14 = _e223; - let _e224 = (*ca_1); - param_15 = _e224; - let _e225 = (*sa_1); - param_16 = _e225; - let _e226 = maxRadius; - param_17 = _e226; - let _e227 = PointShadowOffset_u0028_i1_u003b_f1_u003b_f1_u003b_f1_u003b((¶m_14), (¶m_15), (¶m_16), (¶m_17)); - let _e228 = (*uv_2); - param_18 = _e228; - param_19 = _e227; - let _e229 = (*tile_2); - param_20 = _e229; - let _e230 = (*range_2); - param_21 = _e230; - let _e231 = PointShadowDistance_u0028_vf2_u003b_vf2_u003b_vf2_u003b_f1_u003b((¶m_18), (¶m_19), (¶m_20), (¶m_21)); - stored_1 = _e231; - let _e232 = stored_1; - let _e233 = (*range_2); - if (_e232 >= (_e233 * 0.999f)) { + let _e232 = i_2; + if (_e232 < 8i) { + let _e234 = i_2; + param_20 = _e234; + let _e235 = (*ca_1); + param_21 = _e235; + let _e236 = (*sa_1); + param_22 = _e236; + let _e237 = maxRadius; + param_23 = _e237; + let _e238 = PointShadowOffset_u0028_i1_u003b_f1_u003b_f1_u003b_f1_u003b((¶m_20), (¶m_21), (¶m_22), (¶m_23)); + let _e239 = (*uv_2); + param_24 = _e239; + param_25 = _e238; + let _e240 = (*tile_2); + param_26 = _e240; + let _e241 = (*range_2); + param_27 = _e241; + let _e242 = PointShadowDistance_u0028_vf2_u003b_vf2_u003b_vf2_u003b_f1_u003b((¶m_24), (¶m_25), (¶m_26), (¶m_27)); + stored_1 = _e242; + let _e243 = stored_1; + let _e244 = (*range_2); + if (_e243 >= (_e244 * 0.999f)) { continue; } - let _e236 = stored_1; - let _e237 = (*receiver_1); - if (_e236 >= _e237) { + let _e247 = stored_1; + let _e248 = (*receiver_1); + if (_e247 >= _e248) { continue; } - let _e239 = stored_1; - let _e240 = sum; - sum = (_e240 + _e239); - let _e242 = count; - count = (_e242 + 1f); + let _e250 = stored_1; + let _e251 = sum; + sum = (_e251 + _e250); + let _e253 = count; + count = (_e253 + 1f); continue; } else { break; } continuing { - let _e244 = i_2; - i_2 = (_e244 + 1i); + let _e255 = i_2; + i_2 = (_e255 + 1i); } } - let _e246 = count; - if (_e246 < 0.5f) { + let _e257 = count; + if (_e257 < 0.5f) { return -1f; } - let _e248 = sum; - let _e249 = count; - blocker = max((_e248 / _e249), 0.0001f); - let _e252 = blocker; - (*blockerOut) = _e252; - let _e253 = lightRadius; - let _e254 = (*receiver_1); - let _e255 = blocker; - let _e259 = blocker; - world = ((_e253 * max((_e254 - _e255), 0f)) / _e259); - let _e261 = world; - let _e262 = (*receiver_1); - let _e264 = (*tanHalf); - let _e267 = minRadius; - let _e268 = maxRadius; - return clamp((_e261 / ((2f * _e262) * _e264)), _e267, _e268); + let _e259 = sum; + let _e260 = count; + blocker = max((_e259 / _e260), 0.0001f); + let _e263 = blocker; + (*blockerOut) = _e263; + let _e264 = lightRadius; + let _e265 = (*receiver_1); + let _e266 = blocker; + let _e270 = blocker; + world = ((_e264 * max((_e265 - _e266), 0f)) / _e270); + let _e272 = world; + let _e273 = (*receiver_1); + let _e275 = (*tanHalf); + let _e278 = minRadius; + let _e279 = maxRadius; + return clamp((_e272 / ((2f * _e273) * _e275)), _e278, _e279); } fn FragCoordFromTop_u0028_f1_u003b(rows: ptr) -> vec2 { - var local_2: vec2; + var local_6: vec2; - let _e178 = (*rows); - if (_e178 > 0f) { - let _e181 = gl_FragCoord_1[0u]; - let _e182 = (*rows); - let _e184 = gl_FragCoord_1[1u]; - local_2 = vec2(_e181, (_e182 - _e184)); + let _e189 = (*rows); + if (_e189 > 0f) { + let _e192 = gl_FragCoord_1[0u]; + let _e193 = (*rows); + let _e195 = gl_FragCoord_1[1u]; + local_6 = vec2(_e192, (_e193 - _e195)); } else { - let _e187 = gl_FragCoord_1; - local_2 = _e187.xy; + let _e198 = gl_FragCoord_1; + local_6 = _e198.xy; } - let _e189 = local_2; - return _e189; + let _e200 = local_6; + return _e200; } fn PointShadowFactor_u0028_vf3_u003b_vf3_u003b_i1_u003b(world_1: ptr>, normal: ptr>, lightIndex: ptr) -> f32 { @@ -8961,292 +10758,292 @@ fn PointShadowFactor_u0028_vf3_u003b_vf3_u003b_i1_u003b(world_1: ptr; var receiver_2: f32; var noise: f32; - var param_22: f32; + var param_28: f32; var angle: f32; var ca_2: f32; var sa_2: f32; var tanHalf_1: f32; var blocker_1: f32; var radius_1: f32; - var param_23: vec2; - var param_24: vec2; - var param_25: f32; - var param_26: f32; - var param_27: f32; - var param_28: f32; - var param_29: f32; - var param_30: f32; - var local_3: vec3; - var lit: f32; - var param_31: vec2; - var param_32: vec2; - var param_33: vec2; + var param_29: vec2; + var param_30: vec2; + var param_31: f32; + var param_32: f32; + var param_33: f32; var param_34: f32; var param_35: f32; + var param_36: f32; + var local_7: vec3; + var lit_1: f32; + var param_37: vec2; + var param_38: vec2; + var param_39: vec2; + var param_40: f32; + var param_41: f32; var i_3: i32; - var param_36: i32; - var param_37: f32; - var param_38: f32; - var param_39: f32; - var param_40: vec2; - var param_41: vec2; - var param_42: vec2; + var param_42: i32; var param_43: f32; var param_44: f32; - var phi_2149_: bool; - var phi_2210_: bool; - - let _e230 = (*lightIndex); - let _e234 = point_shadow.slots[_e230][0u]; - slot = i32((_e234 + 0.5f)); - let _e237 = (*lightIndex); - let _e241 = point_shadow.slots[_e237][0u]; - if (_e241 < 0f) { + var param_45: f32; + var param_46: vec2; + var param_47: vec2; + var param_48: vec2; + var param_49: f32; + var param_50: f32; + var phi_2249_: bool; + var phi_2310_: bool; + + let _e241 = (*lightIndex); + let _e245 = point_shadow.slots[_e241][0u]; + slot = i32((_e245 + 0.5f)); + let _e248 = (*lightIndex); + let _e252 = point_shadow.slots[_e248][0u]; + if (_e252 < 0f) { return 1f; } - let _e245 = point_shadow.params[2u]; - strength = _e245; - let _e246 = strength; - if (_e246 <= 0f) { + let _e256 = point_shadow.params[2u]; + strength = _e256; + let _e257 = strength; + if (_e257 <= 0f) { return 1f; } - let _e248 = slot; - let _e251 = point_shadow.lights[_e248]; - let _e253 = (*world_1); - toLight = (_e251.xyz - _e253); - let _e255 = toLight; - toLightLength = max(length(_e255), 0.000001f); - let _e258 = (*normal); - let _e259 = toLight; - let _e260 = toLightLength; - nDotL = max(dot(_e258, (_e259 / vec3(_e260))), 0.15f); - let _e265 = nDotL; - let _e266 = nDotL; - let _e271 = nDotL; - let _e272 = nDotL; - slope = min((sqrt(max((1f - (_e265 * _e266)), 0f)) / (_e271 * _e272)), 8f); - let _e276 = toLightLength; - let _e278 = (*lightIndex); - let _e282 = point_shadow.slots[_e278][2u]; - let _e287 = point_shadow.params3_[1u]; - texel = (((2f * _e276) * max(_e282, 0.0001f)) * _e287); - let _e289 = (*world_1); - let _e290 = (*normal); - let _e291 = texel; - let _e295 = point_shadow.params[3u]; - let _e297 = slope; - origin = (_e289 + (((_e290 * _e291) * _e295) * (1f + _e297))); - let _e301 = origin; - let _e302 = slot; - let _e305 = point_shadow.lights[_e302]; - toFragment = (_e301 - _e305.xyz); - let _e308 = toFragment; - distance_ = length(_e308); - let _e310 = slot; - let _e314 = point_shadow.lights[_e310][3u]; - range_3 = max(_e314, 0.0001f); - let _e316 = distance_; - let _e317 = range_3; - if (_e316 >= _e317) { + let _e259 = slot; + let _e262 = point_shadow.lights[_e259]; + let _e264 = (*world_1); + toLight = (_e262.xyz - _e264); + let _e266 = toLight; + toLightLength = max(length(_e266), 0.000001f); + let _e269 = (*normal); + let _e270 = toLight; + let _e271 = toLightLength; + nDotL = max(dot(_e269, (_e270 / vec3(_e271))), 0.15f); + let _e276 = nDotL; + let _e277 = nDotL; + let _e282 = nDotL; + let _e283 = nDotL; + slope = min((sqrt(max((1f - (_e276 * _e277)), 0f)) / (_e282 * _e283)), 8f); + let _e287 = toLightLength; + let _e289 = (*lightIndex); + let _e293 = point_shadow.slots[_e289][2u]; + let _e298 = point_shadow.params3_[1u]; + texel = (((2f * _e287) * max(_e293, 0.0001f)) * _e298); + let _e300 = (*world_1); + let _e301 = (*normal); + let _e302 = texel; + let _e306 = point_shadow.params[3u]; + let _e308 = slope; + origin = (_e300 + (((_e301 * _e302) * _e306) * (1f + _e308))); + let _e312 = origin; + let _e313 = slot; + let _e316 = point_shadow.lights[_e313]; + toFragment = (_e312 - _e316.xyz); + let _e319 = toFragment; + distance_ = length(_e319); + let _e321 = slot; + let _e325 = point_shadow.lights[_e321][3u]; + range_3 = max(_e325, 0.0001f); + let _e327 = distance_; + let _e328 = range_3; + if (_e327 >= _e328) { return 1f; } face = 0i; - let _e319 = (*lightIndex); - let _e323 = point_shadow.slots[_e319][1u]; - if (_e323 < 0.5f) { - let _e325 = toFragment; - a = abs(_e325); - let _e328 = a[0u]; - let _e330 = a[1u]; - let _e331 = (_e328 >= _e330); - phi_2149_ = _e331; - if _e331 { - let _e333 = a[0u]; - let _e335 = a[2u]; - phi_2149_ = (_e333 >= _e335); - } - let _e338 = phi_2149_; - if _e338 { - let _e340 = toFragment[0u]; - face = select(1i, 0i, (_e340 > 0f)); - } else { - let _e344 = a[1u]; + let _e330 = (*lightIndex); + let _e334 = point_shadow.slots[_e330][1u]; + if (_e334 < 0.5f) { + let _e336 = toFragment; + a = abs(_e336); + let _e339 = a[0u]; + let _e341 = a[1u]; + let _e342 = (_e339 >= _e341); + phi_2249_ = _e342; + if _e342 { + let _e344 = a[0u]; let _e346 = a[2u]; - if (_e344 >= _e346) { - let _e349 = toFragment[1u]; - face = select(3i, 2i, (_e349 > 0f)); + phi_2249_ = (_e344 >= _e346); + } + let _e349 = phi_2249_; + if _e349 { + let _e351 = toFragment[0u]; + face = select(1i, 0i, (_e351 > 0f)); + } else { + let _e355 = a[1u]; + let _e357 = a[2u]; + if (_e355 >= _e357) { + let _e360 = toFragment[1u]; + face = select(3i, 2i, (_e360 > 0f)); } else { - let _e353 = toFragment[2u]; - face = select(5i, 4i, (_e353 > 0f)); + let _e364 = toFragment[2u]; + face = select(5i, 4i, (_e364 > 0f)); } } } - let _e356 = slot; - let _e358 = face; - let _e362 = point_shadow.faces[((_e356 * 6i) + _e358)]; - let _e363 = origin; - clip = (_e362 * vec4(_e363.x, _e363.y, _e363.z, 1f)); - let _e370 = clip[3u]; - if (_e370 <= 0f) { + let _e367 = slot; + let _e369 = face; + let _e373 = point_shadow.faces[((_e367 * 6i) + _e369)]; + let _e374 = origin; + clip = (_e373 * vec4(_e374.x, _e374.y, _e374.z, 1f)); + let _e381 = clip[3u]; + if (_e381 <= 0f) { return 1f; } - let _e372 = clip; - let _e375 = clip[3u]; - ndc = (_e372.xy / vec2(_e375)); - let _e379 = ndc[0u]; - let _e381 = (abs(_e379) > 1f); - phi_2210_ = _e381; - if !(_e381) { - let _e384 = ndc[1u]; - phi_2210_ = (abs(_e384) > 1f); - } - let _e388 = phi_2210_; - if _e388 { + let _e383 = clip; + let _e386 = clip[3u]; + ndc = (_e383.xy / vec2(_e386)); + let _e390 = ndc[0u]; + let _e392 = (abs(_e390) > 1f); + phi_2310_ = _e392; + if !(_e392) { + let _e395 = ndc[1u]; + phi_2310_ = (abs(_e395) > 1f); + } + let _e399 = phi_2310_; + if _e399 { return 1f; } - let _e390 = ndc[0u]; - let _e394 = ndc[1u]; - uv_3 = vec2(((_e390 * 0.5f) + 0.5f), (0.5f - (_e394 * 0.5f))); - let _e398 = face; - let _e400 = slot; - tile_3 = vec2(f32(_e398), f32(_e400)); - let _e403 = distance_; - let _e406 = point_shadow.params[1u]; - receiver_2 = (_e403 - _e406); - let _e410 = frag_info.target_origin[0u]; - param_22 = _e410; - let _e411 = FragCoordFromTop_u0028_f1_u003b((¶m_22)); - noise = fract((52.982918f * fract(dot(_e411, vec2(0.06711056f, 0.00583715f))))); - let _e416 = noise; - angle = (_e416 * 6.2831855f); - let _e418 = angle; - ca_2 = cos(_e418); - let _e420 = angle; - sa_2 = sin(_e420); - let _e422 = (*lightIndex); - let _e426 = point_shadow.slots[_e422][2u]; - tanHalf_1 = max(_e426, 0.0001f); + let _e401 = ndc[0u]; + let _e405 = ndc[1u]; + uv_3 = vec2(((_e401 * 0.5f) + 0.5f), (0.5f - (_e405 * 0.5f))); + let _e409 = face; + let _e411 = slot; + tile_3 = vec2(f32(_e409), f32(_e411)); + let _e414 = distance_; + let _e417 = point_shadow.params[1u]; + receiver_2 = (_e414 - _e417); + let _e421 = frag_info.target_origin[0u]; + param_28 = _e421; + let _e422 = FragCoordFromTop_u0028_f1_u003b((¶m_28)); + noise = fract((52.982918f * fract(dot(_e422, vec2(0.06711056f, 0.00583715f))))); + let _e427 = noise; + angle = (_e427 * 6.2831855f); + let _e429 = angle; + ca_2 = cos(_e429); + let _e431 = angle; + sa_2 = sin(_e431); + let _e433 = (*lightIndex); + let _e437 = point_shadow.slots[_e433][2u]; + tanHalf_1 = max(_e437, 0.0001f); blocker_1 = -1f; - let _e428 = uv_3; - param_23 = _e428; - let _e429 = tile_3; - param_24 = _e429; - let _e430 = range_3; - param_25 = _e430; - let _e431 = receiver_2; - param_26 = _e431; - let _e432 = ca_2; - param_27 = _e432; - let _e433 = sa_2; - param_28 = _e433; - let _e434 = tanHalf_1; - param_29 = _e434; - let _e435 = PointShadowPenumbra_u0028_vf2_u003b_vf2_u003b_f1_u003b_f1_u003b_f1_u003b_f1_u003b_f1_u003b_f1_u003b((¶m_23), (¶m_24), (¶m_25), (¶m_26), (¶m_27), (¶m_28), (¶m_29), (¶m_30)); - let _e436 = param_30; - blocker_1 = _e436; - radius_1 = _e435; - let _e439 = point_shadow.params2_[3u]; - if (_e439 > 0.5f) { + let _e439 = uv_3; + param_29 = _e439; + let _e440 = tile_3; + param_30 = _e440; + let _e441 = range_3; + param_31 = _e441; + let _e442 = receiver_2; + param_32 = _e442; + let _e443 = ca_2; + param_33 = _e443; + let _e444 = sa_2; + param_34 = _e444; + let _e445 = tanHalf_1; + param_35 = _e445; + let _e446 = PointShadowPenumbra_u0028_vf2_u003b_vf2_u003b_f1_u003b_f1_u003b_f1_u003b_f1_u003b_f1_u003b_f1_u003b((¶m_29), (¶m_30), (¶m_31), (¶m_32), (¶m_33), (¶m_34), (¶m_35), (¶m_36)); + let _e447 = param_36; + blocker_1 = _e447; + radius_1 = _e446; + let _e450 = point_shadow.params2_[3u]; + if (_e450 > 0.5f) { g_debug_surface_on = true; - let _e441 = radius_1; - if (_e441 < 0f) { - local_3 = vec3(0f, 0f, 1f); + let _e452 = radius_1; + if (_e452 < 0f) { + local_7 = vec3(0f, 0f, 1f); } else { - let _e443 = radius_1; - let _e446 = point_shadow.params2_[2u]; - let _e450 = blocker_1; - let _e451 = range_3; - local_3 = vec3(clamp((_e443 / max(_e446, 0.000001f)), 0f, 1f), clamp((_e450 / _e451), 0f, 1f), 0f); + let _e454 = radius_1; + let _e457 = point_shadow.params2_[2u]; + let _e461 = blocker_1; + let _e462 = range_3; + local_7 = vec3(clamp((_e454 / max(_e457, 0.000001f)), 0f, 1f), clamp((_e461 / _e462), 0f, 1f), 0f); } - let _e455 = local_3; - g_debug_surface = _e455; + let _e466 = local_7; + g_debug_surface = _e466; } - let _e456 = radius_1; - if (_e456 < 0f) { + let _e467 = radius_1; + if (_e467 < 0f) { return 1f; } - let _e458 = uv_3; - param_31 = _e458; - param_32 = vec2(0f, 0f); - let _e459 = tile_3; - param_33 = _e459; - let _e460 = range_3; - param_34 = _e460; - let _e461 = receiver_2; - param_35 = _e461; - let _e462 = PointShadowTap_u0028_vf2_u003b_vf2_u003b_vf2_u003b_f1_u003b_f1_u003b((¶m_31), (¶m_32), (¶m_33), (¶m_34), (¶m_35)); - lit = _e462; - let _e463 = radius_1; - if (_e463 > 0f) { + let _e469 = uv_3; + param_37 = _e469; + param_38 = vec2(0f, 0f); + let _e470 = tile_3; + param_39 = _e470; + let _e471 = range_3; + param_40 = _e471; + let _e472 = receiver_2; + param_41 = _e472; + let _e473 = PointShadowTap_u0028_vf2_u003b_vf2_u003b_vf2_u003b_f1_u003b_f1_u003b((¶m_37), (¶m_38), (¶m_39), (¶m_40), (¶m_41)); + lit_1 = _e473; + let _e474 = radius_1; + if (_e474 > 0f) { i_3 = 0i; loop { - let _e465 = i_3; - if (_e465 < 8i) { - let _e467 = i_3; - param_36 = _e467; - let _e468 = ca_2; - param_37 = _e468; - let _e469 = sa_2; - param_38 = _e469; - let _e470 = radius_1; - param_39 = _e470; - let _e471 = PointShadowOffset_u0028_i1_u003b_f1_u003b_f1_u003b_f1_u003b((¶m_36), (¶m_37), (¶m_38), (¶m_39)); - let _e472 = uv_3; - param_40 = _e472; - param_41 = _e471; - let _e473 = tile_3; - param_42 = _e473; - let _e474 = range_3; - param_43 = _e474; - let _e475 = receiver_2; - param_44 = _e475; - let _e476 = PointShadowTap_u0028_vf2_u003b_vf2_u003b_vf2_u003b_f1_u003b_f1_u003b((¶m_40), (¶m_41), (¶m_42), (¶m_43), (¶m_44)); - let _e477 = lit; - lit = (_e477 + _e476); + let _e476 = i_3; + if (_e476 < 8i) { + let _e478 = i_3; + param_42 = _e478; + let _e479 = ca_2; + param_43 = _e479; + let _e480 = sa_2; + param_44 = _e480; + let _e481 = radius_1; + param_45 = _e481; + let _e482 = PointShadowOffset_u0028_i1_u003b_f1_u003b_f1_u003b_f1_u003b((¶m_42), (¶m_43), (¶m_44), (¶m_45)); + let _e483 = uv_3; + param_46 = _e483; + param_47 = _e482; + let _e484 = tile_3; + param_48 = _e484; + let _e485 = range_3; + param_49 = _e485; + let _e486 = receiver_2; + param_50 = _e486; + let _e487 = PointShadowTap_u0028_vf2_u003b_vf2_u003b_vf2_u003b_f1_u003b_f1_u003b((¶m_46), (¶m_47), (¶m_48), (¶m_49), (¶m_50)); + let _e488 = lit_1; + lit_1 = (_e488 + _e487); continue; } else { break; } continuing { - let _e479 = i_3; - i_3 = (_e479 + 1i); + let _e490 = i_3; + i_3 = (_e490 + 1i); } } - let _e481 = lit; - lit = (_e481 * 0.11111111f); + let _e492 = lit_1; + lit_1 = (_e492 * 0.11111111f); } - let _e483 = lit; - let _e484 = strength; - return mix(1f, _e483, clamp(_e484, 0f, 1f)); + let _e494 = lit_1; + let _e495 = strength; + return mix(1f, _e494, clamp(_e495, 0f, 1f)); } fn VogelDisc_u0028_i1_u003b_i1_u003b_f1_u003b(i_4: ptr, n_1: ptr, turn: ptr) -> vec2 { var r: f32; var theta: f32; - let _e181 = (*i_4); - let _e184 = (*n_1); - r = sqrt(((f32(_e181) + 0.5f) / f32(_e184))); - let _e188 = (*i_4); - let _e191 = (*turn); - theta = ((f32(_e188) * 2.3999631f) + _e191); - let _e193 = r; - let _e194 = theta; - let _e196 = theta; - return (vec2(cos(_e194), sin(_e196)) * _e193); + let _e192 = (*i_4); + let _e195 = (*n_1); + r = sqrt(((f32(_e192) + 0.5f) / f32(_e195))); + let _e199 = (*i_4); + let _e202 = (*turn); + theta = ((f32(_e199) * 2.3999631f) + _e202); + let _e204 = r; + let _e205 = theta; + let _e207 = theta; + return (vec2(cos(_e205), sin(_e207)) * _e204); } fn ShadowNoise_u0028_() -> f32 { var at: vec2; - var param_45: f32; + var param_51: f32; - let _e180 = frag_info.target_origin[0u]; - param_45 = _e180; - let _e181 = FragCoordFromTop_u0028_f1_u003b((¶m_45)); - let _e184 = frag_info.target_origin[3u]; - at = (_e181 + vec2((5.588238f * max(_e184, 0f)))); - let _e189 = at; - return fract((52.982918f * fract(dot(_e189, vec2(0.06711056f, 0.00583715f))))); + let _e191 = frag_info.target_origin[0u]; + param_51 = _e191; + let _e192 = FragCoordFromTop_u0028_f1_u003b((¶m_51)); + let _e195 = frag_info.target_origin[3u]; + at = (_e192 + vec2((5.588238f * max(_e195, 0f)))); + let _e200 = at; + return fract((52.982918f * fract(dot(_e200, vec2(0.06711056f, 0.00583715f))))); } fn EvsmChebyshev_u0028_vf2_u003b_f1_u003b_f1_u003b_f1_u003b(moments: ptr>, t: ptr, minVariance: ptr, bleed: ptr) -> f32 { @@ -9255,91 +11052,92 @@ fn EvsmChebyshev_u0028_vf2_u003b_f1_u003b_f1_u003b_f1_u003b(moments: ptr) -> vec2 { var d_2: f32; - let _e178 = (*depth); - d_2 = ((2f * clamp(_e178, 0f, 1f)) - 1f); - let _e182 = d_2; - let _e185 = d_2; - return vec2(exp((40f * _e182)), -(exp((-5f * _e185)))); + let _e189 = (*depth); + d_2 = ((2f * clamp(_e189, 0f, 1f)) - 1f); + let _e193 = d_2; + let _e196 = d_2; + return vec2(exp((40f * _e193)), -(exp((-5f * _e196)))); } fn EvsmVisibility_u0028_vf4_u003b_f1_u003b_f1_u003b(moments_1: ptr>, depth_1: ptr, bleed_1: ptr) -> f32 { var warped: vec2; - var param_46: f32; + var param_52: f32; var scale: vec2; var positive: f32; - var param_47: vec2; - var param_48: f32; - var param_49: f32; - var param_50: f32; - var negative: f32; - var param_51: vec2; - var param_52: f32; - var param_53: f32; + var param_53: vec2; var param_54: f32; + var param_55: f32; + var param_56: f32; + var negative: f32; + var param_57: vec2; + var param_58: f32; + var param_59: f32; + var param_60: f32; - let _e192 = (*depth_1); - param_46 = _e192; - let _e193 = EvsmWarp_u0028_f1_u003b((¶m_46)); - warped = _e193; - let _e194 = warped; - scale = (vec2(0.004f, 0.0005f) * _e194); - let _e197 = scale[0u]; - let _e199 = scale[0u]; - let _e201 = (*moments_1); - param_47 = _e201.xy; - let _e204 = warped[0u]; - param_48 = _e204; - param_49 = (_e197 * _e199); - let _e205 = (*bleed_1); - param_50 = _e205; - let _e206 = EvsmChebyshev_u0028_vf2_u003b_f1_u003b_f1_u003b_f1_u003b((¶m_47), (¶m_48), (¶m_49), (¶m_50)); - positive = _e206; - let _e208 = scale[1u]; - let _e210 = scale[1u]; + let _e203 = (*depth_1); + param_52 = _e203; + let _e204 = EvsmWarp_u0028_f1_u003b((¶m_52)); + warped = _e204; + let _e205 = warped; + scale = (vec2(0.004f, 0.0005f) * _e205); + let _e208 = scale[0u]; + let _e210 = scale[0u]; let _e212 = (*moments_1); - param_51 = _e212.zw; - let _e215 = warped[1u]; - param_52 = _e215; - param_53 = (_e208 * _e210); + param_53 = _e212.xy; + let _e215 = warped[0u]; + param_54 = _e215; + param_55 = (_e208 * _e210); let _e216 = (*bleed_1); - param_54 = _e216; - let _e217 = EvsmChebyshev_u0028_vf2_u003b_f1_u003b_f1_u003b_f1_u003b((¶m_51), (¶m_52), (¶m_53), (¶m_54)); - negative = _e217; - let _e218 = positive; - let _e219 = negative; - return min(_e218, _e219); -} - -fn ShadowFactor_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b_struct_u002d_LightSample_u002d_vf3_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf31_u003b_i1_u003b(s_1: ptr, light_1: ptr, lightIndex_1: ptr) -> f32 { + param_56 = _e216; + let _e217 = EvsmChebyshev_u0028_vf2_u003b_f1_u003b_f1_u003b_f1_u003b((¶m_53), (¶m_54), (¶m_55), (¶m_56)); + positive = _e217; + let _e219 = scale[1u]; + let _e221 = scale[1u]; + let _e223 = (*moments_1); + param_57 = _e223.zw; + let _e226 = warped[1u]; + param_58 = _e226; + param_59 = (_e219 * _e221); + let _e227 = (*bleed_1); + param_60 = _e227; + let _e228 = EvsmChebyshev_u0028_vf2_u003b_f1_u003b_f1_u003b_f1_u003b((¶m_57), (¶m_58), (¶m_59), (¶m_60)); + negative = _e228; + let _e229 = positive; + let _e230 = negative; + return min(_e229, _e230); +} + +fn ShadowFactor_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b_struct_u002d_LightSample_u002d_vf3_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf31_u003b_i1_u003b(s_2: ptr, light_1: ptr, lightIndex_1: ptr) -> f32 { var strength_1: f32; var cascadeCount: i32; var viewDistance: f32; + var local_8: f32; var cascade: i32; var uv_4: vec2; var projected: vec3; @@ -9350,8 +11148,8 @@ fn ShadowFactor_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf var attempt: i32; var which: i32; var matrix: mat4x4; - var local_4: mat4x4; - var local_5: mat4x4; + var local_9: mat4x4; + var local_10: mat4x4; var axisX: vec3; var axisY: vec3; var rowX: f32; @@ -9365,17 +11163,19 @@ fn ShadowFactor_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf var inTile: vec2; var cosSlope: f32; var bias: f32; - var local_6: f32; - var local_7: f32; + var local_11: f32; + var local_12: f32; var texel_1: vec2; var tileLo: vec2; var tileHi: vec2; + var stored_2: vec4; + var through: vec3; var softness: f32; - var lit_1: f32; + var lit_2: f32; var moments_2: vec4; - var param_55: vec4; - var param_56: f32; - var param_57: f32; + var param_61: vec4; + var param_62: f32; + var param_63: f32; var y: i32; var x: i32; var occluder: f32; @@ -9386,65 +11186,73 @@ fn ShadowFactor_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf var blockerCount: f32; var i_5: i32; var disc: vec2; - var param_58: i32; - var param_59: i32; - var param_60: f32; + var param_64: i32; + var param_65: i32; + var param_66: f32; var tapBias: f32; var occluder_1: f32; var gap: f32; var radius_2: f32; var i_6: i32; var disc_1: vec2; - var param_61: i32; - var param_62: i32; - var param_63: f32; + var param_67: i32; + var param_68: i32; + var param_69: f32; var tapBias_1: f32; var occluder_2: f32; - var phi_3269_: bool; - var phi_3280_: bool; - var phi_3441_: bool; - var phi_3448_: bool; - var phi_3455_: bool; - - let _e244 = frag_info.shadow_params[3u]; - strength_1 = _e244; - let _e245 = strength_1; - if (_e245 <= 0f) { + var phi_3538_: bool; + var phi_3549_: bool; + var phi_3710_: bool; + var phi_3717_: bool; + var phi_3724_: bool; + + let _e258 = frag_info.shadow_params[3u]; + strength_1 = _e258; + let _e259 = strength_1; + if (_e259 <= 0f) { return 1f; } - let _e247 = (*lightIndex_1); - let _e250 = frag_info.frame_params[2u]; - if (_e247 != i32((_e250 + 0.5f))) { + let _e261 = (*lightIndex_1); + let _e264 = frag_info.frame_params[2u]; + if (_e261 != i32((_e264 + 0.5f))) { return 1f; } - let _e256 = frag_info.shadow_cascades[2u]; - cascadeCount = i32((_e256 + 0.5f)); - let _e259 = v_world_position_1; - let _e261 = frag_info.camera_position; - viewDistance = length((_e259 - _e261.xyz)); + let _e270 = frag_info.shadow_cascades[2u]; + cascadeCount = i32((_e270 + 0.5f)); + let _e273 = Orthographic_u0028_(); + if _e273 { + let _e274 = ViewDepth_u0028_(); + local_8 = _e274; + } else { + let _e275 = v_world_position_1; + let _e277 = frag_info.camera_position; + local_8 = length((_e275 - _e277.xyz)); + } + let _e281 = local_8; + viewDistance = _e281; cascade = 0i; - let _e265 = cascadeCount; - let _e266 = (_e265 > 1i); - phi_3269_ = _e266; - if _e266 { - let _e267 = viewDistance; - let _e270 = frag_info.shadow_cascades[0u]; - phi_3269_ = (_e267 > _e270); + let _e282 = cascadeCount; + let _e283 = (_e282 > 1i); + phi_3538_ = _e283; + if _e283 { + let _e284 = viewDistance; + let _e287 = frag_info.shadow_cascades[0u]; + phi_3538_ = (_e284 > _e287); } - let _e273 = phi_3269_; - if _e273 { + let _e290 = phi_3538_; + if _e290 { cascade = 1i; } - let _e274 = cascadeCount; - let _e275 = (_e274 > 2i); - phi_3280_ = _e275; - if _e275 { - let _e276 = viewDistance; - let _e279 = frag_info.shadow_cascades[1u]; - phi_3280_ = (_e276 > _e279); + let _e291 = cascadeCount; + let _e292 = (_e291 > 2i); + phi_3549_ = _e292; + if _e292 { + let _e293 = viewDistance; + let _e296 = frag_info.shadow_cascades[1u]; + phi_3549_ = (_e293 > _e296); } - let _e282 = phi_3280_; - if _e282 { + let _e299 = phi_3549_; + if _e299 { cascade = 2i; } uv_4 = vec2(0f, 0f); @@ -9455,232 +11263,247 @@ fn ShadowFactor_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf receiverSlope = 0f; attempt = 0i; loop { - let _e283 = attempt; - if (_e283 < 3i) { - let _e285 = cascade; - let _e286 = attempt; - which = (_e285 + _e286); - let _e288 = which; - let _e289 = cascadeCount; - if (_e288 >= _e289) { + let _e300 = attempt; + if (_e300 < 3i) { + let _e302 = cascade; + let _e303 = attempt; + which = (_e302 + _e303); + let _e305 = which; + let _e306 = cascadeCount; + if (_e305 >= _e306) { break; } - let _e291 = which; - if (_e291 == 0i) { - let _e294 = frag_info.shadow_matrix; - local_4 = _e294; + let _e308 = which; + if (_e308 == 0i) { + let _e311 = frag_info.shadow_matrix; + local_9 = _e311; } else { - let _e295 = which; - if (_e295 == 1i) { - let _e298 = frag_info.shadow_matrix_far; - local_5 = _e298; + let _e312 = which; + if (_e312 == 1i) { + let _e315 = frag_info.shadow_matrix_far; + local_10 = _e315; } else { - let _e300 = frag_info.shadow_matrix_farthest; - local_5 = _e300; + let _e317 = frag_info.shadow_matrix_farthest; + local_10 = _e317; } - let _e301 = local_5; - local_4 = _e301; - } - let _e302 = local_4; - matrix = _e302; - let _e305 = matrix[0][0u]; - let _e308 = matrix[1][0u]; - let _e311 = matrix[2][0u]; - axisX = vec3(_e305, _e308, _e311); - let _e315 = matrix[0][1u]; - let _e318 = matrix[1][1u]; - let _e321 = matrix[2][1u]; - axisY = vec3(_e315, _e318, _e321); - let _e323 = axisX; - rowX = max(length(_e323), 0.000001f); - let _e326 = axisY; - rowY = max(length(_e326), 0.000001f); - let _e331 = frag_info.shadow_cascades[3u]; - let _e333 = rowX; - texelMetres = ((2f * _e331) / _e333); - let _e337 = frag_info.shadow_cascades[3u]; - let _e340 = (*s_1).n; - let _e341 = axisX; - let _e344 = rowX; - let _e345 = rowX; - let _e349 = (*s_1).n; - let _e350 = axisY; - let _e353 = rowY; - let _e354 = rowY; - reach = ((3f * _e337) * ((abs(dot(_e340, _e341)) / (_e344 * _e345)) + (abs(dot(_e349, _e350)) / (_e353 * _e354)))); - let _e361 = frag_info.shadow_params[2u]; - let _e362 = texelMetres; - flatOffset = min(_e361, _e362); - let _e364 = v_world_position_1; - let _e366 = (*s_1).n; - let _e367 = flatOffset; - let _e368 = reach; - origin_1 = (_e364 + (_e366 * (_e367 + _e368))); - let _e372 = matrix; - let _e373 = origin_1; - lightSpace = (_e372 * vec4(_e373.x, _e373.y, _e373.z, 1f)); - let _e380 = lightSpace[3u]; - if (_e380 <= 0f) { + let _e318 = local_10; + local_9 = _e318; + } + let _e319 = local_9; + matrix = _e319; + let _e322 = matrix[0][0u]; + let _e325 = matrix[1][0u]; + let _e328 = matrix[2][0u]; + axisX = vec3(_e322, _e325, _e328); + let _e332 = matrix[0][1u]; + let _e335 = matrix[1][1u]; + let _e338 = matrix[2][1u]; + axisY = vec3(_e332, _e335, _e338); + let _e340 = axisX; + rowX = max(length(_e340), 0.000001f); + let _e343 = axisY; + rowY = max(length(_e343), 0.000001f); + let _e348 = frag_info.shadow_cascades[3u]; + let _e350 = rowX; + texelMetres = ((2f * _e348) / _e350); + let _e354 = frag_info.shadow_cascades[3u]; + let _e357 = (*s_2).n; + let _e358 = axisX; + let _e361 = rowX; + let _e362 = rowX; + let _e366 = (*s_2).n; + let _e367 = axisY; + let _e370 = rowY; + let _e371 = rowY; + reach = ((3f * _e354) * ((abs(dot(_e357, _e358)) / (_e361 * _e362)) + (abs(dot(_e366, _e367)) / (_e370 * _e371)))); + let _e378 = frag_info.shadow_params[2u]; + let _e379 = texelMetres; + flatOffset = min(_e378, _e379); + let _e381 = v_world_position_1; + let _e383 = (*s_2).n; + let _e384 = flatOffset; + let _e385 = reach; + origin_1 = (_e381 + (_e383 * (_e384 + _e385))); + let _e389 = matrix; + let _e390 = origin_1; + lightSpace = (_e389 * vec4(_e390.x, _e390.y, _e390.z, 1f)); + let _e397 = lightSpace[3u]; + if (_e397 <= 0f) { continue; } - let _e382 = lightSpace; - let _e385 = lightSpace[3u]; - candidate = (_e382.xyz / vec3(_e385)); - let _e389 = candidate[0u]; - let _e393 = candidate[1u]; - inTile = vec2(((_e389 * 0.5f) + 0.5f), (0.5f - (_e393 * 0.5f))); - let _e398 = inTile[0u]; - let _e399 = (_e398 < 0f); - phi_3441_ = _e399; - if !(_e399) { - let _e402 = inTile[0u]; - phi_3441_ = (_e402 > 1f); - } - let _e405 = phi_3441_; - phi_3448_ = _e405; - if !(_e405) { - let _e408 = inTile[1u]; - phi_3448_ = (_e408 < 0f); - } - let _e411 = phi_3448_; - phi_3455_ = _e411; - if !(_e411) { - let _e414 = inTile[1u]; - phi_3455_ = (_e414 > 1f); - } - let _e417 = phi_3455_; - if _e417 { + let _e399 = lightSpace; + let _e402 = lightSpace[3u]; + candidate = (_e399.xyz / vec3(_e402)); + let _e406 = candidate[0u]; + let _e410 = candidate[1u]; + inTile = vec2(((_e406 * 0.5f) + 0.5f), (0.5f - (_e410 * 0.5f))); + let _e415 = inTile[0u]; + let _e416 = (_e415 < 0f); + phi_3710_ = _e416; + if !(_e416) { + let _e419 = inTile[0u]; + phi_3710_ = (_e419 > 1f); + } + let _e422 = phi_3710_; + phi_3717_ = _e422; + if !(_e422) { + let _e425 = inTile[1u]; + phi_3717_ = (_e425 < 0f); + } + let _e428 = phi_3717_; + phi_3724_ = _e428; + if !(_e428) { + let _e431 = inTile[1u]; + phi_3724_ = (_e431 > 1f); + } + let _e434 = phi_3724_; + if _e434 { continue; } - let _e419 = candidate[2u]; - if (_e419 > 1f) { - let _e421 = which; - let _e422 = cascadeCount; - if (_e421 < (_e422 - 1i)) { + let _e436 = candidate[2u]; + if (_e436 > 1f) { + let _e438 = which; + let _e439 = cascadeCount; + if (_e438 < (_e439 - 1i)) { continue; } candidate[2u] = 1f; } - let _e427 = inTile[0u]; - let _e428 = which; - let _e431 = cascadeCount; - let _e435 = inTile[1u]; - uv_4 = vec2(((_e427 + f32(_e428)) / f32(_e431)), _e435); - let _e437 = candidate; - projected = _e437; - let _e438 = which; - cascade = _e438; - let _e439 = texelMetres; - cascadeTexel = _e439; - let _e442 = matrix[0][2u]; - let _e445 = matrix[1][2u]; - let _e448 = matrix[2][2u]; - cascadeDepth = (1f / max(length(vec3(_e442, _e445, _e448)), 0.000001f)); - let _e454 = (*s_1).n; - let _e457 = matrix[0][2u]; - let _e460 = matrix[1][2u]; - let _e463 = matrix[2][2u]; - let _e467 = cascadeDepth; - cosSlope = clamp((abs(dot(_e454, vec3(_e457, _e460, _e463))) * _e467), 0.1f, 1f); - let _e470 = texelMetres; - let _e471 = cosSlope; - let _e472 = cosSlope; - let _e478 = cosSlope; - let _e480 = cascadeDepth; - receiverSlope = (((_e470 * sqrt(max((1f - (_e471 * _e472)), 0f))) / _e478) / _e480); + let _e444 = inTile[0u]; + let _e445 = which; + let _e448 = cascadeCount; + let _e452 = inTile[1u]; + uv_4 = vec2(((_e444 + f32(_e445)) / f32(_e448)), _e452); + let _e454 = candidate; + projected = _e454; + let _e455 = which; + cascade = _e455; + let _e456 = texelMetres; + cascadeTexel = _e456; + let _e459 = matrix[0][2u]; + let _e462 = matrix[1][2u]; + let _e465 = matrix[2][2u]; + cascadeDepth = (1f / max(length(vec3(_e459, _e462, _e465)), 0.000001f)); + let _e471 = (*s_2).n; + let _e474 = matrix[0][2u]; + let _e477 = matrix[1][2u]; + let _e480 = matrix[2][2u]; + let _e484 = cascadeDepth; + cosSlope = clamp((abs(dot(_e471, vec3(_e474, _e477, _e480))) * _e484), 0.1f, 1f); + let _e487 = texelMetres; + let _e488 = cosSlope; + let _e489 = cosSlope; + let _e495 = cosSlope; + let _e497 = cascadeDepth; + receiverSlope = (((_e487 * sqrt(max((1f - (_e488 * _e489)), 0f))) / _e495) / _e497); found = true; break; } else { break; } continuing { - let _e482 = attempt; - attempt = (_e482 + 1i); + let _e499 = attempt; + attempt = (_e499 + 1i); } } - let _e484 = found; - if !(_e484) { + let _e501 = found; + if !(_e501) { return 1f; } - let _e486 = cascade; - if (_e486 == 0i) { - let _e490 = frag_info.shadow_bias[0u]; - local_6 = _e490; + let _e503 = cascade; + if (_e503 == 0i) { + let _e507 = frag_info.shadow_bias[0u]; + local_11 = _e507; } else { - let _e491 = cascade; - if (_e491 == 1i) { - let _e495 = frag_info.shadow_bias[1u]; - local_7 = _e495; + let _e508 = cascade; + if (_e508 == 1i) { + let _e512 = frag_info.shadow_bias[1u]; + local_12 = _e512; } else { - let _e498 = frag_info.shadow_bias[2u]; - local_7 = _e498; - } - let _e499 = local_7; - local_6 = _e499; - } - let _e500 = local_6; - bias = _e500; - let _e503 = frag_info.shadow_params[0u]; - let _e506 = frag_info.shadow_cascades[3u]; - texel_1 = vec2(_e503, _e506); - let _e508 = cascade; - let _e510 = cascadeCount; - let _e514 = texel_1; - tileLo = (vec2((f32(_e508) / f32(_e510)), 0f) + (_e514 * 0.5f)); - let _e517 = cascade; - let _e520 = cascadeCount; - let _e524 = texel_1; - tileHi = (vec2((f32((_e517 + 1i)) / f32(_e520)), 1f) - (_e524 * 0.5f)); - let _e529 = frag_info.ambient_ground[3u]; - softness = _e529; - lit_1 = 0f; - let _e530 = softness; - if (_e530 < 0f) { - let _e532 = uv_4; - let _e533 = tileLo; - let _e534 = tileHi; - let _e536 = textureSampleLevel(shadow_texture_tex, shadow_texture_smp, clamp(_e532, _e533, _e534), 0f); - moments_2 = _e536; - let _e538 = projected[2u]; - let _e539 = bias; - let _e541 = softness; - let _e545 = moments_2; - param_55 = _e545; - param_56 = (_e538 - _e539); - param_57 = clamp((-(_e541) - 1f), 0f, 0.95f); - let _e546 = EvsmVisibility_u0028_vf4_u003b_f1_u003b_f1_u003b((¶m_55), (¶m_56), (¶m_57)); - lit_1 = _e546; - } else { - let _e547 = softness; - if (_e547 <= 0f) { + let _e515 = frag_info.shadow_bias[2u]; + local_12 = _e515; + } + let _e516 = local_12; + local_11 = _e516; + } + let _e517 = local_11; + bias = _e517; + let _e520 = frag_info.shadow_params[0u]; + let _e523 = frag_info.shadow_cascades[3u]; + texel_1 = vec2(_e520, _e523); + let _e525 = cascade; + let _e527 = cascadeCount; + let _e531 = texel_1; + tileLo = (vec2((f32(_e525) / f32(_e527)), 0f) + (_e531 * 0.5f)); + let _e534 = cascade; + let _e537 = cascadeCount; + let _e541 = texel_1; + tileHi = (vec2((f32((_e534 + 1i)) / f32(_e537)), 1f) - (_e541 * 0.5f)); + let _e546 = frag_info.shadow_bias[3u]; + if (_e546 > 0.5f) { + let _e548 = uv_4; + let _e549 = tileLo; + let _e550 = tileHi; + let _e552 = textureSampleLevel(shadow_texture_tex, shadow_texture_smp, clamp(_e548, _e549, _e550), 0f); + stored_2 = _e552; + let _e554 = stored_2[1u]; + let _e557 = stored_2[2u]; + let _e560 = stored_2[3u]; + through = clamp(vec3((1f - _e554), (1f - _e557), _e560), vec3(0f), vec3(4f)); + let _e565 = through; + let _e566 = strength_1; + light_transmittance = mix(vec3(1f, 1f, 1f), _e565, vec3(clamp(_e566, 0f, 1f))); + } + let _e572 = frag_info.ambient_ground[3u]; + softness = _e572; + lit_2 = 0f; + let _e573 = softness; + if (_e573 < 0f) { + let _e575 = uv_4; + let _e576 = tileLo; + let _e577 = tileHi; + let _e579 = textureSampleLevel(shadow_texture_tex, shadow_texture_smp, clamp(_e575, _e576, _e577), 0f); + moments_2 = _e579; + let _e581 = projected[2u]; + let _e582 = bias; + let _e584 = softness; + let _e588 = moments_2; + param_61 = _e588; + param_62 = (_e581 - _e582); + param_63 = clamp((-(_e584) - 1f), 0f, 0.95f); + let _e589 = EvsmVisibility_u0028_vf4_u003b_f1_u003b_f1_u003b((¶m_61), (¶m_62), (¶m_63)); + lit_2 = _e589; + } else { + let _e590 = softness; + if (_e590 <= 0f) { y = -1i; loop { - let _e549 = y; - if (_e549 <= 1i) { + let _e592 = y; + if (_e592 <= 1i) { x = -1i; loop { - let _e551 = x; - if (_e551 <= 1i) { - let _e553 = uv_4; - let _e554 = x; - let _e556 = y; - let _e559 = texel_1; - let _e562 = tileLo; - let _e563 = tileHi; - let _e565 = textureSampleLevel(shadow_texture_tex, shadow_texture_smp, clamp((_e553 + (vec2(f32(_e554), f32(_e556)) * _e559)), _e562, _e563), 0f); - occluder = _e565.x; - let _e568 = projected[2u]; - let _e569 = bias; - let _e571 = occluder; - let _e574 = lit_1; - lit_1 = (_e574 + select(1f, 0f, ((_e568 - _e569) > _e571))); + let _e594 = x; + if (_e594 <= 1i) { + let _e596 = uv_4; + let _e597 = x; + let _e599 = y; + let _e602 = texel_1; + let _e605 = tileLo; + let _e606 = tileHi; + let _e608 = textureSampleLevel(shadow_texture_tex, shadow_texture_smp, clamp((_e596 + (vec2(f32(_e597), f32(_e599)) * _e602)), _e605, _e606), 0f); + occluder = _e608.x; + let _e611 = projected[2u]; + let _e612 = bias; + let _e614 = occluder; + let _e617 = lit_2; + lit_2 = (_e617 + select(1f, 0f, ((_e611 - _e612) > _e614))); continue; } else { break; } continuing { - let _e576 = x; - x = (_e576 + 1i); + let _e619 = x; + x = (_e619 + 1i); } } continue; @@ -9688,145 +11511,145 @@ fn ShadowFactor_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf break; } continuing { - let _e578 = y; - y = (_e578 + 1i); + let _e621 = y; + y = (_e621 + 1i); } } - let _e580 = lit_1; - lit_1 = (_e580 * 0.11111111f); + let _e623 = lit_2; + lit_2 = (_e623 * 0.11111111f); } else { - let _e582 = softness; - spread = tan(min(_e582, 0.5f)); - let _e585 = ShadowNoise_u0028_(); - turn_1 = (_e585 * 6.2831855f); - let _e587 = spread; - let _e589 = projected[2u]; - let _e591 = cascadeDepth; - let _e593 = cascadeTexel; - searchRadius = clamp((((_e587 * _e589) * _e591) / _e593), 1f, 16f); + let _e625 = softness; + spread = tan(min(_e625, 0.5f)); + let _e628 = ShadowNoise_u0028_(); + turn_1 = (_e628 * 6.2831855f); + let _e630 = spread; + let _e632 = projected[2u]; + let _e634 = cascadeDepth; + let _e636 = cascadeTexel; + searchRadius = clamp((((_e630 * _e632) * _e634) / _e636), 1f, 16f); blockerSum = 0f; blockerCount = 0f; i_5 = 0i; loop { - let _e596 = i_5; - if (_e596 < 16i) { - let _e598 = i_5; - param_58 = _e598; - param_59 = 16i; - let _e599 = turn_1; - param_60 = _e599; - let _e600 = VogelDisc_u0028_i1_u003b_i1_u003b_f1_u003b((¶m_58), (¶m_59), (¶m_60)); - disc = _e600; - let _e601 = bias; - let _e602 = disc; - let _e604 = searchRadius; - let _e608 = receiverSlope; - tapBias = (_e601 + (max(((length(_e602) * _e604) - 1.5f), 0f) * _e608)); - let _e611 = uv_4; - let _e612 = disc; - let _e613 = texel_1; - let _e615 = searchRadius; - let _e618 = tileLo; - let _e619 = tileHi; - let _e621 = textureSampleLevel(shadow_texture_tex, shadow_texture_smp, clamp((_e611 + ((_e612 * _e613) * _e615)), _e618, _e619), 0f); - occluder_1 = _e621.x; - let _e624 = projected[2u]; - let _e625 = tapBias; - let _e627 = occluder_1; - if ((_e624 - _e625) > _e627) { - let _e629 = occluder_1; - let _e630 = blockerSum; - blockerSum = (_e630 + _e629); - let _e632 = blockerCount; - blockerCount = (_e632 + 1f); + let _e639 = i_5; + if (_e639 < 16i) { + let _e641 = i_5; + param_64 = _e641; + param_65 = 16i; + let _e642 = turn_1; + param_66 = _e642; + let _e643 = VogelDisc_u0028_i1_u003b_i1_u003b_f1_u003b((¶m_64), (¶m_65), (¶m_66)); + disc = _e643; + let _e644 = bias; + let _e645 = disc; + let _e647 = searchRadius; + let _e651 = receiverSlope; + tapBias = (_e644 + (max(((length(_e645) * _e647) - 1.5f), 0f) * _e651)); + let _e654 = uv_4; + let _e655 = disc; + let _e656 = texel_1; + let _e658 = searchRadius; + let _e661 = tileLo; + let _e662 = tileHi; + let _e664 = textureSampleLevel(shadow_texture_tex, shadow_texture_smp, clamp((_e654 + ((_e655 * _e656) * _e658)), _e661, _e662), 0f); + occluder_1 = _e664.x; + let _e667 = projected[2u]; + let _e668 = tapBias; + let _e670 = occluder_1; + if ((_e667 - _e668) > _e670) { + let _e672 = occluder_1; + let _e673 = blockerSum; + blockerSum = (_e673 + _e672); + let _e675 = blockerCount; + blockerCount = (_e675 + 1f); } continue; } else { break; } continuing { - let _e634 = i_5; - i_5 = (_e634 + 1i); + let _e677 = i_5; + i_5 = (_e677 + 1i); } } - let _e636 = blockerCount; - if (_e636 <= 0f) { + let _e679 = blockerCount; + if (_e679 <= 0f) { return 1f; } - let _e639 = projected[2u]; - let _e640 = blockerSum; - let _e641 = blockerCount; - let _e645 = cascadeDepth; - gap = (max((_e639 - (_e640 / _e641)), 0f) * _e645); - let _e647 = spread; - let _e648 = gap; - let _e650 = cascadeTexel; - radius_2 = clamp(((_e647 * _e648) / _e650), 1f, 16f); + let _e682 = projected[2u]; + let _e683 = blockerSum; + let _e684 = blockerCount; + let _e688 = cascadeDepth; + gap = (max((_e682 - (_e683 / _e684)), 0f) * _e688); + let _e690 = spread; + let _e691 = gap; + let _e693 = cascadeTexel; + radius_2 = clamp(((_e690 * _e691) / _e693), 1f, 16f); i_6 = 0i; loop { - let _e653 = i_6; - if (_e653 < 16i) { - let _e655 = turn_1; - let _e657 = i_6; - param_61 = _e657; - param_62 = 16i; - param_63 = (_e655 + 1f); - let _e658 = VogelDisc_u0028_i1_u003b_i1_u003b_f1_u003b((¶m_61), (¶m_62), (¶m_63)); - disc_1 = _e658; - let _e659 = bias; - let _e660 = disc_1; - let _e662 = radius_2; - let _e666 = receiverSlope; - tapBias_1 = (_e659 + (max(((length(_e660) * _e662) - 1.5f), 0f) * _e666)); - let _e669 = uv_4; - let _e670 = disc_1; - let _e671 = texel_1; - let _e673 = radius_2; - let _e676 = tileLo; - let _e677 = tileHi; - let _e679 = textureSampleLevel(shadow_texture_tex, shadow_texture_smp, clamp((_e669 + ((_e670 * _e671) * _e673)), _e676, _e677), 0f); - occluder_2 = _e679.x; - let _e682 = projected[2u]; - let _e683 = tapBias_1; - let _e685 = occluder_2; - let _e688 = lit_1; - lit_1 = (_e688 + select(1f, 0f, ((_e682 - _e683) > _e685))); + let _e696 = i_6; + if (_e696 < 16i) { + let _e698 = turn_1; + let _e700 = i_6; + param_67 = _e700; + param_68 = 16i; + param_69 = (_e698 + 1f); + let _e701 = VogelDisc_u0028_i1_u003b_i1_u003b_f1_u003b((¶m_67), (¶m_68), (¶m_69)); + disc_1 = _e701; + let _e702 = bias; + let _e703 = disc_1; + let _e705 = radius_2; + let _e709 = receiverSlope; + tapBias_1 = (_e702 + (max(((length(_e703) * _e705) - 1.5f), 0f) * _e709)); + let _e712 = uv_4; + let _e713 = disc_1; + let _e714 = texel_1; + let _e716 = radius_2; + let _e719 = tileLo; + let _e720 = tileHi; + let _e722 = textureSampleLevel(shadow_texture_tex, shadow_texture_smp, clamp((_e712 + ((_e713 * _e714) * _e716)), _e719, _e720), 0f); + occluder_2 = _e722.x; + let _e725 = projected[2u]; + let _e726 = tapBias_1; + let _e728 = occluder_2; + let _e731 = lit_2; + lit_2 = (_e731 + select(1f, 0f, ((_e725 - _e726) > _e728))); continue; } else { break; } continuing { - let _e690 = i_6; - i_6 = (_e690 + 1i); + let _e733 = i_6; + i_6 = (_e733 + 1i); } } - let _e692 = lit_1; - lit_1 = (_e692 * 0.0625f); + let _e735 = lit_2; + lit_2 = (_e735 * 0.0625f); } } - let _e694 = lit_1; - let _e695 = strength_1; - return mix(1f, _e694, clamp(_e695, 0f, 1f)); + let _e737 = lit_2; + let _e738 = strength_1; + return mix(1f, _e737, clamp(_e738, 0f, 1f)); } -fn LightVisibility_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b_struct_u002d_LightSample_u002d_vf3_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf31_u003b_i1_u003b(s_2: ptr, light_2: ptr, index: ptr) -> f32 { - var param_64: Surface; - var param_65: LightSample; - var param_66: i32; +fn LightVisibility_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b_struct_u002d_LightSample_u002d_vf3_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf31_u003b_i1_u003b(s_3: ptr, light_2: ptr, index: ptr) -> f32 { + var param_70: Surface; + var param_71: LightSample; + var param_72: i32; - let _e182 = (*s_2); - param_64 = _e182; - let _e183 = (*light_2); - param_65 = _e183; - let _e184 = (*index); - param_66 = _e184; - let _e185 = ShadowFactor_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b_struct_u002d_LightSample_u002d_vf3_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf31_u003b_i1_u003b((¶m_64), (¶m_65), (¶m_66)); - return _e185; + let _e193 = (*s_3); + param_70 = _e193; + let _e194 = (*light_2); + param_71 = _e194; + let _e195 = (*index); + param_72 = _e195; + let _e196 = ShadowFactor_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b_struct_u002d_LightSample_u002d_vf3_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf31_u003b_i1_u003b((¶m_70), (¶m_71), (¶m_72)); + return _e196; } fn LightHasShadow_u0028_i1_u003b(index_1: ptr) -> bool { - let _e177 = (*index_1); - return (_e177 < 8i); + let _e188 = (*index_1); + return (_e188 < 8i); } fn PunctualAttenuation_u0028_f1_u003b_f1_u003b(distance_1: ptr, range_4: ptr) -> f32 { @@ -9834,26 +11657,26 @@ fn PunctualAttenuation_u0028_f1_u003b_f1_u003b(distance_1: ptr, r var ratio: f32; var window: f32; - let _e181 = (*distance_1); - let _e182 = (*distance_1); - attenuation = (1f / max((_e181 * _e182), 0.0001f)); - let _e186 = (*range_4); - if (_e186 > 0f) { - let _e188 = (*distance_1); - let _e189 = (*range_4); - ratio = (_e188 / _e189); - let _e191 = ratio; - let _e192 = ratio; - let _e194 = ratio; - let _e196 = ratio; - window = clamp((1f - (((_e191 * _e192) * _e194) * _e196)), 0f, 1f); - let _e200 = window; - let _e201 = window; - let _e203 = attenuation; - attenuation = (_e203 * (_e200 * _e201)); - } - let _e205 = attenuation; - return _e205; + let _e192 = (*distance_1); + let _e193 = (*distance_1); + attenuation = (1f / max((_e192 * _e193), 0.0001f)); + let _e197 = (*range_4); + if (_e197 > 0f) { + let _e199 = (*distance_1); + let _e200 = (*range_4); + ratio = (_e199 / _e200); + let _e202 = ratio; + let _e203 = ratio; + let _e205 = ratio; + let _e207 = ratio; + window = clamp((1f - (((_e202 * _e203) * _e205) * _e207)), 0f, 1f); + let _e211 = window; + let _e212 = window; + let _e214 = attenuation; + attenuation = (_e214 * (_e211 * _e212)); + } + let _e216 = attenuation; + return _e216; } fn RectangleClosestPoint_u0028_vf3_u003b_vf3_u003b_vf3_u003b_vf3_u003b_vf3_u003b(centre: ptr>, halfWidth: ptr>, halfHeight: ptr>, world_2: ptr>, mirror: ptr>) -> vec3 { @@ -9863,82 +11686,82 @@ fn RectangleClosestPoint_u0028_vf3_u003b_vf3_u003b_vf3_u003b_vf3_u003b_vf3_u003b var denom: f32; var onPlane: vec3; var t_1: f32; - var local_8: vec3; + var local_13: vec3; var offset_2: vec3; var wLen2_: f32; var hLen2_: f32; var u: f32; var v: f32; - let _e193 = (*halfWidth); - let _e194 = (*halfHeight); - n_2 = cross(_e193, _e194); - let _e196 = n_2; - nLen = length(_e196); - let _e198 = nLen; - if (_e198 < 0.000000000001f) { - let _e200 = (*centre); - return _e200; - } - let _e201 = nLen; - let _e202 = n_2; - n_2 = (_e202 / vec3(_e201)); - let _e205 = (*centre); - let _e206 = (*world_2); - toPlane = (_e205 - _e206); - let _e208 = (*mirror); - let _e209 = n_2; - denom = dot(_e208, _e209); - let _e211 = denom; - if (abs(_e211) < 0.00001f) { - let _e214 = toPlane; - let _e215 = n_2; - let _e216 = toPlane; - let _e217 = n_2; - onPlane = (_e214 - (_e215 * dot(_e216, _e217))); - } else { - let _e221 = toPlane; - let _e222 = n_2; - let _e224 = denom; - t_1 = (dot(_e221, _e222) / _e224); - let _e226 = t_1; - if (_e226 > 0f) { - let _e228 = (*mirror); - let _e229 = t_1; - local_8 = (_e228 * _e229); + let _e204 = (*halfWidth); + let _e205 = (*halfHeight); + n_2 = cross(_e204, _e205); + let _e207 = n_2; + nLen = length(_e207); + let _e209 = nLen; + if (_e209 < 0.000000000001f) { + let _e211 = (*centre); + return _e211; + } + let _e212 = nLen; + let _e213 = n_2; + n_2 = (_e213 / vec3(_e212)); + let _e216 = (*centre); + let _e217 = (*world_2); + toPlane = (_e216 - _e217); + let _e219 = (*mirror); + let _e220 = n_2; + denom = dot(_e219, _e220); + let _e222 = denom; + if (abs(_e222) < 0.00001f) { + let _e225 = toPlane; + let _e226 = n_2; + let _e227 = toPlane; + let _e228 = n_2; + onPlane = (_e225 - (_e226 * dot(_e227, _e228))); + } else { + let _e232 = toPlane; + let _e233 = n_2; + let _e235 = denom; + t_1 = (dot(_e232, _e233) / _e235); + let _e237 = t_1; + if (_e237 > 0f) { + let _e239 = (*mirror); + let _e240 = t_1; + local_13 = (_e239 * _e240); } else { - let _e231 = toPlane; - let _e232 = n_2; - let _e233 = toPlane; - let _e234 = n_2; - local_8 = (_e231 - (_e232 * dot(_e233, _e234))); - } - let _e238 = local_8; - onPlane = _e238; - } - let _e239 = onPlane; - let _e240 = toPlane; - offset_2 = (_e239 - _e240); - let _e242 = (*halfWidth); - let _e243 = (*halfWidth); - wLen2_ = max(dot(_e242, _e243), 0.000000000001f); - let _e246 = (*halfHeight); - let _e247 = (*halfHeight); - hLen2_ = max(dot(_e246, _e247), 0.000000000001f); - let _e250 = offset_2; - let _e251 = (*halfWidth); - let _e253 = wLen2_; - u = clamp((dot(_e250, _e251) / _e253), -1f, 1f); - let _e256 = offset_2; + let _e242 = toPlane; + let _e243 = n_2; + let _e244 = toPlane; + let _e245 = n_2; + local_13 = (_e242 - (_e243 * dot(_e244, _e245))); + } + let _e249 = local_13; + onPlane = _e249; + } + let _e250 = onPlane; + let _e251 = toPlane; + offset_2 = (_e250 - _e251); + let _e253 = (*halfWidth); + let _e254 = (*halfWidth); + wLen2_ = max(dot(_e253, _e254), 0.000000000001f); let _e257 = (*halfHeight); - let _e259 = hLen2_; - v = clamp((dot(_e256, _e257) / _e259), -1f, 1f); - let _e262 = (*centre); - let _e263 = (*halfWidth); - let _e264 = u; - let _e267 = (*halfHeight); - let _e268 = v; - return ((_e262 + (_e263 * _e264)) + (_e267 * _e268)); + let _e258 = (*halfHeight); + hLen2_ = max(dot(_e257, _e258), 0.000000000001f); + let _e261 = offset_2; + let _e262 = (*halfWidth); + let _e264 = wLen2_; + u = clamp((dot(_e261, _e262) / _e264), -1f, 1f); + let _e267 = offset_2; + let _e268 = (*halfHeight); + let _e270 = hLen2_; + v = clamp((dot(_e267, _e268) / _e270), -1f, 1f); + let _e273 = (*centre); + let _e274 = (*halfWidth); + let _e275 = u; + let _e278 = (*halfHeight); + let _e279 = v; + return ((_e273 + (_e274 * _e275)) + (_e278 * _e279)); } fn LambertEdge_u0028_vf3_u003b_vf3_u003b_vf3_u003b(a_1: ptr>, b: ptr>, n_3: ptr>) -> f32 { @@ -9947,34 +11770,34 @@ fn LambertEdge_u0028_vf3_u003b_vf3_u003b_vf3_u003b(a_1: ptr> var angle_1: f32; var axis: vec3; var len: f32; - var local_9: f32; + var local_14: f32; - let _e185 = (*a_1); - let _e186 = (*a_1); - ua = (_e185 / vec3(max(length(_e186), 0.000000000001f))); - let _e191 = (*b); - let _e192 = (*b); - ub = (_e191 / vec3(max(length(_e192), 0.000000000001f))); - let _e197 = ua; - let _e198 = ub; - angle_1 = acos(clamp(dot(_e197, _e198), -1f, 1f)); - let _e202 = ua; - let _e203 = ub; - axis = cross(_e202, _e203); - let _e205 = axis; - len = length(_e205); - let _e207 = len; - if (_e207 > 0.000001f) { - let _e209 = angle_1; - let _e210 = axis; - let _e211 = (*n_3); - let _e214 = len; - local_9 = ((_e209 * dot(_e210, _e211)) / _e214); - } else { - local_9 = 0f; - } - let _e216 = local_9; - return _e216; + let _e196 = (*a_1); + let _e197 = (*a_1); + ua = (_e196 / vec3(max(length(_e197), 0.000000000001f))); + let _e202 = (*b); + let _e203 = (*b); + ub = (_e202 / vec3(max(length(_e203), 0.000000000001f))); + let _e208 = ua; + let _e209 = ub; + angle_1 = acos(clamp(dot(_e208, _e209), -1f, 1f)); + let _e213 = ua; + let _e214 = ub; + axis = cross(_e213, _e214); + let _e216 = axis; + len = length(_e216); + let _e218 = len; + if (_e218 > 0.000001f) { + let _e220 = angle_1; + let _e221 = axis; + let _e222 = (*n_3); + let _e225 = len; + local_14 = ((_e220 * dot(_e221, _e222)) / _e225); + } else { + local_14 = 0f; + } + let _e227 = local_14; + return _e227; } fn RectangleFormFactor_u0028_vf3_u005b_4_u005d_u003b_vf3_u003b(corners: ptr, 4>>, n_4: ptr>) -> f32 { @@ -9984,212 +11807,212 @@ fn RectangleFormFactor_u0028_vf3_u005b_4_u005d_u003b_vf3_u003b(corners: ptr; var b_1: vec3; - var local_10: i32; + var local_15: i32; var ha: f32; var hb: f32; var d_3: f32; var q: vec3; - var local_11: f32; + var local_16: f32; var aAbove: bool; var bAbove: bool; - var local_12: f32; - var param_67: vec3; - var param_68: vec3; - var param_69: vec3; - var param_70: vec3; - var param_71: vec3; - var param_72: vec3; + var local_17: f32; + var param_73: vec3; + var param_74: vec3; + var param_75: vec3; + var param_76: vec3; + var param_77: vec3; + var param_78: vec3; total = 0f; exit = vec3(0f, 0f, 0f); entry = vec3(0f, 0f, 0f); i_7 = 0i; loop { - let _e199 = i_7; - if (_e199 < 4i) { - let _e201 = i_7; - let _e203 = (*corners)[_e201]; - a_2 = _e203; - let _e204 = i_7; - if (_e204 == 3i) { - local_10 = 0i; + let _e210 = i_7; + if (_e210 < 4i) { + let _e212 = i_7; + let _e214 = (*corners)[_e212]; + a_2 = _e214; + let _e215 = i_7; + if (_e215 == 3i) { + local_15 = 0i; } else { - let _e206 = i_7; - local_10 = (_e206 + 1i); - } - let _e208 = local_10; - let _e210 = (*corners)[_e208]; - b_1 = _e210; - let _e211 = a_2; - let _e212 = (*n_4); - ha = dot(_e211, _e212); - let _e214 = b_1; - let _e215 = (*n_4); - hb = dot(_e214, _e215); - let _e217 = ha; - let _e218 = hb; - d_3 = (_e217 - _e218); - let _e220 = a_2; - let _e221 = b_1; + let _e217 = i_7; + local_15 = (_e217 + 1i); + } + let _e219 = local_15; + let _e221 = (*corners)[_e219]; + b_1 = _e221; let _e222 = a_2; - let _e224 = d_3; - if (abs(_e224) > 0.000000000001f) { - let _e227 = ha; - let _e228 = d_3; - local_11 = (_e227 / _e228); + let _e223 = (*n_4); + ha = dot(_e222, _e223); + let _e225 = b_1; + let _e226 = (*n_4); + hb = dot(_e225, _e226); + let _e228 = ha; + let _e229 = hb; + d_3 = (_e228 - _e229); + let _e231 = a_2; + let _e232 = b_1; + let _e233 = a_2; + let _e235 = d_3; + if (abs(_e235) > 0.000000000001f) { + let _e238 = ha; + let _e239 = d_3; + local_16 = (_e238 / _e239); } else { - local_11 = 0f; - } - let _e230 = local_11; - q = (_e220 + ((_e221 - _e222) * _e230)); - let _e233 = ha; - aAbove = (_e233 > 0f); - let _e235 = hb; - bAbove = (_e235 > 0f); - let _e237 = aAbove; - let _e238 = bAbove; - if (_e237 || _e238) { - let _e240 = aAbove; - let _e241 = a_2; - let _e242 = q; - let _e245 = bAbove; - let _e246 = b_1; - let _e247 = q; - param_67 = select(_e242, _e241, vec3(_e240)); - param_68 = select(_e247, _e246, vec3(_e245)); - let _e250 = (*n_4); - param_69 = _e250; - let _e251 = LambertEdge_u0028_vf3_u003b_vf3_u003b_vf3_u003b((¶m_67), (¶m_68), (¶m_69)); - local_12 = _e251; + local_16 = 0f; + } + let _e241 = local_16; + q = (_e231 + ((_e232 - _e233) * _e241)); + let _e244 = ha; + aAbove = (_e244 > 0f); + let _e246 = hb; + bAbove = (_e246 > 0f); + let _e248 = aAbove; + let _e249 = bAbove; + if (_e248 || _e249) { + let _e251 = aAbove; + let _e252 = a_2; + let _e253 = q; + let _e256 = bAbove; + let _e257 = b_1; + let _e258 = q; + param_73 = select(_e253, _e252, vec3(_e251)); + param_74 = select(_e258, _e257, vec3(_e256)); + let _e261 = (*n_4); + param_75 = _e261; + let _e262 = LambertEdge_u0028_vf3_u003b_vf3_u003b_vf3_u003b((¶m_73), (¶m_74), (¶m_75)); + local_17 = _e262; } else { - local_12 = 0f; - } - let _e252 = local_12; - let _e253 = total; - total = (_e253 + _e252); - let _e255 = aAbove; - let _e256 = bAbove; - let _e259 = q; - let _e260 = exit; - exit = select(_e260, _e259, vec3((_e255 && !(_e256)))); - let _e263 = aAbove; - let _e265 = bAbove; - let _e267 = q; - let _e268 = entry; - entry = select(_e268, _e267, vec3((!(_e263) && _e265))); + local_17 = 0f; + } + let _e263 = local_17; + let _e264 = total; + total = (_e264 + _e263); + let _e266 = aAbove; + let _e267 = bAbove; + let _e270 = q; + let _e271 = exit; + exit = select(_e271, _e270, vec3((_e266 && !(_e267)))); + let _e274 = aAbove; + let _e276 = bAbove; + let _e278 = q; + let _e279 = entry; + entry = select(_e279, _e278, vec3((!(_e274) && _e276))); continue; } else { break; } continuing { - let _e271 = i_7; - i_7 = (_e271 + 1i); - } - } - let _e273 = exit; - param_70 = _e273; - let _e274 = entry; - param_71 = _e274; - let _e275 = (*n_4); - param_72 = _e275; - let _e276 = LambertEdge_u0028_vf3_u003b_vf3_u003b_vf3_u003b((¶m_70), (¶m_71), (¶m_72)); - let _e277 = total; - total = (_e277 + _e276); - let _e279 = total; - return max((-(_e279) * 0.5f), 0f); + let _e282 = i_7; + i_7 = (_e282 + 1i); + } + } + let _e284 = exit; + param_76 = _e284; + let _e285 = entry; + param_77 = _e285; + let _e286 = (*n_4); + param_78 = _e286; + let _e287 = LambertEdge_u0028_vf3_u003b_vf3_u003b_vf3_u003b((¶m_76), (¶m_77), (¶m_78)); + let _e288 = total; + total = (_e288 + _e287); + let _e290 = total; + return max((-(_e290) * 0.5f), 0f); } fn LightListLane_u0028_vf4_u003b_i1_u003b(four: ptr>, slot_1: ptr) -> f32 { var lane: i32; - var local_13: f32; - var local_14: f32; - var local_15: f32; + var local_18: f32; + var local_19: f32; + var local_20: f32; - let _e182 = (*slot_1); - let _e183 = (*slot_1); - lane = (_e182 - ((_e183 / 4i) * 4i)); - let _e187 = lane; - if (_e187 == 0i) { - let _e190 = (*four)[0u]; - local_13 = _e190; + let _e193 = (*slot_1); + let _e194 = (*slot_1); + lane = (_e193 - ((_e194 / 4i) * 4i)); + let _e198 = lane; + if (_e198 == 0i) { + let _e201 = (*four)[0u]; + local_18 = _e201; } else { - let _e191 = lane; - if (_e191 == 1i) { - let _e194 = (*four)[1u]; - local_14 = _e194; + let _e202 = lane; + if (_e202 == 1i) { + let _e205 = (*four)[1u]; + local_19 = _e205; } else { - let _e195 = lane; - if (_e195 == 2i) { - let _e198 = (*four)[2u]; - local_15 = _e198; + let _e206 = lane; + if (_e206 == 2i) { + let _e209 = (*four)[2u]; + local_20 = _e209; } else { - let _e200 = (*four)[3u]; - local_15 = _e200; + let _e211 = (*four)[3u]; + local_20 = _e211; } - let _e201 = local_15; - local_14 = _e201; + let _e212 = local_20; + local_19 = _e212; } - let _e202 = local_14; - local_13 = _e202; + let _e213 = local_19; + local_18 = _e213; } - let _e203 = local_13; - return _e203; + let _e214 = local_18; + return _e214; } fn LightListScale_u0028_i1_u003b(slot_2: ptr) -> f32 { - var param_73: vec4; - var param_74: i32; + var param_79: vec4; + var param_80: i32; - let _e179 = (*slot_2); - let _e183 = light_list_info.scales[(_e179 / 4i)]; - param_73 = _e183; - let _e184 = (*slot_2); - param_74 = _e184; - let _e185 = LightListLane_u0028_vf4_u003b_i1_u003b((¶m_73), (¶m_74)); - return _e185; + let _e190 = (*slot_2); + let _e194 = light_list_info.scales[(_e190 / 4i)]; + param_79 = _e194; + let _e195 = (*slot_2); + param_80 = _e195; + let _e196 = LightListLane_u0028_vf4_u003b_i1_u003b((¶m_79), (¶m_80)); + return _e196; } fn InSlots_u0028_f1_u003b(row: ptr) -> bool { var a_3: vec4; var b_2: vec4; - let _e181 = light_list_info.slot_rows[0i]; - let _e182 = (*row); - a_3 = abs((_e181 - vec4(_e182))); - let _e188 = light_list_info.slot_rows[1i]; - let _e189 = (*row); - b_2 = abs((_e188 - vec4(_e189))); - let _e194 = a_3[0u]; - let _e196 = a_3[1u]; - let _e199 = a_3[2u]; - let _e201 = a_3[3u]; - let _e205 = b_2[0u]; - let _e207 = b_2[1u]; - let _e210 = b_2[2u]; - let _e212 = b_2[3u]; - return (min(min(min(_e194, _e196), min(_e199, _e201)), min(min(_e205, _e207), min(_e210, _e212))) < 0.5f); + let _e192 = light_list_info.slot_rows[0i]; + let _e193 = (*row); + a_3 = abs((_e192 - vec4(_e193))); + let _e199 = light_list_info.slot_rows[1i]; + let _e200 = (*row); + b_2 = abs((_e199 - vec4(_e200))); + let _e205 = a_3[0u]; + let _e207 = a_3[1u]; + let _e210 = a_3[2u]; + let _e212 = a_3[3u]; + let _e216 = b_2[0u]; + let _e218 = b_2[1u]; + let _e221 = b_2[2u]; + let _e223 = b_2[3u]; + return (min(min(min(_e205, _e207), min(_e210, _e212)), min(min(_e216, _e218), min(_e221, _e223))) < 0.5f); } fn LightListRow_u0028_i1_u003b(slot_3: ptr) -> f32 { - var param_75: vec4; - var param_76: i32; + var param_81: vec4; + var param_82: i32; - let _e179 = (*slot_3); - let _e183 = light_list_info.indices[(_e179 / 4i)]; - param_75 = _e183; - let _e184 = (*slot_3); - param_76 = _e184; - let _e185 = LightListLane_u0028_vf4_u003b_i1_u003b((¶m_75), (¶m_76)); - return _e185; + let _e190 = (*slot_3); + let _e194 = light_list_info.indices[(_e190 / 4i)]; + param_81 = _e194; + let _e195 = (*slot_3); + param_82 = _e195; + let _e196 = LightListLane_u0028_vf4_u003b_i1_u003b((¶m_81), (¶m_82)); + return _e196; } fn LightListTexel_u0028_f1_u003b_f1_u003b(texel_2: ptr, row_1: ptr) -> vec4 { - let _e178 = (*texel_2); - let _e182 = light_list_info.list[1u]; - let _e184 = (*row_1); - let _e188 = light_list_info.list[2u]; - let _e191 = textureSampleLevel(light_list_texture_tex, light_list_texture_smp, vec2(((_e178 + 0.5f) * _e182), ((_e184 + 0.5f) * _e188)), 0f); - return _e191; + let _e189 = (*texel_2); + let _e193 = light_list_info.list[1u]; + let _e195 = (*row_1); + let _e199 = light_list_info.list[2u]; + let _e202 = textureSampleLevel(light_list_texture_tex, light_list_texture_smp, vec2(((_e189 + 0.5f) * _e193), ((_e195 + 0.5f) * _e199)), 0f); + return _e202; } fn ClusterRow_u0028_i1_u003b(slot_4: ptr) -> f32 { @@ -10198,43 +12021,43 @@ fn ClusterRow_u0028_i1_u003b(slot_4: ptr) -> f32 { var within: f32; var texel_3: f32; var four_1: vec4; - var param_77: f32; - var param_78: f32; - var param_79: vec4; - var param_80: i32; + var param_83: f32; + var param_84: f32; + var param_85: vec4; + var param_86: i32; - let _e186 = g_cluster_offset; - let _e187 = (*slot_4); - entry_1 = (_e186 + f32(_e187)); - let _e190 = entry_1; - row_2 = floor((_e190 / 16f)); - let _e193 = entry_1; - let _e194 = row_2; - within = (_e193 - (_e194 * 16f)); - let _e197 = within; - texel_3 = floor((_e197 / 4f)); - let _e202 = light_list_info.cluster_depth[3u]; - let _e203 = row_2; - let _e205 = texel_3; - param_77 = _e205; - param_78 = (_e202 + _e203); - let _e206 = LightListTexel_u0028_f1_u003b_f1_u003b((¶m_77), (¶m_78)); - four_1 = _e206; - let _e207 = within; - let _e208 = texel_3; - let _e213 = four_1; - param_79 = _e213; - param_80 = i32(((_e207 - (_e208 * 4f)) + 0.5f)); - let _e214 = LightListLane_u0028_vf4_u003b_i1_u003b((¶m_79), (¶m_80)); - return _e214; + let _e197 = g_cluster_offset; + let _e198 = (*slot_4); + entry_1 = (_e197 + f32(_e198)); + let _e201 = entry_1; + row_2 = floor((_e201 / 16f)); + let _e204 = entry_1; + let _e205 = row_2; + within = (_e204 - (_e205 * 16f)); + let _e208 = within; + texel_3 = floor((_e208 / 4f)); + let _e213 = light_list_info.cluster_depth[3u]; + let _e214 = row_2; + let _e216 = texel_3; + param_83 = _e216; + param_84 = (_e213 + _e214); + let _e217 = LightListTexel_u0028_f1_u003b_f1_u003b((¶m_83), (¶m_84)); + four_1 = _e217; + let _e218 = within; + let _e219 = texel_3; + let _e224 = four_1; + param_85 = _e224; + param_86 = i32(((_e218 - (_e219 * 4f)) + 0.5f)); + let _e225 = LightListLane_u0028_vf4_u003b_i1_u003b((¶m_85), (¶m_86)); + return _e225; } fn Clustered_u0028_() -> bool { - let _e178 = light_list_info.cluster_grid[3u]; - return (_e178 > 0.5f); + let _e189 = light_list_info.cluster_grid[3u]; + return (_e189 > 0.5f); } -fn SampleLight_u0028_i1_u003b_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b(index_2: ptr, s_3: ptr) -> LightSample { +fn SampleLight_u0028_i1_u003b_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b(index_2: ptr, s_4: ptr) -> LightSample { var light_3: LightSample; var position: vec4; var color_1: vec4; @@ -10243,14 +12066,14 @@ fn SampleLight_u0028_i1_u003b_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_ var slot_5: i32; var clustered: bool; var listRow: f32; - var local_16: f32; - var param_81: i32; - var param_82: i32; + var local_21: f32; + var param_87: i32; + var param_88: i32; var v_1: f32; var u_1: f32; - var local_17: f32; - var param_83: f32; - var param_84: i32; + var local_22: f32; + var param_89: f32; + var param_90: i32; var type_39: f32; var halfWidth_1: vec3; var halfHeight_1: vec3; @@ -10258,293 +12081,293 @@ fn SampleLight_u0028_i1_u003b_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_ var corners_1: array, 4>; var behind: bool; var formFactor: f32; - var local_18: f32; - var param_85: array, 4>; - var param_86: vec3; + var local_23: f32; + var param_91: array, 4>; + var param_92: vec3; var area: f32; var radiance: f32; - var local_19: f32; + var local_24: f32; var distance_2: f32; var ratio_1: f32; var window_1: f32; var mirror_1: vec3; var representative: vec3; - var param_87: vec3; - var param_88: vec3; - var param_89: vec3; - var param_90: vec3; - var param_91: vec3; + var param_93: vec3; + var param_94: vec3; + var param_95: vec3; + var param_96: vec3; + var param_97: vec3; var toPoint: vec3; var pointDistance: f32; - var local_20: vec3; + var local_25: vec3; var aim: vec3; var attenuation_1: f32; var toLight_1: vec3; var distance_3: f32; - var param_92: f32; - var param_93: f32; + var param_98: f32; + var param_99: f32; var cosAngle: f32; light_3.integrated = 0f; light_3.ltc = vec3(0f, 0f, 0f); - let _e229 = (*index_2); - if (_e229 < 8i) { - let _e231 = (*index_2); - let _e234 = frag_info.light_position[_e231]; - position = _e234; - let _e235 = (*index_2); - let _e238 = frag_info.light_color[_e235]; - color_1 = _e238; - let _e239 = (*index_2); - let _e242 = frag_info.light_direction[_e239]; - direction = _e242; - let _e243 = (*index_2); - let _e246 = frag_info.light_cone[_e243]; - cone = _e246; + let _e240 = (*index_2); + if (_e240 < 8i) { + let _e242 = (*index_2); + let _e245 = frag_info.light_position[_e242]; + position = _e245; + let _e246 = (*index_2); + let _e249 = frag_info.light_color[_e246]; + color_1 = _e249; + let _e250 = (*index_2); + let _e253 = frag_info.light_direction[_e250]; + direction = _e253; + let _e254 = (*index_2); + let _e257 = frag_info.light_cone[_e254]; + cone = _e257; } else { - let _e247 = (*index_2); - slot_5 = (_e247 - 8i); - let _e249 = Clustered_u0028_(); - clustered = _e249; - let _e250 = clustered; - if _e250 { - let _e251 = slot_5; - param_81 = _e251; - let _e252 = ClusterRow_u0028_i1_u003b((¶m_81)); - local_16 = _e252; + let _e258 = (*index_2); + slot_5 = (_e258 - 8i); + let _e260 = Clustered_u0028_(); + clustered = _e260; + let _e261 = clustered; + if _e261 { + let _e262 = slot_5; + param_87 = _e262; + let _e263 = ClusterRow_u0028_i1_u003b((¶m_87)); + local_21 = _e263; } else { - let _e253 = slot_5; - param_82 = _e253; - let _e254 = LightListRow_u0028_i1_u003b((¶m_82)); - local_16 = _e254; - } - let _e255 = local_16; - listRow = _e255; - let _e256 = listRow; - let _e260 = light_list_info.list[2u]; - v_1 = ((_e256 + 0.5f) * _e260); - let _e264 = light_list_info.list[1u]; - u_1 = _e264; - let _e265 = u_1; - let _e267 = v_1; - let _e269 = textureSampleLevel(light_list_texture_tex, light_list_texture_smp, vec2((0.5f * _e265), _e267), 0f); - position = _e269; - let _e270 = u_1; - let _e272 = v_1; - let _e274 = textureSampleLevel(light_list_texture_tex, light_list_texture_smp, vec2((1.5f * _e270), _e272), 0f); - color_1 = _e274; - let _e275 = u_1; - let _e277 = v_1; - let _e279 = textureSampleLevel(light_list_texture_tex, light_list_texture_smp, vec2((2.5f * _e275), _e277), 0f); - direction = _e279; - let _e280 = u_1; - let _e282 = v_1; - let _e284 = textureSampleLevel(light_list_texture_tex, light_list_texture_smp, vec2((3.5f * _e280), _e282), 0f); - cone = _e284; - let _e285 = clustered; - if _e285 { - let _e286 = listRow; - param_83 = _e286; - let _e287 = InSlots_u0028_f1_u003b((¶m_83)); - local_17 = select(1f, 0f, _e287); + let _e264 = slot_5; + param_88 = _e264; + let _e265 = LightListRow_u0028_i1_u003b((¶m_88)); + local_21 = _e265; + } + let _e266 = local_21; + listRow = _e266; + let _e267 = listRow; + let _e271 = light_list_info.list[2u]; + v_1 = ((_e267 + 0.5f) * _e271); + let _e275 = light_list_info.list[1u]; + u_1 = _e275; + let _e276 = u_1; + let _e278 = v_1; + let _e280 = textureSampleLevel(light_list_texture_tex, light_list_texture_smp, vec2((0.5f * _e276), _e278), 0f); + position = _e280; + let _e281 = u_1; + let _e283 = v_1; + let _e285 = textureSampleLevel(light_list_texture_tex, light_list_texture_smp, vec2((1.5f * _e281), _e283), 0f); + color_1 = _e285; + let _e286 = u_1; + let _e288 = v_1; + let _e290 = textureSampleLevel(light_list_texture_tex, light_list_texture_smp, vec2((2.5f * _e286), _e288), 0f); + direction = _e290; + let _e291 = u_1; + let _e293 = v_1; + let _e295 = textureSampleLevel(light_list_texture_tex, light_list_texture_smp, vec2((3.5f * _e291), _e293), 0f); + cone = _e295; + let _e296 = clustered; + if _e296 { + let _e297 = listRow; + param_89 = _e297; + let _e298 = InSlots_u0028_f1_u003b((¶m_89)); + local_22 = select(1f, 0f, _e298); } else { - let _e289 = slot_5; - param_84 = _e289; - let _e290 = LightListScale_u0028_i1_u003b((¶m_84)); - local_17 = _e290; - } - let _e291 = local_17; - let _e293 = color_1[3u]; - color_1[3u] = (_e293 * _e291); - } - let _e297 = position[3u]; - type_39 = _e297; - let _e298 = type_39; - if (_e298 > 2.5f) { - let _e300 = direction; - halfWidth_1 = _e300.xyz; - let _e302 = cone; - halfHeight_1 = _e302.xyz; - let _e304 = position; - let _e306 = v_world_position_1; - toCentre = (_e304.xyz - _e306); - let _e308 = toCentre; - let _e309 = halfWidth_1; - let _e311 = halfHeight_1; - corners_1[0i] = ((_e308 - _e309) - _e311); - let _e314 = toCentre; - let _e315 = halfWidth_1; - let _e317 = halfHeight_1; - corners_1[1i] = ((_e314 + _e315) - _e317); - let _e320 = toCentre; - let _e321 = halfWidth_1; - let _e323 = halfHeight_1; - corners_1[2i] = ((_e320 + _e321) + _e323); - let _e326 = toCentre; - let _e327 = halfWidth_1; - let _e329 = halfHeight_1; - corners_1[3i] = ((_e326 - _e327) + _e329); - let _e332 = toCentre; - let _e333 = halfWidth_1; + let _e300 = slot_5; + param_90 = _e300; + let _e301 = LightListScale_u0028_i1_u003b((¶m_90)); + local_22 = _e301; + } + let _e302 = local_22; + let _e304 = color_1[3u]; + color_1[3u] = (_e304 * _e302); + } + let _e308 = position[3u]; + type_39 = _e308; + let _e309 = type_39; + if (_e309 > 2.5f) { + let _e311 = direction; + halfWidth_1 = _e311.xyz; + let _e313 = cone; + halfHeight_1 = _e313.xyz; + let _e315 = position; + let _e317 = v_world_position_1; + toCentre = (_e315.xyz - _e317); + let _e319 = toCentre; + let _e320 = halfWidth_1; + let _e322 = halfHeight_1; + corners_1[0i] = ((_e319 - _e320) - _e322); + let _e325 = toCentre; + let _e326 = halfWidth_1; + let _e328 = halfHeight_1; + corners_1[1i] = ((_e325 + _e326) - _e328); + let _e331 = toCentre; + let _e332 = halfWidth_1; let _e334 = halfHeight_1; - behind = (dot(_e332, cross(_e333, _e334)) >= 0f); - let _e338 = behind; - if _e338 { - local_18 = 0f; - } else { - let _e339 = corners_1; - param_85 = _e339; - let _e341 = (*s_3).n; - param_86 = _e341; - let _e342 = RectangleFormFactor_u0028_vf3_u005b_4_u005d_u003b_vf3_u003b((¶m_85), (¶m_86)); - local_18 = _e342; - } - let _e343 = local_18; - formFactor = _e343; + corners_1[2i] = ((_e331 + _e332) + _e334); + let _e337 = toCentre; + let _e338 = halfWidth_1; + let _e340 = halfHeight_1; + corners_1[3i] = ((_e337 - _e338) + _e340); + let _e343 = toCentre; let _e344 = halfWidth_1; let _e345 = halfHeight_1; - area = (length(cross(_e344, _e345)) * 4f); - let _e349 = area; - if (_e349 > 0.000000001f) { - let _e351 = area; - local_19 = (1f / _e351); + behind = (dot(_e343, cross(_e344, _e345)) >= 0f); + let _e349 = behind; + if _e349 { + local_23 = 0f; } else { - local_19 = 0f; - } - let _e353 = local_19; - radiance = _e353; - let _e354 = toCentre; - distance_2 = length(_e354); - let _e357 = direction[3u]; - if (_e357 > 0f) { - let _e359 = distance_2; - let _e361 = direction[3u]; - ratio_1 = (_e359 / _e361); - let _e363 = ratio_1; - let _e364 = ratio_1; - let _e366 = ratio_1; - let _e368 = ratio_1; - window_1 = clamp((1f - (((_e363 * _e364) * _e366) * _e368)), 0f, 1f); - let _e372 = window_1; - let _e373 = window_1; - let _e375 = radiance; - radiance = (_e375 * (_e372 * _e373)); - } - let _e378 = (*s_3).v; - let _e381 = (*s_3).n; - mirror_1 = reflect(-(_e378), _e381); - let _e383 = position; - param_87 = _e383.xyz; - let _e385 = halfWidth_1; - param_88 = _e385; - let _e386 = halfHeight_1; - param_89 = _e386; - let _e387 = v_world_position_1; - param_90 = _e387; - let _e388 = mirror_1; - param_91 = _e388; - let _e389 = RectangleClosestPoint_u0028_vf3_u003b_vf3_u003b_vf3_u003b_vf3_u003b_vf3_u003b((¶m_87), (¶m_88), (¶m_89), (¶m_90), (¶m_91)); - representative = _e389; - let _e390 = representative; - let _e391 = v_world_position_1; - toPoint = (_e390 - _e391); - let _e393 = toPoint; - pointDistance = length(_e393); - let _e395 = pointDistance; - if (_e395 > 0.000001f) { - let _e397 = toPoint; - let _e398 = pointDistance; - local_20 = (_e397 / vec3(_e398)); + let _e350 = corners_1; + param_91 = _e350; + let _e352 = (*s_4).n; + param_92 = _e352; + let _e353 = RectangleFormFactor_u0028_vf3_u005b_4_u005d_u003b_vf3_u003b((¶m_91), (¶m_92)); + local_23 = _e353; + } + let _e354 = local_23; + formFactor = _e354; + let _e355 = halfWidth_1; + let _e356 = halfHeight_1; + area = (length(cross(_e355, _e356)) * 4f); + let _e360 = area; + if (_e360 > 0.000000001f) { + let _e362 = area; + local_24 = (1f / _e362); } else { - let _e402 = (*s_3).n; - local_20 = _e402; - } - let _e403 = local_20; - light_3.l = _e403; - let _e406 = light_3.l; - let _e408 = (*s_3).v; - light_3.h = normalize((_e406 + _e408)); - let _e412 = formFactor; - light_3.n_dot_l = _e412; - let _e415 = (*s_3).n; - let _e417 = light_3.h; - light_3.n_dot_h = max(dot(_e415, _e417), 0f); - let _e422 = (*s_3).v; - let _e424 = light_3.h; - light_3.v_dot_h = max(dot(_e422, _e424), 0f); - let _e428 = color_1; - let _e431 = color_1[3u]; - let _e433 = radiance; - light_3.radiance = ((_e428.xyz * _e431) * _e433); - let _e436 = light_3; - return _e436; - } - let _e437 = direction; - aim = normalize(_e437.xyz); + local_24 = 0f; + } + let _e364 = local_24; + radiance = _e364; + let _e365 = toCentre; + distance_2 = length(_e365); + let _e368 = direction[3u]; + if (_e368 > 0f) { + let _e370 = distance_2; + let _e372 = direction[3u]; + ratio_1 = (_e370 / _e372); + let _e374 = ratio_1; + let _e375 = ratio_1; + let _e377 = ratio_1; + let _e379 = ratio_1; + window_1 = clamp((1f - (((_e374 * _e375) * _e377) * _e379)), 0f, 1f); + let _e383 = window_1; + let _e384 = window_1; + let _e386 = radiance; + radiance = (_e386 * (_e383 * _e384)); + } + let _e389 = (*s_4).v; + let _e392 = (*s_4).n; + mirror_1 = reflect(-(_e389), _e392); + let _e394 = position; + param_93 = _e394.xyz; + let _e396 = halfWidth_1; + param_94 = _e396; + let _e397 = halfHeight_1; + param_95 = _e397; + let _e398 = v_world_position_1; + param_96 = _e398; + let _e399 = mirror_1; + param_97 = _e399; + let _e400 = RectangleClosestPoint_u0028_vf3_u003b_vf3_u003b_vf3_u003b_vf3_u003b_vf3_u003b((¶m_93), (¶m_94), (¶m_95), (¶m_96), (¶m_97)); + representative = _e400; + let _e401 = representative; + let _e402 = v_world_position_1; + toPoint = (_e401 - _e402); + let _e404 = toPoint; + pointDistance = length(_e404); + let _e406 = pointDistance; + if (_e406 > 0.000001f) { + let _e408 = toPoint; + let _e409 = pointDistance; + local_25 = (_e408 / vec3(_e409)); + } else { + let _e413 = (*s_4).n; + local_25 = _e413; + } + let _e414 = local_25; + light_3.l = _e414; + let _e417 = light_3.l; + let _e419 = (*s_4).v; + light_3.h = normalize((_e417 + _e419)); + let _e423 = formFactor; + light_3.n_dot_l = _e423; + let _e426 = (*s_4).n; + let _e428 = light_3.h; + light_3.n_dot_h = max(dot(_e426, _e428), 0f); + let _e433 = (*s_4).v; + let _e435 = light_3.h; + light_3.v_dot_h = max(dot(_e433, _e435), 0f); + let _e439 = color_1; + let _e442 = color_1[3u]; + let _e444 = radiance; + light_3.radiance = ((_e439.xyz * _e442) * _e444); + let _e447 = light_3; + return _e447; + } + let _e448 = direction; + aim = normalize(_e448.xyz); attenuation_1 = 1f; - let _e440 = type_39; - if (_e440 < 0.5f) { - let _e442 = aim; - light_3.l = -(_e442); - } else { - let _e445 = position; - let _e447 = v_world_position_1; - toLight_1 = (_e445.xyz - _e447); - let _e449 = toLight_1; - distance_3 = length(_e449); - let _e451 = distance_3; - if (_e451 < 0.000001f) { - let _e454 = (*s_3).n; - light_3.l = _e454; - let _e457 = (*s_3).n; - light_3.h = _e457; + let _e451 = type_39; + if (_e451 < 0.5f) { + let _e453 = aim; + light_3.l = -(_e453); + } else { + let _e456 = position; + let _e458 = v_world_position_1; + toLight_1 = (_e456.xyz - _e458); + let _e460 = toLight_1; + distance_3 = length(_e460); + let _e462 = distance_3; + if (_e462 < 0.000001f) { + let _e465 = (*s_4).n; + light_3.l = _e465; + let _e468 = (*s_4).n; + light_3.h = _e468; light_3.radiance = vec3(0f, 0f, 0f); light_3.n_dot_l = 0f; light_3.n_dot_h = 0f; light_3.v_dot_h = 0f; - let _e463 = light_3; - return _e463; - } - let _e464 = toLight_1; - let _e465 = distance_3; - light_3.l = (_e464 / vec3(_e465)); - let _e469 = distance_3; - param_92 = _e469; - let _e471 = direction[3u]; - param_93 = _e471; - let _e472 = PunctualAttenuation_u0028_f1_u003b_f1_u003b((¶m_92), (¶m_93)); - attenuation_1 = _e472; - let _e473 = type_39; - if (_e473 > 1.5f) { - let _e475 = aim; - let _e477 = light_3.l; - cosAngle = dot(_e475, -(_e477)); - let _e480 = cosAngle; - let _e482 = cone[1u]; - let _e485 = cone[0u]; - let _e487 = cone[1u]; - let _e491 = attenuation_1; - attenuation_1 = (_e491 * clamp(((_e480 - _e482) / (_e485 - _e487)), 0f, 1f)); - } - } - let _e494 = light_3.l; - let _e496 = (*s_3).v; - light_3.h = normalize((_e494 + _e496)); - let _e501 = (*s_3).n; - let _e503 = light_3.l; - light_3.n_dot_l = max(dot(_e501, _e503), 0f); - let _e508 = (*s_3).n; - let _e510 = light_3.h; - light_3.n_dot_h = max(dot(_e508, _e510), 0f); - let _e515 = (*s_3).v; - let _e517 = light_3.h; - light_3.v_dot_h = max(dot(_e515, _e517), 0f); - let _e521 = color_1; - let _e524 = color_1[3u]; - let _e526 = attenuation_1; - light_3.radiance = ((_e521.xyz * _e524) * _e526); - let _e529 = light_3; - return _e529; + let _e474 = light_3; + return _e474; + } + let _e475 = toLight_1; + let _e476 = distance_3; + light_3.l = (_e475 / vec3(_e476)); + let _e480 = distance_3; + param_98 = _e480; + let _e482 = direction[3u]; + param_99 = _e482; + let _e483 = PunctualAttenuation_u0028_f1_u003b_f1_u003b((¶m_98), (¶m_99)); + attenuation_1 = _e483; + let _e484 = type_39; + if (_e484 > 1.5f) { + let _e486 = aim; + let _e488 = light_3.l; + cosAngle = dot(_e486, -(_e488)); + let _e491 = cosAngle; + let _e493 = cone[1u]; + let _e496 = cone[0u]; + let _e498 = cone[1u]; + let _e502 = attenuation_1; + attenuation_1 = (_e502 * clamp(((_e491 - _e493) / (_e496 - _e498)), 0f, 1f)); + } + } + let _e505 = light_3.l; + let _e507 = (*s_4).v; + light_3.h = normalize((_e505 + _e507)); + let _e512 = (*s_4).n; + let _e514 = light_3.l; + light_3.n_dot_l = max(dot(_e512, _e514), 0f); + let _e519 = (*s_4).n; + let _e521 = light_3.h; + light_3.n_dot_h = max(dot(_e519, _e521), 0f); + let _e526 = (*s_4).v; + let _e528 = light_3.h; + light_3.v_dot_h = max(dot(_e526, _e528), 0f); + let _e532 = color_1; + let _e535 = color_1[3u]; + let _e537 = attenuation_1; + light_3.radiance = ((_e532.xyz * _e535) * _e537); + let _e540 = light_3; + return _e540; } fn FindCluster_u0028_vf3_u003b(world_3: ptr>) { @@ -10555,312 +12378,307 @@ fn FindCluster_u0028_vf3_u003b(world_3: ptr>) { var tx: f32; var ty: f32; var tz: f32; - var local_21: f32; + var local_26: f32; var cell: f32; var row_3: f32; var header: vec4; - var param_94: f32; - var param_95: f32; + var param_100: f32; + var param_101: f32; - let _e191 = light_list_info.cluster_view_projection; - let _e192 = (*world_3); - clip_1 = (_e191 * vec4(_e192.x, _e192.y, _e192.z, 1f)); - let _e198 = clip_1; - let _e201 = clip_1[3u]; - ndc_1 = (_e198.xy / vec2(max(_e201, 0.000001f))); - let _e206 = light_list_info.cluster_grid; - grid = _e206.xyz; - let _e210 = light_list_info.cluster_depth[0u]; - near_1 = _e210; - let _e212 = ndc_1[0u]; - let _e216 = grid[0u]; - let _e220 = grid[0u]; - tx = clamp(floor((((_e212 * 0.5f) + 0.5f) * _e216)), 0f, (_e220 - 1f)); - let _e224 = ndc_1[1u]; - let _e228 = grid[1u]; - let _e232 = grid[1u]; - ty = clamp(floor((((_e224 * 0.5f) + 0.5f) * _e228)), 0f, (_e232 - 1f)); - let _e236 = clip_1[3u]; - let _e237 = near_1; - if (_e236 <= _e237) { - local_21 = 0f; - } else { - let _e240 = clip_1[3u]; - let _e241 = near_1; - let _e246 = light_list_info.cluster_depth[1u]; - let _e250 = grid[2u]; - local_21 = clamp(floor((log((_e240 / _e241)) * _e246)), 0f, (_e250 - 1f)); - } - let _e253 = local_21; - tz = _e253; - let _e254 = tx; - let _e255 = ty; - let _e257 = grid[0u]; - let _e260 = tz; - let _e262 = grid[0u]; - let _e265 = grid[1u]; - cell = ((_e254 + (_e255 * _e257)) + ((_e260 * _e262) * _e265)); - let _e268 = cell; - row_3 = floor((_e268 / 4f)); - let _e271 = cell; - let _e272 = row_3; - let _e277 = light_list_info.cluster_depth[2u]; - let _e278 = row_3; - param_94 = (_e271 - (_e272 * 4f)); - param_95 = (_e277 + _e278); - let _e280 = LightListTexel_u0028_f1_u003b_f1_u003b((¶m_94), (¶m_95)); - header = _e280; - let _e282 = header[0u]; - g_cluster_offset = _e282; - let _e284 = header[1u]; - g_cluster_count = _e284; + let _e202 = light_list_info.cluster_view_projection; + let _e203 = (*world_3); + clip_1 = (_e202 * vec4(_e203.x, _e203.y, _e203.z, 1f)); + let _e209 = clip_1; + let _e212 = clip_1[3u]; + ndc_1 = (_e209.xy / vec2(max(_e212, 0.000001f))); + let _e217 = light_list_info.cluster_grid; + grid = _e217.xyz; + let _e221 = light_list_info.cluster_depth[0u]; + near_1 = _e221; + let _e223 = ndc_1[0u]; + let _e227 = grid[0u]; + let _e231 = grid[0u]; + tx = clamp(floor((((_e223 * 0.5f) + 0.5f) * _e227)), 0f, (_e231 - 1f)); + let _e235 = ndc_1[1u]; + let _e239 = grid[1u]; + let _e243 = grid[1u]; + ty = clamp(floor((((_e235 * 0.5f) + 0.5f) * _e239)), 0f, (_e243 - 1f)); + let _e247 = clip_1[3u]; + let _e248 = near_1; + if (_e247 <= _e248) { + local_26 = 0f; + } else { + let _e251 = clip_1[3u]; + let _e252 = near_1; + let _e257 = light_list_info.cluster_depth[1u]; + let _e261 = grid[2u]; + local_26 = clamp(floor((log((_e251 / _e252)) * _e257)), 0f, (_e261 - 1f)); + } + let _e264 = local_26; + tz = _e264; + let _e265 = tx; + let _e266 = ty; + let _e268 = grid[0u]; + let _e271 = tz; + let _e273 = grid[0u]; + let _e276 = grid[1u]; + cell = ((_e265 + (_e266 * _e268)) + ((_e271 * _e273) * _e276)); + let _e279 = cell; + row_3 = floor((_e279 / 4f)); + let _e282 = cell; + let _e283 = row_3; + let _e288 = light_list_info.cluster_depth[2u]; + let _e289 = row_3; + param_100 = (_e282 - (_e283 * 4f)); + param_101 = (_e288 + _e289); + let _e291 = LightListTexel_u0028_f1_u003b_f1_u003b((¶m_100), (¶m_101)); + header = _e291; + let _e293 = header[0u]; + g_cluster_offset = _e293; + let _e295 = header[1u]; + g_cluster_count = _e295; return; } fn LightCount_u0028_() -> i32 { var tail: f32; - var param_96: vec3; + var param_102: vec3; - let _e180 = light_list_info.list[0u]; - tail = _e180; - let _e181 = Clustered_u0028_(); - if _e181 { - let _e182 = v_world_position_1; - param_96 = _e182; - FindCluster_u0028_vf3_u003b((¶m_96)); - let _e183 = g_cluster_count; - tail = _e183; + let _e191 = light_list_info.list[0u]; + tail = _e191; + let _e192 = Clustered_u0028_(); + if _e192 { + let _e193 = v_world_position_1; + param_102 = _e193; + FindCluster_u0028_vf3_u003b((¶m_102)); + let _e194 = g_cluster_count; + tail = _e194; } - let _e186 = frag_info.frame_params[1u]; - let _e190 = tail; - return (clamp(i32((_e186 + 0.5f)), 0i, 8i) + clamp(i32((_e190 + 0.5f)), 0i, 24i)); + let _e197 = frag_info.frame_params[1u]; + let _e201 = tail; + return (clamp(i32((_e197 + 0.5f)), 0i, 8i) + clamp(i32((_e201 + 0.5f)), 0i, 24i)); } -fn AccumulateLights_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b(s_4: ptr) -> vec3 { +fn AccumulateLights_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b(s_5: ptr) -> vec3 { var total_1: vec3; var count_1: i32; var i_8: i32; var light_4: LightSample; - var param_97: i32; - var param_98: Surface; + var param_103: i32; + var param_104: Surface; var visibility: f32; - var param_99: i32; - var local_22: f32; - var param_100: Surface; - var param_101: LightSample; - var param_102: i32; - var param_103: vec3; - var param_104: vec3; var param_105: i32; + var local_27: f32; var param_106: Surface; var param_107: LightSample; + var param_108: i32; + var param_109: vec3; + var param_110: vec3; + var param_111: i32; + var param_112: Surface; + var param_113: LightSample; total_1 = vec3(0f, 0f, 0f); - let _e194 = LightCount_u0028_(); - count_1 = _e194; + let _e205 = LightCount_u0028_(); + count_1 = _e205; i_8 = 0i; loop { - let _e195 = i_8; - if (_e195 < 32i) { - let _e197 = i_8; - let _e198 = count_1; - if (_e197 >= _e198) { + let _e206 = i_8; + if (_e206 < 32i) { + let _e208 = i_8; + let _e209 = count_1; + if (_e208 >= _e209) { break; } - let _e200 = i_8; - param_97 = _e200; - let _e201 = (*s_4); - param_98 = _e201; - let _e202 = SampleLight_u0028_i1_u003b_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b((¶m_97), (¶m_98)); - light_4 = _e202; - let _e204 = light_4.n_dot_l; - if (_e204 <= 0f) { + let _e211 = i_8; + param_103 = _e211; + let _e212 = (*s_5); + param_104 = _e212; + let _e213 = SampleLight_u0028_i1_u003b_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b((¶m_103), (¶m_104)); + light_4 = _e213; + let _e215 = light_4.n_dot_l; + if (_e215 <= 0f) { continue; } - let _e206 = i_8; - param_99 = _e206; - let _e207 = LightHasShadow_u0028_i1_u003b((¶m_99)); - if _e207 { - let _e208 = (*s_4); - param_100 = _e208; - let _e209 = light_4; - param_101 = _e209; - let _e210 = i_8; - param_102 = _e210; - let _e211 = LightVisibility_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b_struct_u002d_LightSample_u002d_vf3_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf31_u003b_i1_u003b((¶m_100), (¶m_101), (¶m_102)); - let _e212 = v_world_position_1; - param_103 = _e212; - let _e214 = (*s_4).n; - param_104 = _e214; - let _e215 = i_8; - param_105 = _e215; - let _e216 = PointShadowFactor_u0028_vf3_u003b_vf3_u003b_i1_u003b((¶m_103), (¶m_104), (¶m_105)); - local_22 = (_e211 * _e216); + light_transmittance = vec3(1f, 1f, 1f); + let _e217 = i_8; + param_105 = _e217; + let _e218 = LightHasShadow_u0028_i1_u003b((¶m_105)); + if _e218 { + let _e219 = (*s_5); + param_106 = _e219; + let _e220 = light_4; + param_107 = _e220; + let _e221 = i_8; + param_108 = _e221; + let _e222 = LightVisibility_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b_struct_u002d_LightSample_u002d_vf3_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf31_u003b_i1_u003b((¶m_106), (¶m_107), (¶m_108)); + let _e223 = v_world_position_1; + param_109 = _e223; + let _e225 = (*s_5).n; + param_110 = _e225; + let _e226 = i_8; + param_111 = _e226; + let _e227 = PointShadowFactor_u0028_vf3_u003b_vf3_u003b_i1_u003b((¶m_109), (¶m_110), (¶m_111)); + local_27 = (_e222 * _e227); } else { - local_22 = 1f; + local_27 = 1f; } - let _e218 = local_22; - visibility = _e218; - let _e219 = visibility; - if (_e219 <= 0f) { + let _e229 = local_27; + visibility = _e229; + let _e230 = visibility; + if (_e230 <= 0f) { continue; } - let _e221 = (*s_4); - param_106 = _e221; - let _e222 = light_4; - param_107 = _e222; - let _e223 = ShadeLight_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b_struct_u002d_LightSample_u002d_vf3_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf31_u003b((¶m_106), (¶m_107)); - let _e225 = light_4.radiance; - let _e228 = light_4.n_dot_l; - let _e230 = visibility; - let _e232 = total_1; - total_1 = (_e232 + (((_e223 * _e225) * _e228) * _e230)); + let _e232 = (*s_5); + param_112 = _e232; + let _e233 = light_4; + param_113 = _e233; + let _e234 = ShadeLight_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b_struct_u002d_LightSample_u002d_vf3_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf31_u003b((¶m_112), (¶m_113)); + let _e236 = light_4.radiance; + let _e239 = light_4.n_dot_l; + let _e241 = visibility; + let _e243 = light_transmittance; + let _e245 = total_1; + total_1 = (_e245 + ((((_e234 * _e236) * _e239) * _e241) * _e243)); continue; } else { break; } continuing { - let _e234 = i_8; - i_8 = (_e234 + 1i); + let _e247 = i_8; + i_8 = (_e247 + 1i); } } - let _e236 = total_1; - return _e236; + let _e249 = total_1; + return _e249; } fn SampleLightmap_u0028_() -> vec3 { var texel_4: vec4; - let _e177 = v_lightmap_uv_1; - let _e178 = textureSample(lightmap_texture_tex, lightmap_texture_smp, _e177); - texel_4 = _e178; - let _e179 = texel_4; - let _e182 = texel_4[3u]; - return ((_e179.xyz * _e182) * 8f); + let _e188 = v_lightmap_uv_1; + let _e189 = textureSample(lightmap_texture_tex, lightmap_texture_smp, _e188); + texel_4 = _e189; + let _e190 = texel_4; + let _e193 = texel_4[3u]; + return ((_e190.xyz * _e193) * 8f); } fn MaterialLodBias_u0028_() -> f32 { - let _e178 = frag_info.target_origin[1u]; - return _e178; + let _e189 = frag_info.target_origin[1u]; + return _e189; } -fn ApplyMetallicRoughnessMap_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b(s_5: ptr) { +fn ApplyMetallicRoughnessMap_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b(s_6: ptr) { var orm: vec3; - let _e178 = v_texcoord_1; - let _e179 = MaterialLodBias_u0028_(); - let _e180 = textureSampleBias(metallic_roughness_texture_tex, metallic_roughness_texture_smp, _e178, _e179); - orm = _e180.xyz; - let _e183 = (*s_5).metallic; - let _e185 = orm[2u]; - (*s_5).metallic = clamp((_e183 * _e185), 0f, 1f); - let _e190 = (*s_5).roughness; - let _e192 = orm[1u]; - (*s_5).roughness = clamp((_e190 * _e192), 0.02f, 1f); + let _e189 = v_texcoord_1; + let _e190 = MaterialLodBias_u0028_(); + let _e191 = textureSampleBias(metallic_roughness_texture_tex, metallic_roughness_texture_smp, _e189, _e190); + orm = _e191.xyz; + let _e194 = (*s_6).metallic; + let _e196 = orm[2u]; + (*s_6).metallic = clamp((_e194 * _e196), 0f, 1f); + let _e201 = (*s_6).roughness; + let _e203 = orm[1u]; + (*s_6).roughness = clamp((_e201 * _e203), 0.02f, 1f); return; } -fn SrgbToLinear_u0028_vf3_u003b(srgb: ptr>) -> vec3 { - let _e177 = (*srgb); - let _e180 = (*srgb); - let _e185 = (*srgb); - return mix((_e177 / vec3(12.92f)), pow(((_e180 + vec3(0.055f, 0.055f, 0.055f)) / vec3(1.055f)), vec3(2.4f, 2.4f, 2.4f)), step(vec3(0.04045f, 0.04045f, 0.04045f), _e185)); -} - -fn ApplyEmissiveMap_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b(s_6: ptr) { +fn ApplyEmissiveMap_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b(s_7: ptr) { var emissive: vec3; - var param_108: vec3; + var param_114: vec3; - let _e179 = v_texcoord_1; - let _e180 = MaterialLodBias_u0028_(); - let _e181 = textureSampleBias(emissive_texture_tex, emissive_texture_smp, _e179, _e180); - param_108 = _e181.xyz; - let _e183 = SrgbToLinear_u0028_vf3_u003b((¶m_108)); - emissive = _e183; - let _e184 = emissive; - let _e186 = frag_info.emissive; - let _e191 = frag_info.material2_[3u]; - (*s_6).emissive = ((_e184 * _e186.xyz) * _e191); + let _e190 = v_texcoord_1; + let _e191 = MaterialLodBias_u0028_(); + let _e192 = textureSampleBias(emissive_texture_tex, emissive_texture_smp, _e190, _e191); + param_114 = _e192.xyz; + let _e194 = SrgbToLinear_u0028_vf3_u003b((¶m_114)); + emissive = _e194; + let _e195 = emissive; + let _e197 = frag_info.emissive; + let _e202 = frag_info.material2_[3u]; + (*s_7).emissive = ((_e195 * _e197.xyz) * _e202); return; } -fn ApplyOcclusionMap_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b(s_7: ptr) { +fn ApplyOcclusionMap_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b(s_8: ptr) { var occlusion: f32; - let _e178 = v_texcoord_1; - let _e179 = MaterialLodBias_u0028_(); - let _e180 = textureSampleBias(occlusion_texture_tex, occlusion_texture_smp, _e178, _e179); - occlusion = _e180.x; - let _e182 = occlusion; - let _e185 = frag_info.material2_[2u]; - (*s_7).occlusion = mix(1f, _e182, clamp(_e185, 0f, 1f)); + let _e189 = v_texcoord_1; + let _e190 = MaterialLodBias_u0028_(); + let _e191 = textureSampleBias(occlusion_texture_tex, occlusion_texture_smp, _e189, _e190); + occlusion = _e191.x; + let _e193 = occlusion; + let _e196 = frag_info.material2_[2u]; + (*s_8).occlusion = mix(1f, _e193, clamp(_e196, 0f, 1f)); return; } -fn ApplyNormalMap_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b(s_8: ptr) { +fn ApplyNormalMap_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b(s_9: ptr) { var sampledTexel: vec4; var t_2: vec3; var b_3: vec3; var sampled: vec3; - let _e181 = v_texcoord_1; - let _e182 = MaterialLodBias_u0028_(); - let _e183 = textureSampleBias(normal_texture_tex, normal_texture_smp, _e181, _e182); - sampledTexel = _e183; - let _e184 = v_tangent_1; - t_2 = _e184.xyz; - let _e186 = t_2; - let _e188 = (*s_8).n; - let _e190 = (*s_8).n; - let _e191 = t_2; - t_2 = (_e186 - (_e188 * dot(_e190, _e191))); - let _e195 = t_2; - let _e196 = t_2; - if (dot(_e195, _e196) < 0.000000000001f) { + let _e192 = v_texcoord_1; + let _e193 = MaterialLodBias_u0028_(); + let _e194 = textureSampleBias(normal_texture_tex, normal_texture_smp, _e192, _e193); + sampledTexel = _e194; + let _e195 = v_tangent_1; + t_2 = _e195.xyz; + let _e197 = t_2; + let _e199 = (*s_9).n; + let _e201 = (*s_9).n; + let _e202 = t_2; + t_2 = (_e197 - (_e199 * dot(_e201, _e202))); + let _e206 = t_2; + let _e207 = t_2; + if (dot(_e206, _e207) < 0.000000000001f) { return; } - let _e199 = t_2; - t_2 = normalize(_e199); - let _e202 = (*s_8).n; - let _e203 = t_2; - let _e206 = v_tangent_1[3u]; - b_3 = (cross(_e202, _e203) * _e206); - let _e208 = gl_FrontFacing_1; - if !(_e208) { - let _e210 = t_2; - t_2 = -(_e210); - } - let _e212 = sampledTexel; - sampled = ((_e212.xyz * 2f) - vec3(1f)); - let _e219 = frag_info.emissive[3u]; - if (_e219 > 0.5f) { - let _e221 = sampled; - let _e223 = sampled; - sampled[2u] = sqrt(max((1f - dot(_e221.xy, _e223.xy)), 0f)); - } - let _e232 = frag_info.material2_[1u]; - let _e233 = sampled; - let _e235 = (_e233.xy * _e232); - sampled[0u] = _e235.x; - sampled[1u] = _e235.y; - let _e240 = t_2; - let _e242 = sampled[0u]; - let _e244 = b_3; - let _e246 = sampled[1u]; - let _e250 = (*s_8).n; - let _e252 = sampled[2u]; - (*s_8).n = normalize((((_e240 * _e242) + (_e244 * _e246)) + (_e250 * _e252))); - let _e258 = (*s_8).n; - let _e260 = (*s_8).v; - (*s_8).n_dot_v = max(dot(_e258, _e260), 0.0001f); + let _e210 = t_2; + t_2 = normalize(_e210); + let _e213 = (*s_9).n; + let _e214 = t_2; + let _e217 = v_tangent_1[3u]; + b_3 = (cross(_e213, _e214) * _e217); + let _e219 = gl_FrontFacing_1; + if !(_e219) { + let _e221 = t_2; + t_2 = -(_e221); + } + let _e223 = sampledTexel; + sampled = ((_e223.xyz * 2f) - vec3(1f)); + let _e230 = frag_info.emissive[3u]; + if (_e230 > 0.5f) { + let _e232 = sampled; + let _e234 = sampled; + sampled[2u] = sqrt(max((1f - dot(_e232.xy, _e234.xy)), 0f)); + } + let _e243 = frag_info.material2_[1u]; + let _e244 = sampled; + let _e246 = (_e244.xy * _e243); + sampled[0u] = _e246.x; + sampled[1u] = _e246.y; + let _e251 = t_2; + let _e253 = sampled[0u]; + let _e255 = b_3; + let _e257 = sampled[1u]; + let _e261 = (*s_9).n; + let _e263 = sampled[2u]; + (*s_9).n = normalize((((_e251 * _e253) + (_e255 * _e257)) + (_e261 * _e263))); + let _e269 = (*s_9).n; + let _e271 = (*s_9).v; + (*s_9).n_dot_v = max(dot(_e269, _e271), 0.0001f); return; } fn AtlasTexel_u0028_vf2_u003b(texel_5: ptr>) -> vec4 { - let _e177 = (*texel_5); - let _e181 = irradiance_info.atlas; - let _e184 = textureSampleLevel(irradiance_texture_tex, irradiance_texture_smp, ((_e177 + vec2(0.5f)) * _e181.xy), 0f); - return _e184; + let _e188 = (*texel_5); + let _e192 = irradiance_info.atlas; + let _e195 = textureSampleLevel(irradiance_texture_tex, irradiance_texture_smp, ((_e188 + vec2(0.5f)) * _e192.xy), 0f); + return _e195; } fn TileBilinear_u0028_vf2_u003b_f1_u003b_vf2_u003b(corner: ptr>, interior: ptr, uv_5: ptr>) -> vec4 { @@ -10868,50 +12686,50 @@ fn TileBilinear_u0028_vf2_u003b_f1_u003b_vf2_u003b(corner: ptr; var f: vec2; var a_4: vec4; - var param_109: vec2; + var param_115: vec2; var b_4: vec4; - var param_110: vec2; - var c: vec4; - var param_111: vec2; + var param_116: vec2; + var c_1: vec4; + var param_117: vec2; var d_4: vec4; - var param_112: vec2; + var param_118: vec2; - let _e190 = (*uv_5); - let _e191 = (*interior); - at_1 = ((vec2(1f) + (_e190 * _e191)) - vec2(0.5f)); - let _e197 = at_1; - low = floor(_e197); - let _e199 = at_1; - let _e200 = low; - f = (_e199 - _e200); - let _e202 = (*corner); - let _e203 = low; - param_109 = (_e202 + _e203); - let _e205 = AtlasTexel_u0028_vf2_u003b((¶m_109)); - a_4 = _e205; - let _e206 = (*corner); - let _e207 = low; - param_110 = ((_e206 + _e207) + vec2(1f, 0f)); - let _e210 = AtlasTexel_u0028_vf2_u003b((¶m_110)); - b_4 = _e210; - let _e211 = (*corner); - let _e212 = low; - param_111 = ((_e211 + _e212) + vec2(0f, 1f)); - let _e215 = AtlasTexel_u0028_vf2_u003b((¶m_111)); - c = _e215; - let _e216 = (*corner); - let _e217 = low; - param_112 = ((_e216 + _e217) + vec2(1f, 1f)); - let _e220 = AtlasTexel_u0028_vf2_u003b((¶m_112)); - d_4 = _e220; - let _e221 = a_4; - let _e222 = b_4; - let _e224 = f[0u]; - let _e227 = c; - let _e228 = d_4; - let _e230 = f[0u]; - let _e234 = f[1u]; - return mix(mix(_e221, _e222, vec4(_e224)), mix(_e227, _e228, vec4(_e230)), vec4(_e234)); + let _e201 = (*uv_5); + let _e202 = (*interior); + at_1 = ((vec2(1f) + (_e201 * _e202)) - vec2(0.5f)); + let _e208 = at_1; + low = floor(_e208); + let _e210 = at_1; + let _e211 = low; + f = (_e210 - _e211); + let _e213 = (*corner); + let _e214 = low; + param_115 = (_e213 + _e214); + let _e216 = AtlasTexel_u0028_vf2_u003b((¶m_115)); + a_4 = _e216; + let _e217 = (*corner); + let _e218 = low; + param_116 = ((_e217 + _e218) + vec2(1f, 0f)); + let _e221 = AtlasTexel_u0028_vf2_u003b((¶m_116)); + b_4 = _e221; + let _e222 = (*corner); + let _e223 = low; + param_117 = ((_e222 + _e223) + vec2(0f, 1f)); + let _e226 = AtlasTexel_u0028_vf2_u003b((¶m_117)); + c_1 = _e226; + let _e227 = (*corner); + let _e228 = low; + param_118 = ((_e227 + _e228) + vec2(1f, 1f)); + let _e231 = AtlasTexel_u0028_vf2_u003b((¶m_118)); + d_4 = _e231; + let _e232 = a_4; + let _e233 = b_4; + let _e235 = f[0u]; + let _e238 = c_1; + let _e239 = d_4; + let _e241 = f[0u]; + let _e245 = f[1u]; + return mix(mix(_e232, _e233, vec4(_e235)), mix(_e238, _e239, vec4(_e241)), vec4(_e245)); } fn ProbeOctahedral_u0028_vf3_u003b(direction_1: ptr>) -> vec2 { @@ -10919,32 +12737,32 @@ fn ProbeOctahedral_u0028_vf3_u003b(direction_1: ptr>) -> vec var n_5: vec3; var xy: vec2; - let _e181 = (*direction_1)[0u]; - let _e184 = (*direction_1)[1u]; - let _e188 = (*direction_1)[2u]; - sum_1 = ((abs(_e181) + abs(_e184)) + abs(_e188)); - let _e191 = sum_1; - if (_e191 <= 0f) { + let _e192 = (*direction_1)[0u]; + let _e195 = (*direction_1)[1u]; + let _e199 = (*direction_1)[2u]; + sum_1 = ((abs(_e192) + abs(_e195)) + abs(_e199)); + let _e202 = sum_1; + if (_e202 <= 0f) { return vec2(0.5f, 0.5f); } - let _e193 = (*direction_1); - let _e194 = sum_1; - n_5 = (_e193 / vec3(_e194)); - let _e197 = n_5; - xy = _e197.xy; - let _e200 = n_5[2u]; - if (_e200 < 0f) { - let _e203 = n_5[1u]; - let _e207 = n_5[0u]; - let _e212 = n_5[0u]; - let _e216 = n_5[1u]; - xy = vec2(((1f - abs(_e203)) * select(-1f, 1f, (_e207 >= 0f))), ((1f - abs(_e212)) * select(-1f, 1f, (_e216 >= 0f)))); + let _e204 = (*direction_1); + let _e205 = sum_1; + n_5 = (_e204 / vec3(_e205)); + let _e208 = n_5; + xy = _e208.xy; + let _e211 = n_5[2u]; + if (_e211 < 0f) { + let _e214 = n_5[1u]; + let _e218 = n_5[0u]; + let _e223 = n_5[0u]; + let _e227 = n_5[1u]; + xy = vec2(((1f - abs(_e214)) * select(-1f, 1f, (_e218 >= 0f))), ((1f - abs(_e223)) * select(-1f, 1f, (_e227 >= 0f)))); } - let _e221 = xy; - return ((_e221 * 0.5f) + vec2(0.5f)); + let _e232 = xy; + return ((_e232 * 0.5f) + vec2(0.5f)); } -fn SampleIrradiance_u0028_vf3_u003b_vf3_u003b_vf3_u003b(world_4: ptr>, normal_1: ptr>, view: ptr>) -> vec3 { +fn SampleIrradiance_u0028_vf3_u003b_vf3_u003b_vf3_u003b(world_4: ptr>, normal_1: ptr>, view_1: ptr>) -> vec3 { var origin_2: vec3; var spacing: vec3; var counts: vec3; @@ -10966,9 +12784,9 @@ fn SampleIrradiance_u0028_vf3_u003b_vf3_u003b_vf3_u003b(world_4: ptr; var irradianceCorner: vec2; var momentCorner: vec2; - var param_113: vec2; + var param_119: vec2; var trilinear: vec3; - var weight_2: f32; + var weight_3: f32; var probePosition: vec3; var toProbe: vec3; var distance_4: f32; @@ -10976,376 +12794,419 @@ fn SampleIrradiance_u0028_vf3_u003b_vf3_u003b_vf3_u003b(world_4: ptr; - var param_114: vec3; - var param_115: vec2; - var param_116: f32; - var param_117: vec2; + var param_120: vec3; + var param_121: vec2; + var param_122: f32; + var param_123: vec2; var chebyshev: f32; var variance_1: f32; var difference: f32; - var param_118: vec3; - var param_119: vec2; - var param_120: f32; - var param_121: vec2; - var local_23: vec3; - - let _e223 = irradiance_info.origin; - origin_2 = _e223.xyz; - let _e226 = irradiance_info.spacing; - spacing = _e226.xyz; - let _e229 = irradiance_info.counts; - counts = _e229.xyz; - let _e233 = irradiance_info.tiles[0u]; - irradianceTile = _e233; - let _e236 = irradiance_info.tiles[1u]; - depthTile = _e236; - let _e239 = irradiance_info.tiles[2u]; - columns = _e239; - let _e242 = irradiance_info.tiles[3u]; - momentsTop = _e242; - let _e243 = (*normal_1); - unit = normalize(_e243); - let _e245 = (*world_4); - let _e246 = unit; - let _e249 = irradiance_info.spacing[3u]; - let _e252 = (*view); - let _e255 = irradiance_info.counts[3u]; - biased = ((_e245 + (_e246 * _e249)) + (_e252 * _e255)); - let _e258 = biased; - let _e259 = origin_2; - let _e261 = spacing; - grid_1 = ((_e258 - _e259) / _e261); - let _e263 = grid_1; - let _e265 = counts; - base = clamp(floor(_e263), vec3(0f, 0f, 0f), (_e265 - vec3(2f))); - let _e269 = grid_1; - let _e270 = base; - f_1 = clamp((_e269 - _e270), vec3(0f, 0f, 0f), vec3(1f, 1f, 1f)); + var param_124: vec3; + var param_125: vec2; + var param_126: f32; + var param_127: vec2; + var local_28: vec3; + + let _e234 = irradiance_info.origin; + origin_2 = _e234.xyz; + let _e237 = irradiance_info.spacing; + spacing = _e237.xyz; + let _e240 = irradiance_info.counts; + counts = _e240.xyz; + let _e244 = irradiance_info.tiles[0u]; + irradianceTile = _e244; + let _e247 = irradiance_info.tiles[1u]; + depthTile = _e247; + let _e250 = irradiance_info.tiles[2u]; + columns = _e250; + let _e253 = irradiance_info.tiles[3u]; + momentsTop = _e253; + let _e254 = (*normal_1); + unit = normalize(_e254); + let _e256 = (*world_4); + let _e257 = unit; + let _e260 = irradiance_info.spacing[3u]; + let _e263 = (*view_1); + let _e266 = irradiance_info.counts[3u]; + biased = ((_e256 + (_e257 * _e260)) + (_e263 * _e266)); + let _e269 = biased; + let _e270 = origin_2; + let _e272 = spacing; + grid_1 = ((_e269 - _e270) / _e272); + let _e274 = grid_1; + let _e276 = counts; + base = clamp(floor(_e274), vec3(0f, 0f, 0f), (_e276 - vec3(2f))); + let _e280 = grid_1; + let _e281 = base; + f_1 = clamp((_e280 - _e281), vec3(0f, 0f, 0f), vec3(1f, 1f, 1f)); total_2 = vec3(0f, 0f, 0f); weights = 0f; corner_1 = 0i; loop { - let _e273 = corner_1; - if (_e273 < 8i) { - let _e275 = corner_1; - let _e278 = corner_1; - let _e283 = corner_1; - offset_3 = vec3(f32((_e275 & 1i)), f32(((_e278 >> bitcast(1i)) & 1i)), f32(((_e283 >> bitcast(2i)) & 1i))); - let _e289 = base; - let _e290 = offset_3; - cell_1 = (_e289 + _e290); - let _e293 = cell_1[2u]; - let _e295 = counts[1u]; - let _e298 = cell_1[1u]; - let _e301 = counts[0u]; - let _e304 = cell_1[0u]; - probe = ((((_e293 * _e295) + _e298) * _e301) + _e304); - let _e306 = probe; - let _e307 = columns; - let _e312 = probe; - let _e313 = columns; - tile_4 = vec2((_e306 - (floor((_e306 / _e307)) * _e307)), floor((_e312 / _e313))); - let _e317 = tile_4; - let _e318 = irradianceTile; - irradianceCorner = (_e317 * (_e318 + 2f)); - let _e322 = tile_4[0u]; - let _e323 = depthTile; - let _e326 = momentsTop; - let _e328 = tile_4[1u]; - let _e329 = depthTile; - momentCorner = vec2((_e322 * (_e323 + 2f)), (_e326 + (_e328 * (_e329 + 2f)))); - let _e334 = irradianceCorner; - param_113 = (_e334 + vec2(1f)); - let _e337 = AtlasTexel_u0028_vf2_u003b((¶m_113)); - if (_e337.w < 0.5f) { + let _e284 = corner_1; + if (_e284 < 8i) { + let _e286 = corner_1; + let _e289 = corner_1; + let _e294 = corner_1; + offset_3 = vec3(f32((_e286 & 1i)), f32(((_e289 >> bitcast(1i)) & 1i)), f32(((_e294 >> bitcast(2i)) & 1i))); + let _e300 = base; + let _e301 = offset_3; + cell_1 = (_e300 + _e301); + let _e304 = cell_1[2u]; + let _e306 = counts[1u]; + let _e309 = cell_1[1u]; + let _e312 = counts[0u]; + let _e315 = cell_1[0u]; + probe = ((((_e304 * _e306) + _e309) * _e312) + _e315); + let _e317 = probe; + let _e318 = columns; + let _e323 = probe; + let _e324 = columns; + tile_4 = vec2((_e317 - (floor((_e317 / _e318)) * _e318)), floor((_e323 / _e324))); + let _e328 = tile_4; + let _e329 = irradianceTile; + irradianceCorner = (_e328 * (_e329 + 2f)); + let _e333 = tile_4[0u]; + let _e334 = depthTile; + let _e337 = momentsTop; + let _e339 = tile_4[1u]; + let _e340 = depthTile; + momentCorner = vec2((_e333 * (_e334 + 2f)), (_e337 + (_e339 * (_e340 + 2f)))); + let _e345 = irradianceCorner; + param_119 = (_e345 + vec2(1f)); + let _e348 = AtlasTexel_u0028_vf2_u003b((¶m_119)); + if (_e348.w < 0.5f) { continue; } - let _e340 = f_1; - let _e342 = f_1; - let _e343 = offset_3; - trilinear = mix((vec3(1f, 1f, 1f) - _e340), _e342, _e343); - let _e346 = trilinear[0u]; - let _e348 = trilinear[1u]; - let _e351 = trilinear[2u]; - weight_2 = max(((_e346 * _e348) * _e351), 0.001f); - let _e354 = origin_2; - let _e355 = spacing; - let _e356 = cell_1; - probePosition = (_e354 + (_e355 * _e356)); - let _e359 = probePosition; - let _e360 = biased; - toProbe = (_e359 - _e360); - let _e362 = toProbe; - distance_4 = length(_e362); - let _e364 = distance_4; - if (_e364 > 0.000001f) { - let _e366 = toProbe; - let _e367 = distance_4; - direction_2 = (_e366 / vec3(_e367)); - let _e370 = unit; - let _e371 = probePosition; - let _e372 = (*world_4); - facing = ((dot(_e370, normalize((_e371 - _e372))) * 0.5f) + 0.5f); - let _e378 = facing; - let _e379 = facing; - probeWeight = ((_e378 * _e379) + 0.2f); - let _e382 = direction_2; - param_114 = -(_e382); - let _e384 = ProbeOctahedral_u0028_vf3_u003b((¶m_114)); - let _e385 = momentCorner; - param_115 = _e385; - let _e386 = depthTile; - param_116 = _e386; - param_117 = _e384; - let _e387 = TileBilinear_u0028_vf2_u003b_f1_u003b_vf2_u003b((¶m_115), (¶m_116), (¶m_117)); - moments_3 = _e387.xy; + let _e351 = f_1; + let _e353 = f_1; + let _e354 = offset_3; + trilinear = mix((vec3(1f, 1f, 1f) - _e351), _e353, _e354); + let _e357 = trilinear[0u]; + let _e359 = trilinear[1u]; + let _e362 = trilinear[2u]; + weight_3 = max(((_e357 * _e359) * _e362), 0.001f); + let _e365 = origin_2; + let _e366 = spacing; + let _e367 = cell_1; + probePosition = (_e365 + (_e366 * _e367)); + let _e370 = probePosition; + let _e371 = biased; + toProbe = (_e370 - _e371); + let _e373 = toProbe; + distance_4 = length(_e373); + let _e375 = distance_4; + if (_e375 > 0.000001f) { + let _e377 = toProbe; + let _e378 = distance_4; + direction_2 = (_e377 / vec3(_e378)); + let _e381 = unit; + let _e382 = probePosition; + let _e383 = (*world_4); + facing = ((dot(_e381, normalize((_e382 - _e383))) * 0.5f) + 0.5f); + let _e389 = facing; + let _e390 = facing; + probeWeight = ((_e389 * _e390) + 0.2f); + let _e393 = direction_2; + param_120 = -(_e393); + let _e395 = ProbeOctahedral_u0028_vf3_u003b((¶m_120)); + let _e396 = momentCorner; + param_121 = _e396; + let _e397 = depthTile; + param_122 = _e397; + param_123 = _e395; + let _e398 = TileBilinear_u0028_vf2_u003b_f1_u003b_vf2_u003b((¶m_121), (¶m_122), (¶m_123)); + moments_3 = _e398.xy; chebyshev = 1f; - let _e389 = distance_4; - let _e391 = moments_3[0u]; - if (_e389 > _e391) { - let _e394 = moments_3[1u]; - let _e396 = moments_3[0u]; - let _e398 = moments_3[0u]; - variance_1 = max((_e394 - (_e396 * _e398)), 0.000001f); - let _e402 = distance_4; - let _e404 = moments_3[0u]; - difference = (_e402 - _e404); - let _e406 = variance_1; - let _e407 = variance_1; - let _e408 = difference; - let _e409 = difference; - chebyshev = (_e406 / (_e407 + (_e408 * _e409))); - let _e413 = chebyshev; - let _e414 = chebyshev; - let _e416 = chebyshev; - chebyshev = ((_e413 * _e414) * _e416); + let _e400 = distance_4; + let _e402 = moments_3[0u]; + if (_e400 > _e402) { + let _e405 = moments_3[1u]; + let _e407 = moments_3[0u]; + let _e409 = moments_3[0u]; + variance_1 = max((_e405 - (_e407 * _e409)), 0.000001f); + let _e413 = distance_4; + let _e415 = moments_3[0u]; + difference = (_e413 - _e415); + let _e417 = variance_1; + let _e418 = variance_1; + let _e419 = difference; + let _e420 = difference; + chebyshev = (_e417 / (_e418 + (_e419 * _e420))); + let _e424 = chebyshev; + let _e425 = chebyshev; + let _e427 = chebyshev; + chebyshev = ((_e424 * _e425) * _e427); } - let _e418 = probeWeight; - let _e419 = chebyshev; - probeWeight = max((_e418 * max(_e419, 0.05f)), 0.000001f); - let _e423 = probeWeight; - if (_e423 < 0.2f) { - let _e425 = probeWeight; - let _e426 = probeWeight; - let _e429 = probeWeight; - probeWeight = (_e429 * ((_e425 * _e426) * 25f)); + let _e429 = probeWeight; + let _e430 = chebyshev; + probeWeight = max((_e429 * max(_e430, 0.05f)), 0.000001f); + let _e434 = probeWeight; + if (_e434 < 0.2f) { + let _e436 = probeWeight; + let _e437 = probeWeight; + let _e440 = probeWeight; + probeWeight = (_e440 * ((_e436 * _e437) * 25f)); } - let _e431 = probeWeight; - let _e432 = weight_2; - weight_2 = (_e432 * _e431); - } - let _e434 = unit; - param_118 = _e434; - let _e435 = ProbeOctahedral_u0028_vf3_u003b((¶m_118)); - let _e436 = irradianceCorner; - param_119 = _e436; - let _e437 = irradianceTile; - param_120 = _e437; - param_121 = _e435; - let _e438 = TileBilinear_u0028_vf2_u003b_f1_u003b_vf2_u003b((¶m_119), (¶m_120), (¶m_121)); - let _e440 = weight_2; - let _e442 = total_2; - total_2 = (_e442 + (_e438.xyz * _e440)); - let _e444 = weight_2; - let _e445 = weights; - weights = (_e445 + _e444); + let _e442 = probeWeight; + let _e443 = weight_3; + weight_3 = (_e443 * _e442); + } + let _e445 = unit; + param_124 = _e445; + let _e446 = ProbeOctahedral_u0028_vf3_u003b((¶m_124)); + let _e447 = irradianceCorner; + param_125 = _e447; + let _e448 = irradianceTile; + param_126 = _e448; + param_127 = _e446; + let _e449 = TileBilinear_u0028_vf2_u003b_f1_u003b_vf2_u003b((¶m_125), (¶m_126), (¶m_127)); + let _e451 = weight_3; + let _e453 = total_2; + total_2 = (_e453 + (_e449.xyz * _e451)); + let _e455 = weight_3; + let _e456 = weights; + weights = (_e456 + _e455); continue; } else { break; } continuing { - let _e447 = corner_1; - corner_1 = (_e447 + 1i); + let _e458 = corner_1; + corner_1 = (_e458 + 1i); } } - let _e449 = weights; - if (_e449 > 0f) { - let _e451 = total_2; - let _e452 = weights; - local_23 = (_e451 / vec3(_e452)); + let _e460 = weights; + if (_e460 > 0f) { + let _e462 = total_2; + let _e463 = weights; + local_28 = (_e462 / vec3(_e463)); } else { - local_23 = vec3(0f, 0f, 0f); + local_28 = vec3(0f, 0f, 0f); } - let _e455 = local_23; - return _e455; + let _e466 = local_28; + return _e466; } fn IrradianceEnabled_u0028_() -> bool { - let _e178 = irradiance_info.origin[3u]; - return (_e178 > 0.5f); + let _e189 = irradiance_info.origin[3u]; + return (_e189 > 0.5f); } -fn ApplyCommonMaps_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b(s_9: ptr) { - var param_122: vec3; - var param_123: vec3; - var param_124: vec3; - var param_125: Surface; - var param_126: Surface; - var param_127: Surface; - - let _e183 = IrradianceEnabled_u0028_(); - if _e183 { - let _e184 = v_world_position_1; - param_122 = _e184; - let _e186 = (*s_9).n; - param_123 = _e186; - let _e188 = (*s_9).v; - param_124 = _e188; - let _e189 = SampleIrradiance_u0028_vf3_u003b_vf3_u003b_vf3_u003b((¶m_122), (¶m_123), (¶m_124)); - let _e192 = frag_info.material[2u]; - (*s_9).ambient = (_e189 * _e192); - } - let _e195 = (*s_9); - param_125 = _e195; - ApplyNormalMap_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b((¶m_125)); - let _e196 = param_125; - (*s_9) = _e196; - let _e197 = (*s_9); - param_126 = _e197; - ApplyOcclusionMap_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b((¶m_126)); - let _e198 = param_126; - (*s_9) = _e198; - let _e199 = (*s_9); - param_127 = _e199; - ApplyEmissiveMap_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b((¶m_127)); - let _e200 = param_127; - (*s_9) = _e200; +fn ApplyCommonMaps_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b(s_10: ptr) { + var param_128: vec3; + var param_129: vec3; + var param_130: vec3; + var param_131: Surface; + var param_132: Surface; + var param_133: Surface; + + let _e194 = IrradianceEnabled_u0028_(); + if _e194 { + let _e195 = v_world_position_1; + param_128 = _e195; + let _e197 = (*s_10).n; + param_129 = _e197; + let _e199 = (*s_10).v; + param_130 = _e199; + let _e200 = SampleIrradiance_u0028_vf3_u003b_vf3_u003b_vf3_u003b((¶m_128), (¶m_129), (¶m_130)); + let _e203 = frag_info.material[2u]; + (*s_10).ambient = (_e200 * _e203); + } + let _e206 = (*s_10); + param_131 = _e206; + ApplyNormalMap_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b((¶m_131)); + let _e207 = param_131; + (*s_10) = _e207; + let _e208 = (*s_10); + param_132 = _e208; + ApplyOcclusionMap_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b((¶m_132)); + let _e209 = param_132; + (*s_10) = _e209; + let _e210 = (*s_10); + param_133 = _e210; + ApplyEmissiveMap_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b((¶m_133)); + let _e211 = param_133; + (*s_10) = _e211; return; } +fn TowardsEye_u0028_() -> vec3 { + var local_29: vec3; + + let _e188 = Orthographic_u0028_(); + if _e188 { + let _e190 = fog_info.forward; + local_29 = -(_e190.xyz); + } else { + let _e194 = fog_info.eye; + let _e196 = v_world_position_1; + local_29 = normalize((_e194.xyz - _e196)); + } + let _e199 = local_29; + return _e199; +} + fn ReadSurface_u0028_() -> Surface { var texel_6: vec4; - var s_10: Surface; - var param_128: vec3; - var param_129: vec3; + var s_11: Surface; + var param_134: vec3; + var param_135: vec3; var cutoff: f32; + var edge: f32; var anchored: vec3; var noise_1: f32; - let _e183 = v_texcoord_1; - let _e184 = MaterialLodBias_u0028_(); - let _e185 = textureSampleBias(base_color_texture_tex, base_color_texture_smp, _e183, _e184); - texel_6 = _e185; - let _e186 = texel_6; - param_128 = _e186.xyz; - let _e188 = SrgbToLinear_u0028_vf3_u003b((¶m_128)); - let _e190 = frag_info.base_color; - param_129 = _e190.xyz; - let _e192 = SrgbToLinear_u0028_vf3_u003b((¶m_129)); - let _e194 = v_color_1; - s_10.albedo = ((_e188 * _e192) * _e194.xyz); - let _e199 = texel_6[3u]; - let _e202 = frag_info.base_color[3u]; - let _e205 = v_color_1[3u]; - s_10.alpha = ((_e199 * _e202) * _e205); - let _e209 = s_10.albedo; - g_albedo = _e209; - let _e212 = frag_info.material2_[0u]; - cutoff = _e212; - let _e213 = cutoff; - if (_e213 >= 0f) { - let _e216 = s_10.alpha; - let _e217 = cutoff; - if (_e216 < _e217) { - discard; - } + let _e195 = v_texcoord_1; + let _e196 = MaterialLodBias_u0028_(); + let _e197 = textureSampleBias(base_color_texture_tex, base_color_texture_smp, _e195, _e196); + texel_6 = _e197; + let _e198 = texel_6; + param_134 = _e198.xyz; + let _e200 = SrgbToLinear_u0028_vf3_u003b((¶m_134)); + let _e202 = frag_info.base_color; + param_135 = _e202.xyz; + let _e204 = SrgbToLinear_u0028_vf3_u003b((¶m_135)); + let _e206 = v_color_1; + s_11.albedo = ((_e200 * _e204) * _e206.xyz); + let _e211 = texel_6[3u]; + let _e214 = frag_info.base_color[3u]; + let _e217 = v_color_1[3u]; + s_11.alpha = ((_e211 * _e214) * _e217); + let _e221 = s_11.albedo; + g_albedo = _e221; + let _e224 = frag_info.material2_[0u]; + cutoff = _e224; + let _e225 = cutoff; + if (_e225 > 1f) { + let _e227 = cutoff; + edge = (_e227 - 1f); + let _e230 = s_11.alpha; + let _e231 = edge; + let _e234 = s_11.alpha; + let _e235 = fwidth(_e234); + s_11.alpha = clamp((((_e230 - _e231) / max(_e235, 0.0001f)) + 0.5f), 0f, 1f); } else { - let _e219 = cutoff; - if (_e219 < -1.5f) { - let _e221 = v_world_position_1; - anchored = floor((_e221 * 16f)); - let _e224 = anchored; - noise_1 = fract((sin(dot(_e224, vec3(12.9898f, 78.233f, 37.719f))) * 43758.547f)); - let _e230 = s_10.alpha; - let _e231 = noise_1; - if (_e230 < _e231) { + let _e241 = cutoff; + if (_e241 >= 0f) { + let _e244 = s_11.alpha; + let _e245 = cutoff; + if (_e244 < _e245) { discard; } + s_11.alpha = 1f; + } else { + let _e248 = cutoff; + if (_e248 < -1.5f) { + let _e250 = v_world_position_1; + anchored = floor((_e250 * 16f)); + let _e253 = anchored; + noise_1 = fract((sin(dot(_e253, vec3(12.9898f, 78.233f, 37.719f))) * 43758.547f)); + let _e259 = s_11.alpha; + let _e260 = noise_1; + if (_e259 < _e260) { + discard; + } + } } } - let _e233 = cutoff; - let _e235 = cutoff; - g_premultiply = ((_e233 < 0f) && (_e235 > -0.75f)); - let _e238 = v_normal_1; - s_10.n = normalize(_e238); - let _e241 = gl_FrontFacing_1; - if !(_e241) { - let _e244 = s_10.n; - s_10.n = -(_e244); - } - let _e248 = frag_info.camera_position; - let _e250 = v_world_position_1; - s_10.v = normalize((_e248.xyz - _e250)); - let _e255 = s_10.n; - let _e257 = s_10.v; - s_10.n_dot_v = max(dot(_e255, _e257), 0.0001f); - let _e263 = frag_info.material[0u]; - s_10.metallic = clamp(_e263, 0f, 1f); - let _e268 = frag_info.material[1u]; - s_10.roughness = clamp(_e268, 0.02f, 1f); - let _e272 = frag_info.ambient_ground; - let _e275 = frag_info.ambient_sky; - let _e279 = s_10.n[1u]; - let _e286 = frag_info.material[2u]; - s_10.ambient = (mix(_e272.xyz, _e275.xyz, vec3(((_e279 * 0.5f) + 0.5f))) * _e286); - let _e291 = frag_info.frame_params[0u]; - s_10.exposure = max(_e291, 0f); - s_10.occlusion = 1f; - s_10.emissive = vec3(0f, 0f, 0f); - let _e296 = s_10; - return _e296; + let _e262 = cutoff; + let _e264 = cutoff; + g_premultiply = ((_e262 < 0f) && (_e264 > -0.75f)); + let _e267 = v_normal_1; + s_11.n = normalize(_e267); + let _e270 = gl_FrontFacing_1; + if !(_e270) { + let _e273 = s_11.n; + s_11.n = -(_e273); + } + let _e276 = TowardsEye_u0028_(); + s_11.v = _e276; + let _e279 = s_11.n; + let _e281 = s_11.v; + s_11.n_dot_v = max(dot(_e279, _e281), 0.0001f); + let _e287 = frag_info.material[0u]; + s_11.metallic = clamp(_e287, 0f, 1f); + let _e292 = frag_info.material[1u]; + s_11.roughness = clamp(_e292, 0.02f, 1f); + let _e296 = frag_info.ambient_ground; + let _e299 = frag_info.ambient_sky; + let _e303 = s_11.n[1u]; + let _e310 = frag_info.material[2u]; + s_11.ambient = (mix(_e296.xyz, _e299.xyz, vec3(((_e303 * 0.5f) + 0.5f))) * _e310); + let _e315 = frag_info.frame_params[0u]; + s_11.exposure = max(_e315, 0f); + s_11.occlusion = 1f; + s_11.emissive = vec3(0f, 0f, 0f); + let _e320 = s_11; + return _e320; } fn main_1() { - var s_11: Surface; - var param_130: Surface; - var param_131: Surface; + var s_12: Surface; + var param_136: Surface; + var param_137: Surface; var ambient: vec3; - var param_132: Surface; - var param_133: vec3; - var param_134: f32; - var param_135: f32; + var lit_3: vec3; + var param_138: Surface; + var param_139: Surface; + var param_140: vec3; + var param_141: vec3; + var param_142: f32; + var param_143: f32; g_albedo = vec3(0f, 0f, 0f); g_debug_surface = vec3(0f, 0f, 0f); g_debug_surface_on = false; g_premultiply = false; + g_pass_through = 0f; g_cluster_offset = 0f; g_cluster_count = 0f; - let _e184 = ReadSurface_u0028_(); - s_11 = _e184; - let _e185 = s_11; - param_130 = _e185; - ApplyCommonMaps_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b((¶m_130)); - let _e186 = param_130; - s_11 = _e186; - let _e187 = s_11; - param_131 = _e187; - ApplyMetallicRoughnessMap_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b((¶m_131)); - let _e188 = param_131; - s_11 = _e188; - let _e190 = s_11.albedo; - let _e192 = s_11.ambient; - let _e193 = SampleLightmap_u0028_(); - let _e197 = s_11.occlusion; - ambient = ((_e190 * (_e192 + _e193)) * _e197); - let _e199 = s_11; - param_132 = _e199; - let _e200 = AccumulateLights_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b((¶m_132)); - let _e202 = s_11.occlusion; - let _e204 = ambient; - let _e207 = s_11.emissive; - param_133 = (((_e200 * _e202) + _e204) + _e207); - let _e210 = s_11.alpha; - param_134 = _e210; - let _e212 = s_11.roughness; - param_135 = _e212; - WriteSurface_u0028_vf3_u003b_f1_u003b_f1_u003b((¶m_133), (¶m_134), (¶m_135)); + light_transmittance = vec3(1f, 1f, 1f); + let _e198 = ReadSurface_u0028_(); + s_12 = _e198; + let _e199 = s_12; + param_136 = _e199; + ApplyCommonMaps_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b((¶m_136)); + let _e200 = param_136; + s_12 = _e200; + let _e201 = s_12; + param_137 = _e201; + ApplyMetallicRoughnessMap_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b((¶m_137)); + let _e202 = param_137; + s_12 = _e202; + let _e204 = s_12.albedo; + let _e206 = s_12.ambient; + let _e207 = SampleLightmap_u0028_(); + let _e211 = s_12.occlusion; + ambient = ((_e204 * (_e206 + _e207)) * _e211); + let _e213 = s_12; + param_138 = _e213; + let _e214 = AccumulateLights_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b((¶m_138)); + let _e216 = s_12.occlusion; + let _e218 = ambient; + let _e221 = s_12.emissive; + lit_3 = (((_e214 * _e216) + _e218) + _e221); + let _e223 = s_12; + param_139 = _e223; + let _e224 = lit_3; + param_140 = _e224; + let _e225 = WriteDebugView_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b_vf3_u003b((¶m_139), (¶m_140)); + if _e225 { + return; + } + let _e226 = lit_3; + param_141 = _e226; + let _e228 = s_12.alpha; + param_142 = _e228; + let _e230 = s_12.roughness; + param_143 = _e230; + WriteSurface_u0028_vf3_u003b_f1_u003b_f1_u003b((¶m_141), (¶m_142), (¶m_143)); return; } @fragment -fn main(@location(6) v_world_position: vec3, @location(2) v_normal: vec3, @builtin(front_facing) gl_FrontFacing: bool, @builtin(position) gl_FragCoord: vec4, @location(4) v_texcoord: vec2, @location(0) v_color: vec4, @location(1) v_lightmap_uv: vec2, @location(3) v_tangent: vec4) -> FragmentOutput { +fn main(@location(7) v_world_position: vec3, @location(3) v_normal: vec3, @builtin(front_facing) gl_FrontFacing: bool, @builtin(position) gl_FragCoord: vec4, @location(5) v_texcoord: vec2, @location(0) v_color: vec4, @location(2) v_lightmap_uv: vec2, @location(4) v_tangent: vec4) -> FragmentOutput { v_world_position_1 = v_world_position; v_normal_1 = v_normal; gl_FrontFacing_1 = gl_FrontFacing; @@ -11367,7 +13228,7 @@ fn main(@location(6) v_world_position: vec3, @location(2) v_normal: vec3[ WebGpuBlockMember(name: 'fog', offsetInBytes: 0, sizeInBytes: 16), WebGpuBlockMember(name: 'eye', offsetInBytes: 16, sizeInBytes: 16), @@ -11376,13 +13237,18 @@ fn main(@location(6) v_world_position: vec3, @location(2) v_normal: vec3[ WebGpuBlockMember( name: 'light_position', @@ -11479,6 +13345,11 @@ fn main(@location(6) v_world_position: vec3, @location(2) v_normal: vec3, eye: vec4, forward: vec4, + projection: vec4, } struct LightListInfo { @@ -11735,6 +13607,7 @@ struct FragInfo { ambient_ground: vec4, shadow_bias: vec4, target_origin: vec4, + debug_view: vec4, } struct PointShadow { @@ -11764,11 +13637,13 @@ var g_albedo: vec3; var g_debug_surface: vec3; var g_debug_surface_on: bool; var g_premultiply: bool; +var g_pass_through: f32; var g_cluster_offset: f32; var g_cluster_count: f32; -var v_world_position_1: vec3; +var light_transmittance: vec3; @group(1) @binding(0) var fog_info: FogInfo; +var v_world_position_1: vec3; var frag_albedo: vec4; var frag_surface: vec4; var v_normal_1: vec3; @@ -11841,10 +13716,10 @@ var environment_texture_tex: texture_cube; var environment_texture_smp: sampler; fn ViewDepth_u0028_() -> f32 { - let _e195 = v_world_position_1; - let _e197 = fog_info.eye; - let _e201 = fog_info.forward; - return dot((_e195 - _e197.xyz), _e201.xyz); + let _e206 = v_world_position_1; + let _e208 = fog_info.eye; + let _e212 = fog_info.forward; + return dot((_e206 - _e208.xyz), _e212.xyz); } fn WeightedBlendedWeight_u0028_f1_u003b(alpha: ptr) -> f32 { @@ -11853,22 +13728,22 @@ fn WeightedBlendedWeight_u0028_f1_u003b(alpha: ptr) -> f32 { var far: f32; var far3_: f32; - let _e200 = ViewDepth_u0028_(); - z = abs(_e200); - let _e202 = z; - near = (_e202 / 5f); - let _e204 = z; - far = (_e204 / 200f); - let _e206 = far; - let _e207 = far; - let _e209 = far; - far3_ = ((_e206 * _e207) * _e209); - let _e211 = (*alpha); - let _e212 = near; - let _e213 = near; - let _e216 = far3_; - let _e217 = far3_; - return (_e211 * clamp((10f / ((0.00001f + (_e212 * _e213)) + (_e216 * _e217))), 0.01f, 3000f)); + let _e211 = ViewDepth_u0028_(); + z = abs(_e211); + let _e213 = z; + near = (_e213 / 5f); + let _e215 = z; + far = (_e215 / 200f); + let _e217 = far; + let _e218 = far; + let _e220 = far; + far3_ = ((_e217 * _e218) * _e220); + let _e222 = (*alpha); + let _e223 = near; + let _e224 = near; + let _e227 = far3_; + let _e228 = far3_; + return (_e222 * clamp((10f / ((0.00001f + (_e223 * _e224)) + (_e227 * _e228))), 0.01f, 3000f)); } fn WriteWeightedBlended_u0028_() { @@ -11880,39 +13755,39 @@ fn WriteWeightedBlended_u0028_() { var revealage: bool; var local: vec4; - let _e204 = fog_info.forward[3u]; - mode = _e204; - let _e205 = mode; - if (_e205 > 0.5f) { - let _e208 = frag_color[3u]; - alpha_1 = _e208; - let _e209 = alpha_1; - param = _e209; - let _e210 = WeightedBlendedWeight_u0028_f1_u003b((¶m)); - weight = _e210; - let _e211 = frag_color; - let _e213 = weight; - let _e214 = (_e211.xyz * _e213); - let _e215 = alpha_1; - let _e216 = weight; - accumulate = vec4(_e214.x, _e214.y, _e214.z, (_e215 * _e216)); - let _e222 = mode; - let _e224 = mode; - revealage = ((_e222 > 1.5f) && (_e224 < 2.5f)); - let _e227 = revealage; - if _e227 { - let _e228 = alpha_1; - local = vec4(_e228); + let _e215 = fog_info.forward[3u]; + mode = _e215; + let _e216 = mode; + if (_e216 > 0.5f) { + let _e219 = frag_color[3u]; + alpha_1 = _e219; + let _e220 = alpha_1; + param = _e220; + let _e221 = WeightedBlendedWeight_u0028_f1_u003b((¶m)); + weight = _e221; + let _e222 = frag_color; + let _e224 = weight; + let _e225 = (_e222.xyz * _e224); + let _e226 = alpha_1; + let _e227 = weight; + accumulate = vec4(_e225.x, _e225.y, _e225.z, (_e226 * _e227)); + let _e233 = mode; + let _e235 = mode; + revealage = ((_e233 > 1.5f) && (_e235 < 2.5f)); + let _e238 = revealage; + if _e238 { + let _e239 = alpha_1; + local = vec4(_e239); } else { - let _e230 = accumulate; - local = _e230; + let _e241 = accumulate; + local = _e241; } - let _e231 = local; - frag_color = _e231; - let _e232 = mode; - if (_e232 > 2.5f) { - let _e234 = alpha_1; - frag_surface = vec4(_e234); + let _e242 = local; + frag_color = _e242; + let _e243 = mode; + if (_e243 > 2.5f) { + let _e245 = alpha_1; + frag_surface = vec4(_e245); } } return; @@ -11921,29 +13796,29 @@ fn WriteWeightedBlended_u0028_() { fn EncodeOctahedral_u0028_vf3_u003b(n: ptr>) -> vec2 { var e: vec2; - let _e198 = (*n)[0u]; - let _e201 = (*n)[1u]; - let _e205 = (*n)[2u]; - let _e208 = (*n); - (*n) = (_e208 / vec3(((abs(_e198) + abs(_e201)) + abs(_e205)))); - let _e211 = (*n); - e = _e211.xy; - let _e214 = (*n)[2u]; - if (_e214 < 0f) { - let _e216 = (*n); - let _e222 = (*n)[0u]; - let _e226 = (*n)[1u]; - e = ((vec2(1f) - abs(_e216.yx)) * vec2(select(-1f, 1f, (_e222 >= 0f)), select(-1f, 1f, (_e226 >= 0f)))); - } - let _e231 = e; - return ((_e231 * 0.5f) + vec2(0.5f)); + let _e209 = (*n)[0u]; + let _e212 = (*n)[1u]; + let _e216 = (*n)[2u]; + let _e219 = (*n); + (*n) = (_e219 / vec3(((abs(_e209) + abs(_e212)) + abs(_e216)))); + let _e222 = (*n); + e = _e222.xy; + let _e225 = (*n)[2u]; + if (_e225 < 0f) { + let _e227 = (*n); + let _e233 = (*n)[0u]; + let _e237 = (*n)[1u]; + e = ((vec2(1f) - abs(_e227.yx)) * vec2(select(-1f, 1f, (_e233 >= 0f)), select(-1f, 1f, (_e237 >= 0f)))); + } + let _e242 = e; + return ((_e242 * 0.5f) + vec2(0.5f)); } fn LinearToSrgb_u0028_vf3_u003b(linear: ptr>) -> vec3 { - let _e196 = (*linear); - let _e198 = (*linear); - let _e202 = (*linear); - return mix((_e196 * 12.92f), ((pow(_e198, vec3(0.41666666f, 0.41666666f, 0.41666666f)) * 1.055f) - vec3(0.055f, 0.055f, 0.055f)), step(vec3(0.0031308f, 0.0031308f, 0.0031308f), _e202)); + let _e207 = (*linear); + let _e209 = (*linear); + let _e213 = (*linear); + return mix((_e207 * 12.92f), ((pow(_e209, vec3(0.41666666f, 0.41666666f, 0.41666666f)) * 1.055f) - vec3(0.055f, 0.055f, 0.055f)), step(vec3(0.0031308f, 0.0031308f, 0.0031308f), _e213)); } fn WriteSurfaceGeometry_u0028_f1_u003b(roughness: ptr) { @@ -11951,57 +13826,96 @@ fn WriteSurfaceGeometry_u0028_f1_u003b(roughness: ptr) { var geometric: vec3; var param_2: vec3; - let _e199 = g_albedo; - param_1 = clamp(_e199, vec3(0f, 0f, 0f), vec3(1f, 1f, 1f)); - let _e201 = LinearToSrgb_u0028_vf3_u003b((¶m_1)); - frag_albedo = vec4(_e201.x, _e201.y, _e201.z, 1f); - let _e206 = g_debug_surface_on; - if _e206 { - let _e207 = g_debug_surface; - let _e208 = ViewDepth_u0028_(); - frag_surface = vec4(_e207.x, _e207.y, _e207.z, _e208); + let _e210 = g_albedo; + param_1 = clamp(_e210, vec3(0f, 0f, 0f), vec3(1f, 1f, 1f)); + let _e212 = LinearToSrgb_u0028_vf3_u003b((¶m_1)); + frag_albedo = vec4(_e212.x, _e212.y, _e212.z, 1f); + let _e217 = g_debug_surface_on; + if _e217 { + let _e218 = g_debug_surface; + let _e219 = ViewDepth_u0028_(); + frag_surface = vec4(_e218.x, _e218.y, _e218.z, _e219); return; } - let _e213 = v_normal_1; - geometric = normalize(_e213); - let _e215 = gl_FrontFacing_1; - if !(_e215) { - let _e217 = geometric; - geometric = -(_e217); - } - let _e219 = geometric; - param_2 = _e219; - let _e220 = EncodeOctahedral_u0028_vf3_u003b((¶m_2)); - let _e221 = (*roughness); - let _e223 = ViewDepth_u0028_(); - frag_surface = vec4(_e220.x, _e220.y, clamp(_e221, 0f, 1f), _e223); + let _e224 = v_normal_1; + geometric = normalize(_e224); + let _e226 = gl_FrontFacing_1; + if !(_e226) { + let _e228 = geometric; + geometric = -(_e228); + } + let _e230 = geometric; + param_2 = _e230; + let _e231 = EncodeOctahedral_u0028_vf3_u003b((¶m_2)); + let _e232 = (*roughness); + let _e234 = ViewDepth_u0028_(); + frag_surface = vec4(_e231.x, _e231.y, clamp(_e232, 0f, 1f), _e234); return; } +fn Orthographic_u0028_() -> bool { + let _e208 = fog_info.projection[0u]; + return (_e208 > 0.5f); +} + fn EyeDistance_u0028_() -> f32 { - let _e195 = v_world_position_1; - let _e197 = fog_info.eye; - return distance(_e195, _e197.xyz); + var local_1: f32; + + let _e207 = Orthographic_u0028_(); + if _e207 { + let _e208 = ViewDepth_u0028_(); + local_1 = max(_e208, 0f); + } else { + let _e210 = v_world_position_1; + let _e212 = fog_info.eye; + local_1 = distance(_e210, _e212.xyz); + } + let _e215 = local_1; + return _e215; } fn ApplyFog_u0028_vf3_u003b(color: ptr>) -> vec3 { var density: f32; var d: f32; + var falloff: f32; + var k: f32; + var local_2: f32; - let _e200 = fog_info.fog[3u]; - density = _e200; - let _e201 = density; - if (_e201 <= 0f) { - let _e203 = (*color); - return _e203; + let _e214 = fog_info.fog[3u]; + density = _e214; + let _e215 = density; + if (_e215 <= 0f) { + let _e217 = (*color); + return _e217; + } + let _e218 = EyeDistance_u0028_(); + d = _e218; + let _e221 = fog_info.eye[3u]; + falloff = _e221; + let _e222 = falloff; + if (_e222 > 0f) { + let _e224 = falloff; + let _e226 = v_world_position_1[1u]; + let _e229 = fog_info.eye[1u]; + k = (_e224 * (_e226 - _e229)); + let _e232 = k; + if (abs(_e232) > 0.001f) { + let _e235 = k; + let _e239 = k; + local_2 = ((1f - exp(-(_e235))) / _e239); + } else { + let _e241 = k; + local_2 = (1f - (0.5f * _e241)); + } + let _e244 = local_2; + let _e245 = density; + density = (_e245 * _e244); } - let _e204 = EyeDistance_u0028_(); - d = _e204; - let _e206 = fog_info.fog; - let _e208 = (*color); - let _e209 = density; - let _e211 = d; - return mix(_e206.xyz, _e208, vec3(clamp(exp((-(_e209) * _e211)), 0f, 1f))); + let _e248 = fog_info.fog; + let _e250 = (*color); + let _e251 = density; + let _e253 = d; + return mix(_e248.xyz, _e250, vec3(clamp(exp((-(_e251) * _e253)), 0f, 1f))); } fn WriteSurface_u0028_vf3_u003b_f1_u003b_f1_u003b(linearColor: ptr>, alpha_2: ptr, roughness_1: ptr) { @@ -12009,191 +13923,332 @@ fn WriteSurface_u0028_vf3_u003b_f1_u003b_f1_u003b(linearColor: ptr; var param_4: f32; - let _e201 = g_premultiply; - let _e202 = (*alpha_2); - weight_1 = select(1f, _e202, _e201); - let _e204 = (*linearColor); - param_3 = _e204; - let _e205 = ApplyFog_u0028_vf3_u003b((¶m_3)); - let _e206 = weight_1; - let _e207 = (_e205 * _e206); - let _e208 = (*alpha_2); - frag_color = vec4(_e207.x, _e207.y, _e207.z, _e208); - let _e213 = (*roughness_1); - param_4 = _e213; + let _e212 = g_premultiply; + let _e213 = (*alpha_2); + weight_1 = select(1f, _e213, _e212); + let _e215 = (*linearColor); + param_3 = _e215; + let _e216 = ApplyFog_u0028_vf3_u003b((¶m_3)); + let _e217 = weight_1; + let _e218 = (_e216 * _e217); + let _e219 = (*alpha_2); + let _e220 = g_pass_through; + frag_color = vec4(_e218.x, _e218.y, _e218.z, (_e219 * (1f - _e220))); + let _e227 = (*roughness_1); + param_4 = _e227; WriteSurfaceGeometry_u0028_f1_u003b((¶m_4)); WriteWeightedBlended_u0028_(); return; } -fn FonAlbedo_u0028_f1_u003b_f1_u003b(mu: ptr, r: ptr) -> f32 { - var m: f32; - var g: f32; - - let _e199 = (*mu); - m = (1f - _e199); - let _e201 = m; - let _e202 = m; - let _e203 = m; - let _e204 = m; - g = (_e201 * (0.05710853f + (_e202 * (0.49188188f + (_e203 * (-0.33218145f + (_e204 * 0.071443f))))))); - let _e212 = (*r); - let _e213 = g; - let _e216 = (*r); - return ((1f + (_e212 * _e213)) / (1f + (0.2877934f * _e216))); +fn SrgbToLinear_u0028_vf3_u003b(srgb: ptr>) -> vec3 { + let _e207 = (*srgb); + let _e210 = (*srgb); + let _e215 = (*srgb); + return mix((_e207 / vec3(12.92f)), pow(((_e210 + vec3(0.055f, 0.055f, 0.055f)) / vec3(1.055f)), vec3(2.4f, 2.4f, 2.4f)), step(vec3(0.04045f, 0.04045f, 0.04045f), _e215)); } -fn EonMultiAlbedo_u0028_vf3_u003b_f1_u003b(rho: ptr>, average: ptr) -> vec3 { - let _e197 = (*rho); - let _e198 = (*rho); - let _e200 = (*average); - let _e202 = (*rho); - let _e203 = (*average); - return (((_e197 * _e198) * _e200) / (vec3(1f, 1f, 1f) - (_e202 * (1f - _e203)))); -} +fn NonFinite_u0028_vf3_u003b(c: ptr>) -> bool { + var phi_2901_: bool; -fn FonAverage_u0028_f1_u003b(r_1: ptr) -> f32 { - let _e196 = (*r_1); - let _e199 = (*r_1); - return ((1f + (0.07248821f * _e196)) / (1f + (0.2877934f * _e199))); + let _e207 = (*c); + let _e208 = (*c); + let _e210 = any((_e207 != _e208)); + phi_2901_ = _e210; + if !(_e210) { + let _e212 = (*c); + phi_2901_ = any((abs(_e212) > vec3(300000000000000000000000000000000000000f, 300000000000000000000000000000000000000f, 300000000000000000000000000000000000000f))); + } + let _e217 = phi_2901_; + return _e217; } -fn EonLobe_u0028_vf3_u003b_f1_u003b_f1_u003b_f1_u003b_f1_u003b(rho_1: ptr>, r_2: ptr, mu_i: ptr, mu_o: ptr, l_dot_v: ptr) -> vec3 { - var s: f32; - var s_over_t: f32; - var local_1: f32; - var af: f32; - var single: vec3; - var average_1: f32; - var param_5: f32; - var multi: vec3; +fn WriteDebugView_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b_vf3_u003b(s: ptr, lit: ptr>) -> bool { + var view: f32; + var code: i32; + var shown: vec3; + var param_5: vec3; var param_6: vec3; - var param_7: f32; - var param_8: f32; - var param_9: f32; + var grey: f32; + var param_7: vec3; + var param_8: vec3; + var local_3: vec3; + var weight_2: f32; + var local_4: f32; + var param_9: vec3; var param_10: f32; - var param_11: f32; - let _e214 = (*l_dot_v); - let _e215 = (*mu_i); - let _e216 = (*mu_o); - s = (_e214 - (_e215 * _e216)); - let _e219 = s; - if (_e219 > 0f) { - let _e221 = s; - let _e222 = (*mu_i); - let _e223 = (*mu_o); - local_1 = (_e221 / max(_e222, _e223)); - } else { - let _e226 = s; - local_1 = _e226; - } - let _e227 = local_1; - s_over_t = _e227; - let _e228 = (*r_2); - af = (1f / (1f + (0.2877934f * _e228))); - let _e232 = (*rho_1); - let _e233 = af; - let _e234 = (*r_2); - let _e235 = s_over_t; - single = (_e232 * (_e233 * (1f + (_e234 * _e235)))); - let _e240 = (*r_2); - param_5 = _e240; - let _e241 = FonAverage_u0028_f1_u003b((¶m_5)); - average_1 = _e241; - let _e242 = (*rho_1); - param_6 = _e242; - let _e243 = average_1; - param_7 = _e243; - let _e244 = EonMultiAlbedo_u0028_vf3_u003b_f1_u003b((¶m_6), (¶m_7)); - let _e245 = (*mu_o); - param_8 = _e245; - let _e246 = (*r_2); - param_9 = _e246; - let _e247 = FonAlbedo_u0028_f1_u003b_f1_u003b((¶m_8), (¶m_9)); - let _e250 = (*mu_i); - param_10 = _e250; - let _e251 = (*r_2); - param_11 = _e251; - let _e252 = FonAlbedo_u0028_f1_u003b_f1_u003b((¶m_10), (¶m_11)); - let _e256 = average_1; - multi = (_e244 * ((max((1f - _e247), 0.0000001f) * max((1f - _e252), 0.0000001f)) / max((1f - _e256), 0.0000001f))); - let _e261 = single; - let _e262 = multi; - return ((_e261 + _e262) / vec3(3.1415927f)); + let _e223 = frag_info.debug_view[0u]; + view = _e223; + let _e224 = view; + if (_e224 < 0.5f) { + return false; + } + let _e227 = gl_FragCoord_1[0u]; + let _e230 = frag_info.debug_view[1u]; + let _e233 = frag_info.debug_view[2u]; + if (_e227 < (_e230 * _e233)) { + return false; + } + let _e236 = view; + code = i32((_e236 + 0.5f)); + shown = vec3(0f, 0f, 0f); + let _e239 = code; + if (_e239 == 1i) { + let _e242 = (*s).albedo; + param_5 = clamp(_e242, vec3(0f, 0f, 0f), vec3(1f, 1f, 1f)); + let _e244 = LinearToSrgb_u0028_vf3_u003b((¶m_5)); + shown = _e244; + } else { + let _e245 = code; + if (_e245 == 2i) { + let _e248 = (*s).n; + shown = ((_e248 * 0.5f) + vec3(0.5f, 0.5f, 0.5f)); + } else { + let _e251 = code; + if (_e251 == 3i) { + let _e254 = (*s).roughness; + shown = vec3(clamp(_e254, 0f, 1f)); + } else { + let _e257 = code; + if (_e257 == 4i) { + let _e260 = (*s).metallic; + shown = vec3(clamp(_e260, 0f, 1f)); + } else { + let _e263 = code; + if (_e263 == 5i) { + let _e266 = (*s).occlusion; + shown = vec3(clamp(_e266, 0f, 1f)); + } else { + let _e269 = code; + if (_e269 == 6i) { + let _e272 = (*s).emissive; + param_6 = clamp(_e272, vec3(0f, 0f, 0f), vec3(1f, 1f, 1f)); + let _e274 = LinearToSrgb_u0028_vf3_u003b((¶m_6)); + shown = _e274; + } else { + let _e275 = code; + if (_e275 == 7i) { + let _e277 = v_texcoord_1; + let _e278 = fract(_e277); + shown = vec3(_e278.x, _e278.y, 0f); + } else { + let _e282 = code; + if (_e282 == 8i) { + let _e284 = (*lit); + param_7 = clamp(_e284, vec3(0f, 0f, 0f), vec3(1f, 1f, 1f)); + let _e286 = LinearToSrgb_u0028_vf3_u003b((¶m_7)); + grey = dot(_e286, vec3(0.2126f, 0.7152f, 0.0722f)); + let _e288 = (*lit); + param_8 = _e288; + let _e289 = NonFinite_u0028_vf3_u003b((¶m_8)); + if _e289 { + local_3 = vec3(1f, 0f, 1f); + } else { + let _e290 = grey; + local_3 = vec3((_e290 * 0.5f)); + } + let _e293 = local_3; + shown = _e293; + } + } + } + } + } + } + } + } + let _e294 = g_premultiply; + if _e294 { + let _e296 = (*s).alpha; + local_4 = _e296; + } else { + local_4 = 1f; + } + let _e297 = local_4; + weight_2 = _e297; + let _e298 = shown; + param_9 = _e298; + let _e299 = SrgbToLinear_u0028_vf3_u003b((¶m_9)); + let _e300 = weight_2; + let _e301 = (_e299 * _e300); + let _e303 = (*s).alpha; + frag_color = vec4(_e301.x, _e301.y, _e301.z, _e303); + let _e309 = (*s).roughness; + param_10 = _e309; + WriteSurfaceGeometry_u0028_f1_u003b((¶m_10)); + WriteWeightedBlended_u0028_(); + return true; +} + +fn FonAlbedo_u0028_f1_u003b_f1_u003b(mu: ptr, r: ptr) -> f32 { + var m: f32; + var g: f32; + + let _e210 = (*mu); + m = (1f - _e210); + let _e212 = m; + let _e213 = m; + let _e214 = m; + let _e215 = m; + g = (_e212 * (0.05710853f + (_e213 * (0.49188188f + (_e214 * (-0.33218145f + (_e215 * 0.071443f))))))); + let _e223 = (*r); + let _e224 = g; + let _e227 = (*r); + return ((1f + (_e223 * _e224)) / (1f + (0.2877934f * _e227))); +} + +fn EonMultiAlbedo_u0028_vf3_u003b_f1_u003b(rho: ptr>, average: ptr) -> vec3 { + let _e208 = (*rho); + let _e209 = (*rho); + let _e211 = (*average); + let _e213 = (*rho); + let _e214 = (*average); + return (((_e208 * _e209) * _e211) / (vec3(1f, 1f, 1f) - (_e213 * (1f - _e214)))); +} + +fn FonAverage_u0028_f1_u003b(r_1: ptr) -> f32 { + let _e207 = (*r_1); + let _e210 = (*r_1); + return ((1f + (0.07248821f * _e207)) / (1f + (0.2877934f * _e210))); +} + +fn EonLobe_u0028_vf3_u003b_f1_u003b_f1_u003b_f1_u003b_f1_u003b(rho_1: ptr>, r_2: ptr, mu_i: ptr, mu_o: ptr, l_dot_v: ptr) -> vec3 { + var s_1: f32; + var s_over_t: f32; + var local_5: f32; + var af: f32; + var single: vec3; + var average_1: f32; + var param_11: f32; + var multi: vec3; + var param_12: vec3; + var param_13: f32; + var param_14: f32; + var param_15: f32; + var param_16: f32; + var param_17: f32; + + let _e225 = (*l_dot_v); + let _e226 = (*mu_i); + let _e227 = (*mu_o); + s_1 = (_e225 - (_e226 * _e227)); + let _e230 = s_1; + if (_e230 > 0f) { + let _e232 = s_1; + let _e233 = (*mu_i); + let _e234 = (*mu_o); + local_5 = (_e232 / max(_e233, _e234)); + } else { + let _e237 = s_1; + local_5 = _e237; + } + let _e238 = local_5; + s_over_t = _e238; + let _e239 = (*r_2); + af = (1f / (1f + (0.2877934f * _e239))); + let _e243 = (*rho_1); + let _e244 = af; + let _e245 = (*r_2); + let _e246 = s_over_t; + single = (_e243 * (_e244 * (1f + (_e245 * _e246)))); + let _e251 = (*r_2); + param_11 = _e251; + let _e252 = FonAverage_u0028_f1_u003b((¶m_11)); + average_1 = _e252; + let _e253 = (*rho_1); + param_12 = _e253; + let _e254 = average_1; + param_13 = _e254; + let _e255 = EonMultiAlbedo_u0028_vf3_u003b_f1_u003b((¶m_12), (¶m_13)); + let _e256 = (*mu_o); + param_14 = _e256; + let _e257 = (*r_2); + param_15 = _e257; + let _e258 = FonAlbedo_u0028_f1_u003b_f1_u003b((¶m_14), (¶m_15)); + let _e261 = (*mu_i); + param_16 = _e261; + let _e262 = (*r_2); + param_17 = _e262; + let _e263 = FonAlbedo_u0028_f1_u003b_f1_u003b((¶m_16), (¶m_17)); + let _e267 = average_1; + multi = (_e255 * ((max((1f - _e258), 0.0000001f) * max((1f - _e263), 0.0000001f)) / max((1f - _e267), 0.0000001f))); + let _e272 = single; + let _e273 = multi; + return ((_e272 + _e273) / vec3(3.1415927f)); } fn EonDiffuse_u0028_() -> bool { - let _e197 = frag_info.ambient_sky[3u]; - return (_e197 > 0.5f); + let _e208 = frag_info.ambient_sky[3u]; + return (_e208 > 0.5f); } fn LtcUv_u0028_f1_u003b_f1_u003b_f1_u003b(x: ptr, y: ptr, table: ptr) -> vec2 { var inTable: vec2; - let _e199 = (*x); - let _e200 = (*y); - inTable = ((vec2(_e199, _e200) * 0.984375f) + vec2(0.0078125f)); - let _e206 = inTable[0u]; - let _e208 = inTable[1u]; - let _e209 = (*table); - return vec2(_e206, ((_e208 + _e209) * 0.5f)); + let _e210 = (*x); + let _e211 = (*y); + inTable = ((vec2(_e210, _e211) * 0.984375f) + vec2(0.0078125f)); + let _e217 = inTable[0u]; + let _e219 = inTable[1u]; + let _e220 = (*table); + return vec2(_e217, ((_e219 + _e220) * 0.5f)); } fn EnvBrdf_u0028_f1_u003b_f1_u003b(roughness_2: ptr, n_dot_v: ptr) -> vec2 { var dfg: vec2; - var param_12: f32; - var param_13: f32; - var param_14: f32; + var param_18: f32; + var param_19: f32; + var param_20: f32; + + let _e212 = (*roughness_2); + let _e214 = (*n_dot_v); + param_18 = clamp(_e212, 0f, 1f); + param_19 = sqrt(clamp((1f - _e214), 0f, 1f)); + param_20 = 1f; + let _e218 = LtcUv_u0028_f1_u003b_f1_u003b_f1_u003b((¶m_18), (¶m_19), (¶m_20)); + let _e219 = textureSampleLevel(ltc_texture_tex, ltc_texture_smp, _e218, 0f); + dfg = _e219.xy; + let _e222 = dfg[0u]; + let _e224 = dfg[1u]; + let _e227 = dfg[1u]; + return vec2((_e222 - _e224), _e227); +} - let _e201 = (*roughness_2); - let _e203 = (*n_dot_v); - param_12 = clamp(_e201, 0f, 1f); - param_13 = sqrt(clamp((1f - _e203), 0f, 1f)); - param_14 = 1f; - let _e207 = LtcUv_u0028_f1_u003b_f1_u003b_f1_u003b((¶m_12), (¶m_13), (¶m_14)); - let _e208 = textureSampleLevel(ltc_texture_tex, ltc_texture_smp, _e207, 0f); - dfg = _e208.xy; - let _e211 = dfg[0u]; - let _e213 = dfg[1u]; - let _e216 = dfg[1u]; - return vec2((_e211 - _e213), _e216); -} - -fn MultiscatterScale_u0028_vf3_u003b_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b(f0_: ptr>, s_1: ptr) -> vec3 { +fn MultiscatterScale_u0028_vf3_u003b_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b(f0_: ptr>, s_2: ptr) -> vec3 { var ab: vec2; - var param_15: f32; - var param_16: f32; + var param_21: f32; + var param_22: f32; var ess: f32; - let _e202 = (*s_1).roughness; - param_15 = max(_e202, 0.045f); - let _e205 = (*s_1).n_dot_v; - param_16 = _e205; - let _e206 = EnvBrdf_u0028_f1_u003b_f1_u003b((¶m_15), (¶m_16)); - ab = _e206; - let _e208 = ab[0u]; - let _e210 = ab[1u]; - ess = max((_e208 + _e210), 0.0001f); - let _e213 = (*f0_); - let _e214 = ess; - return (vec3(1f, 1f, 1f) + (_e213 * ((1f / _e214) - 1f))); + let _e213 = (*s_2).roughness; + param_21 = max(_e213, 0.045f); + let _e216 = (*s_2).n_dot_v; + param_22 = _e216; + let _e217 = EnvBrdf_u0028_f1_u003b_f1_u003b((¶m_21), (¶m_22)); + ab = _e217; + let _e219 = ab[0u]; + let _e221 = ab[1u]; + ess = max((_e219 + _e221), 0.0001f); + let _e224 = (*f0_); + let _e225 = ess; + return (vec3(1f, 1f, 1f) + (_e224 * ((1f / _e225) - 1f))); } fn EnergyCompensation_u0028_() -> bool { - let _e197 = frag_info.target_origin[2u]; - return (_e197 > 0.5f); + let _e208 = frag_info.target_origin[2u]; + return (_e208 > 0.5f); } fn F_Schlick_u0028_vf3_u003b_f1_u003b(f0_1: ptr>, v_dot_h: ptr) -> vec3 { var f: f32; - let _e198 = (*v_dot_h); - f = pow((1f - _e198), 5f); - let _e201 = (*f0_1); - let _e202 = (*f0_1); - let _e204 = f; - return (_e201 + ((vec3(1f, 1f, 1f) - _e202) * _e204)); + let _e209 = (*v_dot_h); + f = pow((1f - _e209), 5f); + let _e212 = (*f0_1); + let _e213 = (*f0_1); + let _e215 = f; + return (_e212 + ((vec3(1f, 1f, 1f) - _e213) * _e215)); } fn V_SmithGGXCorrelated_u0028_f1_u003b_f1_u003b_f1_u003b(n_dot_v_1: ptr, n_dot_l: ptr, alpha_3: ptr) -> f32 { @@ -12201,179 +14256,179 @@ fn V_SmithGGXCorrelated_u0028_f1_u003b_f1_u003b_f1_u003b(n_dot_v_1: ptr, alpha_4: ptr) -> f32 { var a: f32; - var k: f32; - - let _e199 = (*n_dot_h); - let _e200 = (*alpha_4); - a = (_e199 * _e200); - let _e202 = (*alpha_4); - let _e203 = (*n_dot_h); - let _e204 = (*n_dot_h); - let _e207 = a; - let _e208 = a; - k = (_e202 / max(((1f - (_e203 * _e204)) + (_e207 * _e208)), 0.0000001f)); - let _e213 = k; - let _e214 = k; - return ((_e213 * _e214) * 0.31830987f); -} - -fn ShadeLight_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b_struct_u002d_LightSample_u002d_vf3_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf31_u003b(s_2: ptr, light: ptr) -> vec3 { + var k_1: f32; + + let _e210 = (*n_dot_h); + let _e211 = (*alpha_4); + a = (_e210 * _e211); + let _e213 = (*alpha_4); + let _e214 = (*n_dot_h); + let _e215 = (*n_dot_h); + let _e218 = a; + let _e219 = a; + k_1 = (_e213 / max(((1f - (_e214 * _e215)) + (_e218 * _e219)), 0.0000001f)); + let _e224 = k_1; + let _e225 = k_1; + return ((_e224 * _e225) * 0.31830987f); +} + +fn ShadeLight_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b_struct_u002d_LightSample_u002d_vf3_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf31_u003b(s_3: ptr, light: ptr) -> vec3 { var lobe: f32; var alpha_5: f32; var f0_2: vec3; var diffuseColor: vec3; var d_1: f32; - var param_17: f32; - var param_18: f32; + var param_23: f32; + var param_24: f32; var vis: f32; - var param_19: f32; - var param_20: f32; - var param_21: f32; + var param_25: f32; + var param_26: f32; + var param_27: f32; var f_1: vec3; - var param_22: vec3; - var param_23: f32; + var param_28: vec3; + var param_29: f32; var specular: vec3; - var param_24: vec3; - var param_25: Surface; + var param_30: vec3; + var param_31: Surface; var diffuse: vec3; - var param_26: vec3; - var param_27: f32; - var param_28: f32; - var param_29: f32; - var param_30: f32; + var param_32: vec3; + var param_33: f32; + var param_34: f32; + var param_35: f32; + var param_36: f32; - let _e221 = (*s_2).roughness; - lobe = max(_e221, 0.045f); - let _e223 = lobe; - let _e224 = lobe; - alpha_5 = (_e223 * _e224); - let _e227 = (*s_2).albedo; - let _e229 = (*s_2).metallic; - f0_2 = mix(vec3(0.04f, 0.04f, 0.04f), _e227, vec3(_e229)); - let _e233 = (*s_2).albedo; - let _e235 = (*s_2).metallic; - diffuseColor = (_e233 * (1f - _e235)); - let _e239 = (*light).n_dot_h; - param_17 = _e239; - let _e240 = alpha_5; - param_18 = _e240; - let _e241 = D_GGX_u0028_f1_u003b_f1_u003b((¶m_17), (¶m_18)); - d_1 = _e241; - let _e243 = (*s_2).n_dot_v; - param_19 = _e243; - let _e245 = (*light).n_dot_l; - param_20 = _e245; - let _e246 = alpha_5; - param_21 = _e246; - let _e247 = V_SmithGGXCorrelated_u0028_f1_u003b_f1_u003b_f1_u003b((¶m_19), (¶m_20), (¶m_21)); - vis = _e247; - let _e248 = f0_2; - param_22 = _e248; - let _e250 = (*light).v_dot_h; + let _e232 = (*s_3).roughness; + lobe = max(_e232, 0.045f); + let _e234 = lobe; + let _e235 = lobe; + alpha_5 = (_e234 * _e235); + let _e238 = (*s_3).albedo; + let _e240 = (*s_3).metallic; + f0_2 = mix(vec3(0.04f, 0.04f, 0.04f), _e238, vec3(_e240)); + let _e244 = (*s_3).albedo; + let _e246 = (*s_3).metallic; + diffuseColor = (_e244 * (1f - _e246)); + let _e250 = (*light).n_dot_h; param_23 = _e250; - let _e251 = F_Schlick_u0028_vf3_u003b_f1_u003b((¶m_22), (¶m_23)); - f_1 = _e251; - let _e252 = d_1; - let _e253 = vis; - let _e255 = f_1; - let _e259 = frag_info.material[3u]; - specular = ((_e255 * (_e252 * _e253)) * _e259); - let _e262 = (*light).integrated; - if (_e262 > 0.5f) { - let _e266 = (*light).ltc[0u]; - let _e267 = f0_2; - let _e270 = (*light).ltc[1u]; - let _e272 = f0_2; - let _e277 = (*light).ltc[2u]; - let _e283 = frag_info.material[3u]; - let _e286 = (*light).n_dot_l; - specular = (((((_e267 * _e270) + ((vec3(1f) - _e272) * _e277)) * _e266) * _e283) / vec3(max(_e286, 0.000001f))); - } - let _e290 = EnergyCompensation_u0028_(); - if _e290 { - let _e291 = f0_2; - param_24 = _e291; - let _e292 = (*s_2); - param_25 = _e292; - let _e293 = MultiscatterScale_u0028_vf3_u003b_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b((¶m_24), (¶m_25)); - let _e294 = specular; - specular = (_e294 * _e293); - } - let _e296 = diffuseColor; - let _e297 = f_1; - diffuse = ((_e296 * (vec3(1f, 1f, 1f) - _e297)) / vec3(3.1415927f)); - let _e302 = EonDiffuse_u0028_(); - if _e302 { - let _e304 = (*s_2).n; - let _e306 = (*light).l; - let _e310 = (*light).l; - let _e312 = (*s_2).v; - let _e314 = diffuseColor; - param_26 = _e314; - let _e316 = (*s_2).roughness; - param_27 = _e316; - param_28 = clamp(dot(_e304, _e306), 0.0001f, 1f); - let _e318 = (*s_2).n_dot_v; - param_29 = _e318; - param_30 = dot(_e310, _e312); - let _e319 = EonLobe_u0028_vf3_u003b_f1_u003b_f1_u003b_f1_u003b_f1_u003b((¶m_26), (¶m_27), (¶m_28), (¶m_29), (¶m_30)); - let _e320 = f_1; - diffuse = (_e319 * (vec3(1f, 1f, 1f) - _e320)); - } - let _e323 = diffuse; - let _e324 = specular; - return ((_e323 + _e324) * 3.1415927f); + let _e251 = alpha_5; + param_24 = _e251; + let _e252 = D_GGX_u0028_f1_u003b_f1_u003b((¶m_23), (¶m_24)); + d_1 = _e252; + let _e254 = (*s_3).n_dot_v; + param_25 = _e254; + let _e256 = (*light).n_dot_l; + param_26 = _e256; + let _e257 = alpha_5; + param_27 = _e257; + let _e258 = V_SmithGGXCorrelated_u0028_f1_u003b_f1_u003b_f1_u003b((¶m_25), (¶m_26), (¶m_27)); + vis = _e258; + let _e259 = f0_2; + param_28 = _e259; + let _e261 = (*light).v_dot_h; + param_29 = _e261; + let _e262 = F_Schlick_u0028_vf3_u003b_f1_u003b((¶m_28), (¶m_29)); + f_1 = _e262; + let _e263 = d_1; + let _e264 = vis; + let _e266 = f_1; + let _e270 = frag_info.material[3u]; + specular = ((_e266 * (_e263 * _e264)) * _e270); + let _e273 = (*light).integrated; + if (_e273 > 0.5f) { + let _e277 = (*light).ltc[0u]; + let _e278 = f0_2; + let _e281 = (*light).ltc[1u]; + let _e283 = f0_2; + let _e288 = (*light).ltc[2u]; + let _e294 = frag_info.material[3u]; + let _e297 = (*light).n_dot_l; + specular = (((((_e278 * _e281) + ((vec3(1f) - _e283) * _e288)) * _e277) * _e294) / vec3(max(_e297, 0.000001f))); + } + let _e301 = EnergyCompensation_u0028_(); + if _e301 { + let _e302 = f0_2; + param_30 = _e302; + let _e303 = (*s_3); + param_31 = _e303; + let _e304 = MultiscatterScale_u0028_vf3_u003b_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b((¶m_30), (¶m_31)); + let _e305 = specular; + specular = (_e305 * _e304); + } + let _e307 = diffuseColor; + let _e308 = f_1; + diffuse = ((_e307 * (vec3(1f, 1f, 1f) - _e308)) / vec3(3.1415927f)); + let _e313 = EonDiffuse_u0028_(); + if _e313 { + let _e315 = (*s_3).n; + let _e317 = (*light).l; + let _e321 = (*light).l; + let _e323 = (*s_3).v; + let _e325 = diffuseColor; + param_32 = _e325; + let _e327 = (*s_3).roughness; + param_33 = _e327; + param_34 = clamp(dot(_e315, _e317), 0.0001f, 1f); + let _e329 = (*s_3).n_dot_v; + param_35 = _e329; + param_36 = dot(_e321, _e323); + let _e330 = EonLobe_u0028_vf3_u003b_f1_u003b_f1_u003b_f1_u003b_f1_u003b((¶m_32), (¶m_33), (¶m_34), (¶m_35), (¶m_36)); + let _e331 = f_1; + diffuse = (_e330 * (vec3(1f, 1f, 1f) - _e331)); + } + let _e334 = diffuse; + let _e335 = specular; + return ((_e334 + _e335) * 3.1415927f); } fn PointShadowDiskTap_u0028_i1_u003b(i: ptr) -> vec2 { - let _e196 = (*i); - if (_e196 == 0i) { + let _e207 = (*i); + if (_e207 == 0i) { return vec2(-0.94201624f, -0.39906216f); } - let _e198 = (*i); - if (_e198 == 1i) { + let _e209 = (*i); + if (_e209 == 1i) { return vec2(0.9455861f, -0.76890725f); } - let _e200 = (*i); - if (_e200 == 2i) { + let _e211 = (*i); + if (_e211 == 2i) { return vec2(-0.0941841f, -0.9293887f); } - let _e202 = (*i); - if (_e202 == 3i) { + let _e213 = (*i); + if (_e213 == 3i) { return vec2(0.34495938f, 0.2938776f); } - let _e204 = (*i); - if (_e204 == 4i) { + let _e215 = (*i); + if (_e215 == 4i) { return vec2(-0.9158858f, 0.45771432f); } - let _e206 = (*i); - if (_e206 == 5i) { + let _e217 = (*i); + if (_e217 == 5i) { return vec2(-0.8154423f, -0.87912464f); } - let _e208 = (*i); - if (_e208 == 6i) { + let _e219 = (*i); + if (_e219 == 6i) { return vec2(-0.38277543f, 0.27676845f); } return vec2(0.974844f, 0.7564838f); @@ -12381,208 +14436,208 @@ fn PointShadowDiskTap_u0028_i1_u003b(i: ptr) -> vec2 { fn PointShadowOffset_u0028_i1_u003b_f1_u003b_f1_u003b_f1_u003b(i_1: ptr, ca: ptr, sa: ptr, radius: ptr) -> vec2 { var p: vec2; - var param_31: i32; - - let _e201 = (*i_1); - param_31 = _e201; - let _e202 = PointShadowDiskTap_u0028_i1_u003b((¶m_31)); - p = _e202; - let _e204 = p[0u]; - let _e205 = (*ca); - let _e208 = p[1u]; - let _e209 = (*sa); - let _e213 = p[0u]; - let _e214 = (*sa); - let _e217 = p[1u]; - let _e218 = (*ca); - let _e222 = (*radius); - return (vec2(((_e204 * _e205) - (_e208 * _e209)), ((_e213 * _e214) + (_e217 * _e218))) * _e222); + var param_37: i32; + + let _e212 = (*i_1); + param_37 = _e212; + let _e213 = PointShadowDiskTap_u0028_i1_u003b((¶m_37)); + p = _e213; + let _e215 = p[0u]; + let _e216 = (*ca); + let _e219 = p[1u]; + let _e220 = (*sa); + let _e224 = p[0u]; + let _e225 = (*sa); + let _e228 = p[1u]; + let _e229 = (*ca); + let _e233 = (*radius); + return (vec2(((_e215 * _e216) - (_e219 * _e220)), ((_e224 * _e225) + (_e228 * _e229))) * _e233); } fn PointShadowDistance_u0028_vf2_u003b_vf2_u003b_vf2_u003b_f1_u003b(uv: ptr>, offset: ptr>, tile: ptr>, range: ptr) -> f32 { var inset: f32; - var local_2: vec2; + var local_6: vec2; var atlas: vec2; - let _e204 = point_shadow.params[0u]; - inset = _e204; - let _e205 = (*uv); - let _e206 = (*offset); - let _e208 = inset; - let _e209 = inset; - local_2 = clamp((_e205 + _e206), vec2(_e208), vec2((1f - _e209))); - let _e214 = local_2; - let _e215 = (*tile); - atlas = ((_e214 + _e215) * vec2(0.16666667f, 0.16666667f)); - let _e220 = point_shadow.params3_[0u]; - if (_e220 > 0.5f) { - let _e223 = atlas[1u]; - atlas[1u] = (1f - _e223); - } - let _e226 = atlas; - let _e227 = textureSampleLevel(point_shadow_texture_tex, point_shadow_texture_smp, _e226, 0f); - let _e229 = atlas; - let _e230 = textureSampleLevel(point_shadow_static_texture_tex, point_shadow_static_texture_smp, _e229, 0f); - let _e233 = (*range); - return (min(_e227.x, _e230.x) * _e233); + let _e215 = point_shadow.params[0u]; + inset = _e215; + let _e216 = (*uv); + let _e217 = (*offset); + let _e219 = inset; + let _e220 = inset; + local_6 = clamp((_e216 + _e217), vec2(_e219), vec2((1f - _e220))); + let _e225 = local_6; + let _e226 = (*tile); + atlas = ((_e225 + _e226) * vec2(0.16666667f, 0.16666667f)); + let _e231 = point_shadow.params3_[0u]; + if (_e231 > 0.5f) { + let _e234 = atlas[1u]; + atlas[1u] = (1f - _e234); + } + let _e237 = atlas; + let _e238 = textureSampleLevel(point_shadow_texture_tex, point_shadow_texture_smp, _e237, 0f); + let _e240 = atlas; + let _e241 = textureSampleLevel(point_shadow_static_texture_tex, point_shadow_static_texture_smp, _e240, 0f); + let _e244 = (*range); + return (min(_e238.x, _e241.x) * _e244); } fn PointShadowTap_u0028_vf2_u003b_vf2_u003b_vf2_u003b_f1_u003b_f1_u003b(uv_1: ptr>, offset_1: ptr>, tile_1: ptr>, range_1: ptr, receiver: ptr) -> f32 { var stored: f32; - var param_32: vec2; - var param_33: vec2; - var param_34: vec2; - var param_35: f32; + var param_38: vec2; + var param_39: vec2; + var param_40: vec2; + var param_41: f32; - let _e205 = (*uv_1); - param_32 = _e205; - let _e206 = (*offset_1); - param_33 = _e206; - let _e207 = (*tile_1); - param_34 = _e207; - let _e208 = (*range_1); - param_35 = _e208; - let _e209 = PointShadowDistance_u0028_vf2_u003b_vf2_u003b_vf2_u003b_f1_u003b((¶m_32), (¶m_33), (¶m_34), (¶m_35)); - stored = _e209; - let _e210 = stored; - let _e211 = (*range_1); - if (_e210 >= (_e211 * 0.999f)) { + let _e216 = (*uv_1); + param_38 = _e216; + let _e217 = (*offset_1); + param_39 = _e217; + let _e218 = (*tile_1); + param_40 = _e218; + let _e219 = (*range_1); + param_41 = _e219; + let _e220 = PointShadowDistance_u0028_vf2_u003b_vf2_u003b_vf2_u003b_f1_u003b((¶m_38), (¶m_39), (¶m_40), (¶m_41)); + stored = _e220; + let _e221 = stored; + let _e222 = (*range_1); + if (_e221 >= (_e222 * 0.999f)) { return 1f; } - let _e214 = (*receiver); - let _e215 = stored; - return select(1f, 0f, (_e214 > _e215)); + let _e225 = (*receiver); + let _e226 = stored; + return select(1f, 0f, (_e225 > _e226)); } fn PointShadowPenumbra_u0028_vf2_u003b_vf2_u003b_f1_u003b_f1_u003b_f1_u003b_f1_u003b_f1_u003b_f1_u003b(uv_2: ptr>, tile_2: ptr>, range_2: ptr, receiver_1: ptr, ca_1: ptr, sa_1: ptr, tanHalf: ptr, blockerOut: ptr) -> f32 { var lightRadius: f32; var minRadius: f32; var maxRadius: f32; - var param_36: vec2; - var param_37: vec2; - var param_38: vec2; - var param_39: f32; + var param_42: vec2; + var param_43: vec2; + var param_44: vec2; + var param_45: f32; var sum: f32; var count: f32; var i_2: i32; var stored_1: f32; - var param_40: i32; - var param_41: f32; - var param_42: f32; - var param_43: f32; - var param_44: vec2; - var param_45: vec2; - var param_46: vec2; + var param_46: i32; var param_47: f32; + var param_48: f32; + var param_49: f32; + var param_50: vec2; + var param_51: vec2; + var param_52: vec2; + var param_53: f32; var blocker: f32; var world: f32; (*blockerOut) = -1f; - let _e226 = point_shadow.params2_[1u]; - lightRadius = _e226; - let _e229 = point_shadow.params2_[0u]; - minRadius = _e229; - let _e232 = point_shadow.params2_[2u]; - maxRadius = _e232; - let _e233 = lightRadius; - if (_e233 <= 0f) { - let _e235 = (*uv_2); - param_36 = _e235; - param_37 = vec2(0f, 0f); - let _e236 = (*tile_2); - param_38 = _e236; - let _e237 = (*range_2); - param_39 = _e237; - let _e238 = PointShadowDistance_u0028_vf2_u003b_vf2_u003b_vf2_u003b_f1_u003b((¶m_36), (¶m_37), (¶m_38), (¶m_39)); - (*blockerOut) = _e238; - let _e239 = minRadius; - return _e239; + let _e237 = point_shadow.params2_[1u]; + lightRadius = _e237; + let _e240 = point_shadow.params2_[0u]; + minRadius = _e240; + let _e243 = point_shadow.params2_[2u]; + maxRadius = _e243; + let _e244 = lightRadius; + if (_e244 <= 0f) { + let _e246 = (*uv_2); + param_42 = _e246; + param_43 = vec2(0f, 0f); + let _e247 = (*tile_2); + param_44 = _e247; + let _e248 = (*range_2); + param_45 = _e248; + let _e249 = PointShadowDistance_u0028_vf2_u003b_vf2_u003b_vf2_u003b_f1_u003b((¶m_42), (¶m_43), (¶m_44), (¶m_45)); + (*blockerOut) = _e249; + let _e250 = minRadius; + return _e250; } sum = 0f; count = 0f; i_2 = 0i; loop { - let _e240 = i_2; - if (_e240 < 8i) { - let _e242 = i_2; - param_40 = _e242; - let _e243 = (*ca_1); - param_41 = _e243; - let _e244 = (*sa_1); - param_42 = _e244; - let _e245 = maxRadius; - param_43 = _e245; - let _e246 = PointShadowOffset_u0028_i1_u003b_f1_u003b_f1_u003b_f1_u003b((¶m_40), (¶m_41), (¶m_42), (¶m_43)); - let _e247 = (*uv_2); - param_44 = _e247; - param_45 = _e246; - let _e248 = (*tile_2); - param_46 = _e248; - let _e249 = (*range_2); - param_47 = _e249; - let _e250 = PointShadowDistance_u0028_vf2_u003b_vf2_u003b_vf2_u003b_f1_u003b((¶m_44), (¶m_45), (¶m_46), (¶m_47)); - stored_1 = _e250; - let _e251 = stored_1; - let _e252 = (*range_2); - if (_e251 >= (_e252 * 0.999f)) { + let _e251 = i_2; + if (_e251 < 8i) { + let _e253 = i_2; + param_46 = _e253; + let _e254 = (*ca_1); + param_47 = _e254; + let _e255 = (*sa_1); + param_48 = _e255; + let _e256 = maxRadius; + param_49 = _e256; + let _e257 = PointShadowOffset_u0028_i1_u003b_f1_u003b_f1_u003b_f1_u003b((¶m_46), (¶m_47), (¶m_48), (¶m_49)); + let _e258 = (*uv_2); + param_50 = _e258; + param_51 = _e257; + let _e259 = (*tile_2); + param_52 = _e259; + let _e260 = (*range_2); + param_53 = _e260; + let _e261 = PointShadowDistance_u0028_vf2_u003b_vf2_u003b_vf2_u003b_f1_u003b((¶m_50), (¶m_51), (¶m_52), (¶m_53)); + stored_1 = _e261; + let _e262 = stored_1; + let _e263 = (*range_2); + if (_e262 >= (_e263 * 0.999f)) { continue; } - let _e255 = stored_1; - let _e256 = (*receiver_1); - if (_e255 >= _e256) { + let _e266 = stored_1; + let _e267 = (*receiver_1); + if (_e266 >= _e267) { continue; } - let _e258 = stored_1; - let _e259 = sum; - sum = (_e259 + _e258); - let _e261 = count; - count = (_e261 + 1f); + let _e269 = stored_1; + let _e270 = sum; + sum = (_e270 + _e269); + let _e272 = count; + count = (_e272 + 1f); continue; } else { break; } continuing { - let _e263 = i_2; - i_2 = (_e263 + 1i); + let _e274 = i_2; + i_2 = (_e274 + 1i); } } - let _e265 = count; - if (_e265 < 0.5f) { + let _e276 = count; + if (_e276 < 0.5f) { return -1f; } - let _e267 = sum; - let _e268 = count; - blocker = max((_e267 / _e268), 0.0001f); - let _e271 = blocker; - (*blockerOut) = _e271; - let _e272 = lightRadius; - let _e273 = (*receiver_1); - let _e274 = blocker; - let _e278 = blocker; - world = ((_e272 * max((_e273 - _e274), 0f)) / _e278); - let _e280 = world; - let _e281 = (*receiver_1); - let _e283 = (*tanHalf); - let _e286 = minRadius; - let _e287 = maxRadius; - return clamp((_e280 / ((2f * _e281) * _e283)), _e286, _e287); + let _e278 = sum; + let _e279 = count; + blocker = max((_e278 / _e279), 0.0001f); + let _e282 = blocker; + (*blockerOut) = _e282; + let _e283 = lightRadius; + let _e284 = (*receiver_1); + let _e285 = blocker; + let _e289 = blocker; + world = ((_e283 * max((_e284 - _e285), 0f)) / _e289); + let _e291 = world; + let _e292 = (*receiver_1); + let _e294 = (*tanHalf); + let _e297 = minRadius; + let _e298 = maxRadius; + return clamp((_e291 / ((2f * _e292) * _e294)), _e297, _e298); } fn FragCoordFromTop_u0028_f1_u003b(rows: ptr) -> vec2 { - var local_3: vec2; - - let _e197 = (*rows); - if (_e197 > 0f) { - let _e200 = gl_FragCoord_1[0u]; - let _e201 = (*rows); - let _e203 = gl_FragCoord_1[1u]; - local_3 = vec2(_e200, (_e201 - _e203)); + var local_7: vec2; + + let _e208 = (*rows); + if (_e208 > 0f) { + let _e211 = gl_FragCoord_1[0u]; + let _e212 = (*rows); + let _e214 = gl_FragCoord_1[1u]; + local_7 = vec2(_e211, (_e212 - _e214)); } else { - let _e206 = gl_FragCoord_1; - local_3 = _e206.xy; + let _e217 = gl_FragCoord_1; + local_7 = _e217.xy; } - let _e208 = local_3; - return _e208; + let _e219 = local_7; + return _e219; } fn PointShadowFactor_u0028_vf3_u003b_vf3_u003b_i1_u003b(world_1: ptr>, normal: ptr>, lightIndex: ptr) -> f32 { @@ -12605,292 +14660,292 @@ fn PointShadowFactor_u0028_vf3_u003b_vf3_u003b_i1_u003b(world_1: ptr; var receiver_2: f32; var noise: f32; - var param_48: f32; + var param_54: f32; var angle: f32; var ca_2: f32; var sa_2: f32; var tanHalf_1: f32; var blocker_1: f32; var radius_1: f32; - var param_49: vec2; - var param_50: vec2; - var param_51: f32; - var param_52: f32; - var param_53: f32; - var param_54: f32; - var param_55: f32; - var param_56: f32; - var local_4: vec3; - var lit: f32; - var param_57: vec2; - var param_58: vec2; - var param_59: vec2; + var param_55: vec2; + var param_56: vec2; + var param_57: f32; + var param_58: f32; + var param_59: f32; var param_60: f32; var param_61: f32; + var param_62: f32; + var local_8: vec3; + var lit_1: f32; + var param_63: vec2; + var param_64: vec2; + var param_65: vec2; + var param_66: f32; + var param_67: f32; var i_3: i32; - var param_62: i32; - var param_63: f32; - var param_64: f32; - var param_65: f32; - var param_66: vec2; - var param_67: vec2; - var param_68: vec2; + var param_68: i32; var param_69: f32; var param_70: f32; - var phi_2490_: bool; - var phi_2551_: bool; - - let _e249 = (*lightIndex); - let _e253 = point_shadow.slots[_e249][0u]; - slot = i32((_e253 + 0.5f)); - let _e256 = (*lightIndex); - let _e260 = point_shadow.slots[_e256][0u]; - if (_e260 < 0f) { + var param_71: f32; + var param_72: vec2; + var param_73: vec2; + var param_74: vec2; + var param_75: f32; + var param_76: f32; + var phi_2590_: bool; + var phi_2651_: bool; + + let _e260 = (*lightIndex); + let _e264 = point_shadow.slots[_e260][0u]; + slot = i32((_e264 + 0.5f)); + let _e267 = (*lightIndex); + let _e271 = point_shadow.slots[_e267][0u]; + if (_e271 < 0f) { return 1f; } - let _e264 = point_shadow.params[2u]; - strength = _e264; - let _e265 = strength; - if (_e265 <= 0f) { + let _e275 = point_shadow.params[2u]; + strength = _e275; + let _e276 = strength; + if (_e276 <= 0f) { return 1f; } - let _e267 = slot; - let _e270 = point_shadow.lights[_e267]; - let _e272 = (*world_1); - toLight = (_e270.xyz - _e272); - let _e274 = toLight; - toLightLength = max(length(_e274), 0.000001f); - let _e277 = (*normal); - let _e278 = toLight; - let _e279 = toLightLength; - nDotL = max(dot(_e277, (_e278 / vec3(_e279))), 0.15f); - let _e284 = nDotL; - let _e285 = nDotL; - let _e290 = nDotL; - let _e291 = nDotL; - slope = min((sqrt(max((1f - (_e284 * _e285)), 0f)) / (_e290 * _e291)), 8f); - let _e295 = toLightLength; - let _e297 = (*lightIndex); - let _e301 = point_shadow.slots[_e297][2u]; - let _e306 = point_shadow.params3_[1u]; - texel = (((2f * _e295) * max(_e301, 0.0001f)) * _e306); - let _e308 = (*world_1); - let _e309 = (*normal); - let _e310 = texel; - let _e314 = point_shadow.params[3u]; - let _e316 = slope; - origin = (_e308 + (((_e309 * _e310) * _e314) * (1f + _e316))); - let _e320 = origin; - let _e321 = slot; - let _e324 = point_shadow.lights[_e321]; - toFragment = (_e320 - _e324.xyz); - let _e327 = toFragment; - distance_ = length(_e327); - let _e329 = slot; - let _e333 = point_shadow.lights[_e329][3u]; - range_3 = max(_e333, 0.0001f); - let _e335 = distance_; - let _e336 = range_3; - if (_e335 >= _e336) { + let _e278 = slot; + let _e281 = point_shadow.lights[_e278]; + let _e283 = (*world_1); + toLight = (_e281.xyz - _e283); + let _e285 = toLight; + toLightLength = max(length(_e285), 0.000001f); + let _e288 = (*normal); + let _e289 = toLight; + let _e290 = toLightLength; + nDotL = max(dot(_e288, (_e289 / vec3(_e290))), 0.15f); + let _e295 = nDotL; + let _e296 = nDotL; + let _e301 = nDotL; + let _e302 = nDotL; + slope = min((sqrt(max((1f - (_e295 * _e296)), 0f)) / (_e301 * _e302)), 8f); + let _e306 = toLightLength; + let _e308 = (*lightIndex); + let _e312 = point_shadow.slots[_e308][2u]; + let _e317 = point_shadow.params3_[1u]; + texel = (((2f * _e306) * max(_e312, 0.0001f)) * _e317); + let _e319 = (*world_1); + let _e320 = (*normal); + let _e321 = texel; + let _e325 = point_shadow.params[3u]; + let _e327 = slope; + origin = (_e319 + (((_e320 * _e321) * _e325) * (1f + _e327))); + let _e331 = origin; + let _e332 = slot; + let _e335 = point_shadow.lights[_e332]; + toFragment = (_e331 - _e335.xyz); + let _e338 = toFragment; + distance_ = length(_e338); + let _e340 = slot; + let _e344 = point_shadow.lights[_e340][3u]; + range_3 = max(_e344, 0.0001f); + let _e346 = distance_; + let _e347 = range_3; + if (_e346 >= _e347) { return 1f; } face = 0i; - let _e338 = (*lightIndex); - let _e342 = point_shadow.slots[_e338][1u]; - if (_e342 < 0.5f) { - let _e344 = toFragment; - a_1 = abs(_e344); - let _e347 = a_1[0u]; - let _e349 = a_1[1u]; - let _e350 = (_e347 >= _e349); - phi_2490_ = _e350; - if _e350 { - let _e352 = a_1[0u]; - let _e354 = a_1[2u]; - phi_2490_ = (_e352 >= _e354); - } - let _e357 = phi_2490_; - if _e357 { - let _e359 = toFragment[0u]; - face = select(1i, 0i, (_e359 > 0f)); - } else { - let _e363 = a_1[1u]; + let _e349 = (*lightIndex); + let _e353 = point_shadow.slots[_e349][1u]; + if (_e353 < 0.5f) { + let _e355 = toFragment; + a_1 = abs(_e355); + let _e358 = a_1[0u]; + let _e360 = a_1[1u]; + let _e361 = (_e358 >= _e360); + phi_2590_ = _e361; + if _e361 { + let _e363 = a_1[0u]; let _e365 = a_1[2u]; - if (_e363 >= _e365) { - let _e368 = toFragment[1u]; - face = select(3i, 2i, (_e368 > 0f)); + phi_2590_ = (_e363 >= _e365); + } + let _e368 = phi_2590_; + if _e368 { + let _e370 = toFragment[0u]; + face = select(1i, 0i, (_e370 > 0f)); + } else { + let _e374 = a_1[1u]; + let _e376 = a_1[2u]; + if (_e374 >= _e376) { + let _e379 = toFragment[1u]; + face = select(3i, 2i, (_e379 > 0f)); } else { - let _e372 = toFragment[2u]; - face = select(5i, 4i, (_e372 > 0f)); + let _e383 = toFragment[2u]; + face = select(5i, 4i, (_e383 > 0f)); } } } - let _e375 = slot; - let _e377 = face; - let _e381 = point_shadow.faces[((_e375 * 6i) + _e377)]; - let _e382 = origin; - clip = (_e381 * vec4(_e382.x, _e382.y, _e382.z, 1f)); - let _e389 = clip[3u]; - if (_e389 <= 0f) { + let _e386 = slot; + let _e388 = face; + let _e392 = point_shadow.faces[((_e386 * 6i) + _e388)]; + let _e393 = origin; + clip = (_e392 * vec4(_e393.x, _e393.y, _e393.z, 1f)); + let _e400 = clip[3u]; + if (_e400 <= 0f) { return 1f; } - let _e391 = clip; - let _e394 = clip[3u]; - ndc = (_e391.xy / vec2(_e394)); - let _e398 = ndc[0u]; - let _e400 = (abs(_e398) > 1f); - phi_2551_ = _e400; - if !(_e400) { - let _e403 = ndc[1u]; - phi_2551_ = (abs(_e403) > 1f); - } - let _e407 = phi_2551_; - if _e407 { + let _e402 = clip; + let _e405 = clip[3u]; + ndc = (_e402.xy / vec2(_e405)); + let _e409 = ndc[0u]; + let _e411 = (abs(_e409) > 1f); + phi_2651_ = _e411; + if !(_e411) { + let _e414 = ndc[1u]; + phi_2651_ = (abs(_e414) > 1f); + } + let _e418 = phi_2651_; + if _e418 { return 1f; } - let _e409 = ndc[0u]; - let _e413 = ndc[1u]; - uv_3 = vec2(((_e409 * 0.5f) + 0.5f), (0.5f - (_e413 * 0.5f))); - let _e417 = face; - let _e419 = slot; - tile_3 = vec2(f32(_e417), f32(_e419)); - let _e422 = distance_; - let _e425 = point_shadow.params[1u]; - receiver_2 = (_e422 - _e425); - let _e429 = frag_info.target_origin[0u]; - param_48 = _e429; - let _e430 = FragCoordFromTop_u0028_f1_u003b((¶m_48)); - noise = fract((52.982918f * fract(dot(_e430, vec2(0.06711056f, 0.00583715f))))); - let _e435 = noise; - angle = (_e435 * 6.2831855f); - let _e437 = angle; - ca_2 = cos(_e437); - let _e439 = angle; - sa_2 = sin(_e439); - let _e441 = (*lightIndex); - let _e445 = point_shadow.slots[_e441][2u]; - tanHalf_1 = max(_e445, 0.0001f); + let _e420 = ndc[0u]; + let _e424 = ndc[1u]; + uv_3 = vec2(((_e420 * 0.5f) + 0.5f), (0.5f - (_e424 * 0.5f))); + let _e428 = face; + let _e430 = slot; + tile_3 = vec2(f32(_e428), f32(_e430)); + let _e433 = distance_; + let _e436 = point_shadow.params[1u]; + receiver_2 = (_e433 - _e436); + let _e440 = frag_info.target_origin[0u]; + param_54 = _e440; + let _e441 = FragCoordFromTop_u0028_f1_u003b((¶m_54)); + noise = fract((52.982918f * fract(dot(_e441, vec2(0.06711056f, 0.00583715f))))); + let _e446 = noise; + angle = (_e446 * 6.2831855f); + let _e448 = angle; + ca_2 = cos(_e448); + let _e450 = angle; + sa_2 = sin(_e450); + let _e452 = (*lightIndex); + let _e456 = point_shadow.slots[_e452][2u]; + tanHalf_1 = max(_e456, 0.0001f); blocker_1 = -1f; - let _e447 = uv_3; - param_49 = _e447; - let _e448 = tile_3; - param_50 = _e448; - let _e449 = range_3; - param_51 = _e449; - let _e450 = receiver_2; - param_52 = _e450; - let _e451 = ca_2; - param_53 = _e451; - let _e452 = sa_2; - param_54 = _e452; - let _e453 = tanHalf_1; - param_55 = _e453; - let _e454 = PointShadowPenumbra_u0028_vf2_u003b_vf2_u003b_f1_u003b_f1_u003b_f1_u003b_f1_u003b_f1_u003b_f1_u003b((¶m_49), (¶m_50), (¶m_51), (¶m_52), (¶m_53), (¶m_54), (¶m_55), (¶m_56)); - let _e455 = param_56; - blocker_1 = _e455; - radius_1 = _e454; - let _e458 = point_shadow.params2_[3u]; - if (_e458 > 0.5f) { + let _e458 = uv_3; + param_55 = _e458; + let _e459 = tile_3; + param_56 = _e459; + let _e460 = range_3; + param_57 = _e460; + let _e461 = receiver_2; + param_58 = _e461; + let _e462 = ca_2; + param_59 = _e462; + let _e463 = sa_2; + param_60 = _e463; + let _e464 = tanHalf_1; + param_61 = _e464; + let _e465 = PointShadowPenumbra_u0028_vf2_u003b_vf2_u003b_f1_u003b_f1_u003b_f1_u003b_f1_u003b_f1_u003b_f1_u003b((¶m_55), (¶m_56), (¶m_57), (¶m_58), (¶m_59), (¶m_60), (¶m_61), (¶m_62)); + let _e466 = param_62; + blocker_1 = _e466; + radius_1 = _e465; + let _e469 = point_shadow.params2_[3u]; + if (_e469 > 0.5f) { g_debug_surface_on = true; - let _e460 = radius_1; - if (_e460 < 0f) { - local_4 = vec3(0f, 0f, 1f); + let _e471 = radius_1; + if (_e471 < 0f) { + local_8 = vec3(0f, 0f, 1f); } else { - let _e462 = radius_1; - let _e465 = point_shadow.params2_[2u]; - let _e469 = blocker_1; - let _e470 = range_3; - local_4 = vec3(clamp((_e462 / max(_e465, 0.000001f)), 0f, 1f), clamp((_e469 / _e470), 0f, 1f), 0f); + let _e473 = radius_1; + let _e476 = point_shadow.params2_[2u]; + let _e480 = blocker_1; + let _e481 = range_3; + local_8 = vec3(clamp((_e473 / max(_e476, 0.000001f)), 0f, 1f), clamp((_e480 / _e481), 0f, 1f), 0f); } - let _e474 = local_4; - g_debug_surface = _e474; + let _e485 = local_8; + g_debug_surface = _e485; } - let _e475 = radius_1; - if (_e475 < 0f) { + let _e486 = radius_1; + if (_e486 < 0f) { return 1f; } - let _e477 = uv_3; - param_57 = _e477; - param_58 = vec2(0f, 0f); - let _e478 = tile_3; - param_59 = _e478; - let _e479 = range_3; - param_60 = _e479; - let _e480 = receiver_2; - param_61 = _e480; - let _e481 = PointShadowTap_u0028_vf2_u003b_vf2_u003b_vf2_u003b_f1_u003b_f1_u003b((¶m_57), (¶m_58), (¶m_59), (¶m_60), (¶m_61)); - lit = _e481; - let _e482 = radius_1; - if (_e482 > 0f) { + let _e488 = uv_3; + param_63 = _e488; + param_64 = vec2(0f, 0f); + let _e489 = tile_3; + param_65 = _e489; + let _e490 = range_3; + param_66 = _e490; + let _e491 = receiver_2; + param_67 = _e491; + let _e492 = PointShadowTap_u0028_vf2_u003b_vf2_u003b_vf2_u003b_f1_u003b_f1_u003b((¶m_63), (¶m_64), (¶m_65), (¶m_66), (¶m_67)); + lit_1 = _e492; + let _e493 = radius_1; + if (_e493 > 0f) { i_3 = 0i; loop { - let _e484 = i_3; - if (_e484 < 8i) { - let _e486 = i_3; - param_62 = _e486; - let _e487 = ca_2; - param_63 = _e487; - let _e488 = sa_2; - param_64 = _e488; - let _e489 = radius_1; - param_65 = _e489; - let _e490 = PointShadowOffset_u0028_i1_u003b_f1_u003b_f1_u003b_f1_u003b((¶m_62), (¶m_63), (¶m_64), (¶m_65)); - let _e491 = uv_3; - param_66 = _e491; - param_67 = _e490; - let _e492 = tile_3; - param_68 = _e492; - let _e493 = range_3; - param_69 = _e493; - let _e494 = receiver_2; - param_70 = _e494; - let _e495 = PointShadowTap_u0028_vf2_u003b_vf2_u003b_vf2_u003b_f1_u003b_f1_u003b((¶m_66), (¶m_67), (¶m_68), (¶m_69), (¶m_70)); - let _e496 = lit; - lit = (_e496 + _e495); + let _e495 = i_3; + if (_e495 < 8i) { + let _e497 = i_3; + param_68 = _e497; + let _e498 = ca_2; + param_69 = _e498; + let _e499 = sa_2; + param_70 = _e499; + let _e500 = radius_1; + param_71 = _e500; + let _e501 = PointShadowOffset_u0028_i1_u003b_f1_u003b_f1_u003b_f1_u003b((¶m_68), (¶m_69), (¶m_70), (¶m_71)); + let _e502 = uv_3; + param_72 = _e502; + param_73 = _e501; + let _e503 = tile_3; + param_74 = _e503; + let _e504 = range_3; + param_75 = _e504; + let _e505 = receiver_2; + param_76 = _e505; + let _e506 = PointShadowTap_u0028_vf2_u003b_vf2_u003b_vf2_u003b_f1_u003b_f1_u003b((¶m_72), (¶m_73), (¶m_74), (¶m_75), (¶m_76)); + let _e507 = lit_1; + lit_1 = (_e507 + _e506); continue; } else { break; } continuing { - let _e498 = i_3; - i_3 = (_e498 + 1i); + let _e509 = i_3; + i_3 = (_e509 + 1i); } } - let _e500 = lit; - lit = (_e500 * 0.11111111f); + let _e511 = lit_1; + lit_1 = (_e511 * 0.11111111f); } - let _e502 = lit; - let _e503 = strength; - return mix(1f, _e502, clamp(_e503, 0f, 1f)); + let _e513 = lit_1; + let _e514 = strength; + return mix(1f, _e513, clamp(_e514, 0f, 1f)); } fn VogelDisc_u0028_i1_u003b_i1_u003b_f1_u003b(i_4: ptr, n_1: ptr, turn: ptr) -> vec2 { var r_3: f32; var theta: f32; - let _e200 = (*i_4); - let _e203 = (*n_1); - r_3 = sqrt(((f32(_e200) + 0.5f) / f32(_e203))); - let _e207 = (*i_4); - let _e210 = (*turn); - theta = ((f32(_e207) * 2.3999631f) + _e210); - let _e212 = r_3; - let _e213 = theta; - let _e215 = theta; - return (vec2(cos(_e213), sin(_e215)) * _e212); + let _e211 = (*i_4); + let _e214 = (*n_1); + r_3 = sqrt(((f32(_e211) + 0.5f) / f32(_e214))); + let _e218 = (*i_4); + let _e221 = (*turn); + theta = ((f32(_e218) * 2.3999631f) + _e221); + let _e223 = r_3; + let _e224 = theta; + let _e226 = theta; + return (vec2(cos(_e224), sin(_e226)) * _e223); } fn ShadowNoise_u0028_() -> f32 { var at: vec2; - var param_71: f32; + var param_77: f32; - let _e199 = frag_info.target_origin[0u]; - param_71 = _e199; - let _e200 = FragCoordFromTop_u0028_f1_u003b((¶m_71)); - let _e203 = frag_info.target_origin[3u]; - at = (_e200 + vec2((5.588238f * max(_e203, 0f)))); - let _e208 = at; - return fract((52.982918f * fract(dot(_e208, vec2(0.06711056f, 0.00583715f))))); + let _e210 = frag_info.target_origin[0u]; + param_77 = _e210; + let _e211 = FragCoordFromTop_u0028_f1_u003b((¶m_77)); + let _e214 = frag_info.target_origin[3u]; + at = (_e211 + vec2((5.588238f * max(_e214, 0f)))); + let _e219 = at; + return fract((52.982918f * fract(dot(_e219, vec2(0.06711056f, 0.00583715f))))); } fn EvsmChebyshev_u0028_vf2_u003b_f1_u003b_f1_u003b_f1_u003b(moments: ptr>, t: ptr, minVariance: ptr, bleed: ptr) -> f32 { @@ -12899,91 +14954,92 @@ fn EvsmChebyshev_u0028_vf2_u003b_f1_u003b_f1_u003b_f1_u003b(moments: ptr) -> vec2 { var d_3: f32; - let _e197 = (*depth); - d_3 = ((2f * clamp(_e197, 0f, 1f)) - 1f); - let _e201 = d_3; - let _e204 = d_3; - return vec2(exp((40f * _e201)), -(exp((-5f * _e204)))); + let _e208 = (*depth); + d_3 = ((2f * clamp(_e208, 0f, 1f)) - 1f); + let _e212 = d_3; + let _e215 = d_3; + return vec2(exp((40f * _e212)), -(exp((-5f * _e215)))); } fn EvsmVisibility_u0028_vf4_u003b_f1_u003b_f1_u003b(moments_1: ptr>, depth_1: ptr, bleed_1: ptr) -> f32 { var warped: vec2; - var param_72: f32; + var param_78: f32; var scale: vec2; var positive: f32; - var param_73: vec2; - var param_74: f32; - var param_75: f32; - var param_76: f32; - var negative: f32; - var param_77: vec2; - var param_78: f32; - var param_79: f32; + var param_79: vec2; var param_80: f32; + var param_81: f32; + var param_82: f32; + var negative: f32; + var param_83: vec2; + var param_84: f32; + var param_85: f32; + var param_86: f32; - let _e211 = (*depth_1); - param_72 = _e211; - let _e212 = EvsmWarp_u0028_f1_u003b((¶m_72)); - warped = _e212; - let _e213 = warped; - scale = (vec2(0.004f, 0.0005f) * _e213); - let _e216 = scale[0u]; - let _e218 = scale[0u]; - let _e220 = (*moments_1); - param_73 = _e220.xy; - let _e223 = warped[0u]; - param_74 = _e223; - param_75 = (_e216 * _e218); - let _e224 = (*bleed_1); - param_76 = _e224; - let _e225 = EvsmChebyshev_u0028_vf2_u003b_f1_u003b_f1_u003b_f1_u003b((¶m_73), (¶m_74), (¶m_75), (¶m_76)); - positive = _e225; - let _e227 = scale[1u]; - let _e229 = scale[1u]; + let _e222 = (*depth_1); + param_78 = _e222; + let _e223 = EvsmWarp_u0028_f1_u003b((¶m_78)); + warped = _e223; + let _e224 = warped; + scale = (vec2(0.004f, 0.0005f) * _e224); + let _e227 = scale[0u]; + let _e229 = scale[0u]; let _e231 = (*moments_1); - param_77 = _e231.zw; - let _e234 = warped[1u]; - param_78 = _e234; - param_79 = (_e227 * _e229); + param_79 = _e231.xy; + let _e234 = warped[0u]; + param_80 = _e234; + param_81 = (_e227 * _e229); let _e235 = (*bleed_1); - param_80 = _e235; - let _e236 = EvsmChebyshev_u0028_vf2_u003b_f1_u003b_f1_u003b_f1_u003b((¶m_77), (¶m_78), (¶m_79), (¶m_80)); - negative = _e236; - let _e237 = positive; - let _e238 = negative; - return min(_e237, _e238); -} - -fn ShadowFactor_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b_struct_u002d_LightSample_u002d_vf3_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf31_u003b_i1_u003b(s_3: ptr, light_1: ptr, lightIndex_1: ptr) -> f32 { + param_82 = _e235; + let _e236 = EvsmChebyshev_u0028_vf2_u003b_f1_u003b_f1_u003b_f1_u003b((¶m_79), (¶m_80), (¶m_81), (¶m_82)); + positive = _e236; + let _e238 = scale[1u]; + let _e240 = scale[1u]; + let _e242 = (*moments_1); + param_83 = _e242.zw; + let _e245 = warped[1u]; + param_84 = _e245; + param_85 = (_e238 * _e240); + let _e246 = (*bleed_1); + param_86 = _e246; + let _e247 = EvsmChebyshev_u0028_vf2_u003b_f1_u003b_f1_u003b_f1_u003b((¶m_83), (¶m_84), (¶m_85), (¶m_86)); + negative = _e247; + let _e248 = positive; + let _e249 = negative; + return min(_e248, _e249); +} + +fn ShadowFactor_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b_struct_u002d_LightSample_u002d_vf3_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf31_u003b_i1_u003b(s_4: ptr, light_1: ptr, lightIndex_1: ptr) -> f32 { var strength_1: f32; var cascadeCount: i32; var viewDistance: f32; + var local_9: f32; var cascade: i32; var uv_4: vec2; var projected: vec3; @@ -12994,8 +15050,8 @@ fn ShadowFactor_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf var attempt: i32; var which: i32; var matrix: mat4x4; - var local_5: mat4x4; - var local_6: mat4x4; + var local_10: mat4x4; + var local_11: mat4x4; var axisX: vec3; var axisY: vec3; var rowX: f32; @@ -13009,17 +15065,19 @@ fn ShadowFactor_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf var inTile: vec2; var cosSlope: f32; var bias: f32; - var local_7: f32; - var local_8: f32; + var local_12: f32; + var local_13: f32; var texel_1: vec2; var tileLo: vec2; var tileHi: vec2; + var stored_2: vec4; + var through: vec3; var softness: f32; - var lit_1: f32; + var lit_2: f32; var moments_2: vec4; - var param_81: vec4; - var param_82: f32; - var param_83: f32; + var param_87: vec4; + var param_88: f32; + var param_89: f32; var y_1: i32; var x_1: i32; var occluder: f32; @@ -13030,65 +15088,73 @@ fn ShadowFactor_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf var blockerCount: f32; var i_5: i32; var disc: vec2; - var param_84: i32; - var param_85: i32; - var param_86: f32; + var param_90: i32; + var param_91: i32; + var param_92: f32; var tapBias: f32; var occluder_1: f32; var gap: f32; var radius_2: f32; var i_6: i32; var disc_1: vec2; - var param_87: i32; - var param_88: i32; - var param_89: f32; + var param_93: i32; + var param_94: i32; + var param_95: f32; var tapBias_1: f32; var occluder_2: f32; - var phi_3608_: bool; - var phi_3619_: bool; - var phi_3780_: bool; - var phi_3787_: bool; - var phi_3794_: bool; - - let _e263 = frag_info.shadow_params[3u]; - strength_1 = _e263; - let _e264 = strength_1; - if (_e264 <= 0f) { + var phi_3877_: bool; + var phi_3888_: bool; + var phi_4049_: bool; + var phi_4056_: bool; + var phi_4063_: bool; + + let _e277 = frag_info.shadow_params[3u]; + strength_1 = _e277; + let _e278 = strength_1; + if (_e278 <= 0f) { return 1f; } - let _e266 = (*lightIndex_1); - let _e269 = frag_info.frame_params[2u]; - if (_e266 != i32((_e269 + 0.5f))) { + let _e280 = (*lightIndex_1); + let _e283 = frag_info.frame_params[2u]; + if (_e280 != i32((_e283 + 0.5f))) { return 1f; } - let _e275 = frag_info.shadow_cascades[2u]; - cascadeCount = i32((_e275 + 0.5f)); - let _e278 = v_world_position_1; - let _e280 = frag_info.camera_position; - viewDistance = length((_e278 - _e280.xyz)); - cascade = 0i; - let _e284 = cascadeCount; - let _e285 = (_e284 > 1i); - phi_3608_ = _e285; - if _e285 { - let _e286 = viewDistance; - let _e289 = frag_info.shadow_cascades[0u]; - phi_3608_ = (_e286 > _e289); - } - let _e292 = phi_3608_; + let _e289 = frag_info.shadow_cascades[2u]; + cascadeCount = i32((_e289 + 0.5f)); + let _e292 = Orthographic_u0028_(); if _e292 { + let _e293 = ViewDepth_u0028_(); + local_9 = _e293; + } else { + let _e294 = v_world_position_1; + let _e296 = frag_info.camera_position; + local_9 = length((_e294 - _e296.xyz)); + } + let _e300 = local_9; + viewDistance = _e300; + cascade = 0i; + let _e301 = cascadeCount; + let _e302 = (_e301 > 1i); + phi_3877_ = _e302; + if _e302 { + let _e303 = viewDistance; + let _e306 = frag_info.shadow_cascades[0u]; + phi_3877_ = (_e303 > _e306); + } + let _e309 = phi_3877_; + if _e309 { cascade = 1i; } - let _e293 = cascadeCount; - let _e294 = (_e293 > 2i); - phi_3619_ = _e294; - if _e294 { - let _e295 = viewDistance; - let _e298 = frag_info.shadow_cascades[1u]; - phi_3619_ = (_e295 > _e298); + let _e310 = cascadeCount; + let _e311 = (_e310 > 2i); + phi_3888_ = _e311; + if _e311 { + let _e312 = viewDistance; + let _e315 = frag_info.shadow_cascades[1u]; + phi_3888_ = (_e312 > _e315); } - let _e301 = phi_3619_; - if _e301 { + let _e318 = phi_3888_; + if _e318 { cascade = 2i; } uv_4 = vec2(0f, 0f); @@ -13099,232 +15165,247 @@ fn ShadowFactor_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf receiverSlope = 0f; attempt = 0i; loop { - let _e302 = attempt; - if (_e302 < 3i) { - let _e304 = cascade; - let _e305 = attempt; - which = (_e304 + _e305); - let _e307 = which; - let _e308 = cascadeCount; - if (_e307 >= _e308) { + let _e319 = attempt; + if (_e319 < 3i) { + let _e321 = cascade; + let _e322 = attempt; + which = (_e321 + _e322); + let _e324 = which; + let _e325 = cascadeCount; + if (_e324 >= _e325) { break; } - let _e310 = which; - if (_e310 == 0i) { - let _e313 = frag_info.shadow_matrix; - local_5 = _e313; + let _e327 = which; + if (_e327 == 0i) { + let _e330 = frag_info.shadow_matrix; + local_10 = _e330; } else { - let _e314 = which; - if (_e314 == 1i) { - let _e317 = frag_info.shadow_matrix_far; - local_6 = _e317; + let _e331 = which; + if (_e331 == 1i) { + let _e334 = frag_info.shadow_matrix_far; + local_11 = _e334; } else { - let _e319 = frag_info.shadow_matrix_farthest; - local_6 = _e319; + let _e336 = frag_info.shadow_matrix_farthest; + local_11 = _e336; } - let _e320 = local_6; - local_5 = _e320; - } - let _e321 = local_5; - matrix = _e321; - let _e324 = matrix[0][0u]; - let _e327 = matrix[1][0u]; - let _e330 = matrix[2][0u]; - axisX = vec3(_e324, _e327, _e330); - let _e334 = matrix[0][1u]; - let _e337 = matrix[1][1u]; - let _e340 = matrix[2][1u]; - axisY = vec3(_e334, _e337, _e340); - let _e342 = axisX; - rowX = max(length(_e342), 0.000001f); - let _e345 = axisY; - rowY = max(length(_e345), 0.000001f); - let _e350 = frag_info.shadow_cascades[3u]; - let _e352 = rowX; - texelMetres = ((2f * _e350) / _e352); - let _e356 = frag_info.shadow_cascades[3u]; - let _e359 = (*s_3).n; - let _e360 = axisX; - let _e363 = rowX; - let _e364 = rowX; - let _e368 = (*s_3).n; - let _e369 = axisY; - let _e372 = rowY; - let _e373 = rowY; - reach = ((3f * _e356) * ((abs(dot(_e359, _e360)) / (_e363 * _e364)) + (abs(dot(_e368, _e369)) / (_e372 * _e373)))); - let _e380 = frag_info.shadow_params[2u]; - let _e381 = texelMetres; - flatOffset = min(_e380, _e381); - let _e383 = v_world_position_1; - let _e385 = (*s_3).n; - let _e386 = flatOffset; - let _e387 = reach; - origin_1 = (_e383 + (_e385 * (_e386 + _e387))); - let _e391 = matrix; - let _e392 = origin_1; - lightSpace = (_e391 * vec4(_e392.x, _e392.y, _e392.z, 1f)); - let _e399 = lightSpace[3u]; - if (_e399 <= 0f) { + let _e337 = local_11; + local_10 = _e337; + } + let _e338 = local_10; + matrix = _e338; + let _e341 = matrix[0][0u]; + let _e344 = matrix[1][0u]; + let _e347 = matrix[2][0u]; + axisX = vec3(_e341, _e344, _e347); + let _e351 = matrix[0][1u]; + let _e354 = matrix[1][1u]; + let _e357 = matrix[2][1u]; + axisY = vec3(_e351, _e354, _e357); + let _e359 = axisX; + rowX = max(length(_e359), 0.000001f); + let _e362 = axisY; + rowY = max(length(_e362), 0.000001f); + let _e367 = frag_info.shadow_cascades[3u]; + let _e369 = rowX; + texelMetres = ((2f * _e367) / _e369); + let _e373 = frag_info.shadow_cascades[3u]; + let _e376 = (*s_4).n; + let _e377 = axisX; + let _e380 = rowX; + let _e381 = rowX; + let _e385 = (*s_4).n; + let _e386 = axisY; + let _e389 = rowY; + let _e390 = rowY; + reach = ((3f * _e373) * ((abs(dot(_e376, _e377)) / (_e380 * _e381)) + (abs(dot(_e385, _e386)) / (_e389 * _e390)))); + let _e397 = frag_info.shadow_params[2u]; + let _e398 = texelMetres; + flatOffset = min(_e397, _e398); + let _e400 = v_world_position_1; + let _e402 = (*s_4).n; + let _e403 = flatOffset; + let _e404 = reach; + origin_1 = (_e400 + (_e402 * (_e403 + _e404))); + let _e408 = matrix; + let _e409 = origin_1; + lightSpace = (_e408 * vec4(_e409.x, _e409.y, _e409.z, 1f)); + let _e416 = lightSpace[3u]; + if (_e416 <= 0f) { continue; } - let _e401 = lightSpace; - let _e404 = lightSpace[3u]; - candidate = (_e401.xyz / vec3(_e404)); - let _e408 = candidate[0u]; - let _e412 = candidate[1u]; - inTile = vec2(((_e408 * 0.5f) + 0.5f), (0.5f - (_e412 * 0.5f))); - let _e417 = inTile[0u]; - let _e418 = (_e417 < 0f); - phi_3780_ = _e418; - if !(_e418) { - let _e421 = inTile[0u]; - phi_3780_ = (_e421 > 1f); - } - let _e424 = phi_3780_; - phi_3787_ = _e424; - if !(_e424) { - let _e427 = inTile[1u]; - phi_3787_ = (_e427 < 0f); - } - let _e430 = phi_3787_; - phi_3794_ = _e430; - if !(_e430) { - let _e433 = inTile[1u]; - phi_3794_ = (_e433 > 1f); - } - let _e436 = phi_3794_; - if _e436 { + let _e418 = lightSpace; + let _e421 = lightSpace[3u]; + candidate = (_e418.xyz / vec3(_e421)); + let _e425 = candidate[0u]; + let _e429 = candidate[1u]; + inTile = vec2(((_e425 * 0.5f) + 0.5f), (0.5f - (_e429 * 0.5f))); + let _e434 = inTile[0u]; + let _e435 = (_e434 < 0f); + phi_4049_ = _e435; + if !(_e435) { + let _e438 = inTile[0u]; + phi_4049_ = (_e438 > 1f); + } + let _e441 = phi_4049_; + phi_4056_ = _e441; + if !(_e441) { + let _e444 = inTile[1u]; + phi_4056_ = (_e444 < 0f); + } + let _e447 = phi_4056_; + phi_4063_ = _e447; + if !(_e447) { + let _e450 = inTile[1u]; + phi_4063_ = (_e450 > 1f); + } + let _e453 = phi_4063_; + if _e453 { continue; } - let _e438 = candidate[2u]; - if (_e438 > 1f) { - let _e440 = which; - let _e441 = cascadeCount; - if (_e440 < (_e441 - 1i)) { + let _e455 = candidate[2u]; + if (_e455 > 1f) { + let _e457 = which; + let _e458 = cascadeCount; + if (_e457 < (_e458 - 1i)) { continue; } candidate[2u] = 1f; } - let _e446 = inTile[0u]; - let _e447 = which; - let _e450 = cascadeCount; - let _e454 = inTile[1u]; - uv_4 = vec2(((_e446 + f32(_e447)) / f32(_e450)), _e454); - let _e456 = candidate; - projected = _e456; - let _e457 = which; - cascade = _e457; - let _e458 = texelMetres; - cascadeTexel = _e458; - let _e461 = matrix[0][2u]; - let _e464 = matrix[1][2u]; - let _e467 = matrix[2][2u]; - cascadeDepth = (1f / max(length(vec3(_e461, _e464, _e467)), 0.000001f)); - let _e473 = (*s_3).n; - let _e476 = matrix[0][2u]; - let _e479 = matrix[1][2u]; - let _e482 = matrix[2][2u]; - let _e486 = cascadeDepth; - cosSlope = clamp((abs(dot(_e473, vec3(_e476, _e479, _e482))) * _e486), 0.1f, 1f); - let _e489 = texelMetres; - let _e490 = cosSlope; - let _e491 = cosSlope; - let _e497 = cosSlope; - let _e499 = cascadeDepth; - receiverSlope = (((_e489 * sqrt(max((1f - (_e490 * _e491)), 0f))) / _e497) / _e499); + let _e463 = inTile[0u]; + let _e464 = which; + let _e467 = cascadeCount; + let _e471 = inTile[1u]; + uv_4 = vec2(((_e463 + f32(_e464)) / f32(_e467)), _e471); + let _e473 = candidate; + projected = _e473; + let _e474 = which; + cascade = _e474; + let _e475 = texelMetres; + cascadeTexel = _e475; + let _e478 = matrix[0][2u]; + let _e481 = matrix[1][2u]; + let _e484 = matrix[2][2u]; + cascadeDepth = (1f / max(length(vec3(_e478, _e481, _e484)), 0.000001f)); + let _e490 = (*s_4).n; + let _e493 = matrix[0][2u]; + let _e496 = matrix[1][2u]; + let _e499 = matrix[2][2u]; + let _e503 = cascadeDepth; + cosSlope = clamp((abs(dot(_e490, vec3(_e493, _e496, _e499))) * _e503), 0.1f, 1f); + let _e506 = texelMetres; + let _e507 = cosSlope; + let _e508 = cosSlope; + let _e514 = cosSlope; + let _e516 = cascadeDepth; + receiverSlope = (((_e506 * sqrt(max((1f - (_e507 * _e508)), 0f))) / _e514) / _e516); found = true; break; } else { break; } continuing { - let _e501 = attempt; - attempt = (_e501 + 1i); + let _e518 = attempt; + attempt = (_e518 + 1i); } } - let _e503 = found; - if !(_e503) { + let _e520 = found; + if !(_e520) { return 1f; } - let _e505 = cascade; - if (_e505 == 0i) { - let _e509 = frag_info.shadow_bias[0u]; - local_7 = _e509; + let _e522 = cascade; + if (_e522 == 0i) { + let _e526 = frag_info.shadow_bias[0u]; + local_12 = _e526; } else { - let _e510 = cascade; - if (_e510 == 1i) { - let _e514 = frag_info.shadow_bias[1u]; - local_8 = _e514; + let _e527 = cascade; + if (_e527 == 1i) { + let _e531 = frag_info.shadow_bias[1u]; + local_13 = _e531; } else { - let _e517 = frag_info.shadow_bias[2u]; - local_8 = _e517; - } - let _e518 = local_8; - local_7 = _e518; - } - let _e519 = local_7; - bias = _e519; - let _e522 = frag_info.shadow_params[0u]; - let _e525 = frag_info.shadow_cascades[3u]; - texel_1 = vec2(_e522, _e525); - let _e527 = cascade; - let _e529 = cascadeCount; - let _e533 = texel_1; - tileLo = (vec2((f32(_e527) / f32(_e529)), 0f) + (_e533 * 0.5f)); - let _e536 = cascade; - let _e539 = cascadeCount; - let _e543 = texel_1; - tileHi = (vec2((f32((_e536 + 1i)) / f32(_e539)), 1f) - (_e543 * 0.5f)); - let _e548 = frag_info.ambient_ground[3u]; - softness = _e548; - lit_1 = 0f; - let _e549 = softness; - if (_e549 < 0f) { - let _e551 = uv_4; - let _e552 = tileLo; - let _e553 = tileHi; - let _e555 = textureSampleLevel(shadow_texture_tex, shadow_texture_smp, clamp(_e551, _e552, _e553), 0f); - moments_2 = _e555; - let _e557 = projected[2u]; - let _e558 = bias; - let _e560 = softness; - let _e564 = moments_2; - param_81 = _e564; - param_82 = (_e557 - _e558); - param_83 = clamp((-(_e560) - 1f), 0f, 0.95f); - let _e565 = EvsmVisibility_u0028_vf4_u003b_f1_u003b_f1_u003b((¶m_81), (¶m_82), (¶m_83)); - lit_1 = _e565; - } else { - let _e566 = softness; - if (_e566 <= 0f) { + let _e534 = frag_info.shadow_bias[2u]; + local_13 = _e534; + } + let _e535 = local_13; + local_12 = _e535; + } + let _e536 = local_12; + bias = _e536; + let _e539 = frag_info.shadow_params[0u]; + let _e542 = frag_info.shadow_cascades[3u]; + texel_1 = vec2(_e539, _e542); + let _e544 = cascade; + let _e546 = cascadeCount; + let _e550 = texel_1; + tileLo = (vec2((f32(_e544) / f32(_e546)), 0f) + (_e550 * 0.5f)); + let _e553 = cascade; + let _e556 = cascadeCount; + let _e560 = texel_1; + tileHi = (vec2((f32((_e553 + 1i)) / f32(_e556)), 1f) - (_e560 * 0.5f)); + let _e565 = frag_info.shadow_bias[3u]; + if (_e565 > 0.5f) { + let _e567 = uv_4; + let _e568 = tileLo; + let _e569 = tileHi; + let _e571 = textureSampleLevel(shadow_texture_tex, shadow_texture_smp, clamp(_e567, _e568, _e569), 0f); + stored_2 = _e571; + let _e573 = stored_2[1u]; + let _e576 = stored_2[2u]; + let _e579 = stored_2[3u]; + through = clamp(vec3((1f - _e573), (1f - _e576), _e579), vec3(0f), vec3(4f)); + let _e584 = through; + let _e585 = strength_1; + light_transmittance = mix(vec3(1f, 1f, 1f), _e584, vec3(clamp(_e585, 0f, 1f))); + } + let _e591 = frag_info.ambient_ground[3u]; + softness = _e591; + lit_2 = 0f; + let _e592 = softness; + if (_e592 < 0f) { + let _e594 = uv_4; + let _e595 = tileLo; + let _e596 = tileHi; + let _e598 = textureSampleLevel(shadow_texture_tex, shadow_texture_smp, clamp(_e594, _e595, _e596), 0f); + moments_2 = _e598; + let _e600 = projected[2u]; + let _e601 = bias; + let _e603 = softness; + let _e607 = moments_2; + param_87 = _e607; + param_88 = (_e600 - _e601); + param_89 = clamp((-(_e603) - 1f), 0f, 0.95f); + let _e608 = EvsmVisibility_u0028_vf4_u003b_f1_u003b_f1_u003b((¶m_87), (¶m_88), (¶m_89)); + lit_2 = _e608; + } else { + let _e609 = softness; + if (_e609 <= 0f) { y_1 = -1i; loop { - let _e568 = y_1; - if (_e568 <= 1i) { + let _e611 = y_1; + if (_e611 <= 1i) { x_1 = -1i; loop { - let _e570 = x_1; - if (_e570 <= 1i) { - let _e572 = uv_4; - let _e573 = x_1; - let _e575 = y_1; - let _e578 = texel_1; - let _e581 = tileLo; - let _e582 = tileHi; - let _e584 = textureSampleLevel(shadow_texture_tex, shadow_texture_smp, clamp((_e572 + (vec2(f32(_e573), f32(_e575)) * _e578)), _e581, _e582), 0f); - occluder = _e584.x; - let _e587 = projected[2u]; - let _e588 = bias; - let _e590 = occluder; - let _e593 = lit_1; - lit_1 = (_e593 + select(1f, 0f, ((_e587 - _e588) > _e590))); + let _e613 = x_1; + if (_e613 <= 1i) { + let _e615 = uv_4; + let _e616 = x_1; + let _e618 = y_1; + let _e621 = texel_1; + let _e624 = tileLo; + let _e625 = tileHi; + let _e627 = textureSampleLevel(shadow_texture_tex, shadow_texture_smp, clamp((_e615 + (vec2(f32(_e616), f32(_e618)) * _e621)), _e624, _e625), 0f); + occluder = _e627.x; + let _e630 = projected[2u]; + let _e631 = bias; + let _e633 = occluder; + let _e636 = lit_2; + lit_2 = (_e636 + select(1f, 0f, ((_e630 - _e631) > _e633))); continue; } else { break; } continuing { - let _e595 = x_1; - x_1 = (_e595 + 1i); + let _e638 = x_1; + x_1 = (_e638 + 1i); } } continue; @@ -13332,145 +15413,145 @@ fn ShadowFactor_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf break; } continuing { - let _e597 = y_1; - y_1 = (_e597 + 1i); + let _e640 = y_1; + y_1 = (_e640 + 1i); } } - let _e599 = lit_1; - lit_1 = (_e599 * 0.11111111f); + let _e642 = lit_2; + lit_2 = (_e642 * 0.11111111f); } else { - let _e601 = softness; - spread = tan(min(_e601, 0.5f)); - let _e604 = ShadowNoise_u0028_(); - turn_1 = (_e604 * 6.2831855f); - let _e606 = spread; - let _e608 = projected[2u]; - let _e610 = cascadeDepth; - let _e612 = cascadeTexel; - searchRadius = clamp((((_e606 * _e608) * _e610) / _e612), 1f, 16f); + let _e644 = softness; + spread = tan(min(_e644, 0.5f)); + let _e647 = ShadowNoise_u0028_(); + turn_1 = (_e647 * 6.2831855f); + let _e649 = spread; + let _e651 = projected[2u]; + let _e653 = cascadeDepth; + let _e655 = cascadeTexel; + searchRadius = clamp((((_e649 * _e651) * _e653) / _e655), 1f, 16f); blockerSum = 0f; blockerCount = 0f; i_5 = 0i; loop { - let _e615 = i_5; - if (_e615 < 16i) { - let _e617 = i_5; - param_84 = _e617; - param_85 = 16i; - let _e618 = turn_1; - param_86 = _e618; - let _e619 = VogelDisc_u0028_i1_u003b_i1_u003b_f1_u003b((¶m_84), (¶m_85), (¶m_86)); - disc = _e619; - let _e620 = bias; - let _e621 = disc; - let _e623 = searchRadius; - let _e627 = receiverSlope; - tapBias = (_e620 + (max(((length(_e621) * _e623) - 1.5f), 0f) * _e627)); - let _e630 = uv_4; - let _e631 = disc; - let _e632 = texel_1; - let _e634 = searchRadius; - let _e637 = tileLo; - let _e638 = tileHi; - let _e640 = textureSampleLevel(shadow_texture_tex, shadow_texture_smp, clamp((_e630 + ((_e631 * _e632) * _e634)), _e637, _e638), 0f); - occluder_1 = _e640.x; - let _e643 = projected[2u]; - let _e644 = tapBias; - let _e646 = occluder_1; - if ((_e643 - _e644) > _e646) { - let _e648 = occluder_1; - let _e649 = blockerSum; - blockerSum = (_e649 + _e648); - let _e651 = blockerCount; - blockerCount = (_e651 + 1f); + let _e658 = i_5; + if (_e658 < 16i) { + let _e660 = i_5; + param_90 = _e660; + param_91 = 16i; + let _e661 = turn_1; + param_92 = _e661; + let _e662 = VogelDisc_u0028_i1_u003b_i1_u003b_f1_u003b((¶m_90), (¶m_91), (¶m_92)); + disc = _e662; + let _e663 = bias; + let _e664 = disc; + let _e666 = searchRadius; + let _e670 = receiverSlope; + tapBias = (_e663 + (max(((length(_e664) * _e666) - 1.5f), 0f) * _e670)); + let _e673 = uv_4; + let _e674 = disc; + let _e675 = texel_1; + let _e677 = searchRadius; + let _e680 = tileLo; + let _e681 = tileHi; + let _e683 = textureSampleLevel(shadow_texture_tex, shadow_texture_smp, clamp((_e673 + ((_e674 * _e675) * _e677)), _e680, _e681), 0f); + occluder_1 = _e683.x; + let _e686 = projected[2u]; + let _e687 = tapBias; + let _e689 = occluder_1; + if ((_e686 - _e687) > _e689) { + let _e691 = occluder_1; + let _e692 = blockerSum; + blockerSum = (_e692 + _e691); + let _e694 = blockerCount; + blockerCount = (_e694 + 1f); } continue; } else { break; } continuing { - let _e653 = i_5; - i_5 = (_e653 + 1i); + let _e696 = i_5; + i_5 = (_e696 + 1i); } } - let _e655 = blockerCount; - if (_e655 <= 0f) { + let _e698 = blockerCount; + if (_e698 <= 0f) { return 1f; } - let _e658 = projected[2u]; - let _e659 = blockerSum; - let _e660 = blockerCount; - let _e664 = cascadeDepth; - gap = (max((_e658 - (_e659 / _e660)), 0f) * _e664); - let _e666 = spread; - let _e667 = gap; - let _e669 = cascadeTexel; - radius_2 = clamp(((_e666 * _e667) / _e669), 1f, 16f); + let _e701 = projected[2u]; + let _e702 = blockerSum; + let _e703 = blockerCount; + let _e707 = cascadeDepth; + gap = (max((_e701 - (_e702 / _e703)), 0f) * _e707); + let _e709 = spread; + let _e710 = gap; + let _e712 = cascadeTexel; + radius_2 = clamp(((_e709 * _e710) / _e712), 1f, 16f); i_6 = 0i; loop { - let _e672 = i_6; - if (_e672 < 16i) { - let _e674 = turn_1; - let _e676 = i_6; - param_87 = _e676; - param_88 = 16i; - param_89 = (_e674 + 1f); - let _e677 = VogelDisc_u0028_i1_u003b_i1_u003b_f1_u003b((¶m_87), (¶m_88), (¶m_89)); - disc_1 = _e677; - let _e678 = bias; - let _e679 = disc_1; - let _e681 = radius_2; - let _e685 = receiverSlope; - tapBias_1 = (_e678 + (max(((length(_e679) * _e681) - 1.5f), 0f) * _e685)); - let _e688 = uv_4; - let _e689 = disc_1; - let _e690 = texel_1; - let _e692 = radius_2; - let _e695 = tileLo; - let _e696 = tileHi; - let _e698 = textureSampleLevel(shadow_texture_tex, shadow_texture_smp, clamp((_e688 + ((_e689 * _e690) * _e692)), _e695, _e696), 0f); - occluder_2 = _e698.x; - let _e701 = projected[2u]; - let _e702 = tapBias_1; - let _e704 = occluder_2; - let _e707 = lit_1; - lit_1 = (_e707 + select(1f, 0f, ((_e701 - _e702) > _e704))); + let _e715 = i_6; + if (_e715 < 16i) { + let _e717 = turn_1; + let _e719 = i_6; + param_93 = _e719; + param_94 = 16i; + param_95 = (_e717 + 1f); + let _e720 = VogelDisc_u0028_i1_u003b_i1_u003b_f1_u003b((¶m_93), (¶m_94), (¶m_95)); + disc_1 = _e720; + let _e721 = bias; + let _e722 = disc_1; + let _e724 = radius_2; + let _e728 = receiverSlope; + tapBias_1 = (_e721 + (max(((length(_e722) * _e724) - 1.5f), 0f) * _e728)); + let _e731 = uv_4; + let _e732 = disc_1; + let _e733 = texel_1; + let _e735 = radius_2; + let _e738 = tileLo; + let _e739 = tileHi; + let _e741 = textureSampleLevel(shadow_texture_tex, shadow_texture_smp, clamp((_e731 + ((_e732 * _e733) * _e735)), _e738, _e739), 0f); + occluder_2 = _e741.x; + let _e744 = projected[2u]; + let _e745 = tapBias_1; + let _e747 = occluder_2; + let _e750 = lit_2; + lit_2 = (_e750 + select(1f, 0f, ((_e744 - _e745) > _e747))); continue; } else { break; } continuing { - let _e709 = i_6; - i_6 = (_e709 + 1i); + let _e752 = i_6; + i_6 = (_e752 + 1i); } } - let _e711 = lit_1; - lit_1 = (_e711 * 0.0625f); + let _e754 = lit_2; + lit_2 = (_e754 * 0.0625f); } } - let _e713 = lit_1; - let _e714 = strength_1; - return mix(1f, _e713, clamp(_e714, 0f, 1f)); + let _e756 = lit_2; + let _e757 = strength_1; + return mix(1f, _e756, clamp(_e757, 0f, 1f)); } -fn LightVisibility_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b_struct_u002d_LightSample_u002d_vf3_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf31_u003b_i1_u003b(s_4: ptr, light_2: ptr, index: ptr) -> f32 { - var param_90: Surface; - var param_91: LightSample; - var param_92: i32; +fn LightVisibility_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b_struct_u002d_LightSample_u002d_vf3_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf31_u003b_i1_u003b(s_5: ptr, light_2: ptr, index: ptr) -> f32 { + var param_96: Surface; + var param_97: LightSample; + var param_98: i32; - let _e201 = (*s_4); - param_90 = _e201; - let _e202 = (*light_2); - param_91 = _e202; - let _e203 = (*index); - param_92 = _e203; - let _e204 = ShadowFactor_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b_struct_u002d_LightSample_u002d_vf3_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf31_u003b_i1_u003b((¶m_90), (¶m_91), (¶m_92)); - return _e204; + let _e212 = (*s_5); + param_96 = _e212; + let _e213 = (*light_2); + param_97 = _e213; + let _e214 = (*index); + param_98 = _e214; + let _e215 = ShadowFactor_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b_struct_u002d_LightSample_u002d_vf3_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf31_u003b_i1_u003b((¶m_96), (¶m_97), (¶m_98)); + return _e215; } fn LightHasShadow_u0028_i1_u003b(index_1: ptr) -> bool { - let _e196 = (*index_1); - return (_e196 < 8i); + let _e207 = (*index_1); + return (_e207 < 8i); } fn PunctualAttenuation_u0028_f1_u003b_f1_u003b(distance_1: ptr, range_4: ptr) -> f32 { @@ -13478,211 +15559,211 @@ fn PunctualAttenuation_u0028_f1_u003b_f1_u003b(distance_1: ptr, r var ratio: f32; var window: f32; - let _e200 = (*distance_1); - let _e201 = (*distance_1); - attenuation = (1f / max((_e200 * _e201), 0.0001f)); - let _e205 = (*range_4); - if (_e205 > 0f) { - let _e207 = (*distance_1); - let _e208 = (*range_4); - ratio = (_e207 / _e208); - let _e210 = ratio; - let _e211 = ratio; - let _e213 = ratio; - let _e215 = ratio; - window = clamp((1f - (((_e210 * _e211) * _e213) * _e215)), 0f, 1f); - let _e219 = window; - let _e220 = window; - let _e222 = attenuation; - attenuation = (_e222 * (_e219 * _e220)); - } - let _e224 = attenuation; - return _e224; + let _e211 = (*distance_1); + let _e212 = (*distance_1); + attenuation = (1f / max((_e211 * _e212), 0.0001f)); + let _e216 = (*range_4); + if (_e216 > 0f) { + let _e218 = (*distance_1); + let _e219 = (*range_4); + ratio = (_e218 / _e219); + let _e221 = ratio; + let _e222 = ratio; + let _e224 = ratio; + let _e226 = ratio; + window = clamp((1f - (((_e221 * _e222) * _e224) * _e226)), 0f, 1f); + let _e230 = window; + let _e231 = window; + let _e233 = attenuation; + attenuation = (_e233 * (_e230 * _e231)); + } + let _e235 = attenuation; + return _e235; } fn LtcEdge_u0028_vf3_u003b_vf3_u003b(a_2: ptr>, b: ptr>) -> vec3 { var axis: vec3; var len: f32; var angle_1: f32; - var local_9: vec3; - - let _e201 = (*a_2); - let _e202 = (*b); - axis = cross(_e201, _e202); - let _e204 = axis; - len = length(_e204); - let _e206 = (*a_2); - let _e207 = (*b); - angle_1 = acos(clamp(dot(_e206, _e207), -1f, 1f)); - let _e211 = len; - if (_e211 > 0.000001f) { - let _e213 = axis; - let _e214 = angle_1; - let _e215 = len; - local_9 = (_e213 * (_e214 / (_e215 * 6.2831855f))); - } else { - local_9 = vec3(0f, 0f, 0f); - } - let _e219 = local_9; - return _e219; + var local_14: vec3; + + let _e212 = (*a_2); + let _e213 = (*b); + axis = cross(_e212, _e213); + let _e215 = axis; + len = length(_e215); + let _e217 = (*a_2); + let _e218 = (*b); + angle_1 = acos(clamp(dot(_e217, _e218), -1f, 1f)); + let _e222 = len; + if (_e222 > 0.000001f) { + let _e224 = axis; + let _e225 = angle_1; + let _e226 = len; + local_14 = (_e224 * (_e225 / (_e226 * 6.2831855f))); + } else { + local_14 = vec3(0f, 0f, 0f); + } + let _e230 = local_14; + return _e230; } fn LtcRectangle_u0028_vf3_u003b_vf3_u003b_f1_u003b_vf3_u005b_4_u005d_u003b(n_2: ptr>, v: ptr>, roughness_3: ptr, corners: ptr, 4>>) -> vec3 { var uv_5: vec2; var inverse: vec4; - var param_93: f32; - var param_94: f32; - var param_95: f32; + var param_99: f32; + var param_100: f32; + var param_101: f32; var fit: vec4; - var param_96: f32; - var param_97: f32; - var param_98: f32; + var param_102: f32; + var param_103: f32; + var param_104: f32; var along: vec3; var alongLength: f32; var t1_: vec3; - var local_10: vec3; + var local_15: vec3; var t2_: vec3; var minv: mat3x3; var i_7: i32; var p_1: vec3; var l: array, 4>; var f_2: vec3; - var param_99: vec3; - var param_100: vec3; - var param_101: vec3; - var param_102: vec3; - var param_103: vec3; - var param_104: vec3; var param_105: vec3; var param_106: vec3; + var param_107: vec3; + var param_108: vec3; + var param_109: vec3; + var param_110: vec3; + var param_111: vec3; + var param_112: vec3; var len_1: f32; var z_1: f32; - var local_11: f32; + var local_16: f32; var sphere: f32; - var param_107: f32; - var param_108: f32; - var param_109: f32; + var param_113: f32; + var param_114: f32; + var param_115: f32; - let _e233 = (*roughness_3); - let _e235 = (*n_2); - let _e236 = (*v); - uv_5 = vec2(clamp(_e233, 0f, 1f), sqrt(clamp((1f - dot(_e235, _e236)), 0f, 1f))); - let _e243 = uv_5[0u]; - param_93 = _e243; - let _e245 = uv_5[1u]; - param_94 = _e245; - param_95 = 0f; - let _e246 = LtcUv_u0028_f1_u003b_f1_u003b_f1_u003b((¶m_93), (¶m_94), (¶m_95)); - let _e247 = textureSampleLevel(ltc_texture_tex, ltc_texture_smp, _e246, 0f); - inverse = _e247; - let _e249 = uv_5[0u]; - param_96 = _e249; - let _e251 = uv_5[1u]; - param_97 = _e251; - param_98 = 1f; - let _e252 = LtcUv_u0028_f1_u003b_f1_u003b_f1_u003b((¶m_96), (¶m_97), (¶m_98)); - let _e253 = textureSampleLevel(ltc_texture_tex, ltc_texture_smp, _e252, 0f); - fit = _e253; - let _e254 = (*v); - let _e255 = (*n_2); - let _e256 = (*v); - let _e257 = (*n_2); - along = (_e254 - (_e255 * dot(_e256, _e257))); - let _e261 = along; - alongLength = length(_e261); - let _e263 = alongLength; - if (_e263 > 0.00001f) { - let _e265 = along; - let _e266 = alongLength; - local_10 = (_e265 / vec3(_e266)); - } else { - let _e269 = (*n_2); - let _e271 = (*n_2)[2u]; - local_10 = normalize(cross(_e269, select(vec3(1f, 0f, 0f), vec3(0f, 0f, 1f), vec3((abs(_e271) < 0.999f))))); - } - let _e278 = local_10; - t1_ = _e278; - let _e279 = (*n_2); - let _e280 = t1_; - t2_ = cross(_e279, _e280); - let _e283 = inverse[0u]; - let _e285 = inverse[1u]; - let _e286 = vec3(_e283, 0f, _e285); - let _e288 = inverse[2u]; - let _e290 = inverse[3u]; - let _e291 = vec3(_e288, 0f, _e290); - minv = mat3x3(vec3(_e286.x, _e286.y, _e286.z), vec3(vec3(0f, 1f, 0f).x, vec3(0f, 1f, 0f).y, vec3(0f, 1f, 0f).z), vec3(_e291.x, _e291.y, _e291.z)); + let _e244 = (*roughness_3); + let _e246 = (*n_2); + let _e247 = (*v); + uv_5 = vec2(clamp(_e244, 0f, 1f), sqrt(clamp((1f - dot(_e246, _e247)), 0f, 1f))); + let _e254 = uv_5[0u]; + param_99 = _e254; + let _e256 = uv_5[1u]; + param_100 = _e256; + param_101 = 0f; + let _e257 = LtcUv_u0028_f1_u003b_f1_u003b_f1_u003b((¶m_99), (¶m_100), (¶m_101)); + let _e258 = textureSampleLevel(ltc_texture_tex, ltc_texture_smp, _e257, 0f); + inverse = _e258; + let _e260 = uv_5[0u]; + param_102 = _e260; + let _e262 = uv_5[1u]; + param_103 = _e262; + param_104 = 1f; + let _e263 = LtcUv_u0028_f1_u003b_f1_u003b_f1_u003b((¶m_102), (¶m_103), (¶m_104)); + let _e264 = textureSampleLevel(ltc_texture_tex, ltc_texture_smp, _e263, 0f); + fit = _e264; + let _e265 = (*v); + let _e266 = (*n_2); + let _e267 = (*v); + let _e268 = (*n_2); + along = (_e265 - (_e266 * dot(_e267, _e268))); + let _e272 = along; + alongLength = length(_e272); + let _e274 = alongLength; + if (_e274 > 0.00001f) { + let _e276 = along; + let _e277 = alongLength; + local_15 = (_e276 / vec3(_e277)); + } else { + let _e280 = (*n_2); + let _e282 = (*n_2)[2u]; + local_15 = normalize(cross(_e280, select(vec3(1f, 0f, 0f), vec3(0f, 0f, 1f), vec3((abs(_e282) < 0.999f))))); + } + let _e289 = local_15; + t1_ = _e289; + let _e290 = (*n_2); + let _e291 = t1_; + t2_ = cross(_e290, _e291); + let _e294 = inverse[0u]; + let _e296 = inverse[1u]; + let _e297 = vec3(_e294, 0f, _e296); + let _e299 = inverse[2u]; + let _e301 = inverse[3u]; + let _e302 = vec3(_e299, 0f, _e301); + minv = mat3x3(vec3(_e297.x, _e297.y, _e297.z), vec3(vec3(0f, 1f, 0f).x, vec3(0f, 1f, 0f).y, vec3(0f, 1f, 0f).z), vec3(_e302.x, _e302.y, _e302.z)); i_7 = 0i; loop { - let _e305 = i_7; - if (_e305 < 4i) { - let _e307 = i_7; - let _e309 = (*corners)[_e307]; - p_1 = _e309; - let _e310 = i_7; - let _e311 = minv; - let _e312 = p_1; - let _e313 = t1_; - let _e315 = p_1; - let _e316 = t2_; - let _e318 = p_1; - let _e319 = (*n_2); - l[_e310] = normalize((_e311 * vec3(dot(_e312, _e313), dot(_e315, _e316), dot(_e318, _e319)))); + let _e316 = i_7; + if (_e316 < 4i) { + let _e318 = i_7; + let _e320 = (*corners)[_e318]; + p_1 = _e320; + let _e321 = i_7; + let _e322 = minv; + let _e323 = p_1; + let _e324 = t1_; + let _e326 = p_1; + let _e327 = t2_; + let _e329 = p_1; + let _e330 = (*n_2); + l[_e321] = normalize((_e322 * vec3(dot(_e323, _e324), dot(_e326, _e327), dot(_e329, _e330)))); continue; } else { break; } continuing { - let _e325 = i_7; - i_7 = (_e325 + 1i); - } - } - let _e328 = l[0i]; - param_99 = _e328; - let _e330 = l[1i]; - param_100 = _e330; - let _e331 = LtcEdge_u0028_vf3_u003b_vf3_u003b((¶m_99), (¶m_100)); - let _e333 = l[1i]; - param_101 = _e333; - let _e335 = l[2i]; - param_102 = _e335; - let _e336 = LtcEdge_u0028_vf3_u003b_vf3_u003b((¶m_101), (¶m_102)); - let _e339 = l[2i]; - param_103 = _e339; - let _e341 = l[3i]; - param_104 = _e341; - let _e342 = LtcEdge_u0028_vf3_u003b_vf3_u003b((¶m_103), (¶m_104)); - let _e345 = l[3i]; - param_105 = _e345; - let _e347 = l[0i]; - param_106 = _e347; - let _e348 = LtcEdge_u0028_vf3_u003b_vf3_u003b((¶m_105), (¶m_106)); - f_2 = -((((_e331 + _e336) + _e342) + _e348)); - let _e351 = f_2; - len_1 = length(_e351); - let _e353 = len_1; - if (_e353 > 0.000000001f) { - let _e356 = f_2[2u]; - let _e357 = len_1; - local_11 = (_e356 / _e357); + let _e336 = i_7; + i_7 = (_e336 + 1i); + } + } + let _e339 = l[0i]; + param_105 = _e339; + let _e341 = l[1i]; + param_106 = _e341; + let _e342 = LtcEdge_u0028_vf3_u003b_vf3_u003b((¶m_105), (¶m_106)); + let _e344 = l[1i]; + param_107 = _e344; + let _e346 = l[2i]; + param_108 = _e346; + let _e347 = LtcEdge_u0028_vf3_u003b_vf3_u003b((¶m_107), (¶m_108)); + let _e350 = l[2i]; + param_109 = _e350; + let _e352 = l[3i]; + param_110 = _e352; + let _e353 = LtcEdge_u0028_vf3_u003b_vf3_u003b((¶m_109), (¶m_110)); + let _e356 = l[3i]; + param_111 = _e356; + let _e358 = l[0i]; + param_112 = _e358; + let _e359 = LtcEdge_u0028_vf3_u003b_vf3_u003b((¶m_111), (¶m_112)); + f_2 = -((((_e342 + _e347) + _e353) + _e359)); + let _e362 = f_2; + len_1 = length(_e362); + let _e364 = len_1; + if (_e364 > 0.000000001f) { + let _e367 = f_2[2u]; + let _e368 = len_1; + local_16 = (_e367 / _e368); } else { - local_11 = 0f; - } - let _e359 = local_11; - z_1 = _e359; - let _e360 = z_1; - let _e363 = len_1; - param_107 = ((_e360 * 0.5f) + 0.5f); - param_108 = clamp(_e363, 0f, 1f); - param_109 = 1f; - let _e365 = LtcUv_u0028_f1_u003b_f1_u003b_f1_u003b((¶m_107), (¶m_108), (¶m_109)); - let _e366 = textureSampleLevel(ltc_texture_tex, ltc_texture_smp, _e365, 0f); - sphere = _e366.w; - let _e368 = len_1; - let _e369 = sphere; - let _e373 = fit[0u]; - let _e375 = fit[1u]; - return vec3(max((_e368 * _e369), 0f), _e373, _e375); + local_16 = 0f; + } + let _e370 = local_16; + z_1 = _e370; + let _e371 = z_1; + let _e374 = len_1; + param_113 = ((_e371 * 0.5f) + 0.5f); + param_114 = clamp(_e374, 0f, 1f); + param_115 = 1f; + let _e376 = LtcUv_u0028_f1_u003b_f1_u003b_f1_u003b((¶m_113), (¶m_114), (¶m_115)); + let _e377 = textureSampleLevel(ltc_texture_tex, ltc_texture_smp, _e376, 0f); + sphere = _e377.w; + let _e379 = len_1; + let _e380 = sphere; + let _e384 = fit[0u]; + let _e386 = fit[1u]; + return vec3(max((_e379 * _e380), 0f), _e384, _e386); } fn RectangleClosestPoint_u0028_vf3_u003b_vf3_u003b_vf3_u003b_vf3_u003b_vf3_u003b(centre: ptr>, halfWidth: ptr>, halfHeight: ptr>, world_2: ptr>, mirror: ptr>) -> vec3 { @@ -13692,82 +15773,82 @@ fn RectangleClosestPoint_u0028_vf3_u003b_vf3_u003b_vf3_u003b_vf3_u003b_vf3_u003b var denom: f32; var onPlane: vec3; var t_1: f32; - var local_12: vec3; + var local_17: vec3; var offset_2: vec3; var wLen2_: f32; var hLen2_: f32; var u: f32; var v_1: f32; - let _e212 = (*halfWidth); - let _e213 = (*halfHeight); - n_3 = cross(_e212, _e213); - let _e215 = n_3; - nLen = length(_e215); - let _e217 = nLen; - if (_e217 < 0.000000000001f) { - let _e219 = (*centre); - return _e219; - } - let _e220 = nLen; - let _e221 = n_3; - n_3 = (_e221 / vec3(_e220)); - let _e224 = (*centre); - let _e225 = (*world_2); - toPlane = (_e224 - _e225); - let _e227 = (*mirror); - let _e228 = n_3; - denom = dot(_e227, _e228); - let _e230 = denom; - if (abs(_e230) < 0.00001f) { - let _e233 = toPlane; - let _e234 = n_3; - let _e235 = toPlane; - let _e236 = n_3; - onPlane = (_e233 - (_e234 * dot(_e235, _e236))); - } else { - let _e240 = toPlane; - let _e241 = n_3; - let _e243 = denom; - t_1 = (dot(_e240, _e241) / _e243); - let _e245 = t_1; - if (_e245 > 0f) { - let _e247 = (*mirror); - let _e248 = t_1; - local_12 = (_e247 * _e248); + let _e223 = (*halfWidth); + let _e224 = (*halfHeight); + n_3 = cross(_e223, _e224); + let _e226 = n_3; + nLen = length(_e226); + let _e228 = nLen; + if (_e228 < 0.000000000001f) { + let _e230 = (*centre); + return _e230; + } + let _e231 = nLen; + let _e232 = n_3; + n_3 = (_e232 / vec3(_e231)); + let _e235 = (*centre); + let _e236 = (*world_2); + toPlane = (_e235 - _e236); + let _e238 = (*mirror); + let _e239 = n_3; + denom = dot(_e238, _e239); + let _e241 = denom; + if (abs(_e241) < 0.00001f) { + let _e244 = toPlane; + let _e245 = n_3; + let _e246 = toPlane; + let _e247 = n_3; + onPlane = (_e244 - (_e245 * dot(_e246, _e247))); + } else { + let _e251 = toPlane; + let _e252 = n_3; + let _e254 = denom; + t_1 = (dot(_e251, _e252) / _e254); + let _e256 = t_1; + if (_e256 > 0f) { + let _e258 = (*mirror); + let _e259 = t_1; + local_17 = (_e258 * _e259); } else { - let _e250 = toPlane; - let _e251 = n_3; - let _e252 = toPlane; - let _e253 = n_3; - local_12 = (_e250 - (_e251 * dot(_e252, _e253))); - } - let _e257 = local_12; - onPlane = _e257; - } - let _e258 = onPlane; - let _e259 = toPlane; - offset_2 = (_e258 - _e259); - let _e261 = (*halfWidth); - let _e262 = (*halfWidth); - wLen2_ = max(dot(_e261, _e262), 0.000000000001f); - let _e265 = (*halfHeight); - let _e266 = (*halfHeight); - hLen2_ = max(dot(_e265, _e266), 0.000000000001f); - let _e269 = offset_2; - let _e270 = (*halfWidth); - let _e272 = wLen2_; - u = clamp((dot(_e269, _e270) / _e272), -1f, 1f); - let _e275 = offset_2; + let _e261 = toPlane; + let _e262 = n_3; + let _e263 = toPlane; + let _e264 = n_3; + local_17 = (_e261 - (_e262 * dot(_e263, _e264))); + } + let _e268 = local_17; + onPlane = _e268; + } + let _e269 = onPlane; + let _e270 = toPlane; + offset_2 = (_e269 - _e270); + let _e272 = (*halfWidth); + let _e273 = (*halfWidth); + wLen2_ = max(dot(_e272, _e273), 0.000000000001f); let _e276 = (*halfHeight); - let _e278 = hLen2_; - v_1 = clamp((dot(_e275, _e276) / _e278), -1f, 1f); - let _e281 = (*centre); - let _e282 = (*halfWidth); - let _e283 = u; - let _e286 = (*halfHeight); - let _e287 = v_1; - return ((_e281 + (_e282 * _e283)) + (_e286 * _e287)); + let _e277 = (*halfHeight); + hLen2_ = max(dot(_e276, _e277), 0.000000000001f); + let _e280 = offset_2; + let _e281 = (*halfWidth); + let _e283 = wLen2_; + u = clamp((dot(_e280, _e281) / _e283), -1f, 1f); + let _e286 = offset_2; + let _e287 = (*halfHeight); + let _e289 = hLen2_; + v_1 = clamp((dot(_e286, _e287) / _e289), -1f, 1f); + let _e292 = (*centre); + let _e293 = (*halfWidth); + let _e294 = u; + let _e297 = (*halfHeight); + let _e298 = v_1; + return ((_e292 + (_e293 * _e294)) + (_e297 * _e298)); } fn LambertEdge_u0028_vf3_u003b_vf3_u003b_vf3_u003b(a_3: ptr>, b_1: ptr>, n_4: ptr>) -> f32 { @@ -13776,34 +15857,34 @@ fn LambertEdge_u0028_vf3_u003b_vf3_u003b_vf3_u003b(a_3: ptr> var angle_2: f32; var axis_1: vec3; var len_2: f32; - var local_13: f32; + var local_18: f32; - let _e204 = (*a_3); - let _e205 = (*a_3); - ua = (_e204 / vec3(max(length(_e205), 0.000000000001f))); - let _e210 = (*b_1); - let _e211 = (*b_1); - ub = (_e210 / vec3(max(length(_e211), 0.000000000001f))); - let _e216 = ua; - let _e217 = ub; - angle_2 = acos(clamp(dot(_e216, _e217), -1f, 1f)); - let _e221 = ua; - let _e222 = ub; - axis_1 = cross(_e221, _e222); - let _e224 = axis_1; - len_2 = length(_e224); - let _e226 = len_2; - if (_e226 > 0.000001f) { - let _e228 = angle_2; - let _e229 = axis_1; - let _e230 = (*n_4); - let _e233 = len_2; - local_13 = ((_e228 * dot(_e229, _e230)) / _e233); - } else { - local_13 = 0f; - } - let _e235 = local_13; - return _e235; + let _e215 = (*a_3); + let _e216 = (*a_3); + ua = (_e215 / vec3(max(length(_e216), 0.000000000001f))); + let _e221 = (*b_1); + let _e222 = (*b_1); + ub = (_e221 / vec3(max(length(_e222), 0.000000000001f))); + let _e227 = ua; + let _e228 = ub; + angle_2 = acos(clamp(dot(_e227, _e228), -1f, 1f)); + let _e232 = ua; + let _e233 = ub; + axis_1 = cross(_e232, _e233); + let _e235 = axis_1; + len_2 = length(_e235); + let _e237 = len_2; + if (_e237 > 0.000001f) { + let _e239 = angle_2; + let _e240 = axis_1; + let _e241 = (*n_4); + let _e244 = len_2; + local_18 = ((_e239 * dot(_e240, _e241)) / _e244); + } else { + local_18 = 0f; + } + let _e246 = local_18; + return _e246; } fn RectangleFormFactor_u0028_vf3_u005b_4_u005d_u003b_vf3_u003b(corners_1: ptr, 4>>, n_5: ptr>) -> f32 { @@ -13813,212 +15894,212 @@ fn RectangleFormFactor_u0028_vf3_u005b_4_u005d_u003b_vf3_u003b(corners_1: ptr; var b_2: vec3; - var local_14: i32; + var local_19: i32; var ha: f32; var hb: f32; var d_4: f32; var q: vec3; - var local_15: f32; + var local_20: f32; var aAbove: bool; var bAbove: bool; - var local_16: f32; - var param_110: vec3; - var param_111: vec3; - var param_112: vec3; - var param_113: vec3; - var param_114: vec3; - var param_115: vec3; + var local_21: f32; + var param_116: vec3; + var param_117: vec3; + var param_118: vec3; + var param_119: vec3; + var param_120: vec3; + var param_121: vec3; total = 0f; exit = vec3(0f, 0f, 0f); entry = vec3(0f, 0f, 0f); i_8 = 0i; loop { - let _e218 = i_8; - if (_e218 < 4i) { - let _e220 = i_8; - let _e222 = (*corners_1)[_e220]; - a_4 = _e222; - let _e223 = i_8; - if (_e223 == 3i) { - local_14 = 0i; + let _e229 = i_8; + if (_e229 < 4i) { + let _e231 = i_8; + let _e233 = (*corners_1)[_e231]; + a_4 = _e233; + let _e234 = i_8; + if (_e234 == 3i) { + local_19 = 0i; } else { - let _e225 = i_8; - local_14 = (_e225 + 1i); - } - let _e227 = local_14; - let _e229 = (*corners_1)[_e227]; - b_2 = _e229; - let _e230 = a_4; - let _e231 = (*n_5); - ha = dot(_e230, _e231); - let _e233 = b_2; - let _e234 = (*n_5); - hb = dot(_e233, _e234); - let _e236 = ha; - let _e237 = hb; - d_4 = (_e236 - _e237); - let _e239 = a_4; - let _e240 = b_2; + let _e236 = i_8; + local_19 = (_e236 + 1i); + } + let _e238 = local_19; + let _e240 = (*corners_1)[_e238]; + b_2 = _e240; let _e241 = a_4; - let _e243 = d_4; - if (abs(_e243) > 0.000000000001f) { - let _e246 = ha; - let _e247 = d_4; - local_15 = (_e246 / _e247); + let _e242 = (*n_5); + ha = dot(_e241, _e242); + let _e244 = b_2; + let _e245 = (*n_5); + hb = dot(_e244, _e245); + let _e247 = ha; + let _e248 = hb; + d_4 = (_e247 - _e248); + let _e250 = a_4; + let _e251 = b_2; + let _e252 = a_4; + let _e254 = d_4; + if (abs(_e254) > 0.000000000001f) { + let _e257 = ha; + let _e258 = d_4; + local_20 = (_e257 / _e258); } else { - local_15 = 0f; - } - let _e249 = local_15; - q = (_e239 + ((_e240 - _e241) * _e249)); - let _e252 = ha; - aAbove = (_e252 > 0f); - let _e254 = hb; - bAbove = (_e254 > 0f); - let _e256 = aAbove; - let _e257 = bAbove; - if (_e256 || _e257) { - let _e259 = aAbove; - let _e260 = a_4; - let _e261 = q; - let _e264 = bAbove; - let _e265 = b_2; - let _e266 = q; - param_110 = select(_e261, _e260, vec3(_e259)); - param_111 = select(_e266, _e265, vec3(_e264)); - let _e269 = (*n_5); - param_112 = _e269; - let _e270 = LambertEdge_u0028_vf3_u003b_vf3_u003b_vf3_u003b((¶m_110), (¶m_111), (¶m_112)); - local_16 = _e270; + local_20 = 0f; + } + let _e260 = local_20; + q = (_e250 + ((_e251 - _e252) * _e260)); + let _e263 = ha; + aAbove = (_e263 > 0f); + let _e265 = hb; + bAbove = (_e265 > 0f); + let _e267 = aAbove; + let _e268 = bAbove; + if (_e267 || _e268) { + let _e270 = aAbove; + let _e271 = a_4; + let _e272 = q; + let _e275 = bAbove; + let _e276 = b_2; + let _e277 = q; + param_116 = select(_e272, _e271, vec3(_e270)); + param_117 = select(_e277, _e276, vec3(_e275)); + let _e280 = (*n_5); + param_118 = _e280; + let _e281 = LambertEdge_u0028_vf3_u003b_vf3_u003b_vf3_u003b((¶m_116), (¶m_117), (¶m_118)); + local_21 = _e281; } else { - local_16 = 0f; - } - let _e271 = local_16; - let _e272 = total; - total = (_e272 + _e271); - let _e274 = aAbove; - let _e275 = bAbove; - let _e278 = q; - let _e279 = exit; - exit = select(_e279, _e278, vec3((_e274 && !(_e275)))); - let _e282 = aAbove; - let _e284 = bAbove; - let _e286 = q; - let _e287 = entry; - entry = select(_e287, _e286, vec3((!(_e282) && _e284))); + local_21 = 0f; + } + let _e282 = local_21; + let _e283 = total; + total = (_e283 + _e282); + let _e285 = aAbove; + let _e286 = bAbove; + let _e289 = q; + let _e290 = exit; + exit = select(_e290, _e289, vec3((_e285 && !(_e286)))); + let _e293 = aAbove; + let _e295 = bAbove; + let _e297 = q; + let _e298 = entry; + entry = select(_e298, _e297, vec3((!(_e293) && _e295))); continue; } else { break; } continuing { - let _e290 = i_8; - i_8 = (_e290 + 1i); + let _e301 = i_8; + i_8 = (_e301 + 1i); } } - let _e292 = exit; - param_113 = _e292; - let _e293 = entry; - param_114 = _e293; - let _e294 = (*n_5); - param_115 = _e294; - let _e295 = LambertEdge_u0028_vf3_u003b_vf3_u003b_vf3_u003b((¶m_113), (¶m_114), (¶m_115)); - let _e296 = total; - total = (_e296 + _e295); - let _e298 = total; - return max((-(_e298) * 0.5f), 0f); + let _e303 = exit; + param_119 = _e303; + let _e304 = entry; + param_120 = _e304; + let _e305 = (*n_5); + param_121 = _e305; + let _e306 = LambertEdge_u0028_vf3_u003b_vf3_u003b_vf3_u003b((¶m_119), (¶m_120), (¶m_121)); + let _e307 = total; + total = (_e307 + _e306); + let _e309 = total; + return max((-(_e309) * 0.5f), 0f); } fn LightListLane_u0028_vf4_u003b_i1_u003b(four: ptr>, slot_1: ptr) -> f32 { var lane: i32; - var local_17: f32; - var local_18: f32; - var local_19: f32; - - let _e201 = (*slot_1); - let _e202 = (*slot_1); - lane = (_e201 - ((_e202 / 4i) * 4i)); - let _e206 = lane; - if (_e206 == 0i) { - let _e209 = (*four)[0u]; - local_17 = _e209; - } else { - let _e210 = lane; - if (_e210 == 1i) { - let _e213 = (*four)[1u]; - local_18 = _e213; + var local_22: f32; + var local_23: f32; + var local_24: f32; + + let _e212 = (*slot_1); + let _e213 = (*slot_1); + lane = (_e212 - ((_e213 / 4i) * 4i)); + let _e217 = lane; + if (_e217 == 0i) { + let _e220 = (*four)[0u]; + local_22 = _e220; + } else { + let _e221 = lane; + if (_e221 == 1i) { + let _e224 = (*four)[1u]; + local_23 = _e224; } else { - let _e214 = lane; - if (_e214 == 2i) { - let _e217 = (*four)[2u]; - local_19 = _e217; + let _e225 = lane; + if (_e225 == 2i) { + let _e228 = (*four)[2u]; + local_24 = _e228; } else { - let _e219 = (*four)[3u]; - local_19 = _e219; + let _e230 = (*four)[3u]; + local_24 = _e230; } - let _e220 = local_19; - local_18 = _e220; + let _e231 = local_24; + local_23 = _e231; } - let _e221 = local_18; - local_17 = _e221; + let _e232 = local_23; + local_22 = _e232; } - let _e222 = local_17; - return _e222; + let _e233 = local_22; + return _e233; } fn LightListScale_u0028_i1_u003b(slot_2: ptr) -> f32 { - var param_116: vec4; - var param_117: i32; - - let _e198 = (*slot_2); - let _e202 = light_list_info.scales[(_e198 / 4i)]; - param_116 = _e202; - let _e203 = (*slot_2); - param_117 = _e203; - let _e204 = LightListLane_u0028_vf4_u003b_i1_u003b((¶m_116), (¶m_117)); - return _e204; + var param_122: vec4; + var param_123: i32; + + let _e209 = (*slot_2); + let _e213 = light_list_info.scales[(_e209 / 4i)]; + param_122 = _e213; + let _e214 = (*slot_2); + param_123 = _e214; + let _e215 = LightListLane_u0028_vf4_u003b_i1_u003b((¶m_122), (¶m_123)); + return _e215; } fn InSlots_u0028_f1_u003b(row: ptr) -> bool { var a_5: vec4; var b_3: vec4; - let _e200 = light_list_info.slot_rows[0i]; - let _e201 = (*row); - a_5 = abs((_e200 - vec4(_e201))); - let _e207 = light_list_info.slot_rows[1i]; - let _e208 = (*row); - b_3 = abs((_e207 - vec4(_e208))); - let _e213 = a_5[0u]; - let _e215 = a_5[1u]; - let _e218 = a_5[2u]; - let _e220 = a_5[3u]; - let _e224 = b_3[0u]; - let _e226 = b_3[1u]; - let _e229 = b_3[2u]; - let _e231 = b_3[3u]; - return (min(min(min(_e213, _e215), min(_e218, _e220)), min(min(_e224, _e226), min(_e229, _e231))) < 0.5f); + let _e211 = light_list_info.slot_rows[0i]; + let _e212 = (*row); + a_5 = abs((_e211 - vec4(_e212))); + let _e218 = light_list_info.slot_rows[1i]; + let _e219 = (*row); + b_3 = abs((_e218 - vec4(_e219))); + let _e224 = a_5[0u]; + let _e226 = a_5[1u]; + let _e229 = a_5[2u]; + let _e231 = a_5[3u]; + let _e235 = b_3[0u]; + let _e237 = b_3[1u]; + let _e240 = b_3[2u]; + let _e242 = b_3[3u]; + return (min(min(min(_e224, _e226), min(_e229, _e231)), min(min(_e235, _e237), min(_e240, _e242))) < 0.5f); } fn LightListRow_u0028_i1_u003b(slot_3: ptr) -> f32 { - var param_118: vec4; - var param_119: i32; - - let _e198 = (*slot_3); - let _e202 = light_list_info.indices[(_e198 / 4i)]; - param_118 = _e202; - let _e203 = (*slot_3); - param_119 = _e203; - let _e204 = LightListLane_u0028_vf4_u003b_i1_u003b((¶m_118), (¶m_119)); - return _e204; + var param_124: vec4; + var param_125: i32; + + let _e209 = (*slot_3); + let _e213 = light_list_info.indices[(_e209 / 4i)]; + param_124 = _e213; + let _e214 = (*slot_3); + param_125 = _e214; + let _e215 = LightListLane_u0028_vf4_u003b_i1_u003b((¶m_124), (¶m_125)); + return _e215; } fn LightListTexel_u0028_f1_u003b_f1_u003b(texel_2: ptr, row_1: ptr) -> vec4 { - let _e197 = (*texel_2); - let _e201 = light_list_info.list[1u]; - let _e203 = (*row_1); - let _e207 = light_list_info.list[2u]; - let _e210 = textureSampleLevel(light_list_texture_tex, light_list_texture_smp, vec2(((_e197 + 0.5f) * _e201), ((_e203 + 0.5f) * _e207)), 0f); - return _e210; + let _e208 = (*texel_2); + let _e212 = light_list_info.list[1u]; + let _e214 = (*row_1); + let _e218 = light_list_info.list[2u]; + let _e221 = textureSampleLevel(light_list_texture_tex, light_list_texture_smp, vec2(((_e208 + 0.5f) * _e212), ((_e214 + 0.5f) * _e218)), 0f); + return _e221; } fn ClusterRow_u0028_i1_u003b(slot_4: ptr) -> f32 { @@ -14027,43 +16108,43 @@ fn ClusterRow_u0028_i1_u003b(slot_4: ptr) -> f32 { var within: f32; var texel_3: f32; var four_1: vec4; - var param_120: f32; - var param_121: f32; - var param_122: vec4; - var param_123: i32; - - let _e205 = g_cluster_offset; - let _e206 = (*slot_4); - entry_1 = (_e205 + f32(_e206)); - let _e209 = entry_1; - row_2 = floor((_e209 / 16f)); - let _e212 = entry_1; - let _e213 = row_2; - within = (_e212 - (_e213 * 16f)); - let _e216 = within; - texel_3 = floor((_e216 / 4f)); - let _e221 = light_list_info.cluster_depth[3u]; - let _e222 = row_2; - let _e224 = texel_3; - param_120 = _e224; - param_121 = (_e221 + _e222); - let _e225 = LightListTexel_u0028_f1_u003b_f1_u003b((¶m_120), (¶m_121)); - four_1 = _e225; - let _e226 = within; - let _e227 = texel_3; - let _e232 = four_1; - param_122 = _e232; - param_123 = i32(((_e226 - (_e227 * 4f)) + 0.5f)); - let _e233 = LightListLane_u0028_vf4_u003b_i1_u003b((¶m_122), (¶m_123)); - return _e233; + var param_126: f32; + var param_127: f32; + var param_128: vec4; + var param_129: i32; + + let _e216 = g_cluster_offset; + let _e217 = (*slot_4); + entry_1 = (_e216 + f32(_e217)); + let _e220 = entry_1; + row_2 = floor((_e220 / 16f)); + let _e223 = entry_1; + let _e224 = row_2; + within = (_e223 - (_e224 * 16f)); + let _e227 = within; + texel_3 = floor((_e227 / 4f)); + let _e232 = light_list_info.cluster_depth[3u]; + let _e233 = row_2; + let _e235 = texel_3; + param_126 = _e235; + param_127 = (_e232 + _e233); + let _e236 = LightListTexel_u0028_f1_u003b_f1_u003b((¶m_126), (¶m_127)); + four_1 = _e236; + let _e237 = within; + let _e238 = texel_3; + let _e243 = four_1; + param_128 = _e243; + param_129 = i32(((_e237 - (_e238 * 4f)) + 0.5f)); + let _e244 = LightListLane_u0028_vf4_u003b_i1_u003b((¶m_128), (¶m_129)); + return _e244; } fn Clustered_u0028_() -> bool { - let _e197 = light_list_info.cluster_grid[3u]; - return (_e197 > 0.5f); + let _e208 = light_list_info.cluster_grid[3u]; + return (_e208 > 0.5f); } -fn SampleLight_u0028_i1_u003b_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b(index_2: ptr, s_5: ptr) -> LightSample { +fn SampleLight_u0028_i1_u003b_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b(index_2: ptr, s_6: ptr) -> LightSample { var light_3: LightSample; var position: vec4; var color_1: vec4; @@ -14072,14 +16153,14 @@ fn SampleLight_u0028_i1_u003b_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_ var slot_5: i32; var clustered: bool; var listRow: f32; - var local_20: f32; - var param_124: i32; - var param_125: i32; + var local_25: f32; + var param_130: i32; + var param_131: i32; var v_2: f32; var u_1: f32; - var local_21: f32; - var param_126: f32; - var param_127: i32; + var local_26: f32; + var param_132: f32; + var param_133: i32; var type_42: f32; var halfWidth_1: vec3; var halfHeight_1: vec3; @@ -14087,308 +16168,308 @@ fn SampleLight_u0028_i1_u003b_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_ var corners_2: array, 4>; var behind: bool; var formFactor: f32; - var local_22: f32; - var param_128: array, 4>; - var param_129: vec3; + var local_27: f32; + var param_134: array, 4>; + var param_135: vec3; var area: f32; var radiance: f32; - var local_23: f32; + var local_28: f32; var distance_2: f32; var ratio_1: f32; var window_1: f32; var mirror_1: vec3; var representative: vec3; - var param_130: vec3; - var param_131: vec3; - var param_132: vec3; - var param_133: vec3; - var param_134: vec3; + var param_136: vec3; + var param_137: vec3; + var param_138: vec3; + var param_139: vec3; + var param_140: vec3; var toPoint: vec3; var pointDistance: f32; - var local_24: vec3; - var param_135: vec3; - var param_136: vec3; - var param_137: f32; - var param_138: array, 4>; + var local_29: vec3; + var param_141: vec3; + var param_142: vec3; + var param_143: f32; + var param_144: array, 4>; var aim: vec3; var attenuation_1: f32; var toLight_1: vec3; var distance_3: f32; - var param_139: f32; - var param_140: f32; + var param_145: f32; + var param_146: f32; var cosAngle: f32; light_3.integrated = 0f; light_3.ltc = vec3(0f, 0f, 0f); - let _e252 = (*index_2); - if (_e252 < 8i) { - let _e254 = (*index_2); - let _e257 = frag_info.light_position[_e254]; - position = _e257; - let _e258 = (*index_2); - let _e261 = frag_info.light_color[_e258]; - color_1 = _e261; - let _e262 = (*index_2); - let _e265 = frag_info.light_direction[_e262]; - direction = _e265; - let _e266 = (*index_2); - let _e269 = frag_info.light_cone[_e266]; - cone = _e269; - } else { - let _e270 = (*index_2); - slot_5 = (_e270 - 8i); - let _e272 = Clustered_u0028_(); - clustered = _e272; - let _e273 = clustered; - if _e273 { - let _e274 = slot_5; - param_124 = _e274; - let _e275 = ClusterRow_u0028_i1_u003b((¶m_124)); - local_20 = _e275; + let _e263 = (*index_2); + if (_e263 < 8i) { + let _e265 = (*index_2); + let _e268 = frag_info.light_position[_e265]; + position = _e268; + let _e269 = (*index_2); + let _e272 = frag_info.light_color[_e269]; + color_1 = _e272; + let _e273 = (*index_2); + let _e276 = frag_info.light_direction[_e273]; + direction = _e276; + let _e277 = (*index_2); + let _e280 = frag_info.light_cone[_e277]; + cone = _e280; + } else { + let _e281 = (*index_2); + slot_5 = (_e281 - 8i); + let _e283 = Clustered_u0028_(); + clustered = _e283; + let _e284 = clustered; + if _e284 { + let _e285 = slot_5; + param_130 = _e285; + let _e286 = ClusterRow_u0028_i1_u003b((¶m_130)); + local_25 = _e286; } else { - let _e276 = slot_5; - param_125 = _e276; - let _e277 = LightListRow_u0028_i1_u003b((¶m_125)); - local_20 = _e277; - } - let _e278 = local_20; - listRow = _e278; - let _e279 = listRow; - let _e283 = light_list_info.list[2u]; - v_2 = ((_e279 + 0.5f) * _e283); - let _e287 = light_list_info.list[1u]; - u_1 = _e287; - let _e288 = u_1; - let _e290 = v_2; - let _e292 = textureSampleLevel(light_list_texture_tex, light_list_texture_smp, vec2((0.5f * _e288), _e290), 0f); - position = _e292; - let _e293 = u_1; - let _e295 = v_2; - let _e297 = textureSampleLevel(light_list_texture_tex, light_list_texture_smp, vec2((1.5f * _e293), _e295), 0f); - color_1 = _e297; - let _e298 = u_1; - let _e300 = v_2; - let _e302 = textureSampleLevel(light_list_texture_tex, light_list_texture_smp, vec2((2.5f * _e298), _e300), 0f); - direction = _e302; - let _e303 = u_1; - let _e305 = v_2; - let _e307 = textureSampleLevel(light_list_texture_tex, light_list_texture_smp, vec2((3.5f * _e303), _e305), 0f); - cone = _e307; - let _e308 = clustered; - if _e308 { - let _e309 = listRow; - param_126 = _e309; - let _e310 = InSlots_u0028_f1_u003b((¶m_126)); - local_21 = select(1f, 0f, _e310); + let _e287 = slot_5; + param_131 = _e287; + let _e288 = LightListRow_u0028_i1_u003b((¶m_131)); + local_25 = _e288; + } + let _e289 = local_25; + listRow = _e289; + let _e290 = listRow; + let _e294 = light_list_info.list[2u]; + v_2 = ((_e290 + 0.5f) * _e294); + let _e298 = light_list_info.list[1u]; + u_1 = _e298; + let _e299 = u_1; + let _e301 = v_2; + let _e303 = textureSampleLevel(light_list_texture_tex, light_list_texture_smp, vec2((0.5f * _e299), _e301), 0f); + position = _e303; + let _e304 = u_1; + let _e306 = v_2; + let _e308 = textureSampleLevel(light_list_texture_tex, light_list_texture_smp, vec2((1.5f * _e304), _e306), 0f); + color_1 = _e308; + let _e309 = u_1; + let _e311 = v_2; + let _e313 = textureSampleLevel(light_list_texture_tex, light_list_texture_smp, vec2((2.5f * _e309), _e311), 0f); + direction = _e313; + let _e314 = u_1; + let _e316 = v_2; + let _e318 = textureSampleLevel(light_list_texture_tex, light_list_texture_smp, vec2((3.5f * _e314), _e316), 0f); + cone = _e318; + let _e319 = clustered; + if _e319 { + let _e320 = listRow; + param_132 = _e320; + let _e321 = InSlots_u0028_f1_u003b((¶m_132)); + local_26 = select(1f, 0f, _e321); } else { - let _e312 = slot_5; - param_127 = _e312; - let _e313 = LightListScale_u0028_i1_u003b((¶m_127)); - local_21 = _e313; - } - let _e314 = local_21; - let _e316 = color_1[3u]; - color_1[3u] = (_e316 * _e314); - } - let _e320 = position[3u]; - type_42 = _e320; - let _e321 = type_42; - if (_e321 > 2.5f) { - let _e323 = direction; - halfWidth_1 = _e323.xyz; - let _e325 = cone; - halfHeight_1 = _e325.xyz; - let _e327 = position; - let _e329 = v_world_position_1; - toCentre = (_e327.xyz - _e329); - let _e331 = toCentre; - let _e332 = halfWidth_1; - let _e334 = halfHeight_1; - corners_2[0i] = ((_e331 - _e332) - _e334); - let _e337 = toCentre; - let _e338 = halfWidth_1; - let _e340 = halfHeight_1; - corners_2[1i] = ((_e337 + _e338) - _e340); - let _e343 = toCentre; - let _e344 = halfWidth_1; - let _e346 = halfHeight_1; - corners_2[2i] = ((_e343 + _e344) + _e346); - let _e349 = toCentre; - let _e350 = halfWidth_1; - let _e352 = halfHeight_1; - corners_2[3i] = ((_e349 - _e350) + _e352); - let _e355 = toCentre; - let _e356 = halfWidth_1; + let _e323 = slot_5; + param_133 = _e323; + let _e324 = LightListScale_u0028_i1_u003b((¶m_133)); + local_26 = _e324; + } + let _e325 = local_26; + let _e327 = color_1[3u]; + color_1[3u] = (_e327 * _e325); + } + let _e331 = position[3u]; + type_42 = _e331; + let _e332 = type_42; + if (_e332 > 2.5f) { + let _e334 = direction; + halfWidth_1 = _e334.xyz; + let _e336 = cone; + halfHeight_1 = _e336.xyz; + let _e338 = position; + let _e340 = v_world_position_1; + toCentre = (_e338.xyz - _e340); + let _e342 = toCentre; + let _e343 = halfWidth_1; + let _e345 = halfHeight_1; + corners_2[0i] = ((_e342 - _e343) - _e345); + let _e348 = toCentre; + let _e349 = halfWidth_1; + let _e351 = halfHeight_1; + corners_2[1i] = ((_e348 + _e349) - _e351); + let _e354 = toCentre; + let _e355 = halfWidth_1; let _e357 = halfHeight_1; - behind = (dot(_e355, cross(_e356, _e357)) >= 0f); - let _e361 = behind; - if _e361 { - local_22 = 0f; - } else { - let _e362 = corners_2; - param_128 = _e362; - let _e364 = (*s_5).n; - param_129 = _e364; - let _e365 = RectangleFormFactor_u0028_vf3_u005b_4_u005d_u003b_vf3_u003b((¶m_128), (¶m_129)); - local_22 = _e365; - } - let _e366 = local_22; - formFactor = _e366; + corners_2[2i] = ((_e354 + _e355) + _e357); + let _e360 = toCentre; + let _e361 = halfWidth_1; + let _e363 = halfHeight_1; + corners_2[3i] = ((_e360 - _e361) + _e363); + let _e366 = toCentre; let _e367 = halfWidth_1; let _e368 = halfHeight_1; - area = (length(cross(_e367, _e368)) * 4f); - let _e372 = area; - if (_e372 > 0.000000001f) { - let _e374 = area; - local_23 = (1f / _e374); + behind = (dot(_e366, cross(_e367, _e368)) >= 0f); + let _e372 = behind; + if _e372 { + local_27 = 0f; } else { - local_23 = 0f; - } - let _e376 = local_23; - radiance = _e376; - let _e377 = toCentre; - distance_2 = length(_e377); - let _e380 = direction[3u]; - if (_e380 > 0f) { - let _e382 = distance_2; - let _e384 = direction[3u]; - ratio_1 = (_e382 / _e384); - let _e386 = ratio_1; - let _e387 = ratio_1; - let _e389 = ratio_1; - let _e391 = ratio_1; - window_1 = clamp((1f - (((_e386 * _e387) * _e389) * _e391)), 0f, 1f); - let _e395 = window_1; - let _e396 = window_1; - let _e398 = radiance; - radiance = (_e398 * (_e395 * _e396)); - } - let _e401 = (*s_5).v; - let _e404 = (*s_5).n; - mirror_1 = reflect(-(_e401), _e404); - let _e406 = position; - param_130 = _e406.xyz; - let _e408 = halfWidth_1; - param_131 = _e408; - let _e409 = halfHeight_1; - param_132 = _e409; - let _e410 = v_world_position_1; - param_133 = _e410; - let _e411 = mirror_1; - param_134 = _e411; - let _e412 = RectangleClosestPoint_u0028_vf3_u003b_vf3_u003b_vf3_u003b_vf3_u003b_vf3_u003b((¶m_130), (¶m_131), (¶m_132), (¶m_133), (¶m_134)); - representative = _e412; - let _e413 = representative; - let _e414 = v_world_position_1; - toPoint = (_e413 - _e414); - let _e416 = toPoint; - pointDistance = length(_e416); - let _e418 = pointDistance; - if (_e418 > 0.000001f) { - let _e420 = toPoint; - let _e421 = pointDistance; - local_24 = (_e420 / vec3(_e421)); + let _e373 = corners_2; + param_134 = _e373; + let _e375 = (*s_6).n; + param_135 = _e375; + let _e376 = RectangleFormFactor_u0028_vf3_u005b_4_u005d_u003b_vf3_u003b((¶m_134), (¶m_135)); + local_27 = _e376; + } + let _e377 = local_27; + formFactor = _e377; + let _e378 = halfWidth_1; + let _e379 = halfHeight_1; + area = (length(cross(_e378, _e379)) * 4f); + let _e383 = area; + if (_e383 > 0.000000001f) { + let _e385 = area; + local_28 = (1f / _e385); + } else { + local_28 = 0f; + } + let _e387 = local_28; + radiance = _e387; + let _e388 = toCentre; + distance_2 = length(_e388); + let _e391 = direction[3u]; + if (_e391 > 0f) { + let _e393 = distance_2; + let _e395 = direction[3u]; + ratio_1 = (_e393 / _e395); + let _e397 = ratio_1; + let _e398 = ratio_1; + let _e400 = ratio_1; + let _e402 = ratio_1; + window_1 = clamp((1f - (((_e397 * _e398) * _e400) * _e402)), 0f, 1f); + let _e406 = window_1; + let _e407 = window_1; + let _e409 = radiance; + radiance = (_e409 * (_e406 * _e407)); + } + let _e412 = (*s_6).v; + let _e415 = (*s_6).n; + mirror_1 = reflect(-(_e412), _e415); + let _e417 = position; + param_136 = _e417.xyz; + let _e419 = halfWidth_1; + param_137 = _e419; + let _e420 = halfHeight_1; + param_138 = _e420; + let _e421 = v_world_position_1; + param_139 = _e421; + let _e422 = mirror_1; + param_140 = _e422; + let _e423 = RectangleClosestPoint_u0028_vf3_u003b_vf3_u003b_vf3_u003b_vf3_u003b_vf3_u003b((¶m_136), (¶m_137), (¶m_138), (¶m_139), (¶m_140)); + representative = _e423; + let _e424 = representative; + let _e425 = v_world_position_1; + toPoint = (_e424 - _e425); + let _e427 = toPoint; + pointDistance = length(_e427); + let _e429 = pointDistance; + if (_e429 > 0.000001f) { + let _e431 = toPoint; + let _e432 = pointDistance; + local_29 = (_e431 / vec3(_e432)); } else { - let _e425 = (*s_5).n; - local_24 = _e425; - } - let _e426 = local_24; - light_3.l = _e426; - let _e429 = light_3.l; - let _e431 = (*s_5).v; - light_3.h = normalize((_e429 + _e431)); - let _e435 = formFactor; - light_3.n_dot_l = _e435; - let _e438 = (*s_5).n; - let _e440 = light_3.h; - light_3.n_dot_h = max(dot(_e438, _e440), 0f); - let _e445 = (*s_5).v; - let _e447 = light_3.h; - light_3.v_dot_h = max(dot(_e445, _e447), 0f); - let _e451 = color_1; - let _e454 = color_1[3u]; - let _e456 = radiance; - light_3.radiance = ((_e451.xyz * _e454) * _e456); + let _e436 = (*s_6).n; + local_29 = _e436; + } + let _e437 = local_29; + light_3.l = _e437; + let _e440 = light_3.l; + let _e442 = (*s_6).v; + light_3.h = normalize((_e440 + _e442)); + let _e446 = formFactor; + light_3.n_dot_l = _e446; + let _e449 = (*s_6).n; + let _e451 = light_3.h; + light_3.n_dot_h = max(dot(_e449, _e451), 0f); + let _e456 = (*s_6).v; + let _e458 = light_3.h; + light_3.v_dot_h = max(dot(_e456, _e458), 0f); + let _e462 = color_1; + let _e465 = color_1[3u]; + let _e467 = radiance; + light_3.radiance = ((_e462.xyz * _e465) * _e467); light_3.integrated = 1f; - let _e461 = (*s_5).n; - param_135 = _e461; - let _e463 = (*s_5).v; - param_136 = _e463; - let _e465 = (*s_5).roughness; - param_137 = _e465; - let _e466 = corners_2; - param_138 = _e466; - let _e467 = LtcRectangle_u0028_vf3_u003b_vf3_u003b_f1_u003b_vf3_u005b_4_u005d_u003b((¶m_135), (¶m_136), (¶m_137), (¶m_138)); - light_3.ltc = _e467; - let _e469 = light_3; - return _e469; - } - let _e470 = direction; - aim = normalize(_e470.xyz); + let _e472 = (*s_6).n; + param_141 = _e472; + let _e474 = (*s_6).v; + param_142 = _e474; + let _e476 = (*s_6).roughness; + param_143 = _e476; + let _e477 = corners_2; + param_144 = _e477; + let _e478 = LtcRectangle_u0028_vf3_u003b_vf3_u003b_f1_u003b_vf3_u005b_4_u005d_u003b((¶m_141), (¶m_142), (¶m_143), (¶m_144)); + light_3.ltc = _e478; + let _e480 = light_3; + return _e480; + } + let _e481 = direction; + aim = normalize(_e481.xyz); attenuation_1 = 1f; - let _e473 = type_42; - if (_e473 < 0.5f) { - let _e475 = aim; - light_3.l = -(_e475); - } else { - let _e478 = position; - let _e480 = v_world_position_1; - toLight_1 = (_e478.xyz - _e480); - let _e482 = toLight_1; - distance_3 = length(_e482); - let _e484 = distance_3; - if (_e484 < 0.000001f) { - let _e487 = (*s_5).n; - light_3.l = _e487; - let _e490 = (*s_5).n; - light_3.h = _e490; + let _e484 = type_42; + if (_e484 < 0.5f) { + let _e486 = aim; + light_3.l = -(_e486); + } else { + let _e489 = position; + let _e491 = v_world_position_1; + toLight_1 = (_e489.xyz - _e491); + let _e493 = toLight_1; + distance_3 = length(_e493); + let _e495 = distance_3; + if (_e495 < 0.000001f) { + let _e498 = (*s_6).n; + light_3.l = _e498; + let _e501 = (*s_6).n; + light_3.h = _e501; light_3.radiance = vec3(0f, 0f, 0f); light_3.n_dot_l = 0f; light_3.n_dot_h = 0f; light_3.v_dot_h = 0f; - let _e496 = light_3; - return _e496; - } - let _e497 = toLight_1; - let _e498 = distance_3; - light_3.l = (_e497 / vec3(_e498)); - let _e502 = distance_3; - param_139 = _e502; - let _e504 = direction[3u]; - param_140 = _e504; - let _e505 = PunctualAttenuation_u0028_f1_u003b_f1_u003b((¶m_139), (¶m_140)); - attenuation_1 = _e505; - let _e506 = type_42; - if (_e506 > 1.5f) { - let _e508 = aim; - let _e510 = light_3.l; - cosAngle = dot(_e508, -(_e510)); - let _e513 = cosAngle; - let _e515 = cone[1u]; - let _e518 = cone[0u]; - let _e520 = cone[1u]; - let _e524 = attenuation_1; - attenuation_1 = (_e524 * clamp(((_e513 - _e515) / (_e518 - _e520)), 0f, 1f)); - } - } - let _e527 = light_3.l; - let _e529 = (*s_5).v; - light_3.h = normalize((_e527 + _e529)); - let _e534 = (*s_5).n; - let _e536 = light_3.l; - light_3.n_dot_l = max(dot(_e534, _e536), 0f); - let _e541 = (*s_5).n; - let _e543 = light_3.h; - light_3.n_dot_h = max(dot(_e541, _e543), 0f); - let _e548 = (*s_5).v; - let _e550 = light_3.h; - light_3.v_dot_h = max(dot(_e548, _e550), 0f); - let _e554 = color_1; - let _e557 = color_1[3u]; - let _e559 = attenuation_1; - light_3.radiance = ((_e554.xyz * _e557) * _e559); - let _e562 = light_3; - return _e562; + let _e507 = light_3; + return _e507; + } + let _e508 = toLight_1; + let _e509 = distance_3; + light_3.l = (_e508 / vec3(_e509)); + let _e513 = distance_3; + param_145 = _e513; + let _e515 = direction[3u]; + param_146 = _e515; + let _e516 = PunctualAttenuation_u0028_f1_u003b_f1_u003b((¶m_145), (¶m_146)); + attenuation_1 = _e516; + let _e517 = type_42; + if (_e517 > 1.5f) { + let _e519 = aim; + let _e521 = light_3.l; + cosAngle = dot(_e519, -(_e521)); + let _e524 = cosAngle; + let _e526 = cone[1u]; + let _e529 = cone[0u]; + let _e531 = cone[1u]; + let _e535 = attenuation_1; + attenuation_1 = (_e535 * clamp(((_e524 - _e526) / (_e529 - _e531)), 0f, 1f)); + } + } + let _e538 = light_3.l; + let _e540 = (*s_6).v; + light_3.h = normalize((_e538 + _e540)); + let _e545 = (*s_6).n; + let _e547 = light_3.l; + light_3.n_dot_l = max(dot(_e545, _e547), 0f); + let _e552 = (*s_6).n; + let _e554 = light_3.h; + light_3.n_dot_h = max(dot(_e552, _e554), 0f); + let _e559 = (*s_6).v; + let _e561 = light_3.h; + light_3.v_dot_h = max(dot(_e559, _e561), 0f); + let _e565 = color_1; + let _e568 = color_1[3u]; + let _e570 = attenuation_1; + light_3.radiance = ((_e565.xyz * _e568) * _e570); + let _e573 = light_3; + return _e573; } fn FindCluster_u0028_vf3_u003b(world_3: ptr>) { @@ -14399,339 +16480,334 @@ fn FindCluster_u0028_vf3_u003b(world_3: ptr>) { var tx: f32; var ty: f32; var tz: f32; - var local_25: f32; + var local_30: f32; var cell: f32; var row_3: f32; var header: vec4; - var param_141: f32; - var param_142: f32; - - let _e210 = light_list_info.cluster_view_projection; - let _e211 = (*world_3); - clip_1 = (_e210 * vec4(_e211.x, _e211.y, _e211.z, 1f)); - let _e217 = clip_1; - let _e220 = clip_1[3u]; - ndc_1 = (_e217.xy / vec2(max(_e220, 0.000001f))); - let _e225 = light_list_info.cluster_grid; - grid = _e225.xyz; - let _e229 = light_list_info.cluster_depth[0u]; - near_1 = _e229; - let _e231 = ndc_1[0u]; - let _e235 = grid[0u]; - let _e239 = grid[0u]; - tx = clamp(floor((((_e231 * 0.5f) + 0.5f) * _e235)), 0f, (_e239 - 1f)); - let _e243 = ndc_1[1u]; - let _e247 = grid[1u]; - let _e251 = grid[1u]; - ty = clamp(floor((((_e243 * 0.5f) + 0.5f) * _e247)), 0f, (_e251 - 1f)); - let _e255 = clip_1[3u]; - let _e256 = near_1; - if (_e255 <= _e256) { - local_25 = 0f; - } else { - let _e259 = clip_1[3u]; - let _e260 = near_1; - let _e265 = light_list_info.cluster_depth[1u]; - let _e269 = grid[2u]; - local_25 = clamp(floor((log((_e259 / _e260)) * _e265)), 0f, (_e269 - 1f)); - } - let _e272 = local_25; - tz = _e272; - let _e273 = tx; - let _e274 = ty; - let _e276 = grid[0u]; - let _e279 = tz; - let _e281 = grid[0u]; - let _e284 = grid[1u]; - cell = ((_e273 + (_e274 * _e276)) + ((_e279 * _e281) * _e284)); - let _e287 = cell; - row_3 = floor((_e287 / 4f)); - let _e290 = cell; - let _e291 = row_3; - let _e296 = light_list_info.cluster_depth[2u]; - let _e297 = row_3; - param_141 = (_e290 - (_e291 * 4f)); - param_142 = (_e296 + _e297); - let _e299 = LightListTexel_u0028_f1_u003b_f1_u003b((¶m_141), (¶m_142)); - header = _e299; - let _e301 = header[0u]; - g_cluster_offset = _e301; - let _e303 = header[1u]; - g_cluster_count = _e303; + var param_147: f32; + var param_148: f32; + + let _e221 = light_list_info.cluster_view_projection; + let _e222 = (*world_3); + clip_1 = (_e221 * vec4(_e222.x, _e222.y, _e222.z, 1f)); + let _e228 = clip_1; + let _e231 = clip_1[3u]; + ndc_1 = (_e228.xy / vec2(max(_e231, 0.000001f))); + let _e236 = light_list_info.cluster_grid; + grid = _e236.xyz; + let _e240 = light_list_info.cluster_depth[0u]; + near_1 = _e240; + let _e242 = ndc_1[0u]; + let _e246 = grid[0u]; + let _e250 = grid[0u]; + tx = clamp(floor((((_e242 * 0.5f) + 0.5f) * _e246)), 0f, (_e250 - 1f)); + let _e254 = ndc_1[1u]; + let _e258 = grid[1u]; + let _e262 = grid[1u]; + ty = clamp(floor((((_e254 * 0.5f) + 0.5f) * _e258)), 0f, (_e262 - 1f)); + let _e266 = clip_1[3u]; + let _e267 = near_1; + if (_e266 <= _e267) { + local_30 = 0f; + } else { + let _e270 = clip_1[3u]; + let _e271 = near_1; + let _e276 = light_list_info.cluster_depth[1u]; + let _e280 = grid[2u]; + local_30 = clamp(floor((log((_e270 / _e271)) * _e276)), 0f, (_e280 - 1f)); + } + let _e283 = local_30; + tz = _e283; + let _e284 = tx; + let _e285 = ty; + let _e287 = grid[0u]; + let _e290 = tz; + let _e292 = grid[0u]; + let _e295 = grid[1u]; + cell = ((_e284 + (_e285 * _e287)) + ((_e290 * _e292) * _e295)); + let _e298 = cell; + row_3 = floor((_e298 / 4f)); + let _e301 = cell; + let _e302 = row_3; + let _e307 = light_list_info.cluster_depth[2u]; + let _e308 = row_3; + param_147 = (_e301 - (_e302 * 4f)); + param_148 = (_e307 + _e308); + let _e310 = LightListTexel_u0028_f1_u003b_f1_u003b((¶m_147), (¶m_148)); + header = _e310; + let _e312 = header[0u]; + g_cluster_offset = _e312; + let _e314 = header[1u]; + g_cluster_count = _e314; return; } fn LightCount_u0028_() -> i32 { var tail: f32; - var param_143: vec3; + var param_149: vec3; - let _e199 = light_list_info.list[0u]; - tail = _e199; - let _e200 = Clustered_u0028_(); - if _e200 { - let _e201 = v_world_position_1; - param_143 = _e201; - FindCluster_u0028_vf3_u003b((¶m_143)); - let _e202 = g_cluster_count; - tail = _e202; + let _e210 = light_list_info.list[0u]; + tail = _e210; + let _e211 = Clustered_u0028_(); + if _e211 { + let _e212 = v_world_position_1; + param_149 = _e212; + FindCluster_u0028_vf3_u003b((¶m_149)); + let _e213 = g_cluster_count; + tail = _e213; } - let _e205 = frag_info.frame_params[1u]; - let _e209 = tail; - return (clamp(i32((_e205 + 0.5f)), 0i, 8i) + clamp(i32((_e209 + 0.5f)), 0i, 24i)); + let _e216 = frag_info.frame_params[1u]; + let _e220 = tail; + return (clamp(i32((_e216 + 0.5f)), 0i, 8i) + clamp(i32((_e220 + 0.5f)), 0i, 24i)); } -fn AccumulateLights_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b(s_6: ptr) -> vec3 { +fn AccumulateLights_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b(s_7: ptr) -> vec3 { var total_1: vec3; var count_1: i32; var i_9: i32; var light_4: LightSample; - var param_144: i32; - var param_145: Surface; + var param_150: i32; + var param_151: Surface; var visibility: f32; - var param_146: i32; - var local_26: f32; - var param_147: Surface; - var param_148: LightSample; - var param_149: i32; - var param_150: vec3; - var param_151: vec3; var param_152: i32; + var local_31: f32; var param_153: Surface; var param_154: LightSample; + var param_155: i32; + var param_156: vec3; + var param_157: vec3; + var param_158: i32; + var param_159: Surface; + var param_160: LightSample; total_1 = vec3(0f, 0f, 0f); - let _e213 = LightCount_u0028_(); - count_1 = _e213; + let _e224 = LightCount_u0028_(); + count_1 = _e224; i_9 = 0i; loop { - let _e214 = i_9; - if (_e214 < 32i) { - let _e216 = i_9; - let _e217 = count_1; - if (_e216 >= _e217) { + let _e225 = i_9; + if (_e225 < 32i) { + let _e227 = i_9; + let _e228 = count_1; + if (_e227 >= _e228) { break; } - let _e219 = i_9; - param_144 = _e219; - let _e220 = (*s_6); - param_145 = _e220; - let _e221 = SampleLight_u0028_i1_u003b_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b((¶m_144), (¶m_145)); - light_4 = _e221; - let _e223 = light_4.n_dot_l; - if (_e223 <= 0f) { + let _e230 = i_9; + param_150 = _e230; + let _e231 = (*s_7); + param_151 = _e231; + let _e232 = SampleLight_u0028_i1_u003b_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b((¶m_150), (¶m_151)); + light_4 = _e232; + let _e234 = light_4.n_dot_l; + if (_e234 <= 0f) { continue; } - let _e225 = i_9; - param_146 = _e225; - let _e226 = LightHasShadow_u0028_i1_u003b((¶m_146)); - if _e226 { - let _e227 = (*s_6); - param_147 = _e227; - let _e228 = light_4; - param_148 = _e228; - let _e229 = i_9; - param_149 = _e229; - let _e230 = LightVisibility_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b_struct_u002d_LightSample_u002d_vf3_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf31_u003b_i1_u003b((¶m_147), (¶m_148), (¶m_149)); - let _e231 = v_world_position_1; - param_150 = _e231; - let _e233 = (*s_6).n; - param_151 = _e233; - let _e234 = i_9; - param_152 = _e234; - let _e235 = PointShadowFactor_u0028_vf3_u003b_vf3_u003b_i1_u003b((¶m_150), (¶m_151), (¶m_152)); - local_26 = (_e230 * _e235); + light_transmittance = vec3(1f, 1f, 1f); + let _e236 = i_9; + param_152 = _e236; + let _e237 = LightHasShadow_u0028_i1_u003b((¶m_152)); + if _e237 { + let _e238 = (*s_7); + param_153 = _e238; + let _e239 = light_4; + param_154 = _e239; + let _e240 = i_9; + param_155 = _e240; + let _e241 = LightVisibility_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b_struct_u002d_LightSample_u002d_vf3_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf31_u003b_i1_u003b((¶m_153), (¶m_154), (¶m_155)); + let _e242 = v_world_position_1; + param_156 = _e242; + let _e244 = (*s_7).n; + param_157 = _e244; + let _e245 = i_9; + param_158 = _e245; + let _e246 = PointShadowFactor_u0028_vf3_u003b_vf3_u003b_i1_u003b((¶m_156), (¶m_157), (¶m_158)); + local_31 = (_e241 * _e246); } else { - local_26 = 1f; + local_31 = 1f; } - let _e237 = local_26; - visibility = _e237; - let _e238 = visibility; - if (_e238 <= 0f) { + let _e248 = local_31; + visibility = _e248; + let _e249 = visibility; + if (_e249 <= 0f) { continue; } - let _e240 = (*s_6); - param_153 = _e240; - let _e241 = light_4; - param_154 = _e241; - let _e242 = ShadeLight_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b_struct_u002d_LightSample_u002d_vf3_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf31_u003b((¶m_153), (¶m_154)); - let _e244 = light_4.radiance; - let _e247 = light_4.n_dot_l; - let _e249 = visibility; - let _e251 = total_1; - total_1 = (_e251 + (((_e242 * _e244) * _e247) * _e249)); + let _e251 = (*s_7); + param_159 = _e251; + let _e252 = light_4; + param_160 = _e252; + let _e253 = ShadeLight_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b_struct_u002d_LightSample_u002d_vf3_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf31_u003b((¶m_159), (¶m_160)); + let _e255 = light_4.radiance; + let _e258 = light_4.n_dot_l; + let _e260 = visibility; + let _e262 = light_transmittance; + let _e264 = total_1; + total_1 = (_e264 + ((((_e253 * _e255) * _e258) * _e260) * _e262)); continue; } else { break; } continuing { - let _e253 = i_9; - i_9 = (_e253 + 1i); + let _e266 = i_9; + i_9 = (_e266 + 1i); } } - let _e255 = total_1; - return _e255; + let _e268 = total_1; + return _e268; } fn SampleLightmap_u0028_() -> vec3 { var texel_4: vec4; - let _e196 = v_lightmap_uv_1; - let _e197 = textureSample(lightmap_texture_tex, lightmap_texture_smp, _e196); - texel_4 = _e197; - let _e198 = texel_4; - let _e201 = texel_4[3u]; - return ((_e198.xyz * _e201) * 8f); + let _e207 = v_lightmap_uv_1; + let _e208 = textureSample(lightmap_texture_tex, lightmap_texture_smp, _e207); + texel_4 = _e208; + let _e209 = texel_4; + let _e212 = texel_4[3u]; + return ((_e209.xyz * _e212) * 8f); } fn EonAlbedo_u0028_vf3_u003b_f1_u003b_f1_u003b(rho_2: ptr>, r_4: ptr, mu_1: ptr) -> vec3 { var e_1: f32; - var param_155: f32; - var param_156: f32; - var param_157: f32; - var param_158: vec3; - var param_159: f32; - - let _e204 = (*mu_1); - param_155 = _e204; - let _e205 = (*r_4); - param_156 = _e205; - let _e206 = FonAlbedo_u0028_f1_u003b_f1_u003b((¶m_155), (¶m_156)); - e_1 = _e206; - let _e207 = (*rho_2); - let _e208 = e_1; - let _e210 = (*r_4); - param_157 = _e210; - let _e211 = FonAverage_u0028_f1_u003b((¶m_157)); - let _e212 = (*rho_2); - param_158 = _e212; - param_159 = _e211; - let _e213 = EonMultiAlbedo_u0028_vf3_u003b_f1_u003b((¶m_158), (¶m_159)); - let _e214 = e_1; - return ((_e207 * _e208) + (_e213 * (1f - _e214))); + var param_161: f32; + var param_162: f32; + var param_163: f32; + var param_164: vec3; + var param_165: f32; + + let _e215 = (*mu_1); + param_161 = _e215; + let _e216 = (*r_4); + param_162 = _e216; + let _e217 = FonAlbedo_u0028_f1_u003b_f1_u003b((¶m_161), (¶m_162)); + e_1 = _e217; + let _e218 = (*rho_2); + let _e219 = e_1; + let _e221 = (*r_4); + param_163 = _e221; + let _e222 = FonAverage_u0028_f1_u003b((¶m_163)); + let _e223 = (*rho_2); + param_164 = _e223; + param_165 = _e222; + let _e224 = EonMultiAlbedo_u0028_vf3_u003b_f1_u003b((¶m_164), (¶m_165)); + let _e225 = e_1; + return ((_e218 * _e219) + (_e224 * (1f - _e225))); } fn MaterialLodBias_u0028_() -> f32 { - let _e197 = frag_info.target_origin[1u]; - return _e197; + let _e208 = frag_info.target_origin[1u]; + return _e208; } -fn ApplyMetallicRoughnessMap_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b(s_7: ptr) { +fn ApplyMetallicRoughnessMap_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b(s_8: ptr) { var orm: vec3; - let _e197 = v_texcoord_1; - let _e198 = MaterialLodBias_u0028_(); - let _e199 = textureSampleBias(metallic_roughness_texture_tex, metallic_roughness_texture_smp, _e197, _e198); - orm = _e199.xyz; - let _e202 = (*s_7).metallic; - let _e204 = orm[2u]; - (*s_7).metallic = clamp((_e202 * _e204), 0f, 1f); - let _e209 = (*s_7).roughness; - let _e211 = orm[1u]; - (*s_7).roughness = clamp((_e209 * _e211), 0.02f, 1f); + let _e208 = v_texcoord_1; + let _e209 = MaterialLodBias_u0028_(); + let _e210 = textureSampleBias(metallic_roughness_texture_tex, metallic_roughness_texture_smp, _e208, _e209); + orm = _e210.xyz; + let _e213 = (*s_8).metallic; + let _e215 = orm[2u]; + (*s_8).metallic = clamp((_e213 * _e215), 0f, 1f); + let _e220 = (*s_8).roughness; + let _e222 = orm[1u]; + (*s_8).roughness = clamp((_e220 * _e222), 0.02f, 1f); return; } -fn SrgbToLinear_u0028_vf3_u003b(srgb: ptr>) -> vec3 { - let _e196 = (*srgb); - let _e199 = (*srgb); - let _e204 = (*srgb); - return mix((_e196 / vec3(12.92f)), pow(((_e199 + vec3(0.055f, 0.055f, 0.055f)) / vec3(1.055f)), vec3(2.4f, 2.4f, 2.4f)), step(vec3(0.04045f, 0.04045f, 0.04045f), _e204)); -} - -fn ApplyEmissiveMap_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b(s_8: ptr) { +fn ApplyEmissiveMap_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b(s_9: ptr) { var emissive: vec3; - var param_160: vec3; - - let _e198 = v_texcoord_1; - let _e199 = MaterialLodBias_u0028_(); - let _e200 = textureSampleBias(emissive_texture_tex, emissive_texture_smp, _e198, _e199); - param_160 = _e200.xyz; - let _e202 = SrgbToLinear_u0028_vf3_u003b((¶m_160)); - emissive = _e202; - let _e203 = emissive; - let _e205 = frag_info.emissive; - let _e210 = frag_info.material2_[3u]; - (*s_8).emissive = ((_e203 * _e205.xyz) * _e210); + var param_166: vec3; + + let _e209 = v_texcoord_1; + let _e210 = MaterialLodBias_u0028_(); + let _e211 = textureSampleBias(emissive_texture_tex, emissive_texture_smp, _e209, _e210); + param_166 = _e211.xyz; + let _e213 = SrgbToLinear_u0028_vf3_u003b((¶m_166)); + emissive = _e213; + let _e214 = emissive; + let _e216 = frag_info.emissive; + let _e221 = frag_info.material2_[3u]; + (*s_9).emissive = ((_e214 * _e216.xyz) * _e221); return; } -fn ApplyOcclusionMap_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b(s_9: ptr) { +fn ApplyOcclusionMap_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b(s_10: ptr) { var occlusion: f32; - let _e197 = v_texcoord_1; - let _e198 = MaterialLodBias_u0028_(); - let _e199 = textureSampleBias(occlusion_texture_tex, occlusion_texture_smp, _e197, _e198); - occlusion = _e199.x; - let _e201 = occlusion; - let _e204 = frag_info.material2_[2u]; - (*s_9).occlusion = mix(1f, _e201, clamp(_e204, 0f, 1f)); + let _e208 = v_texcoord_1; + let _e209 = MaterialLodBias_u0028_(); + let _e210 = textureSampleBias(occlusion_texture_tex, occlusion_texture_smp, _e208, _e209); + occlusion = _e210.x; + let _e212 = occlusion; + let _e215 = frag_info.material2_[2u]; + (*s_10).occlusion = mix(1f, _e212, clamp(_e215, 0f, 1f)); return; } -fn ApplyNormalMap_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b(s_10: ptr) { +fn ApplyNormalMap_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b(s_11: ptr) { var sampledTexel: vec4; var t_2: vec3; var b_4: vec3; var sampled: vec3; - let _e200 = v_texcoord_1; - let _e201 = MaterialLodBias_u0028_(); - let _e202 = textureSampleBias(normal_texture_tex, normal_texture_smp, _e200, _e201); - sampledTexel = _e202; - let _e203 = v_tangent_1; - t_2 = _e203.xyz; - let _e205 = t_2; - let _e207 = (*s_10).n; - let _e209 = (*s_10).n; - let _e210 = t_2; - t_2 = (_e205 - (_e207 * dot(_e209, _e210))); - let _e214 = t_2; - let _e215 = t_2; - if (dot(_e214, _e215) < 0.000000000001f) { + let _e211 = v_texcoord_1; + let _e212 = MaterialLodBias_u0028_(); + let _e213 = textureSampleBias(normal_texture_tex, normal_texture_smp, _e211, _e212); + sampledTexel = _e213; + let _e214 = v_tangent_1; + t_2 = _e214.xyz; + let _e216 = t_2; + let _e218 = (*s_11).n; + let _e220 = (*s_11).n; + let _e221 = t_2; + t_2 = (_e216 - (_e218 * dot(_e220, _e221))); + let _e225 = t_2; + let _e226 = t_2; + if (dot(_e225, _e226) < 0.000000000001f) { return; } - let _e218 = t_2; - t_2 = normalize(_e218); - let _e221 = (*s_10).n; - let _e222 = t_2; - let _e225 = v_tangent_1[3u]; - b_4 = (cross(_e221, _e222) * _e225); - let _e227 = gl_FrontFacing_1; - if !(_e227) { - let _e229 = t_2; - t_2 = -(_e229); - } - let _e231 = sampledTexel; - sampled = ((_e231.xyz * 2f) - vec3(1f)); - let _e238 = frag_info.emissive[3u]; - if (_e238 > 0.5f) { - let _e240 = sampled; - let _e242 = sampled; - sampled[2u] = sqrt(max((1f - dot(_e240.xy, _e242.xy)), 0f)); - } - let _e251 = frag_info.material2_[1u]; - let _e252 = sampled; - let _e254 = (_e252.xy * _e251); - sampled[0u] = _e254.x; - sampled[1u] = _e254.y; - let _e259 = t_2; - let _e261 = sampled[0u]; - let _e263 = b_4; - let _e265 = sampled[1u]; - let _e269 = (*s_10).n; - let _e271 = sampled[2u]; - (*s_10).n = normalize((((_e259 * _e261) + (_e263 * _e265)) + (_e269 * _e271))); - let _e277 = (*s_10).n; - let _e279 = (*s_10).v; - (*s_10).n_dot_v = max(dot(_e277, _e279), 0.0001f); + let _e229 = t_2; + t_2 = normalize(_e229); + let _e232 = (*s_11).n; + let _e233 = t_2; + let _e236 = v_tangent_1[3u]; + b_4 = (cross(_e232, _e233) * _e236); + let _e238 = gl_FrontFacing_1; + if !(_e238) { + let _e240 = t_2; + t_2 = -(_e240); + } + let _e242 = sampledTexel; + sampled = ((_e242.xyz * 2f) - vec3(1f)); + let _e249 = frag_info.emissive[3u]; + if (_e249 > 0.5f) { + let _e251 = sampled; + let _e253 = sampled; + sampled[2u] = sqrt(max((1f - dot(_e251.xy, _e253.xy)), 0f)); + } + let _e262 = frag_info.material2_[1u]; + let _e263 = sampled; + let _e265 = (_e263.xy * _e262); + sampled[0u] = _e265.x; + sampled[1u] = _e265.y; + let _e270 = t_2; + let _e272 = sampled[0u]; + let _e274 = b_4; + let _e276 = sampled[1u]; + let _e280 = (*s_11).n; + let _e282 = sampled[2u]; + (*s_11).n = normalize((((_e270 * _e272) + (_e274 * _e276)) + (_e280 * _e282))); + let _e288 = (*s_11).n; + let _e290 = (*s_11).v; + (*s_11).n_dot_v = max(dot(_e288, _e290), 0.0001f); return; } fn AtlasTexel_u0028_vf2_u003b(texel_5: ptr>) -> vec4 { - let _e196 = (*texel_5); - let _e200 = irradiance_info.atlas; - let _e203 = textureSampleLevel(irradiance_texture_tex, irradiance_texture_smp, ((_e196 + vec2(0.5f)) * _e200.xy), 0f); - return _e203; + let _e207 = (*texel_5); + let _e211 = irradiance_info.atlas; + let _e214 = textureSampleLevel(irradiance_texture_tex, irradiance_texture_smp, ((_e207 + vec2(0.5f)) * _e211.xy), 0f); + return _e214; } fn TileBilinear_u0028_vf2_u003b_f1_u003b_vf2_u003b(corner: ptr>, interior: ptr, uv_6: ptr>) -> vec4 { @@ -14739,50 +16815,50 @@ fn TileBilinear_u0028_vf2_u003b_f1_u003b_vf2_u003b(corner: ptr; var f_3: vec2; var a_6: vec4; - var param_161: vec2; + var param_167: vec2; var b_5: vec4; - var param_162: vec2; - var c: vec4; - var param_163: vec2; + var param_168: vec2; + var c_1: vec4; + var param_169: vec2; var d_5: vec4; - var param_164: vec2; - - let _e209 = (*uv_6); - let _e210 = (*interior); - at_1 = ((vec2(1f) + (_e209 * _e210)) - vec2(0.5f)); - let _e216 = at_1; - low = floor(_e216); - let _e218 = at_1; - let _e219 = low; - f_3 = (_e218 - _e219); - let _e221 = (*corner); - let _e222 = low; - param_161 = (_e221 + _e222); - let _e224 = AtlasTexel_u0028_vf2_u003b((¶m_161)); - a_6 = _e224; - let _e225 = (*corner); - let _e226 = low; - param_162 = ((_e225 + _e226) + vec2(1f, 0f)); - let _e229 = AtlasTexel_u0028_vf2_u003b((¶m_162)); - b_5 = _e229; - let _e230 = (*corner); - let _e231 = low; - param_163 = ((_e230 + _e231) + vec2(0f, 1f)); - let _e234 = AtlasTexel_u0028_vf2_u003b((¶m_163)); - c = _e234; - let _e235 = (*corner); - let _e236 = low; - param_164 = ((_e235 + _e236) + vec2(1f, 1f)); - let _e239 = AtlasTexel_u0028_vf2_u003b((¶m_164)); - d_5 = _e239; - let _e240 = a_6; - let _e241 = b_5; - let _e243 = f_3[0u]; - let _e246 = c; - let _e247 = d_5; - let _e249 = f_3[0u]; - let _e253 = f_3[1u]; - return mix(mix(_e240, _e241, vec4(_e243)), mix(_e246, _e247, vec4(_e249)), vec4(_e253)); + var param_170: vec2; + + let _e220 = (*uv_6); + let _e221 = (*interior); + at_1 = ((vec2(1f) + (_e220 * _e221)) - vec2(0.5f)); + let _e227 = at_1; + low = floor(_e227); + let _e229 = at_1; + let _e230 = low; + f_3 = (_e229 - _e230); + let _e232 = (*corner); + let _e233 = low; + param_167 = (_e232 + _e233); + let _e235 = AtlasTexel_u0028_vf2_u003b((¶m_167)); + a_6 = _e235; + let _e236 = (*corner); + let _e237 = low; + param_168 = ((_e236 + _e237) + vec2(1f, 0f)); + let _e240 = AtlasTexel_u0028_vf2_u003b((¶m_168)); + b_5 = _e240; + let _e241 = (*corner); + let _e242 = low; + param_169 = ((_e241 + _e242) + vec2(0f, 1f)); + let _e245 = AtlasTexel_u0028_vf2_u003b((¶m_169)); + c_1 = _e245; + let _e246 = (*corner); + let _e247 = low; + param_170 = ((_e246 + _e247) + vec2(1f, 1f)); + let _e250 = AtlasTexel_u0028_vf2_u003b((¶m_170)); + d_5 = _e250; + let _e251 = a_6; + let _e252 = b_5; + let _e254 = f_3[0u]; + let _e257 = c_1; + let _e258 = d_5; + let _e260 = f_3[0u]; + let _e264 = f_3[1u]; + return mix(mix(_e251, _e252, vec4(_e254)), mix(_e257, _e258, vec4(_e260)), vec4(_e264)); } fn ProbeOctahedral_u0028_vf3_u003b(direction_1: ptr>) -> vec2 { @@ -14790,32 +16866,32 @@ fn ProbeOctahedral_u0028_vf3_u003b(direction_1: ptr>) -> vec var n_6: vec3; var xy: vec2; - let _e200 = (*direction_1)[0u]; - let _e203 = (*direction_1)[1u]; - let _e207 = (*direction_1)[2u]; - sum_1 = ((abs(_e200) + abs(_e203)) + abs(_e207)); - let _e210 = sum_1; - if (_e210 <= 0f) { + let _e211 = (*direction_1)[0u]; + let _e214 = (*direction_1)[1u]; + let _e218 = (*direction_1)[2u]; + sum_1 = ((abs(_e211) + abs(_e214)) + abs(_e218)); + let _e221 = sum_1; + if (_e221 <= 0f) { return vec2(0.5f, 0.5f); } - let _e212 = (*direction_1); - let _e213 = sum_1; - n_6 = (_e212 / vec3(_e213)); - let _e216 = n_6; - xy = _e216.xy; - let _e219 = n_6[2u]; - if (_e219 < 0f) { - let _e222 = n_6[1u]; - let _e226 = n_6[0u]; - let _e231 = n_6[0u]; - let _e235 = n_6[1u]; - xy = vec2(((1f - abs(_e222)) * select(-1f, 1f, (_e226 >= 0f))), ((1f - abs(_e231)) * select(-1f, 1f, (_e235 >= 0f)))); + let _e223 = (*direction_1); + let _e224 = sum_1; + n_6 = (_e223 / vec3(_e224)); + let _e227 = n_6; + xy = _e227.xy; + let _e230 = n_6[2u]; + if (_e230 < 0f) { + let _e233 = n_6[1u]; + let _e237 = n_6[0u]; + let _e242 = n_6[0u]; + let _e246 = n_6[1u]; + xy = vec2(((1f - abs(_e233)) * select(-1f, 1f, (_e237 >= 0f))), ((1f - abs(_e242)) * select(-1f, 1f, (_e246 >= 0f)))); } - let _e240 = xy; - return ((_e240 * 0.5f) + vec2(0.5f)); + let _e251 = xy; + return ((_e251 * 0.5f) + vec2(0.5f)); } -fn SampleIrradiance_u0028_vf3_u003b_vf3_u003b_vf3_u003b(world_4: ptr>, normal_1: ptr>, view: ptr>) -> vec3 { +fn SampleIrradiance_u0028_vf3_u003b_vf3_u003b_vf3_u003b(world_4: ptr>, normal_1: ptr>, view_1: ptr>) -> vec3 { var origin_2: vec3; var spacing: vec3; var counts: vec3; @@ -14837,9 +16913,9 @@ fn SampleIrradiance_u0028_vf3_u003b_vf3_u003b_vf3_u003b(world_4: ptr; var irradianceCorner: vec2; var momentCorner: vec2; - var param_165: vec2; + var param_171: vec2; var trilinear: vec3; - var weight_2: f32; + var weight_3: f32; var probePosition: vec3; var toProbe: vec3; var distance_4: f32; @@ -14847,335 +16923,363 @@ fn SampleIrradiance_u0028_vf3_u003b_vf3_u003b_vf3_u003b(world_4: ptr; - var param_166: vec3; - var param_167: vec2; - var param_168: f32; - var param_169: vec2; + var param_172: vec3; + var param_173: vec2; + var param_174: f32; + var param_175: vec2; var chebyshev: f32; var variance_1: f32; var difference: f32; - var param_170: vec3; - var param_171: vec2; - var param_172: f32; - var param_173: vec2; - var local_27: vec3; - - let _e242 = irradiance_info.origin; - origin_2 = _e242.xyz; - let _e245 = irradiance_info.spacing; - spacing = _e245.xyz; - let _e248 = irradiance_info.counts; - counts = _e248.xyz; - let _e252 = irradiance_info.tiles[0u]; - irradianceTile = _e252; - let _e255 = irradiance_info.tiles[1u]; - depthTile = _e255; - let _e258 = irradiance_info.tiles[2u]; - columns = _e258; - let _e261 = irradiance_info.tiles[3u]; - momentsTop = _e261; - let _e262 = (*normal_1); - unit = normalize(_e262); - let _e264 = (*world_4); - let _e265 = unit; - let _e268 = irradiance_info.spacing[3u]; - let _e271 = (*view); - let _e274 = irradiance_info.counts[3u]; - biased = ((_e264 + (_e265 * _e268)) + (_e271 * _e274)); - let _e277 = biased; - let _e278 = origin_2; - let _e280 = spacing; - grid_1 = ((_e277 - _e278) / _e280); - let _e282 = grid_1; - let _e284 = counts; - base = clamp(floor(_e282), vec3(0f, 0f, 0f), (_e284 - vec3(2f))); - let _e288 = grid_1; - let _e289 = base; - f_4 = clamp((_e288 - _e289), vec3(0f, 0f, 0f), vec3(1f, 1f, 1f)); + var param_176: vec3; + var param_177: vec2; + var param_178: f32; + var param_179: vec2; + var local_32: vec3; + + let _e253 = irradiance_info.origin; + origin_2 = _e253.xyz; + let _e256 = irradiance_info.spacing; + spacing = _e256.xyz; + let _e259 = irradiance_info.counts; + counts = _e259.xyz; + let _e263 = irradiance_info.tiles[0u]; + irradianceTile = _e263; + let _e266 = irradiance_info.tiles[1u]; + depthTile = _e266; + let _e269 = irradiance_info.tiles[2u]; + columns = _e269; + let _e272 = irradiance_info.tiles[3u]; + momentsTop = _e272; + let _e273 = (*normal_1); + unit = normalize(_e273); + let _e275 = (*world_4); + let _e276 = unit; + let _e279 = irradiance_info.spacing[3u]; + let _e282 = (*view_1); + let _e285 = irradiance_info.counts[3u]; + biased = ((_e275 + (_e276 * _e279)) + (_e282 * _e285)); + let _e288 = biased; + let _e289 = origin_2; + let _e291 = spacing; + grid_1 = ((_e288 - _e289) / _e291); + let _e293 = grid_1; + let _e295 = counts; + base = clamp(floor(_e293), vec3(0f, 0f, 0f), (_e295 - vec3(2f))); + let _e299 = grid_1; + let _e300 = base; + f_4 = clamp((_e299 - _e300), vec3(0f, 0f, 0f), vec3(1f, 1f, 1f)); total_2 = vec3(0f, 0f, 0f); weights = 0f; corner_1 = 0i; loop { - let _e292 = corner_1; - if (_e292 < 8i) { - let _e294 = corner_1; - let _e297 = corner_1; - let _e302 = corner_1; - offset_3 = vec3(f32((_e294 & 1i)), f32(((_e297 >> bitcast(1i)) & 1i)), f32(((_e302 >> bitcast(2i)) & 1i))); - let _e308 = base; - let _e309 = offset_3; - cell_1 = (_e308 + _e309); - let _e312 = cell_1[2u]; - let _e314 = counts[1u]; - let _e317 = cell_1[1u]; - let _e320 = counts[0u]; - let _e323 = cell_1[0u]; - probe = ((((_e312 * _e314) + _e317) * _e320) + _e323); - let _e325 = probe; - let _e326 = columns; - let _e331 = probe; - let _e332 = columns; - tile_4 = vec2((_e325 - (floor((_e325 / _e326)) * _e326)), floor((_e331 / _e332))); - let _e336 = tile_4; - let _e337 = irradianceTile; - irradianceCorner = (_e336 * (_e337 + 2f)); - let _e341 = tile_4[0u]; - let _e342 = depthTile; - let _e345 = momentsTop; - let _e347 = tile_4[1u]; - let _e348 = depthTile; - momentCorner = vec2((_e341 * (_e342 + 2f)), (_e345 + (_e347 * (_e348 + 2f)))); - let _e353 = irradianceCorner; - param_165 = (_e353 + vec2(1f)); - let _e356 = AtlasTexel_u0028_vf2_u003b((¶m_165)); - if (_e356.w < 0.5f) { + let _e303 = corner_1; + if (_e303 < 8i) { + let _e305 = corner_1; + let _e308 = corner_1; + let _e313 = corner_1; + offset_3 = vec3(f32((_e305 & 1i)), f32(((_e308 >> bitcast(1i)) & 1i)), f32(((_e313 >> bitcast(2i)) & 1i))); + let _e319 = base; + let _e320 = offset_3; + cell_1 = (_e319 + _e320); + let _e323 = cell_1[2u]; + let _e325 = counts[1u]; + let _e328 = cell_1[1u]; + let _e331 = counts[0u]; + let _e334 = cell_1[0u]; + probe = ((((_e323 * _e325) + _e328) * _e331) + _e334); + let _e336 = probe; + let _e337 = columns; + let _e342 = probe; + let _e343 = columns; + tile_4 = vec2((_e336 - (floor((_e336 / _e337)) * _e337)), floor((_e342 / _e343))); + let _e347 = tile_4; + let _e348 = irradianceTile; + irradianceCorner = (_e347 * (_e348 + 2f)); + let _e352 = tile_4[0u]; + let _e353 = depthTile; + let _e356 = momentsTop; + let _e358 = tile_4[1u]; + let _e359 = depthTile; + momentCorner = vec2((_e352 * (_e353 + 2f)), (_e356 + (_e358 * (_e359 + 2f)))); + let _e364 = irradianceCorner; + param_171 = (_e364 + vec2(1f)); + let _e367 = AtlasTexel_u0028_vf2_u003b((¶m_171)); + if (_e367.w < 0.5f) { continue; } - let _e359 = f_4; - let _e361 = f_4; - let _e362 = offset_3; - trilinear = mix((vec3(1f, 1f, 1f) - _e359), _e361, _e362); - let _e365 = trilinear[0u]; - let _e367 = trilinear[1u]; - let _e370 = trilinear[2u]; - weight_2 = max(((_e365 * _e367) * _e370), 0.001f); - let _e373 = origin_2; - let _e374 = spacing; - let _e375 = cell_1; - probePosition = (_e373 + (_e374 * _e375)); - let _e378 = probePosition; - let _e379 = biased; - toProbe = (_e378 - _e379); - let _e381 = toProbe; - distance_4 = length(_e381); - let _e383 = distance_4; - if (_e383 > 0.000001f) { - let _e385 = toProbe; - let _e386 = distance_4; - direction_2 = (_e385 / vec3(_e386)); - let _e389 = unit; - let _e390 = probePosition; - let _e391 = (*world_4); - facing = ((dot(_e389, normalize((_e390 - _e391))) * 0.5f) + 0.5f); - let _e397 = facing; - let _e398 = facing; - probeWeight = ((_e397 * _e398) + 0.2f); - let _e401 = direction_2; - param_166 = -(_e401); - let _e403 = ProbeOctahedral_u0028_vf3_u003b((¶m_166)); - let _e404 = momentCorner; - param_167 = _e404; - let _e405 = depthTile; - param_168 = _e405; - param_169 = _e403; - let _e406 = TileBilinear_u0028_vf2_u003b_f1_u003b_vf2_u003b((¶m_167), (¶m_168), (¶m_169)); - moments_3 = _e406.xy; + let _e370 = f_4; + let _e372 = f_4; + let _e373 = offset_3; + trilinear = mix((vec3(1f, 1f, 1f) - _e370), _e372, _e373); + let _e376 = trilinear[0u]; + let _e378 = trilinear[1u]; + let _e381 = trilinear[2u]; + weight_3 = max(((_e376 * _e378) * _e381), 0.001f); + let _e384 = origin_2; + let _e385 = spacing; + let _e386 = cell_1; + probePosition = (_e384 + (_e385 * _e386)); + let _e389 = probePosition; + let _e390 = biased; + toProbe = (_e389 - _e390); + let _e392 = toProbe; + distance_4 = length(_e392); + let _e394 = distance_4; + if (_e394 > 0.000001f) { + let _e396 = toProbe; + let _e397 = distance_4; + direction_2 = (_e396 / vec3(_e397)); + let _e400 = unit; + let _e401 = probePosition; + let _e402 = (*world_4); + facing = ((dot(_e400, normalize((_e401 - _e402))) * 0.5f) + 0.5f); + let _e408 = facing; + let _e409 = facing; + probeWeight = ((_e408 * _e409) + 0.2f); + let _e412 = direction_2; + param_172 = -(_e412); + let _e414 = ProbeOctahedral_u0028_vf3_u003b((¶m_172)); + let _e415 = momentCorner; + param_173 = _e415; + let _e416 = depthTile; + param_174 = _e416; + param_175 = _e414; + let _e417 = TileBilinear_u0028_vf2_u003b_f1_u003b_vf2_u003b((¶m_173), (¶m_174), (¶m_175)); + moments_3 = _e417.xy; chebyshev = 1f; - let _e408 = distance_4; - let _e410 = moments_3[0u]; - if (_e408 > _e410) { - let _e413 = moments_3[1u]; - let _e415 = moments_3[0u]; - let _e417 = moments_3[0u]; - variance_1 = max((_e413 - (_e415 * _e417)), 0.000001f); - let _e421 = distance_4; - let _e423 = moments_3[0u]; - difference = (_e421 - _e423); - let _e425 = variance_1; - let _e426 = variance_1; - let _e427 = difference; - let _e428 = difference; - chebyshev = (_e425 / (_e426 + (_e427 * _e428))); - let _e432 = chebyshev; - let _e433 = chebyshev; - let _e435 = chebyshev; - chebyshev = ((_e432 * _e433) * _e435); + let _e419 = distance_4; + let _e421 = moments_3[0u]; + if (_e419 > _e421) { + let _e424 = moments_3[1u]; + let _e426 = moments_3[0u]; + let _e428 = moments_3[0u]; + variance_1 = max((_e424 - (_e426 * _e428)), 0.000001f); + let _e432 = distance_4; + let _e434 = moments_3[0u]; + difference = (_e432 - _e434); + let _e436 = variance_1; + let _e437 = variance_1; + let _e438 = difference; + let _e439 = difference; + chebyshev = (_e436 / (_e437 + (_e438 * _e439))); + let _e443 = chebyshev; + let _e444 = chebyshev; + let _e446 = chebyshev; + chebyshev = ((_e443 * _e444) * _e446); } - let _e437 = probeWeight; - let _e438 = chebyshev; - probeWeight = max((_e437 * max(_e438, 0.05f)), 0.000001f); - let _e442 = probeWeight; - if (_e442 < 0.2f) { - let _e444 = probeWeight; - let _e445 = probeWeight; - let _e448 = probeWeight; - probeWeight = (_e448 * ((_e444 * _e445) * 25f)); + let _e448 = probeWeight; + let _e449 = chebyshev; + probeWeight = max((_e448 * max(_e449, 0.05f)), 0.000001f); + let _e453 = probeWeight; + if (_e453 < 0.2f) { + let _e455 = probeWeight; + let _e456 = probeWeight; + let _e459 = probeWeight; + probeWeight = (_e459 * ((_e455 * _e456) * 25f)); } - let _e450 = probeWeight; - let _e451 = weight_2; - weight_2 = (_e451 * _e450); - } - let _e453 = unit; - param_170 = _e453; - let _e454 = ProbeOctahedral_u0028_vf3_u003b((¶m_170)); - let _e455 = irradianceCorner; - param_171 = _e455; - let _e456 = irradianceTile; - param_172 = _e456; - param_173 = _e454; - let _e457 = TileBilinear_u0028_vf2_u003b_f1_u003b_vf2_u003b((¶m_171), (¶m_172), (¶m_173)); - let _e459 = weight_2; - let _e461 = total_2; - total_2 = (_e461 + (_e457.xyz * _e459)); - let _e463 = weight_2; - let _e464 = weights; - weights = (_e464 + _e463); + let _e461 = probeWeight; + let _e462 = weight_3; + weight_3 = (_e462 * _e461); + } + let _e464 = unit; + param_176 = _e464; + let _e465 = ProbeOctahedral_u0028_vf3_u003b((¶m_176)); + let _e466 = irradianceCorner; + param_177 = _e466; + let _e467 = irradianceTile; + param_178 = _e467; + param_179 = _e465; + let _e468 = TileBilinear_u0028_vf2_u003b_f1_u003b_vf2_u003b((¶m_177), (¶m_178), (¶m_179)); + let _e470 = weight_3; + let _e472 = total_2; + total_2 = (_e472 + (_e468.xyz * _e470)); + let _e474 = weight_3; + let _e475 = weights; + weights = (_e475 + _e474); continue; } else { break; } continuing { - let _e466 = corner_1; - corner_1 = (_e466 + 1i); + let _e477 = corner_1; + corner_1 = (_e477 + 1i); } } - let _e468 = weights; - if (_e468 > 0f) { - let _e470 = total_2; - let _e471 = weights; - local_27 = (_e470 / vec3(_e471)); + let _e479 = weights; + if (_e479 > 0f) { + let _e481 = total_2; + let _e482 = weights; + local_32 = (_e481 / vec3(_e482)); } else { - local_27 = vec3(0f, 0f, 0f); + local_32 = vec3(0f, 0f, 0f); } - let _e474 = local_27; - return _e474; + let _e485 = local_32; + return _e485; } fn IrradianceEnabled_u0028_() -> bool { - let _e197 = irradiance_info.origin[3u]; - return (_e197 > 0.5f); + let _e208 = irradiance_info.origin[3u]; + return (_e208 > 0.5f); } -fn ApplyCommonMaps_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b(s_11: ptr) { - var param_174: vec3; - var param_175: vec3; - var param_176: vec3; - var param_177: Surface; - var param_178: Surface; - var param_179: Surface; - - let _e202 = IrradianceEnabled_u0028_(); - if _e202 { - let _e203 = v_world_position_1; - param_174 = _e203; - let _e205 = (*s_11).n; - param_175 = _e205; - let _e207 = (*s_11).v; - param_176 = _e207; - let _e208 = SampleIrradiance_u0028_vf3_u003b_vf3_u003b_vf3_u003b((¶m_174), (¶m_175), (¶m_176)); - let _e211 = frag_info.material[2u]; - (*s_11).ambient = (_e208 * _e211); - } - let _e214 = (*s_11); - param_177 = _e214; - ApplyNormalMap_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b((¶m_177)); - let _e215 = param_177; - (*s_11) = _e215; - let _e216 = (*s_11); - param_178 = _e216; - ApplyOcclusionMap_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b((¶m_178)); - let _e217 = param_178; - (*s_11) = _e217; - let _e218 = (*s_11); - param_179 = _e218; - ApplyEmissiveMap_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b((¶m_179)); - let _e219 = param_179; - (*s_11) = _e219; +fn ApplyCommonMaps_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b(s_12: ptr) { + var param_180: vec3; + var param_181: vec3; + var param_182: vec3; + var param_183: Surface; + var param_184: Surface; + var param_185: Surface; + + let _e213 = IrradianceEnabled_u0028_(); + if _e213 { + let _e214 = v_world_position_1; + param_180 = _e214; + let _e216 = (*s_12).n; + param_181 = _e216; + let _e218 = (*s_12).v; + param_182 = _e218; + let _e219 = SampleIrradiance_u0028_vf3_u003b_vf3_u003b_vf3_u003b((¶m_180), (¶m_181), (¶m_182)); + let _e222 = frag_info.material[2u]; + (*s_12).ambient = (_e219 * _e222); + } + let _e225 = (*s_12); + param_183 = _e225; + ApplyNormalMap_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b((¶m_183)); + let _e226 = param_183; + (*s_12) = _e226; + let _e227 = (*s_12); + param_184 = _e227; + ApplyOcclusionMap_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b((¶m_184)); + let _e228 = param_184; + (*s_12) = _e228; + let _e229 = (*s_12); + param_185 = _e229; + ApplyEmissiveMap_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b((¶m_185)); + let _e230 = param_185; + (*s_12) = _e230; return; } +fn TowardsEye_u0028_() -> vec3 { + var local_33: vec3; + + let _e207 = Orthographic_u0028_(); + if _e207 { + let _e209 = fog_info.forward; + local_33 = -(_e209.xyz); + } else { + let _e213 = fog_info.eye; + let _e215 = v_world_position_1; + local_33 = normalize((_e213.xyz - _e215)); + } + let _e218 = local_33; + return _e218; +} + fn ReadSurface_u0028_() -> Surface { var texel_6: vec4; - var s_12: Surface; - var param_180: vec3; - var param_181: vec3; + var s_13: Surface; + var param_186: vec3; + var param_187: vec3; var cutoff: f32; + var edge: f32; var anchored: vec3; var noise_1: f32; - let _e202 = v_texcoord_1; - let _e203 = MaterialLodBias_u0028_(); - let _e204 = textureSampleBias(base_color_texture_tex, base_color_texture_smp, _e202, _e203); - texel_6 = _e204; - let _e205 = texel_6; - param_180 = _e205.xyz; - let _e207 = SrgbToLinear_u0028_vf3_u003b((¶m_180)); - let _e209 = frag_info.base_color; - param_181 = _e209.xyz; - let _e211 = SrgbToLinear_u0028_vf3_u003b((¶m_181)); - let _e213 = v_color_1; - s_12.albedo = ((_e207 * _e211) * _e213.xyz); - let _e218 = texel_6[3u]; - let _e221 = frag_info.base_color[3u]; - let _e224 = v_color_1[3u]; - s_12.alpha = ((_e218 * _e221) * _e224); - let _e228 = s_12.albedo; - g_albedo = _e228; - let _e231 = frag_info.material2_[0u]; - cutoff = _e231; - let _e232 = cutoff; - if (_e232 >= 0f) { - let _e235 = s_12.alpha; - let _e236 = cutoff; - if (_e235 < _e236) { - discard; - } + let _e214 = v_texcoord_1; + let _e215 = MaterialLodBias_u0028_(); + let _e216 = textureSampleBias(base_color_texture_tex, base_color_texture_smp, _e214, _e215); + texel_6 = _e216; + let _e217 = texel_6; + param_186 = _e217.xyz; + let _e219 = SrgbToLinear_u0028_vf3_u003b((¶m_186)); + let _e221 = frag_info.base_color; + param_187 = _e221.xyz; + let _e223 = SrgbToLinear_u0028_vf3_u003b((¶m_187)); + let _e225 = v_color_1; + s_13.albedo = ((_e219 * _e223) * _e225.xyz); + let _e230 = texel_6[3u]; + let _e233 = frag_info.base_color[3u]; + let _e236 = v_color_1[3u]; + s_13.alpha = ((_e230 * _e233) * _e236); + let _e240 = s_13.albedo; + g_albedo = _e240; + let _e243 = frag_info.material2_[0u]; + cutoff = _e243; + let _e244 = cutoff; + if (_e244 > 1f) { + let _e246 = cutoff; + edge = (_e246 - 1f); + let _e249 = s_13.alpha; + let _e250 = edge; + let _e253 = s_13.alpha; + let _e254 = fwidth(_e253); + s_13.alpha = clamp((((_e249 - _e250) / max(_e254, 0.0001f)) + 0.5f), 0f, 1f); } else { - let _e238 = cutoff; - if (_e238 < -1.5f) { - let _e240 = v_world_position_1; - anchored = floor((_e240 * 16f)); - let _e243 = anchored; - noise_1 = fract((sin(dot(_e243, vec3(12.9898f, 78.233f, 37.719f))) * 43758.547f)); - let _e249 = s_12.alpha; - let _e250 = noise_1; - if (_e249 < _e250) { + let _e260 = cutoff; + if (_e260 >= 0f) { + let _e263 = s_13.alpha; + let _e264 = cutoff; + if (_e263 < _e264) { discard; } + s_13.alpha = 1f; + } else { + let _e267 = cutoff; + if (_e267 < -1.5f) { + let _e269 = v_world_position_1; + anchored = floor((_e269 * 16f)); + let _e272 = anchored; + noise_1 = fract((sin(dot(_e272, vec3(12.9898f, 78.233f, 37.719f))) * 43758.547f)); + let _e278 = s_13.alpha; + let _e279 = noise_1; + if (_e278 < _e279) { + discard; + } + } } } - let _e252 = cutoff; - let _e254 = cutoff; - g_premultiply = ((_e252 < 0f) && (_e254 > -0.75f)); - let _e257 = v_normal_1; - s_12.n = normalize(_e257); - let _e260 = gl_FrontFacing_1; - if !(_e260) { - let _e263 = s_12.n; - s_12.n = -(_e263); - } - let _e267 = frag_info.camera_position; - let _e269 = v_world_position_1; - s_12.v = normalize((_e267.xyz - _e269)); - let _e274 = s_12.n; - let _e276 = s_12.v; - s_12.n_dot_v = max(dot(_e274, _e276), 0.0001f); - let _e282 = frag_info.material[0u]; - s_12.metallic = clamp(_e282, 0f, 1f); - let _e287 = frag_info.material[1u]; - s_12.roughness = clamp(_e287, 0.02f, 1f); - let _e291 = frag_info.ambient_ground; - let _e294 = frag_info.ambient_sky; - let _e298 = s_12.n[1u]; - let _e305 = frag_info.material[2u]; - s_12.ambient = (mix(_e291.xyz, _e294.xyz, vec3(((_e298 * 0.5f) + 0.5f))) * _e305); - let _e310 = frag_info.frame_params[0u]; - s_12.exposure = max(_e310, 0f); - s_12.occlusion = 1f; - s_12.emissive = vec3(0f, 0f, 0f); - let _e315 = s_12; - return _e315; + let _e281 = cutoff; + let _e283 = cutoff; + g_premultiply = ((_e281 < 0f) && (_e283 > -0.75f)); + let _e286 = v_normal_1; + s_13.n = normalize(_e286); + let _e289 = gl_FrontFacing_1; + if !(_e289) { + let _e292 = s_13.n; + s_13.n = -(_e292); + } + let _e295 = TowardsEye_u0028_(); + s_13.v = _e295; + let _e298 = s_13.n; + let _e300 = s_13.v; + s_13.n_dot_v = max(dot(_e298, _e300), 0.0001f); + let _e306 = frag_info.material[0u]; + s_13.metallic = clamp(_e306, 0f, 1f); + let _e311 = frag_info.material[1u]; + s_13.roughness = clamp(_e311, 0.02f, 1f); + let _e315 = frag_info.ambient_ground; + let _e318 = frag_info.ambient_sky; + let _e322 = s_13.n[1u]; + let _e329 = frag_info.material[2u]; + s_13.ambient = (mix(_e315.xyz, _e318.xyz, vec3(((_e322 * 0.5f) + 0.5f))) * _e329); + let _e334 = frag_info.frame_params[0u]; + s_13.exposure = max(_e334, 0f); + s_13.occlusion = 1f; + s_13.emissive = vec3(0f, 0f, 0f); + let _e339 = s_13; + return _e339; } fn main_1() { - var s_13: Surface; - var param_182: Surface; - var param_183: Surface; + var s_14: Surface; + var param_188: Surface; + var param_189: Surface; var metallic: f32; var diffuseColor_1: vec3; - var param_184: vec3; - var param_185: f32; - var param_186: f32; + var param_190: vec3; + var param_191: f32; + var param_192: f32; var ambient: vec3; var levels: f32; var f0_3: vec3; @@ -15183,136 +17287,151 @@ fn main_1() { var irradiance: vec3; var prefiltered: vec3; var ab_1: vec2; - var param_187: f32; - var param_188: f32; + var param_193: f32; + var param_194: f32; var single_1: vec3; var specular_1: vec3; var missed: f32; var average_2: vec3; var multiple: vec3; - var param_189: Surface; - var param_190: vec3; - var param_191: f32; - var param_192: f32; + var lit_3: vec3; + var param_195: Surface; + var param_196: Surface; + var param_197: vec3; + var param_198: vec3; + var param_199: f32; + var param_200: f32; g_albedo = vec3(0f, 0f, 0f); g_debug_surface = vec3(0f, 0f, 0f); g_debug_surface_on = false; g_premultiply = false; + g_pass_through = 0f; g_cluster_offset = 0f; g_cluster_count = 0f; - let _e221 = ReadSurface_u0028_(); - s_13 = _e221; - let _e222 = s_13; - param_182 = _e222; - ApplyCommonMaps_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b((¶m_182)); - let _e223 = param_182; - s_13 = _e223; - let _e224 = s_13; - param_183 = _e224; - ApplyMetallicRoughnessMap_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b((¶m_183)); - let _e225 = param_183; - s_13 = _e225; - let _e227 = s_13.metallic; - metallic = clamp(_e227, 0f, 1f); - let _e230 = s_13.albedo; - let _e231 = metallic; - diffuseColor_1 = (_e230 * (1f - _e231)); - let _e234 = EonDiffuse_u0028_(); - if _e234 { - let _e235 = diffuseColor_1; - param_184 = _e235; - let _e237 = s_13.roughness; - param_185 = _e237; - let _e239 = s_13.n_dot_v; - param_186 = _e239; - let _e240 = EonAlbedo_u0028_vf3_u003b_f1_u003b_f1_u003b((¶m_184), (¶m_185), (¶m_186)); - diffuseColor_1 = _e240; - } - let _e241 = diffuseColor_1; - let _e243 = s_13.ambient; - let _e246 = s_13.occlusion; - ambient = ((_e241 * _e243) * _e246); - let _e250 = frag_info.frame_params[3u]; - levels = _e250; - let _e251 = levels; - if (_e251 > 0f) { - let _e254 = s_13.albedo; - let _e255 = metallic; - f0_3 = mix(vec3(0.04f, 0.04f, 0.04f), _e254, vec3(_e255)); - let _e259 = s_13.v; - let _e262 = s_13.n; - reflected = reflect(-(_e259), _e262); - let _e265 = s_13.n; - let _e266 = levels; - let _e267 = textureSampleLevel(environment_texture_tex, environment_texture_smp, _e265, _e266); - irradiance = _e267.xyz; - let _e269 = reflected; - let _e271 = s_13.roughness; - let _e272 = levels; - let _e274 = textureSampleLevel(environment_texture_tex, environment_texture_smp, _e269, (_e271 * _e272)); - prefiltered = _e274.xyz; - let _e277 = s_13.roughness; - param_187 = _e277; - let _e279 = s_13.n_dot_v; - param_188 = _e279; - let _e280 = EnvBrdf_u0028_f1_u003b_f1_u003b((¶m_187), (¶m_188)); - ab_1 = _e280; - let _e281 = f0_3; - let _e283 = ab_1[0u]; - let _e286 = ab_1[1u]; - single_1 = ((_e281 * _e283) + vec3(_e286)); - let _e289 = prefiltered; - let _e290 = single_1; - specular_1 = (_e289 * _e290); - let _e292 = EnergyCompensation_u0028_(); - if _e292 { - let _e294 = ab_1[0u]; - let _e296 = ab_1[1u]; - missed = (1f - (_e294 + _e296)); - let _e299 = f0_3; - let _e300 = f0_3; - average_2 = (_e299 + ((vec3(1f, 1f, 1f) - _e300) / vec3(21f))); - let _e305 = single_1; - let _e306 = average_2; - let _e308 = missed; - let _e309 = average_2; - multiple = ((_e305 * _e306) / (vec3(1f, 1f, 1f) - (_e309 * _e308))); - let _e313 = multiple; - let _e314 = missed; - let _e316 = irradiance; - let _e318 = specular_1; - specular_1 = (_e318 + ((_e313 * _e314) * _e316)); - } - let _e320 = diffuseColor_1; - let _e321 = irradiance; - let _e323 = specular_1; - let _e327 = frag_info.material[2u]; - let _e330 = s_13.occlusion; - ambient = ((((_e320 * _e321) + _e323) * _e327) * _e330); - } - let _e332 = diffuseColor_1; - let _e333 = SampleLightmap_u0028_(); - let _e336 = s_13.occlusion; - let _e338 = ambient; - ambient = (_e338 + ((_e332 * _e333) * _e336)); - let _e340 = s_13; - param_189 = _e340; - let _e341 = AccumulateLights_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b((¶m_189)); - let _e343 = s_13.occlusion; - let _e345 = ambient; - let _e348 = s_13.emissive; - param_190 = (((_e341 * _e343) + _e345) + _e348); - let _e351 = s_13.alpha; - param_191 = _e351; - let _e353 = s_13.roughness; - param_192 = _e353; - WriteSurface_u0028_vf3_u003b_f1_u003b_f1_u003b((¶m_190), (¶m_191), (¶m_192)); + light_transmittance = vec3(1f, 1f, 1f); + let _e235 = ReadSurface_u0028_(); + s_14 = _e235; + let _e236 = s_14; + param_188 = _e236; + ApplyCommonMaps_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b((¶m_188)); + let _e237 = param_188; + s_14 = _e237; + let _e238 = s_14; + param_189 = _e238; + ApplyMetallicRoughnessMap_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b((¶m_189)); + let _e239 = param_189; + s_14 = _e239; + let _e241 = s_14.metallic; + metallic = clamp(_e241, 0f, 1f); + let _e244 = s_14.albedo; + let _e245 = metallic; + diffuseColor_1 = (_e244 * (1f - _e245)); + let _e248 = EonDiffuse_u0028_(); + if _e248 { + let _e249 = diffuseColor_1; + param_190 = _e249; + let _e251 = s_14.roughness; + param_191 = _e251; + let _e253 = s_14.n_dot_v; + param_192 = _e253; + let _e254 = EonAlbedo_u0028_vf3_u003b_f1_u003b_f1_u003b((¶m_190), (¶m_191), (¶m_192)); + diffuseColor_1 = _e254; + } + let _e255 = diffuseColor_1; + let _e257 = s_14.ambient; + let _e260 = s_14.occlusion; + ambient = ((_e255 * _e257) * _e260); + let _e264 = frag_info.frame_params[3u]; + levels = _e264; + let _e265 = levels; + if (_e265 > 0f) { + let _e268 = s_14.albedo; + let _e269 = metallic; + f0_3 = mix(vec3(0.04f, 0.04f, 0.04f), _e268, vec3(_e269)); + let _e273 = s_14.v; + let _e276 = s_14.n; + reflected = reflect(-(_e273), _e276); + let _e279 = s_14.n; + let _e280 = levels; + let _e281 = textureSampleLevel(environment_texture_tex, environment_texture_smp, _e279, _e280); + irradiance = _e281.xyz; + let _e283 = reflected; + let _e285 = s_14.roughness; + let _e286 = levels; + let _e288 = textureSampleLevel(environment_texture_tex, environment_texture_smp, _e283, (_e285 * _e286)); + prefiltered = _e288.xyz; + let _e291 = s_14.roughness; + param_193 = _e291; + let _e293 = s_14.n_dot_v; + param_194 = _e293; + let _e294 = EnvBrdf_u0028_f1_u003b_f1_u003b((¶m_193), (¶m_194)); + ab_1 = _e294; + let _e295 = f0_3; + let _e297 = ab_1[0u]; + let _e300 = ab_1[1u]; + single_1 = ((_e295 * _e297) + vec3(_e300)); + let _e303 = prefiltered; + let _e304 = single_1; + specular_1 = (_e303 * _e304); + let _e306 = EnergyCompensation_u0028_(); + if _e306 { + let _e308 = ab_1[0u]; + let _e310 = ab_1[1u]; + missed = (1f - (_e308 + _e310)); + let _e313 = f0_3; + let _e314 = f0_3; + average_2 = (_e313 + ((vec3(1f, 1f, 1f) - _e314) / vec3(21f))); + let _e319 = single_1; + let _e320 = average_2; + let _e322 = missed; + let _e323 = average_2; + multiple = ((_e319 * _e320) / (vec3(1f, 1f, 1f) - (_e323 * _e322))); + let _e327 = multiple; + let _e328 = missed; + let _e330 = irradiance; + let _e332 = specular_1; + specular_1 = (_e332 + ((_e327 * _e328) * _e330)); + } + let _e334 = diffuseColor_1; + let _e335 = irradiance; + let _e337 = specular_1; + let _e341 = frag_info.material[2u]; + let _e344 = s_14.occlusion; + ambient = ((((_e334 * _e335) + _e337) * _e341) * _e344); + } + let _e346 = diffuseColor_1; + let _e347 = SampleLightmap_u0028_(); + let _e350 = s_14.occlusion; + let _e352 = ambient; + ambient = (_e352 + ((_e346 * _e347) * _e350)); + let _e354 = s_14; + param_195 = _e354; + let _e355 = AccumulateLights_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b((¶m_195)); + let _e357 = s_14.occlusion; + let _e359 = ambient; + let _e362 = s_14.emissive; + lit_3 = (((_e355 * _e357) + _e359) + _e362); + let _e364 = s_14; + param_196 = _e364; + let _e365 = lit_3; + param_197 = _e365; + let _e366 = WriteDebugView_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b_vf3_u003b((¶m_196), (¶m_197)); + if _e366 { + return; + } + let _e367 = lit_3; + param_198 = _e367; + let _e369 = s_14.alpha; + param_199 = _e369; + let _e371 = s_14.roughness; + param_200 = _e371; + WriteSurface_u0028_vf3_u003b_f1_u003b_f1_u003b((¶m_198), (¶m_199), (¶m_200)); return; } @fragment -fn main(@location(6) v_world_position: vec3, @location(2) v_normal: vec3, @builtin(front_facing) gl_FrontFacing: bool, @builtin(position) gl_FragCoord: vec4, @location(4) v_texcoord: vec2, @location(0) v_color: vec4, @location(1) v_lightmap_uv: vec2, @location(3) v_tangent: vec4) -> FragmentOutput { +fn main(@location(7) v_world_position: vec3, @location(3) v_normal: vec3, @builtin(front_facing) gl_FrontFacing: bool, @builtin(position) gl_FragCoord: vec4, @location(5) v_texcoord: vec2, @location(0) v_color: vec4, @location(2) v_lightmap_uv: vec2, @location(4) v_tangent: vec4) -> FragmentOutput { v_world_position_1 = v_world_position; v_normal_1 = v_normal; gl_FrontFacing_1 = gl_FrontFacing; @@ -15334,7 +17453,7 @@ fn main(@location(6) v_world_position: vec3, @location(2) v_normal: vec3[ WebGpuBlockMember(name: 'fog', offsetInBytes: 0, sizeInBytes: 16), WebGpuBlockMember(name: 'eye', offsetInBytes: 16, sizeInBytes: 16), @@ -15343,13 +17462,18 @@ fn main(@location(6) v_world_position: vec3, @location(2) v_normal: vec3[ WebGpuBlockMember( name: 'light_position', @@ -15446,6 +17570,11 @@ fn main(@location(6) v_world_position: vec3, @location(2) v_normal: vec3, eye: vec4, forward: vec4, + projection: vec4, } struct LightListInfo { @@ -15716,6 +17846,7 @@ struct FragInfo { ambient_ground: vec4, shadow_bias: vec4, target_origin: vec4, + debug_view: vec4, } struct PointShadow { @@ -15760,8 +17891,10 @@ var g_albedo: vec3; var g_debug_surface: vec3; var g_debug_surface_on: bool; var g_premultiply: bool; +var g_pass_through: f32; var g_cluster_offset: f32; var g_cluster_count: f32; +var light_transmittance: vec3; var g_f0_dielectric: vec3; var g_f90_: f32; var g_coat: f32; @@ -15781,9 +17914,10 @@ var g_thickness: f32; var g_transmittance: vec3; var g_iridescence: f32; var g_irid_fresnel: vec3; -var v_world_position_1: vec3; +var g_pane: bool; @group(1) @binding(0) var fog_info: FogInfo; +var v_world_position_1: vec3; var frag_albedo: vec4; var frag_surface: vec4; var v_normal_1: vec3; @@ -15871,10 +18005,10 @@ var scene_colour_texture_tex: texture_2d; var scene_colour_texture_smp: sampler; fn ViewDepth_u0028_() -> f32 { - let _e261 = v_world_position_1; - let _e263 = fog_info.eye; - let _e267 = fog_info.forward; - return dot((_e261 - _e263.xyz), _e267.xyz); + let _e273 = v_world_position_1; + let _e275 = fog_info.eye; + let _e279 = fog_info.forward; + return dot((_e273 - _e275.xyz), _e279.xyz); } fn WeightedBlendedWeight_u0028_f1_u003b(alpha: ptr) -> f32 { @@ -15883,22 +18017,22 @@ fn WeightedBlendedWeight_u0028_f1_u003b(alpha: ptr) -> f32 { var far: f32; var far3_: f32; - let _e266 = ViewDepth_u0028_(); - z = abs(_e266); - let _e268 = z; - near = (_e268 / 5f); - let _e270 = z; - far = (_e270 / 200f); - let _e272 = far; - let _e273 = far; - let _e275 = far; - far3_ = ((_e272 * _e273) * _e275); - let _e277 = (*alpha); - let _e278 = near; - let _e279 = near; - let _e282 = far3_; - let _e283 = far3_; - return (_e277 * clamp((10f / ((0.00001f + (_e278 * _e279)) + (_e282 * _e283))), 0.01f, 3000f)); + let _e278 = ViewDepth_u0028_(); + z = abs(_e278); + let _e280 = z; + near = (_e280 / 5f); + let _e282 = z; + far = (_e282 / 200f); + let _e284 = far; + let _e285 = far; + let _e287 = far; + far3_ = ((_e284 * _e285) * _e287); + let _e289 = (*alpha); + let _e290 = near; + let _e291 = near; + let _e294 = far3_; + let _e295 = far3_; + return (_e289 * clamp((10f / ((0.00001f + (_e290 * _e291)) + (_e294 * _e295))), 0.01f, 3000f)); } fn WriteWeightedBlended_u0028_() { @@ -15910,39 +18044,39 @@ fn WriteWeightedBlended_u0028_() { var revealage: bool; var local: vec4; - let _e270 = fog_info.forward[3u]; - mode = _e270; - let _e271 = mode; - if (_e271 > 0.5f) { - let _e274 = frag_color[3u]; - alpha_1 = _e274; - let _e275 = alpha_1; - param = _e275; - let _e276 = WeightedBlendedWeight_u0028_f1_u003b((¶m)); - weight = _e276; - let _e277 = frag_color; - let _e279 = weight; - let _e280 = (_e277.xyz * _e279); - let _e281 = alpha_1; - let _e282 = weight; - accumulate = vec4(_e280.x, _e280.y, _e280.z, (_e281 * _e282)); - let _e288 = mode; - let _e290 = mode; - revealage = ((_e288 > 1.5f) && (_e290 < 2.5f)); - let _e293 = revealage; - if _e293 { - let _e294 = alpha_1; - local = vec4(_e294); + let _e282 = fog_info.forward[3u]; + mode = _e282; + let _e283 = mode; + if (_e283 > 0.5f) { + let _e286 = frag_color[3u]; + alpha_1 = _e286; + let _e287 = alpha_1; + param = _e287; + let _e288 = WeightedBlendedWeight_u0028_f1_u003b((¶m)); + weight = _e288; + let _e289 = frag_color; + let _e291 = weight; + let _e292 = (_e289.xyz * _e291); + let _e293 = alpha_1; + let _e294 = weight; + accumulate = vec4(_e292.x, _e292.y, _e292.z, (_e293 * _e294)); + let _e300 = mode; + let _e302 = mode; + revealage = ((_e300 > 1.5f) && (_e302 < 2.5f)); + let _e305 = revealage; + if _e305 { + let _e306 = alpha_1; + local = vec4(_e306); } else { - let _e296 = accumulate; - local = _e296; + let _e308 = accumulate; + local = _e308; } - let _e297 = local; - frag_color = _e297; - let _e298 = mode; - if (_e298 > 2.5f) { - let _e300 = alpha_1; - frag_surface = vec4(_e300); + let _e309 = local; + frag_color = _e309; + let _e310 = mode; + if (_e310 > 2.5f) { + let _e312 = alpha_1; + frag_surface = vec4(_e312); } } return; @@ -15951,29 +18085,29 @@ fn WriteWeightedBlended_u0028_() { fn EncodeOctahedral_u0028_vf3_u003b(n: ptr>) -> vec2 { var e: vec2; - let _e264 = (*n)[0u]; - let _e267 = (*n)[1u]; - let _e271 = (*n)[2u]; - let _e274 = (*n); - (*n) = (_e274 / vec3(((abs(_e264) + abs(_e267)) + abs(_e271)))); - let _e277 = (*n); - e = _e277.xy; - let _e280 = (*n)[2u]; - if (_e280 < 0f) { - let _e282 = (*n); - let _e288 = (*n)[0u]; - let _e292 = (*n)[1u]; - e = ((vec2(1f) - abs(_e282.yx)) * vec2(select(-1f, 1f, (_e288 >= 0f)), select(-1f, 1f, (_e292 >= 0f)))); + let _e276 = (*n)[0u]; + let _e279 = (*n)[1u]; + let _e283 = (*n)[2u]; + let _e286 = (*n); + (*n) = (_e286 / vec3(((abs(_e276) + abs(_e279)) + abs(_e283)))); + let _e289 = (*n); + e = _e289.xy; + let _e292 = (*n)[2u]; + if (_e292 < 0f) { + let _e294 = (*n); + let _e300 = (*n)[0u]; + let _e304 = (*n)[1u]; + e = ((vec2(1f) - abs(_e294.yx)) * vec2(select(-1f, 1f, (_e300 >= 0f)), select(-1f, 1f, (_e304 >= 0f)))); } - let _e297 = e; - return ((_e297 * 0.5f) + vec2(0.5f)); + let _e309 = e; + return ((_e309 * 0.5f) + vec2(0.5f)); } fn LinearToSrgb_u0028_vf3_u003b(linear: ptr>) -> vec3 { - let _e262 = (*linear); - let _e264 = (*linear); - let _e268 = (*linear); - return mix((_e262 * 12.92f), ((pow(_e264, vec3(0.41666666f, 0.41666666f, 0.41666666f)) * 1.055f) - vec3(0.055f, 0.055f, 0.055f)), step(vec3(0.0031308f, 0.0031308f, 0.0031308f), _e268)); + let _e274 = (*linear); + let _e276 = (*linear); + let _e280 = (*linear); + return mix((_e274 * 12.92f), ((pow(_e276, vec3(0.41666666f, 0.41666666f, 0.41666666f)) * 1.055f) - vec3(0.055f, 0.055f, 0.055f)), step(vec3(0.0031308f, 0.0031308f, 0.0031308f), _e280)); } fn WriteSurfaceGeometry_u0028_f1_u003b(roughness: ptr) { @@ -15981,57 +18115,96 @@ fn WriteSurfaceGeometry_u0028_f1_u003b(roughness: ptr) { var geometric: vec3; var param_2: vec3; - let _e265 = g_albedo; - param_1 = clamp(_e265, vec3(0f, 0f, 0f), vec3(1f, 1f, 1f)); - let _e267 = LinearToSrgb_u0028_vf3_u003b((¶m_1)); - frag_albedo = vec4(_e267.x, _e267.y, _e267.z, 1f); - let _e272 = g_debug_surface_on; - if _e272 { - let _e273 = g_debug_surface; - let _e274 = ViewDepth_u0028_(); - frag_surface = vec4(_e273.x, _e273.y, _e273.z, _e274); + let _e277 = g_albedo; + param_1 = clamp(_e277, vec3(0f, 0f, 0f), vec3(1f, 1f, 1f)); + let _e279 = LinearToSrgb_u0028_vf3_u003b((¶m_1)); + frag_albedo = vec4(_e279.x, _e279.y, _e279.z, 1f); + let _e284 = g_debug_surface_on; + if _e284 { + let _e285 = g_debug_surface; + let _e286 = ViewDepth_u0028_(); + frag_surface = vec4(_e285.x, _e285.y, _e285.z, _e286); return; } - let _e279 = v_normal_1; - geometric = normalize(_e279); - let _e281 = gl_FrontFacing_1; - if !(_e281) { - let _e283 = geometric; - geometric = -(_e283); - } - let _e285 = geometric; - param_2 = _e285; - let _e286 = EncodeOctahedral_u0028_vf3_u003b((¶m_2)); - let _e287 = (*roughness); - let _e289 = ViewDepth_u0028_(); - frag_surface = vec4(_e286.x, _e286.y, clamp(_e287, 0f, 1f), _e289); + let _e291 = v_normal_1; + geometric = normalize(_e291); + let _e293 = gl_FrontFacing_1; + if !(_e293) { + let _e295 = geometric; + geometric = -(_e295); + } + let _e297 = geometric; + param_2 = _e297; + let _e298 = EncodeOctahedral_u0028_vf3_u003b((¶m_2)); + let _e299 = (*roughness); + let _e301 = ViewDepth_u0028_(); + frag_surface = vec4(_e298.x, _e298.y, clamp(_e299, 0f, 1f), _e301); return; } +fn Orthographic_u0028_() -> bool { + let _e275 = fog_info.projection[0u]; + return (_e275 > 0.5f); +} + fn EyeDistance_u0028_() -> f32 { - let _e261 = v_world_position_1; - let _e263 = fog_info.eye; - return distance(_e261, _e263.xyz); + var local_1: f32; + + let _e274 = Orthographic_u0028_(); + if _e274 { + let _e275 = ViewDepth_u0028_(); + local_1 = max(_e275, 0f); + } else { + let _e277 = v_world_position_1; + let _e279 = fog_info.eye; + local_1 = distance(_e277, _e279.xyz); + } + let _e282 = local_1; + return _e282; } fn ApplyFog_u0028_vf3_u003b(color: ptr>) -> vec3 { var density: f32; var d: f32; + var falloff: f32; + var k: f32; + var local_2: f32; - let _e266 = fog_info.fog[3u]; - density = _e266; - let _e267 = density; - if (_e267 <= 0f) { - let _e269 = (*color); - return _e269; + let _e281 = fog_info.fog[3u]; + density = _e281; + let _e282 = density; + if (_e282 <= 0f) { + let _e284 = (*color); + return _e284; + } + let _e285 = EyeDistance_u0028_(); + d = _e285; + let _e288 = fog_info.eye[3u]; + falloff = _e288; + let _e289 = falloff; + if (_e289 > 0f) { + let _e291 = falloff; + let _e293 = v_world_position_1[1u]; + let _e296 = fog_info.eye[1u]; + k = (_e291 * (_e293 - _e296)); + let _e299 = k; + if (abs(_e299) > 0.001f) { + let _e302 = k; + let _e306 = k; + local_2 = ((1f - exp(-(_e302))) / _e306); + } else { + let _e308 = k; + local_2 = (1f - (0.5f * _e308)); + } + let _e311 = local_2; + let _e312 = density; + density = (_e312 * _e311); } - let _e270 = EyeDistance_u0028_(); - d = _e270; - let _e272 = fog_info.fog; - let _e274 = (*color); - let _e275 = density; - let _e277 = d; - return mix(_e272.xyz, _e274, vec3(clamp(exp((-(_e275) * _e277)), 0f, 1f))); + let _e315 = fog_info.fog; + let _e317 = (*color); + let _e318 = density; + let _e320 = d; + return mix(_e315.xyz, _e317, vec3(clamp(exp((-(_e318) * _e320)), 0f, 1f))); } fn WriteSurface_u0028_vf3_u003b_f1_u003b_f1_u003b(linearColor: ptr>, alpha_2: ptr, roughness_1: ptr) { @@ -16039,541 +18212,698 @@ fn WriteSurface_u0028_vf3_u003b_f1_u003b_f1_u003b(linearColor: ptr; var param_4: f32; - let _e267 = g_premultiply; - let _e268 = (*alpha_2); - weight_1 = select(1f, _e268, _e267); - let _e270 = (*linearColor); - param_3 = _e270; - let _e271 = ApplyFog_u0028_vf3_u003b((¶m_3)); - let _e272 = weight_1; - let _e273 = (_e271 * _e272); - let _e274 = (*alpha_2); - frag_color = vec4(_e273.x, _e273.y, _e273.z, _e274); - let _e279 = (*roughness_1); - param_4 = _e279; + let _e279 = g_premultiply; + let _e280 = (*alpha_2); + weight_1 = select(1f, _e280, _e279); + let _e282 = (*linearColor); + param_3 = _e282; + let _e283 = ApplyFog_u0028_vf3_u003b((¶m_3)); + let _e284 = weight_1; + let _e285 = (_e283 * _e284); + let _e286 = (*alpha_2); + let _e287 = g_pass_through; + frag_color = vec4(_e285.x, _e285.y, _e285.z, (_e286 * (1f - _e287))); + let _e294 = (*roughness_1); + param_4 = _e294; WriteSurfaceGeometry_u0028_f1_u003b((¶m_4)); WriteWeightedBlended_u0028_(); return; } +fn SrgbToLinear_u0028_vf3_u003b(srgb: ptr>) -> vec3 { + let _e274 = (*srgb); + let _e277 = (*srgb); + let _e282 = (*srgb); + return mix((_e274 / vec3(12.92f)), pow(((_e277 + vec3(0.055f, 0.055f, 0.055f)) / vec3(1.055f)), vec3(2.4f, 2.4f, 2.4f)), step(vec3(0.04045f, 0.04045f, 0.04045f), _e282)); +} + +fn NonFinite_u0028_vf3_u003b(c: ptr>) -> bool { + var phi_3014_: bool; + + let _e274 = (*c); + let _e275 = (*c); + let _e277 = any((_e274 != _e275)); + phi_3014_ = _e277; + if !(_e277) { + let _e279 = (*c); + phi_3014_ = any((abs(_e279) > vec3(300000000000000000000000000000000000000f, 300000000000000000000000000000000000000f, 300000000000000000000000000000000000000f))); + } + let _e284 = phi_3014_; + return _e284; +} + +fn MapUv_u0028_i1_u003b(slot: ptr) -> vec2 { + var uvw: vec3; + + let _e275 = v_texcoord_1; + uvw = vec3(_e275.x, _e275.y, 1f); + let _e279 = (*slot); + let _e283 = layer_info.uv_transform[(_e279 * 2i)]; + let _e285 = uvw; + let _e287 = (*slot); + let _e292 = layer_info.uv_transform[((_e287 * 2i) + 1i)]; + let _e294 = uvw; + return vec2(dot(_e283.xyz, _e285), dot(_e292.xyz, _e294)); +} + +fn WriteDebugView_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b_vf3_u003b(s: ptr, lit: ptr>) -> bool { + var view: f32; + var code: i32; + var shown: vec3; + var param_5: vec3; + var param_6: vec3; + var param_7: i32; + var grey: f32; + var param_8: vec3; + var param_9: vec3; + var local_3: vec3; + var weight_2: f32; + var local_4: f32; + var param_10: vec3; + var param_11: f32; + + let _e291 = frag_info.debug_view[0u]; + view = _e291; + let _e292 = view; + if (_e292 < 0.5f) { + return false; + } + let _e295 = gl_FragCoord_1[0u]; + let _e298 = frag_info.debug_view[1u]; + let _e301 = frag_info.debug_view[2u]; + if (_e295 < (_e298 * _e301)) { + return false; + } + let _e304 = view; + code = i32((_e304 + 0.5f)); + shown = vec3(0f, 0f, 0f); + let _e307 = code; + if (_e307 == 1i) { + let _e310 = (*s).albedo; + param_5 = clamp(_e310, vec3(0f, 0f, 0f), vec3(1f, 1f, 1f)); + let _e312 = LinearToSrgb_u0028_vf3_u003b((¶m_5)); + shown = _e312; + } else { + let _e313 = code; + if (_e313 == 2i) { + let _e316 = (*s).n; + shown = ((_e316 * 0.5f) + vec3(0.5f, 0.5f, 0.5f)); + } else { + let _e319 = code; + if (_e319 == 3i) { + let _e322 = (*s).roughness; + shown = vec3(clamp(_e322, 0f, 1f)); + } else { + let _e325 = code; + if (_e325 == 4i) { + let _e328 = (*s).metallic; + shown = vec3(clamp(_e328, 0f, 1f)); + } else { + let _e331 = code; + if (_e331 == 5i) { + let _e334 = (*s).occlusion; + shown = vec3(clamp(_e334, 0f, 1f)); + } else { + let _e337 = code; + if (_e337 == 6i) { + let _e340 = (*s).emissive; + param_6 = clamp(_e340, vec3(0f, 0f, 0f), vec3(1f, 1f, 1f)); + let _e342 = LinearToSrgb_u0028_vf3_u003b((¶m_6)); + shown = _e342; + } else { + let _e343 = code; + if (_e343 == 7i) { + param_7 = 0i; + let _e345 = MapUv_u0028_i1_u003b((¶m_7)); + let _e346 = fract(_e345); + shown = vec3(_e346.x, _e346.y, 0f); + } else { + let _e350 = code; + if (_e350 == 8i) { + let _e352 = (*lit); + param_8 = clamp(_e352, vec3(0f, 0f, 0f), vec3(1f, 1f, 1f)); + let _e354 = LinearToSrgb_u0028_vf3_u003b((¶m_8)); + grey = dot(_e354, vec3(0.2126f, 0.7152f, 0.0722f)); + let _e356 = (*lit); + param_9 = _e356; + let _e357 = NonFinite_u0028_vf3_u003b((¶m_9)); + if _e357 { + local_3 = vec3(1f, 0f, 1f); + } else { + let _e358 = grey; + local_3 = vec3((_e358 * 0.5f)); + } + let _e361 = local_3; + shown = _e361; + } + } + } + } + } + } + } + } + let _e362 = g_premultiply; + if _e362 { + let _e364 = (*s).alpha; + local_4 = _e364; + } else { + local_4 = 1f; + } + let _e365 = local_4; + weight_2 = _e365; + let _e366 = shown; + param_10 = _e366; + let _e367 = SrgbToLinear_u0028_vf3_u003b((¶m_10)); + let _e368 = weight_2; + let _e369 = (_e367 * _e368); + let _e371 = (*s).alpha; + frag_color = vec4(_e369.x, _e369.y, _e369.z, _e371); + let _e377 = (*s).roughness; + param_11 = _e377; + WriteSurfaceGeometry_u0028_f1_u003b((¶m_11)); + WriteWeightedBlended_u0028_(); + return true; +} + fn V_SmithGGXCorrelated_u0028_f1_u003b_f1_u003b_f1_u003b(n_dot_v: ptr, n_dot_l: ptr, alpha_3: ptr) -> f32 { var a2_: f32; var lambda_v: f32; var lambda_l: f32; - let _e267 = (*alpha_3); - let _e268 = (*alpha_3); - a2_ = (_e267 * _e268); - let _e270 = (*n_dot_l); - let _e271 = (*n_dot_v); - let _e272 = (*n_dot_v); - let _e274 = a2_; - let _e277 = a2_; - lambda_v = (_e270 * sqrt((((_e271 * _e272) * (1f - _e274)) + _e277))); - let _e281 = (*n_dot_v); + let _e279 = (*alpha_3); + let _e280 = (*alpha_3); + a2_ = (_e279 * _e280); let _e282 = (*n_dot_l); - let _e283 = (*n_dot_l); - let _e285 = a2_; - let _e288 = a2_; - lambda_l = (_e281 * sqrt((((_e282 * _e283) * (1f - _e285)) + _e288))); - let _e292 = lambda_v; - let _e293 = lambda_l; - return (0.5f / max((_e292 + _e293), 0.00001f)); + let _e283 = (*n_dot_v); + let _e284 = (*n_dot_v); + let _e286 = a2_; + let _e289 = a2_; + lambda_v = (_e282 * sqrt((((_e283 * _e284) * (1f - _e286)) + _e289))); + let _e293 = (*n_dot_v); + let _e294 = (*n_dot_l); + let _e295 = (*n_dot_l); + let _e297 = a2_; + let _e300 = a2_; + lambda_l = (_e293 * sqrt((((_e294 * _e295) * (1f - _e297)) + _e300))); + let _e304 = lambda_v; + let _e305 = lambda_l; + return (0.5f / max((_e304 + _e305), 0.00001f)); } fn D_GGX_u0028_f1_u003b_f1_u003b(n_dot_h: ptr, alpha_4: ptr) -> f32 { var a: f32; - var k: f32; - - let _e265 = (*n_dot_h); - let _e266 = (*alpha_4); - a = (_e265 * _e266); - let _e268 = (*alpha_4); - let _e269 = (*n_dot_h); - let _e270 = (*n_dot_h); - let _e273 = a; - let _e274 = a; - k = (_e268 / max(((1f - (_e269 * _e270)) + (_e273 * _e274)), 0.0000001f)); - let _e279 = k; - let _e280 = k; - return ((_e279 * _e280) * 0.31830987f); + var k_1: f32; + + let _e277 = (*n_dot_h); + let _e278 = (*alpha_4); + a = (_e277 * _e278); + let _e280 = (*alpha_4); + let _e281 = (*n_dot_h); + let _e282 = (*n_dot_h); + let _e285 = a; + let _e286 = a; + k_1 = (_e280 / max(((1f - (_e281 * _e282)) + (_e285 * _e286)), 0.0000001f)); + let _e291 = k_1; + let _e292 = k_1; + return ((_e291 * _e292) * 0.31830987f); } fn LtcEdge_u0028_vf3_u003b_vf3_u003b(a_1: ptr>, b: ptr>) -> vec3 { var axis: vec3; var len: f32; var angle: f32; - var local_1: vec3; + var local_5: vec3; - let _e267 = (*a_1); - let _e268 = (*b); - axis = cross(_e267, _e268); - let _e270 = axis; - len = length(_e270); - let _e272 = (*a_1); - let _e273 = (*b); - angle = acos(clamp(dot(_e272, _e273), -1f, 1f)); - let _e277 = len; - if (_e277 > 0.000001f) { - let _e279 = axis; - let _e280 = angle; - let _e281 = len; - local_1 = (_e279 * (_e280 / (_e281 * 6.2831855f))); + let _e279 = (*a_1); + let _e280 = (*b); + axis = cross(_e279, _e280); + let _e282 = axis; + len = length(_e282); + let _e284 = (*a_1); + let _e285 = (*b); + angle = acos(clamp(dot(_e284, _e285), -1f, 1f)); + let _e289 = len; + if (_e289 > 0.000001f) { + let _e291 = axis; + let _e292 = angle; + let _e293 = len; + local_5 = (_e291 * (_e292 / (_e293 * 6.2831855f))); } else { - local_1 = vec3(0f, 0f, 0f); + local_5 = vec3(0f, 0f, 0f); } - let _e285 = local_1; - return _e285; + let _e297 = local_5; + return _e297; } fn LtcUv_u0028_f1_u003b_f1_u003b_f1_u003b(x: ptr, y: ptr, table: ptr) -> vec2 { var inTable: vec2; - let _e265 = (*x); - let _e266 = (*y); - inTable = ((vec2(_e265, _e266) * 0.984375f) + vec2(0.0078125f)); - let _e272 = inTable[0u]; - let _e274 = inTable[1u]; - let _e275 = (*table); - return vec2(_e272, ((_e274 + _e275) * 0.5f)); + let _e277 = (*x); + let _e278 = (*y); + inTable = ((vec2(_e277, _e278) * 0.984375f) + vec2(0.0078125f)); + let _e284 = inTable[0u]; + let _e286 = inTable[1u]; + let _e287 = (*table); + return vec2(_e284, ((_e286 + _e287) * 0.5f)); } fn LtcRectangle_u0028_vf3_u003b_vf3_u003b_f1_u003b_vf3_u005b_4_u005d_u003b(n_1: ptr>, v: ptr>, roughness_2: ptr, corners: ptr, 4>>) -> vec3 { var uv: vec2; var inverse: vec4; - var param_5: f32; - var param_6: f32; - var param_7: f32; + var param_12: f32; + var param_13: f32; + var param_14: f32; var fit: vec4; - var param_8: f32; - var param_9: f32; - var param_10: f32; + var param_15: f32; + var param_16: f32; + var param_17: f32; var along: vec3; var alongLength: f32; var t1_: vec3; - var local_2: vec3; + var local_6: vec3; var t2_: vec3; var minv: mat3x3; var i: i32; var p: vec3; var l: array, 4>; var f: vec3; - var param_11: vec3; - var param_12: vec3; - var param_13: vec3; - var param_14: vec3; - var param_15: vec3; - var param_16: vec3; - var param_17: vec3; var param_18: vec3; + var param_19: vec3; + var param_20: vec3; + var param_21: vec3; + var param_22: vec3; + var param_23: vec3; + var param_24: vec3; + var param_25: vec3; var len_1: f32; var z_1: f32; - var local_3: f32; + var local_7: f32; var sphere: f32; - var param_19: f32; - var param_20: f32; - var param_21: f32; + var param_26: f32; + var param_27: f32; + var param_28: f32; - let _e299 = (*roughness_2); - let _e301 = (*n_1); - let _e302 = (*v); - uv = vec2(clamp(_e299, 0f, 1f), sqrt(clamp((1f - dot(_e301, _e302)), 0f, 1f))); - let _e309 = uv[0u]; - param_5 = _e309; - let _e311 = uv[1u]; - param_6 = _e311; - param_7 = 0f; - let _e312 = LtcUv_u0028_f1_u003b_f1_u003b_f1_u003b((¶m_5), (¶m_6), (¶m_7)); - let _e313 = textureSampleLevel(ltc_texture_tex, ltc_texture_smp, _e312, 0f); - inverse = _e313; - let _e315 = uv[0u]; - param_8 = _e315; - let _e317 = uv[1u]; - param_9 = _e317; - param_10 = 1f; - let _e318 = LtcUv_u0028_f1_u003b_f1_u003b_f1_u003b((¶m_8), (¶m_9), (¶m_10)); - let _e319 = textureSampleLevel(ltc_texture_tex, ltc_texture_smp, _e318, 0f); - fit = _e319; - let _e320 = (*v); - let _e321 = (*n_1); - let _e322 = (*v); - let _e323 = (*n_1); - along = (_e320 - (_e321 * dot(_e322, _e323))); - let _e327 = along; - alongLength = length(_e327); - let _e329 = alongLength; - if (_e329 > 0.00001f) { - let _e331 = along; - let _e332 = alongLength; - local_2 = (_e331 / vec3(_e332)); - } else { - let _e335 = (*n_1); - let _e337 = (*n_1)[2u]; - local_2 = normalize(cross(_e335, select(vec3(1f, 0f, 0f), vec3(0f, 0f, 1f), vec3((abs(_e337) < 0.999f))))); - } - let _e344 = local_2; - t1_ = _e344; - let _e345 = (*n_1); - let _e346 = t1_; - t2_ = cross(_e345, _e346); - let _e349 = inverse[0u]; - let _e351 = inverse[1u]; - let _e352 = vec3(_e349, 0f, _e351); - let _e354 = inverse[2u]; - let _e356 = inverse[3u]; - let _e357 = vec3(_e354, 0f, _e356); - minv = mat3x3(vec3(_e352.x, _e352.y, _e352.z), vec3(vec3(0f, 1f, 0f).x, vec3(0f, 1f, 0f).y, vec3(0f, 1f, 0f).z), vec3(_e357.x, _e357.y, _e357.z)); + let _e311 = (*roughness_2); + let _e313 = (*n_1); + let _e314 = (*v); + uv = vec2(clamp(_e311, 0f, 1f), sqrt(clamp((1f - dot(_e313, _e314)), 0f, 1f))); + let _e321 = uv[0u]; + param_12 = _e321; + let _e323 = uv[1u]; + param_13 = _e323; + param_14 = 0f; + let _e324 = LtcUv_u0028_f1_u003b_f1_u003b_f1_u003b((¶m_12), (¶m_13), (¶m_14)); + let _e325 = textureSampleLevel(ltc_texture_tex, ltc_texture_smp, _e324, 0f); + inverse = _e325; + let _e327 = uv[0u]; + param_15 = _e327; + let _e329 = uv[1u]; + param_16 = _e329; + param_17 = 1f; + let _e330 = LtcUv_u0028_f1_u003b_f1_u003b_f1_u003b((¶m_15), (¶m_16), (¶m_17)); + let _e331 = textureSampleLevel(ltc_texture_tex, ltc_texture_smp, _e330, 0f); + fit = _e331; + let _e332 = (*v); + let _e333 = (*n_1); + let _e334 = (*v); + let _e335 = (*n_1); + along = (_e332 - (_e333 * dot(_e334, _e335))); + let _e339 = along; + alongLength = length(_e339); + let _e341 = alongLength; + if (_e341 > 0.00001f) { + let _e343 = along; + let _e344 = alongLength; + local_6 = (_e343 / vec3(_e344)); + } else { + let _e347 = (*n_1); + let _e349 = (*n_1)[2u]; + local_6 = normalize(cross(_e347, select(vec3(1f, 0f, 0f), vec3(0f, 0f, 1f), vec3((abs(_e349) < 0.999f))))); + } + let _e356 = local_6; + t1_ = _e356; + let _e357 = (*n_1); + let _e358 = t1_; + t2_ = cross(_e357, _e358); + let _e361 = inverse[0u]; + let _e363 = inverse[1u]; + let _e364 = vec3(_e361, 0f, _e363); + let _e366 = inverse[2u]; + let _e368 = inverse[3u]; + let _e369 = vec3(_e366, 0f, _e368); + minv = mat3x3(vec3(_e364.x, _e364.y, _e364.z), vec3(vec3(0f, 1f, 0f).x, vec3(0f, 1f, 0f).y, vec3(0f, 1f, 0f).z), vec3(_e369.x, _e369.y, _e369.z)); i = 0i; loop { - let _e371 = i; - if (_e371 < 4i) { - let _e373 = i; - let _e375 = (*corners)[_e373]; - p = _e375; - let _e376 = i; - let _e377 = minv; - let _e378 = p; - let _e379 = t1_; - let _e381 = p; - let _e382 = t2_; - let _e384 = p; - let _e385 = (*n_1); - l[_e376] = normalize((_e377 * vec3(dot(_e378, _e379), dot(_e381, _e382), dot(_e384, _e385)))); + let _e383 = i; + if (_e383 < 4i) { + let _e385 = i; + let _e387 = (*corners)[_e385]; + p = _e387; + let _e388 = i; + let _e389 = minv; + let _e390 = p; + let _e391 = t1_; + let _e393 = p; + let _e394 = t2_; + let _e396 = p; + let _e397 = (*n_1); + l[_e388] = normalize((_e389 * vec3(dot(_e390, _e391), dot(_e393, _e394), dot(_e396, _e397)))); continue; } else { break; } continuing { - let _e391 = i; - i = (_e391 + 1i); - } - } - let _e394 = l[0i]; - param_11 = _e394; - let _e396 = l[1i]; - param_12 = _e396; - let _e397 = LtcEdge_u0028_vf3_u003b_vf3_u003b((¶m_11), (¶m_12)); - let _e399 = l[1i]; - param_13 = _e399; - let _e401 = l[2i]; - param_14 = _e401; - let _e402 = LtcEdge_u0028_vf3_u003b_vf3_u003b((¶m_13), (¶m_14)); - let _e405 = l[2i]; - param_15 = _e405; - let _e407 = l[3i]; - param_16 = _e407; - let _e408 = LtcEdge_u0028_vf3_u003b_vf3_u003b((¶m_15), (¶m_16)); - let _e411 = l[3i]; - param_17 = _e411; - let _e413 = l[0i]; - param_18 = _e413; - let _e414 = LtcEdge_u0028_vf3_u003b_vf3_u003b((¶m_17), (¶m_18)); - f = -((((_e397 + _e402) + _e408) + _e414)); - let _e417 = f; - len_1 = length(_e417); - let _e419 = len_1; - if (_e419 > 0.000000001f) { - let _e422 = f[2u]; - let _e423 = len_1; - local_3 = (_e422 / _e423); - } else { - local_3 = 0f; - } - let _e425 = local_3; - z_1 = _e425; - let _e426 = z_1; - let _e429 = len_1; - param_19 = ((_e426 * 0.5f) + 0.5f); - param_20 = clamp(_e429, 0f, 1f); - param_21 = 1f; - let _e431 = LtcUv_u0028_f1_u003b_f1_u003b_f1_u003b((¶m_19), (¶m_20), (¶m_21)); - let _e432 = textureSampleLevel(ltc_texture_tex, ltc_texture_smp, _e431, 0f); - sphere = _e432.w; - let _e434 = len_1; - let _e435 = sphere; - let _e439 = fit[0u]; - let _e441 = fit[1u]; - return vec3(max((_e434 * _e435), 0f), _e439, _e441); -} - -fn CoatLobe_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b_struct_u002d_LightSample_u002d_vf3_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf31_u003b(s: ptr, light: ptr) -> f32 { + let _e403 = i; + i = (_e403 + 1i); + } + } + let _e406 = l[0i]; + param_18 = _e406; + let _e408 = l[1i]; + param_19 = _e408; + let _e409 = LtcEdge_u0028_vf3_u003b_vf3_u003b((¶m_18), (¶m_19)); + let _e411 = l[1i]; + param_20 = _e411; + let _e413 = l[2i]; + param_21 = _e413; + let _e414 = LtcEdge_u0028_vf3_u003b_vf3_u003b((¶m_20), (¶m_21)); + let _e417 = l[2i]; + param_22 = _e417; + let _e419 = l[3i]; + param_23 = _e419; + let _e420 = LtcEdge_u0028_vf3_u003b_vf3_u003b((¶m_22), (¶m_23)); + let _e423 = l[3i]; + param_24 = _e423; + let _e425 = l[0i]; + param_25 = _e425; + let _e426 = LtcEdge_u0028_vf3_u003b_vf3_u003b((¶m_24), (¶m_25)); + f = -((((_e409 + _e414) + _e420) + _e426)); + let _e429 = f; + len_1 = length(_e429); + let _e431 = len_1; + if (_e431 > 0.000000001f) { + let _e434 = f[2u]; + let _e435 = len_1; + local_7 = (_e434 / _e435); + } else { + local_7 = 0f; + } + let _e437 = local_7; + z_1 = _e437; + let _e438 = z_1; + let _e441 = len_1; + param_26 = ((_e438 * 0.5f) + 0.5f); + param_27 = clamp(_e441, 0f, 1f); + param_28 = 1f; + let _e443 = LtcUv_u0028_f1_u003b_f1_u003b_f1_u003b((¶m_26), (¶m_27), (¶m_28)); + let _e444 = textureSampleLevel(ltc_texture_tex, ltc_texture_smp, _e443, 0f); + sphere = _e444.w; + let _e446 = len_1; + let _e447 = sphere; + let _e451 = fit[0u]; + let _e453 = fit[1u]; + return vec3(max((_e446 * _e447), 0f), _e451, _e453); +} + +fn CoatLobe_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b_struct_u002d_LightSample_u002d_vf3_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf31_u003b(s_1: ptr, light: ptr) -> f32 { var ltc: vec3; - var param_22: vec3; - var param_23: vec3; - var param_24: f32; - var param_25: array, 4>; + var param_29: vec3; + var param_30: vec3; + var param_31: f32; + var param_32: array, 4>; var lobe: f32; var alpha_5: f32; var n_dot_l_1: f32; var n_dot_h_1: f32; var d_1: f32; - var param_26: f32; - var param_27: f32; + var param_33: f32; + var param_34: f32; var vis: f32; - var param_28: f32; - var param_29: f32; - var param_30: f32; + var param_35: f32; + var param_36: f32; + var param_37: f32; var f_1: f32; - let _e281 = (*light).integrated; - if (_e281 > 0.5f) { - let _e283 = g_coat_n; - param_22 = _e283; - let _e285 = (*s).v; - param_23 = _e285; - let _e286 = g_coat_roughness; - param_24 = _e286; - let _e287 = g_rect_corners; - param_25 = _e287; - let _e288 = LtcRectangle_u0028_vf3_u003b_vf3_u003b_f1_u003b_vf3_u005b_4_u005d_u003b((¶m_22), (¶m_23), (¶m_24), (¶m_25)); - ltc = _e288; - let _e290 = ltc[0u]; - let _e292 = ltc[1u]; - let _e295 = ltc[2u]; - let _e301 = frag_info.material[3u]; - let _e304 = (*light).n_dot_l; - return (((_e290 * ((0.04f * _e292) + (0.96f * _e295))) * _e301) / max(_e304, 0.000001f)); - } - let _e307 = g_coat_roughness; - lobe = max(_e307, 0.045f); - let _e309 = lobe; - let _e310 = lobe; - alpha_5 = (_e309 * _e310); - let _e312 = g_coat_n; - let _e314 = (*light).l; - n_dot_l_1 = max(dot(_e312, _e314), 0f); - let _e317 = g_coat_n; - let _e319 = (*light).h; - n_dot_h_1 = max(dot(_e317, _e319), 0f); - let _e322 = n_dot_h_1; - param_26 = _e322; - let _e323 = alpha_5; - param_27 = _e323; - let _e324 = D_GGX_u0028_f1_u003b_f1_u003b((¶m_26), (¶m_27)); - d_1 = _e324; - let _e325 = g_coat_n_dot_v; - param_28 = _e325; - let _e326 = n_dot_l_1; - param_29 = _e326; - let _e327 = alpha_5; - param_30 = _e327; - let _e328 = V_SmithGGXCorrelated_u0028_f1_u003b_f1_u003b_f1_u003b((¶m_28), (¶m_29), (¶m_30)); - vis = _e328; - let _e330 = (*light).v_dot_h; - f_1 = (0.04f + (0.96f * pow((1f - _e330), 5f))); - let _e335 = d_1; - let _e336 = vis; - let _e338 = f_1; - let _e342 = frag_info.material[3u]; - let _e344 = n_dot_l_1; - let _e347 = (*light).n_dot_l; - return (((((_e335 * _e336) * _e338) * _e342) * _e344) / max(_e347, 0.000001f)); + let _e293 = (*light).integrated; + if (_e293 > 0.5f) { + let _e295 = g_coat_n; + param_29 = _e295; + let _e297 = (*s_1).v; + param_30 = _e297; + let _e298 = g_coat_roughness; + param_31 = _e298; + let _e299 = g_rect_corners; + param_32 = _e299; + let _e300 = LtcRectangle_u0028_vf3_u003b_vf3_u003b_f1_u003b_vf3_u005b_4_u005d_u003b((¶m_29), (¶m_30), (¶m_31), (¶m_32)); + ltc = _e300; + let _e302 = ltc[0u]; + let _e304 = ltc[1u]; + let _e307 = ltc[2u]; + let _e313 = frag_info.material[3u]; + let _e316 = (*light).n_dot_l; + return (((_e302 * ((0.04f * _e304) + (0.96f * _e307))) * _e313) / max(_e316, 0.000001f)); + } + let _e319 = g_coat_roughness; + lobe = max(_e319, 0.045f); + let _e321 = lobe; + let _e322 = lobe; + alpha_5 = (_e321 * _e322); + let _e324 = g_coat_n; + let _e326 = (*light).l; + n_dot_l_1 = max(dot(_e324, _e326), 0f); + let _e329 = g_coat_n; + let _e331 = (*light).h; + n_dot_h_1 = max(dot(_e329, _e331), 0f); + let _e334 = n_dot_h_1; + param_33 = _e334; + let _e335 = alpha_5; + param_34 = _e335; + let _e336 = D_GGX_u0028_f1_u003b_f1_u003b((¶m_33), (¶m_34)); + d_1 = _e336; + let _e337 = g_coat_n_dot_v; + param_35 = _e337; + let _e338 = n_dot_l_1; + param_36 = _e338; + let _e339 = alpha_5; + param_37 = _e339; + let _e340 = V_SmithGGXCorrelated_u0028_f1_u003b_f1_u003b_f1_u003b((¶m_35), (¶m_36), (¶m_37)); + vis = _e340; + let _e342 = (*light).v_dot_h; + f_1 = (0.04f + (0.96f * pow((1f - _e342), 5f))); + let _e347 = d_1; + let _e348 = vis; + let _e350 = f_1; + let _e354 = frag_info.material[3u]; + let _e356 = n_dot_l_1; + let _e359 = (*light).n_dot_l; + return (((((_e347 * _e348) * _e350) * _e354) * _e356) / max(_e359, 0.000001f)); } fn V_Neubelt_u0028_f1_u003b_f1_u003b(n_dot_v_1: ptr, n_dot_l_2: ptr) -> f32 { - let _e263 = (*n_dot_l_2); - let _e264 = (*n_dot_v_1); - let _e266 = (*n_dot_l_2); - let _e267 = (*n_dot_v_1); - return (1f / (4f * ((_e263 + _e264) - (_e266 * _e267)))); + let _e275 = (*n_dot_l_2); + let _e276 = (*n_dot_v_1); + let _e278 = (*n_dot_l_2); + let _e279 = (*n_dot_v_1); + return (1f / (4f * ((_e275 + _e276) - (_e278 * _e279)))); } fn D_Charlie_u0028_f1_u003b_f1_u003b(roughness_3: ptr, n_dot_h_2: ptr) -> f32 { var inv_alpha: f32; var sin2h: f32; - let _e265 = (*roughness_3); - let _e266 = (*roughness_3); - inv_alpha = (1f / (_e265 * _e266)); - let _e269 = (*n_dot_h_2); - let _e270 = (*n_dot_h_2); - sin2h = max((1f - (_e269 * _e270)), 0.0078125f); - let _e274 = inv_alpha; - let _e276 = sin2h; - let _e277 = inv_alpha; - return (((2f + _e274) * pow(_e276, (_e277 * 0.5f))) / 6.2831855f); + let _e277 = (*roughness_3); + let _e278 = (*roughness_3); + inv_alpha = (1f / (_e277 * _e278)); + let _e281 = (*n_dot_h_2); + let _e282 = (*n_dot_h_2); + sin2h = max((1f - (_e281 * _e282)), 0.0078125f); + let _e286 = inv_alpha; + let _e288 = sin2h; + let _e289 = inv_alpha; + return (((2f + _e286) * pow(_e288, (_e289 * 0.5f))) / 6.2831855f); } fn FonAlbedo_u0028_f1_u003b_f1_u003b(mu: ptr, r: ptr) -> f32 { var m: f32; var g: f32; - let _e265 = (*mu); - m = (1f - _e265); - let _e267 = m; - let _e268 = m; - let _e269 = m; - let _e270 = m; - g = (_e267 * (0.05710853f + (_e268 * (0.49188188f + (_e269 * (-0.33218145f + (_e270 * 0.071443f))))))); - let _e278 = (*r); - let _e279 = g; - let _e282 = (*r); - return ((1f + (_e278 * _e279)) / (1f + (0.2877934f * _e282))); + let _e277 = (*mu); + m = (1f - _e277); + let _e279 = m; + let _e280 = m; + let _e281 = m; + let _e282 = m; + g = (_e279 * (0.05710853f + (_e280 * (0.49188188f + (_e281 * (-0.33218145f + (_e282 * 0.071443f))))))); + let _e290 = (*r); + let _e291 = g; + let _e294 = (*r); + return ((1f + (_e290 * _e291)) / (1f + (0.2877934f * _e294))); } fn EonMultiAlbedo_u0028_vf3_u003b_f1_u003b(rho: ptr>, average: ptr) -> vec3 { - let _e263 = (*rho); - let _e264 = (*rho); - let _e266 = (*average); - let _e268 = (*rho); - let _e269 = (*average); - return (((_e263 * _e264) * _e266) / (vec3(1f, 1f, 1f) - (_e268 * (1f - _e269)))); + let _e275 = (*rho); + let _e276 = (*rho); + let _e278 = (*average); + let _e280 = (*rho); + let _e281 = (*average); + return (((_e275 * _e276) * _e278) / (vec3(1f, 1f, 1f) - (_e280 * (1f - _e281)))); } fn FonAverage_u0028_f1_u003b(r_1: ptr) -> f32 { - let _e262 = (*r_1); - let _e265 = (*r_1); - return ((1f + (0.07248821f * _e262)) / (1f + (0.2877934f * _e265))); + let _e274 = (*r_1); + let _e277 = (*r_1); + return ((1f + (0.07248821f * _e274)) / (1f + (0.2877934f * _e277))); } fn EonLobe_u0028_vf3_u003b_f1_u003b_f1_u003b_f1_u003b_f1_u003b(rho_1: ptr>, r_2: ptr, mu_i: ptr, mu_o: ptr, l_dot_v: ptr) -> vec3 { - var s_1: f32; + var s_2: f32; var s_over_t: f32; - var local_4: f32; + var local_8: f32; var af: f32; var single: vec3; var average_1: f32; - var param_31: f32; + var param_38: f32; var multi: vec3; - var param_32: vec3; - var param_33: f32; - var param_34: f32; - var param_35: f32; - var param_36: f32; - var param_37: f32; + var param_39: vec3; + var param_40: f32; + var param_41: f32; + var param_42: f32; + var param_43: f32; + var param_44: f32; - let _e280 = (*l_dot_v); - let _e281 = (*mu_i); - let _e282 = (*mu_o); - s_1 = (_e280 - (_e281 * _e282)); - let _e285 = s_1; - if (_e285 > 0f) { - let _e287 = s_1; - let _e288 = (*mu_i); - let _e289 = (*mu_o); - local_4 = (_e287 / max(_e288, _e289)); - } else { - let _e292 = s_1; - local_4 = _e292; - } - let _e293 = local_4; - s_over_t = _e293; - let _e294 = (*r_2); - af = (1f / (1f + (0.2877934f * _e294))); - let _e298 = (*rho_1); - let _e299 = af; - let _e300 = (*r_2); - let _e301 = s_over_t; - single = (_e298 * (_e299 * (1f + (_e300 * _e301)))); + let _e292 = (*l_dot_v); + let _e293 = (*mu_i); + let _e294 = (*mu_o); + s_2 = (_e292 - (_e293 * _e294)); + let _e297 = s_2; + if (_e297 > 0f) { + let _e299 = s_2; + let _e300 = (*mu_i); + let _e301 = (*mu_o); + local_8 = (_e299 / max(_e300, _e301)); + } else { + let _e304 = s_2; + local_8 = _e304; + } + let _e305 = local_8; + s_over_t = _e305; let _e306 = (*r_2); - param_31 = _e306; - let _e307 = FonAverage_u0028_f1_u003b((¶m_31)); - average_1 = _e307; - let _e308 = (*rho_1); - param_32 = _e308; - let _e309 = average_1; - param_33 = _e309; - let _e310 = EonMultiAlbedo_u0028_vf3_u003b_f1_u003b((¶m_32), (¶m_33)); - let _e311 = (*mu_o); - param_34 = _e311; + af = (1f / (1f + (0.2877934f * _e306))); + let _e310 = (*rho_1); + let _e311 = af; let _e312 = (*r_2); - param_35 = _e312; - let _e313 = FonAlbedo_u0028_f1_u003b_f1_u003b((¶m_34), (¶m_35)); - let _e316 = (*mu_i); - param_36 = _e316; - let _e317 = (*r_2); - param_37 = _e317; - let _e318 = FonAlbedo_u0028_f1_u003b_f1_u003b((¶m_36), (¶m_37)); - let _e322 = average_1; - multi = (_e310 * ((max((1f - _e313), 0.0000001f) * max((1f - _e318), 0.0000001f)) / max((1f - _e322), 0.0000001f))); - let _e327 = single; - let _e328 = multi; - return ((_e327 + _e328) / vec3(3.1415927f)); + let _e313 = s_over_t; + single = (_e310 * (_e311 * (1f + (_e312 * _e313)))); + let _e318 = (*r_2); + param_38 = _e318; + let _e319 = FonAverage_u0028_f1_u003b((¶m_38)); + average_1 = _e319; + let _e320 = (*rho_1); + param_39 = _e320; + let _e321 = average_1; + param_40 = _e321; + let _e322 = EonMultiAlbedo_u0028_vf3_u003b_f1_u003b((¶m_39), (¶m_40)); + let _e323 = (*mu_o); + param_41 = _e323; + let _e324 = (*r_2); + param_42 = _e324; + let _e325 = FonAlbedo_u0028_f1_u003b_f1_u003b((¶m_41), (¶m_42)); + let _e328 = (*mu_i); + param_43 = _e328; + let _e329 = (*r_2); + param_44 = _e329; + let _e330 = FonAlbedo_u0028_f1_u003b_f1_u003b((¶m_43), (¶m_44)); + let _e334 = average_1; + multi = (_e322 * ((max((1f - _e325), 0.0000001f) * max((1f - _e330), 0.0000001f)) / max((1f - _e334), 0.0000001f))); + let _e339 = single; + let _e340 = multi; + return ((_e339 + _e340) / vec3(3.1415927f)); } fn EonDiffuse_u0028_() -> bool { - let _e263 = frag_info.ambient_sky[3u]; - return (_e263 > 0.5f); + let _e275 = frag_info.ambient_sky[3u]; + return (_e275 > 0.5f); } fn EnvBrdf_u0028_f1_u003b_f1_u003b(roughness_4: ptr, n_dot_v_2: ptr) -> vec2 { var dfg: vec2; - var param_38: f32; - var param_39: f32; - var param_40: f32; - - let _e267 = (*roughness_4); - let _e269 = (*n_dot_v_2); - param_38 = clamp(_e267, 0f, 1f); - param_39 = sqrt(clamp((1f - _e269), 0f, 1f)); - param_40 = 1f; - let _e273 = LtcUv_u0028_f1_u003b_f1_u003b_f1_u003b((¶m_38), (¶m_39), (¶m_40)); - let _e274 = textureSampleLevel(ltc_texture_tex, ltc_texture_smp, _e273, 0f); - dfg = _e274.xy; - let _e277 = dfg[0u]; - let _e279 = dfg[1u]; - let _e282 = dfg[1u]; - return vec2((_e277 - _e279), _e282); -} + var param_45: f32; + var param_46: f32; + var param_47: f32; -fn MultiscatterScale_u0028_vf3_u003b_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b(f0_: ptr>, s_2: ptr) -> vec3 { + let _e279 = (*roughness_4); + let _e281 = (*n_dot_v_2); + param_45 = clamp(_e279, 0f, 1f); + param_46 = sqrt(clamp((1f - _e281), 0f, 1f)); + param_47 = 1f; + let _e285 = LtcUv_u0028_f1_u003b_f1_u003b_f1_u003b((¶m_45), (¶m_46), (¶m_47)); + let _e286 = textureSampleLevel(ltc_texture_tex, ltc_texture_smp, _e285, 0f); + dfg = _e286.xy; + let _e289 = dfg[0u]; + let _e291 = dfg[1u]; + let _e294 = dfg[1u]; + return vec2((_e289 - _e291), _e294); +} + +fn MultiscatterScale_u0028_vf3_u003b_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b(f0_: ptr>, s_3: ptr) -> vec3 { var ab: vec2; - var param_41: f32; - var param_42: f32; + var param_48: f32; + var param_49: f32; var ess: f32; - let _e268 = (*s_2).roughness; - param_41 = max(_e268, 0.045f); - let _e271 = (*s_2).n_dot_v; - param_42 = _e271; - let _e272 = EnvBrdf_u0028_f1_u003b_f1_u003b((¶m_41), (¶m_42)); - ab = _e272; - let _e274 = ab[0u]; - let _e276 = ab[1u]; - ess = max((_e274 + _e276), 0.0001f); - let _e279 = (*f0_); - let _e280 = ess; - return (vec3(1f, 1f, 1f) + (_e279 * ((1f / _e280) - 1f))); + let _e280 = (*s_3).roughness; + param_48 = max(_e280, 0.045f); + let _e283 = (*s_3).n_dot_v; + param_49 = _e283; + let _e284 = EnvBrdf_u0028_f1_u003b_f1_u003b((¶m_48), (¶m_49)); + ab = _e284; + let _e286 = ab[0u]; + let _e288 = ab[1u]; + ess = max((_e286 + _e288), 0.0001f); + let _e291 = (*f0_); + let _e292 = ess; + return (vec3(1f, 1f, 1f) + (_e291 * ((1f / _e292) - 1f))); } fn EnergyCompensation_u0028_() -> bool { - let _e263 = frag_info.target_origin[2u]; - return (_e263 > 0.5f); + let _e275 = frag_info.target_origin[2u]; + return (_e275 > 0.5f); } fn F_SchlickF90_u0028_vf3_u003b_vf3_u003b_f1_u003b(f0_1: ptr>, f90_: ptr>, v_dot_h: ptr) -> vec3 { var f_2: f32; - let _e265 = (*v_dot_h); - f_2 = pow((1f - _e265), 5f); - let _e268 = (*f0_1); - let _e269 = (*f90_); - let _e270 = (*f0_1); - let _e272 = f_2; - return (_e268 + ((_e269 - _e270) * _e272)); + let _e277 = (*v_dot_h); + f_2 = pow((1f - _e277), 5f); + let _e280 = (*f0_1); + let _e281 = (*f90_); + let _e282 = (*f0_1); + let _e284 = f_2; + return (_e280 + ((_e281 - _e282) * _e284)); } fn V_GGXAnisotropic_u0028_f1_u003b_f1_u003b_f1_u003b_f1_u003b_f1_u003b_f1_u003b_f1_u003b_f1_u003b(n_dot_l_3: ptr, n_dot_v_3: ptr, b_dot_v: ptr, t_dot_v: ptr, t_dot_l: ptr, b_dot_l: ptr, at: ptr, ab_1: ptr) -> f32 { var ggx_v: f32; var ggx_l: f32; - let _e271 = (*n_dot_l_3); - let _e272 = (*at); - let _e273 = (*t_dot_v); - let _e275 = (*ab_1); - let _e276 = (*b_dot_v); - let _e278 = (*n_dot_v_3); - ggx_v = (_e271 * length(vec3((_e272 * _e273), (_e275 * _e276), _e278))); - let _e282 = (*n_dot_v_3); - let _e283 = (*at); - let _e284 = (*t_dot_l); - let _e286 = (*ab_1); - let _e287 = (*b_dot_l); - let _e289 = (*n_dot_l_3); - ggx_l = (_e282 * length(vec3((_e283 * _e284), (_e286 * _e287), _e289))); - let _e293 = ggx_v; - let _e294 = ggx_l; - return clamp((0.5f / max((_e293 + _e294), 0.00001f)), 0f, 1f); + let _e283 = (*n_dot_l_3); + let _e284 = (*at); + let _e285 = (*t_dot_v); + let _e287 = (*ab_1); + let _e288 = (*b_dot_v); + let _e290 = (*n_dot_v_3); + ggx_v = (_e283 * length(vec3((_e284 * _e285), (_e287 * _e288), _e290))); + let _e294 = (*n_dot_v_3); + let _e295 = (*at); + let _e296 = (*t_dot_l); + let _e298 = (*ab_1); + let _e299 = (*b_dot_l); + let _e301 = (*n_dot_l_3); + ggx_l = (_e294 * length(vec3((_e295 * _e296), (_e298 * _e299), _e301))); + let _e305 = ggx_v; + let _e306 = ggx_l; + return clamp((0.5f / max((_e305 + _e306), 0.00001f)), 0f, 1f); } fn D_GGXAnisotropic_u0028_f1_u003b_f1_u003b_f1_u003b_f1_u003b_f1_u003b(n_dot_h_3: ptr, t_dot_h: ptr, b_dot_h: ptr, at_1: ptr, ab_2: ptr) -> f32 { @@ -16581,266 +18911,266 @@ fn D_GGXAnisotropic_u0028_f1_u003b_f1_u003b_f1_u003b_f1_u003b_f1_u003b(n_dot_h_3 var f_3: vec3; var w2_: f32; - let _e269 = (*at_1); - let _e270 = (*ab_2); - a2_1 = (_e269 * _e270); - let _e272 = (*ab_2); - let _e273 = (*t_dot_h); - let _e275 = (*at_1); - let _e276 = (*b_dot_h); - let _e278 = a2_1; - let _e279 = (*n_dot_h_3); - f_3 = vec3((_e272 * _e273), (_e275 * _e276), (_e278 * _e279)); - let _e282 = a2_1; - let _e283 = f_3; - let _e284 = f_3; - w2_ = (_e282 / max(dot(_e283, _e284), 0.000000000001f)); - let _e288 = a2_1; - let _e289 = w2_; - let _e291 = w2_; - return (((_e288 * _e289) * _e291) / 3.1415927f); -} - -fn ShadeLight_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b_struct_u002d_LightSample_u002d_vf3_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf31_u003b(s_3: ptr, light_1: ptr) -> vec3 { + let _e281 = (*at_1); + let _e282 = (*ab_2); + a2_1 = (_e281 * _e282); + let _e284 = (*ab_2); + let _e285 = (*t_dot_h); + let _e287 = (*at_1); + let _e288 = (*b_dot_h); + let _e290 = a2_1; + let _e291 = (*n_dot_h_3); + f_3 = vec3((_e284 * _e285), (_e287 * _e288), (_e290 * _e291)); + let _e294 = a2_1; + let _e295 = f_3; + let _e296 = f_3; + w2_ = (_e294 / max(dot(_e295, _e296), 0.000000000001f)); + let _e300 = a2_1; + let _e301 = w2_; + let _e303 = w2_; + return (((_e300 * _e301) * _e303) / 3.1415927f); +} + +fn ShadeLight_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b_struct_u002d_LightSample_u002d_vf3_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf31_u003b(s_4: ptr, light_1: ptr) -> vec3 { var lobe_1: f32; var alpha_6: f32; var f0_2: vec3; var f90_1: vec3; var diffuseColor: vec3; var d_2: f32; - var param_43: f32; - var param_44: f32; - var vis_1: f32; - var param_45: f32; - var param_46: f32; - var param_47: f32; - var at_2: f32; - var ab_3: f32; - var param_48: f32; - var param_49: f32; var param_50: f32; var param_51: f32; + var vis_1: f32; var param_52: f32; var param_53: f32; var param_54: f32; + var at_2: f32; + var ab_3: f32; var param_55: f32; var param_56: f32; var param_57: f32; var param_58: f32; var param_59: f32; var param_60: f32; - var f_4: vec3; - var param_61: vec3; - var param_62: vec3; + var param_61: f32; + var param_62: f32; var param_63: f32; - var specular: vec3; - var param_64: vec3; - var param_65: Surface; - var diffuse: vec3; - var param_66: vec3; + var param_64: f32; + var param_65: f32; + var param_66: f32; var param_67: f32; - var param_68: f32; - var param_69: f32; + var f_4: vec3; + var param_68: vec3; + var param_69: vec3; var param_70: f32; - var sheen: vec3; - var param_71: f32; - var param_72: f32; - var param_73: f32; + var specular: vec3; + var param_71: vec3; + var param_72: Surface; + var diffuse: vec3; + var param_73: vec3; var param_74: f32; - var param_75: Surface; - var param_76: LightSample; - - let _e311 = (*s_3).roughness; - lobe_1 = max(_e311, 0.045f); - let _e313 = lobe_1; - let _e314 = lobe_1; - alpha_6 = (_e313 * _e314); - let _e316 = g_f0_dielectric; - let _e318 = (*s_3).albedo; - let _e320 = (*s_3).metallic; - f0_2 = mix(_e316, _e318, vec3(_e320)); - let _e323 = g_f90_; - let _e325 = (*s_3).metallic; - f90_1 = vec3(mix(_e323, 1f, _e325)); - let _e329 = (*s_3).albedo; - let _e331 = (*s_3).metallic; - diffuseColor = (_e329 * (1f - _e331)); - let _e335 = (*light_1).n_dot_h; - param_43 = _e335; - let _e336 = alpha_6; - param_44 = _e336; - let _e337 = D_GGX_u0028_f1_u003b_f1_u003b((¶m_43), (¶m_44)); - d_2 = _e337; - let _e339 = (*s_3).n_dot_v; - param_45 = _e339; - let _e341 = (*light_1).n_dot_l; - param_46 = _e341; - let _e342 = alpha_6; - param_47 = _e342; - let _e343 = V_SmithGGXCorrelated_u0028_f1_u003b_f1_u003b_f1_u003b((¶m_45), (¶m_46), (¶m_47)); - vis_1 = _e343; - let _e344 = g_aniso; - if (_e344 > 0f) { - let _e346 = alpha_6; - let _e347 = g_aniso; - let _e348 = g_aniso; - at_2 = mix(_e346, 1f, (_e347 * _e348)); - let _e351 = alpha_6; - ab_3 = max(_e351, 0.001f); - let _e353 = g_aniso_t; - let _e355 = (*light_1).h; - let _e357 = g_aniso_b; - let _e359 = (*light_1).h; - let _e362 = (*light_1).n_dot_h; - param_48 = _e362; - param_49 = dot(_e353, _e355); - param_50 = dot(_e357, _e359); - let _e363 = at_2; - param_51 = _e363; - let _e364 = ab_3; - param_52 = _e364; - let _e365 = D_GGXAnisotropic_u0028_f1_u003b_f1_u003b_f1_u003b_f1_u003b_f1_u003b((¶m_48), (¶m_49), (¶m_50), (¶m_51), (¶m_52)); - d_2 = _e365; - let _e366 = g_aniso_b; - let _e368 = (*s_3).v; - let _e370 = g_aniso_t; - let _e372 = (*s_3).v; - let _e374 = g_aniso_t; - let _e376 = (*light_1).l; + var param_75: f32; + var param_76: f32; + var param_77: f32; + var sheen: vec3; + var param_78: f32; + var param_79: f32; + var param_80: f32; + var param_81: f32; + var param_82: Surface; + var param_83: LightSample; + + let _e323 = (*s_4).roughness; + lobe_1 = max(_e323, 0.045f); + let _e325 = lobe_1; + let _e326 = lobe_1; + alpha_6 = (_e325 * _e326); + let _e328 = g_f0_dielectric; + let _e330 = (*s_4).albedo; + let _e332 = (*s_4).metallic; + f0_2 = mix(_e328, _e330, vec3(_e332)); + let _e335 = g_f90_; + let _e337 = (*s_4).metallic; + f90_1 = vec3(mix(_e335, 1f, _e337)); + let _e341 = (*s_4).albedo; + let _e343 = (*s_4).metallic; + diffuseColor = (_e341 * (1f - _e343)); + let _e347 = (*light_1).n_dot_h; + param_50 = _e347; + let _e348 = alpha_6; + param_51 = _e348; + let _e349 = D_GGX_u0028_f1_u003b_f1_u003b((¶m_50), (¶m_51)); + d_2 = _e349; + let _e351 = (*s_4).n_dot_v; + param_52 = _e351; + let _e353 = (*light_1).n_dot_l; + param_53 = _e353; + let _e354 = alpha_6; + param_54 = _e354; + let _e355 = V_SmithGGXCorrelated_u0028_f1_u003b_f1_u003b_f1_u003b((¶m_52), (¶m_53), (¶m_54)); + vis_1 = _e355; + let _e356 = g_aniso; + if (_e356 > 0f) { + let _e358 = alpha_6; + let _e359 = g_aniso; + let _e360 = g_aniso; + at_2 = mix(_e358, 1f, (_e359 * _e360)); + let _e363 = alpha_6; + ab_3 = max(_e363, 0.001f); + let _e365 = g_aniso_t; + let _e367 = (*light_1).h; + let _e369 = g_aniso_b; + let _e371 = (*light_1).h; + let _e374 = (*light_1).n_dot_h; + param_55 = _e374; + param_56 = dot(_e365, _e367); + param_57 = dot(_e369, _e371); + let _e375 = at_2; + param_58 = _e375; + let _e376 = ab_3; + param_59 = _e376; + let _e377 = D_GGXAnisotropic_u0028_f1_u003b_f1_u003b_f1_u003b_f1_u003b_f1_u003b((¶m_55), (¶m_56), (¶m_57), (¶m_58), (¶m_59)); + d_2 = _e377; let _e378 = g_aniso_b; - let _e380 = (*light_1).l; - let _e383 = (*light_1).n_dot_l; - param_53 = _e383; - let _e385 = (*s_3).n_dot_v; - param_54 = _e385; - param_55 = dot(_e366, _e368); - param_56 = dot(_e370, _e372); - param_57 = dot(_e374, _e376); - param_58 = dot(_e378, _e380); - let _e386 = at_2; - param_59 = _e386; - let _e387 = ab_3; - param_60 = _e387; - let _e388 = V_GGXAnisotropic_u0028_f1_u003b_f1_u003b_f1_u003b_f1_u003b_f1_u003b_f1_u003b_f1_u003b_f1_u003b((¶m_53), (¶m_54), (¶m_55), (¶m_56), (¶m_57), (¶m_58), (¶m_59), (¶m_60)); - vis_1 = _e388; - } - let _e389 = f0_2; - param_61 = _e389; - let _e390 = f90_1; - param_62 = _e390; - let _e392 = (*light_1).v_dot_h; - param_63 = _e392; - let _e393 = F_SchlickF90_u0028_vf3_u003b_vf3_u003b_f1_u003b((¶m_61), (¶m_62), (¶m_63)); - f_4 = _e393; - let _e394 = f_4; - let _e395 = g_irid_fresnel; - let _e396 = g_iridescence; - f_4 = mix(_e394, _e395, vec3(_e396)); - let _e399 = d_2; - let _e400 = vis_1; - let _e402 = f_4; - let _e406 = frag_info.material[3u]; - specular = ((_e402 * (_e399 * _e400)) * _e406); - let _e409 = (*light_1).integrated; - if (_e409 > 0.5f) { - let _e413 = (*light_1).ltc[0u]; - let _e414 = f0_2; - let _e417 = (*light_1).ltc[1u]; - let _e419 = f90_1; - let _e420 = f0_2; - let _e424 = (*light_1).ltc[2u]; - let _e430 = frag_info.material[3u]; - let _e433 = (*light_1).n_dot_l; - specular = (((((_e414 * _e417) + ((_e419 - _e420) * _e424)) * _e413) * _e430) / vec3(max(_e433, 0.000001f))); - } - let _e437 = EnergyCompensation_u0028_(); - if _e437 { - let _e438 = f0_2; - param_64 = _e438; - let _e439 = (*s_3); - param_65 = _e439; - let _e440 = MultiscatterScale_u0028_vf3_u003b_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b((¶m_64), (¶m_65)); - let _e441 = specular; - specular = (_e441 * _e440); - } - let _e443 = diffuseColor; - let _e444 = f_4; - diffuse = ((_e443 * (vec3(1f, 1f, 1f) - _e444)) / vec3(3.1415927f)); - let _e449 = EonDiffuse_u0028_(); + let _e380 = (*s_4).v; + let _e382 = g_aniso_t; + let _e384 = (*s_4).v; + let _e386 = g_aniso_t; + let _e388 = (*light_1).l; + let _e390 = g_aniso_b; + let _e392 = (*light_1).l; + let _e395 = (*light_1).n_dot_l; + param_60 = _e395; + let _e397 = (*s_4).n_dot_v; + param_61 = _e397; + param_62 = dot(_e378, _e380); + param_63 = dot(_e382, _e384); + param_64 = dot(_e386, _e388); + param_65 = dot(_e390, _e392); + let _e398 = at_2; + param_66 = _e398; + let _e399 = ab_3; + param_67 = _e399; + let _e400 = V_GGXAnisotropic_u0028_f1_u003b_f1_u003b_f1_u003b_f1_u003b_f1_u003b_f1_u003b_f1_u003b_f1_u003b((¶m_60), (¶m_61), (¶m_62), (¶m_63), (¶m_64), (¶m_65), (¶m_66), (¶m_67)); + vis_1 = _e400; + } + let _e401 = f0_2; + param_68 = _e401; + let _e402 = f90_1; + param_69 = _e402; + let _e404 = (*light_1).v_dot_h; + param_70 = _e404; + let _e405 = F_SchlickF90_u0028_vf3_u003b_vf3_u003b_f1_u003b((¶m_68), (¶m_69), (¶m_70)); + f_4 = _e405; + let _e406 = f_4; + let _e407 = g_irid_fresnel; + let _e408 = g_iridescence; + f_4 = mix(_e406, _e407, vec3(_e408)); + let _e411 = d_2; + let _e412 = vis_1; + let _e414 = f_4; + let _e418 = frag_info.material[3u]; + specular = ((_e414 * (_e411 * _e412)) * _e418); + let _e421 = (*light_1).integrated; + if (_e421 > 0.5f) { + let _e425 = (*light_1).ltc[0u]; + let _e426 = f0_2; + let _e429 = (*light_1).ltc[1u]; + let _e431 = f90_1; + let _e432 = f0_2; + let _e436 = (*light_1).ltc[2u]; + let _e442 = frag_info.material[3u]; + let _e445 = (*light_1).n_dot_l; + specular = (((((_e426 * _e429) + ((_e431 - _e432) * _e436)) * _e425) * _e442) / vec3(max(_e445, 0.000001f))); + } + let _e449 = EnergyCompensation_u0028_(); if _e449 { - let _e451 = (*s_3).n; - let _e453 = (*light_1).l; - let _e457 = (*light_1).l; - let _e459 = (*s_3).v; - let _e461 = diffuseColor; - param_66 = _e461; - let _e463 = (*s_3).roughness; - param_67 = _e463; - param_68 = clamp(dot(_e451, _e453), 0.0001f, 1f); - let _e465 = (*s_3).n_dot_v; - param_69 = _e465; - param_70 = dot(_e457, _e459); - let _e466 = EonLobe_u0028_vf3_u003b_f1_u003b_f1_u003b_f1_u003b_f1_u003b((¶m_66), (¶m_67), (¶m_68), (¶m_69), (¶m_70)); - let _e467 = f_4; - diffuse = (_e466 * (vec3(1f, 1f, 1f) - _e467)); - } - let _e470 = g_transmission; - let _e472 = diffuse; - diffuse = (_e472 * (1f - _e470)); - let _e474 = g_sheen; - let _e475 = g_sheen_roughness; - param_71 = _e475; - let _e477 = (*light_1).n_dot_h; - param_72 = _e477; - let _e478 = D_Charlie_u0028_f1_u003b_f1_u003b((¶m_71), (¶m_72)); - let _e481 = (*s_3).n; - let _e483 = (*light_1).l; - let _e487 = (*s_3).n_dot_v; - param_73 = _e487; - param_74 = clamp(dot(_e481, _e483), 0f, 1f); - let _e488 = V_Neubelt_u0028_f1_u003b_f1_u003b((¶m_73), (¶m_74)); - sheen = ((_e474 * _e478) * _e488); - let _e490 = diffuse; - let _e491 = specular; - let _e493 = g_sheen_scale; - let _e495 = sheen; - let _e497 = g_coat_through; - let _e499 = g_coat; - let _e500 = (*s_3); - param_75 = _e500; - let _e501 = (*light_1); - param_76 = _e501; - let _e502 = CoatLobe_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b_struct_u002d_LightSample_u002d_vf3_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf31_u003b((¶m_75), (¶m_76)); - return ((((((_e490 + _e491) * _e493) + _e495) * _e497) + vec3((_e499 * _e502))) * 3.1415927f); + let _e450 = f0_2; + param_71 = _e450; + let _e451 = (*s_4); + param_72 = _e451; + let _e452 = MultiscatterScale_u0028_vf3_u003b_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b((¶m_71), (¶m_72)); + let _e453 = specular; + specular = (_e453 * _e452); + } + let _e455 = diffuseColor; + let _e456 = f_4; + diffuse = ((_e455 * (vec3(1f, 1f, 1f) - _e456)) / vec3(3.1415927f)); + let _e461 = EonDiffuse_u0028_(); + if _e461 { + let _e463 = (*s_4).n; + let _e465 = (*light_1).l; + let _e469 = (*light_1).l; + let _e471 = (*s_4).v; + let _e473 = diffuseColor; + param_73 = _e473; + let _e475 = (*s_4).roughness; + param_74 = _e475; + param_75 = clamp(dot(_e463, _e465), 0.0001f, 1f); + let _e477 = (*s_4).n_dot_v; + param_76 = _e477; + param_77 = dot(_e469, _e471); + let _e478 = EonLobe_u0028_vf3_u003b_f1_u003b_f1_u003b_f1_u003b_f1_u003b((¶m_73), (¶m_74), (¶m_75), (¶m_76), (¶m_77)); + let _e479 = f_4; + diffuse = (_e478 * (vec3(1f, 1f, 1f) - _e479)); + } + let _e482 = g_transmission; + let _e484 = diffuse; + diffuse = (_e484 * (1f - _e482)); + let _e486 = g_sheen; + let _e487 = g_sheen_roughness; + param_78 = _e487; + let _e489 = (*light_1).n_dot_h; + param_79 = _e489; + let _e490 = D_Charlie_u0028_f1_u003b_f1_u003b((¶m_78), (¶m_79)); + let _e493 = (*s_4).n; + let _e495 = (*light_1).l; + let _e499 = (*s_4).n_dot_v; + param_80 = _e499; + param_81 = clamp(dot(_e493, _e495), 0f, 1f); + let _e500 = V_Neubelt_u0028_f1_u003b_f1_u003b((¶m_80), (¶m_81)); + sheen = ((_e486 * _e490) * _e500); + let _e502 = diffuse; + let _e503 = specular; + let _e505 = g_sheen_scale; + let _e507 = sheen; + let _e509 = g_coat_through; + let _e511 = g_coat; + let _e512 = (*s_4); + param_82 = _e512; + let _e513 = (*light_1); + param_83 = _e513; + let _e514 = CoatLobe_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b_struct_u002d_LightSample_u002d_vf3_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf31_u003b((¶m_82), (¶m_83)); + return ((((((_e502 + _e503) * _e505) + _e507) * _e509) + vec3((_e511 * _e514))) * 3.1415927f); } fn PointShadowDiskTap_u0028_i1_u003b(i_1: ptr) -> vec2 { - let _e262 = (*i_1); - if (_e262 == 0i) { + let _e274 = (*i_1); + if (_e274 == 0i) { return vec2(-0.94201624f, -0.39906216f); } - let _e264 = (*i_1); - if (_e264 == 1i) { + let _e276 = (*i_1); + if (_e276 == 1i) { return vec2(0.9455861f, -0.76890725f); } - let _e266 = (*i_1); - if (_e266 == 2i) { + let _e278 = (*i_1); + if (_e278 == 2i) { return vec2(-0.0941841f, -0.9293887f); } - let _e268 = (*i_1); - if (_e268 == 3i) { + let _e280 = (*i_1); + if (_e280 == 3i) { return vec2(0.34495938f, 0.2938776f); } - let _e270 = (*i_1); - if (_e270 == 4i) { + let _e282 = (*i_1); + if (_e282 == 4i) { return vec2(-0.9158858f, 0.45771432f); } - let _e272 = (*i_1); - if (_e272 == 5i) { + let _e284 = (*i_1); + if (_e284 == 5i) { return vec2(-0.8154423f, -0.87912464f); } - let _e274 = (*i_1); - if (_e274 == 6i) { + let _e286 = (*i_1); + if (_e286 == 6i) { return vec2(-0.38277543f, 0.27676845f); } return vec2(0.974844f, 0.7564838f); @@ -16848,212 +19178,212 @@ fn PointShadowDiskTap_u0028_i1_u003b(i_1: ptr) -> vec2 { fn PointShadowOffset_u0028_i1_u003b_f1_u003b_f1_u003b_f1_u003b(i_2: ptr, ca: ptr, sa: ptr, radius: ptr) -> vec2 { var p_1: vec2; - var param_77: i32; - - let _e267 = (*i_2); - param_77 = _e267; - let _e268 = PointShadowDiskTap_u0028_i1_u003b((¶m_77)); - p_1 = _e268; - let _e270 = p_1[0u]; - let _e271 = (*ca); - let _e274 = p_1[1u]; - let _e275 = (*sa); - let _e279 = p_1[0u]; - let _e280 = (*sa); - let _e283 = p_1[1u]; - let _e284 = (*ca); - let _e288 = (*radius); - return (vec2(((_e270 * _e271) - (_e274 * _e275)), ((_e279 * _e280) + (_e283 * _e284))) * _e288); + var param_84: i32; + + let _e279 = (*i_2); + param_84 = _e279; + let _e280 = PointShadowDiskTap_u0028_i1_u003b((¶m_84)); + p_1 = _e280; + let _e282 = p_1[0u]; + let _e283 = (*ca); + let _e286 = p_1[1u]; + let _e287 = (*sa); + let _e291 = p_1[0u]; + let _e292 = (*sa); + let _e295 = p_1[1u]; + let _e296 = (*ca); + let _e300 = (*radius); + return (vec2(((_e282 * _e283) - (_e286 * _e287)), ((_e291 * _e292) + (_e295 * _e296))) * _e300); } fn PointShadowDistance_u0028_vf2_u003b_vf2_u003b_vf2_u003b_f1_u003b(uv_1: ptr>, offset: ptr>, tile: ptr>, range: ptr) -> f32 { var inset: f32; - var local_5: vec2; + var local_9: vec2; var atlas: vec2; - let _e270 = point_shadow.params[0u]; - inset = _e270; - let _e271 = (*uv_1); - let _e272 = (*offset); - let _e274 = inset; - let _e275 = inset; - local_5 = clamp((_e271 + _e272), vec2(_e274), vec2((1f - _e275))); - let _e280 = local_5; - let _e281 = (*tile); - atlas = ((_e280 + _e281) * vec2(0.16666667f, 0.16666667f)); - let _e286 = point_shadow.params3_[0u]; - if (_e286 > 0.5f) { - let _e289 = atlas[1u]; - atlas[1u] = (1f - _e289); - } - let _e292 = atlas; - let _e293 = textureSampleLevel(point_shadow_texture_tex, point_shadow_texture_smp, _e292, 0f); - let _e295 = atlas; - let _e296 = textureSampleLevel(point_shadow_static_texture_tex, point_shadow_static_texture_smp, _e295, 0f); - let _e299 = (*range); - return (min(_e293.x, _e296.x) * _e299); + let _e282 = point_shadow.params[0u]; + inset = _e282; + let _e283 = (*uv_1); + let _e284 = (*offset); + let _e286 = inset; + let _e287 = inset; + local_9 = clamp((_e283 + _e284), vec2(_e286), vec2((1f - _e287))); + let _e292 = local_9; + let _e293 = (*tile); + atlas = ((_e292 + _e293) * vec2(0.16666667f, 0.16666667f)); + let _e298 = point_shadow.params3_[0u]; + if (_e298 > 0.5f) { + let _e301 = atlas[1u]; + atlas[1u] = (1f - _e301); + } + let _e304 = atlas; + let _e305 = textureSampleLevel(point_shadow_texture_tex, point_shadow_texture_smp, _e304, 0f); + let _e307 = atlas; + let _e308 = textureSampleLevel(point_shadow_static_texture_tex, point_shadow_static_texture_smp, _e307, 0f); + let _e311 = (*range); + return (min(_e305.x, _e308.x) * _e311); } fn PointShadowTap_u0028_vf2_u003b_vf2_u003b_vf2_u003b_f1_u003b_f1_u003b(uv_2: ptr>, offset_1: ptr>, tile_1: ptr>, range_1: ptr, receiver: ptr) -> f32 { var stored: f32; - var param_78: vec2; - var param_79: vec2; - var param_80: vec2; - var param_81: f32; + var param_85: vec2; + var param_86: vec2; + var param_87: vec2; + var param_88: f32; - let _e271 = (*uv_2); - param_78 = _e271; - let _e272 = (*offset_1); - param_79 = _e272; - let _e273 = (*tile_1); - param_80 = _e273; - let _e274 = (*range_1); - param_81 = _e274; - let _e275 = PointShadowDistance_u0028_vf2_u003b_vf2_u003b_vf2_u003b_f1_u003b((¶m_78), (¶m_79), (¶m_80), (¶m_81)); - stored = _e275; - let _e276 = stored; - let _e277 = (*range_1); - if (_e276 >= (_e277 * 0.999f)) { + let _e283 = (*uv_2); + param_85 = _e283; + let _e284 = (*offset_1); + param_86 = _e284; + let _e285 = (*tile_1); + param_87 = _e285; + let _e286 = (*range_1); + param_88 = _e286; + let _e287 = PointShadowDistance_u0028_vf2_u003b_vf2_u003b_vf2_u003b_f1_u003b((¶m_85), (¶m_86), (¶m_87), (¶m_88)); + stored = _e287; + let _e288 = stored; + let _e289 = (*range_1); + if (_e288 >= (_e289 * 0.999f)) { return 1f; } - let _e280 = (*receiver); - let _e281 = stored; - return select(1f, 0f, (_e280 > _e281)); + let _e292 = (*receiver); + let _e293 = stored; + return select(1f, 0f, (_e292 > _e293)); } fn PointShadowPenumbra_u0028_vf2_u003b_vf2_u003b_f1_u003b_f1_u003b_f1_u003b_f1_u003b_f1_u003b_f1_u003b(uv_3: ptr>, tile_2: ptr>, range_2: ptr, receiver_1: ptr, ca_1: ptr, sa_1: ptr, tanHalf: ptr, blockerOut: ptr) -> f32 { var lightRadius: f32; var minRadius: f32; var maxRadius: f32; - var param_82: vec2; - var param_83: vec2; - var param_84: vec2; - var param_85: f32; + var param_89: vec2; + var param_90: vec2; + var param_91: vec2; + var param_92: f32; var sum: f32; var count: f32; var i_3: i32; var stored_1: f32; - var param_86: i32; - var param_87: f32; - var param_88: f32; - var param_89: f32; - var param_90: vec2; - var param_91: vec2; - var param_92: vec2; - var param_93: f32; + var param_93: i32; + var param_94: f32; + var param_95: f32; + var param_96: f32; + var param_97: vec2; + var param_98: vec2; + var param_99: vec2; + var param_100: f32; var blocker: f32; var world: f32; (*blockerOut) = -1f; - let _e292 = point_shadow.params2_[1u]; - lightRadius = _e292; - let _e295 = point_shadow.params2_[0u]; - minRadius = _e295; - let _e298 = point_shadow.params2_[2u]; - maxRadius = _e298; - let _e299 = lightRadius; - if (_e299 <= 0f) { - let _e301 = (*uv_3); - param_82 = _e301; - param_83 = vec2(0f, 0f); - let _e302 = (*tile_2); - param_84 = _e302; - let _e303 = (*range_2); - param_85 = _e303; - let _e304 = PointShadowDistance_u0028_vf2_u003b_vf2_u003b_vf2_u003b_f1_u003b((¶m_82), (¶m_83), (¶m_84), (¶m_85)); - (*blockerOut) = _e304; - let _e305 = minRadius; - return _e305; + let _e304 = point_shadow.params2_[1u]; + lightRadius = _e304; + let _e307 = point_shadow.params2_[0u]; + minRadius = _e307; + let _e310 = point_shadow.params2_[2u]; + maxRadius = _e310; + let _e311 = lightRadius; + if (_e311 <= 0f) { + let _e313 = (*uv_3); + param_89 = _e313; + param_90 = vec2(0f, 0f); + let _e314 = (*tile_2); + param_91 = _e314; + let _e315 = (*range_2); + param_92 = _e315; + let _e316 = PointShadowDistance_u0028_vf2_u003b_vf2_u003b_vf2_u003b_f1_u003b((¶m_89), (¶m_90), (¶m_91), (¶m_92)); + (*blockerOut) = _e316; + let _e317 = minRadius; + return _e317; } sum = 0f; count = 0f; i_3 = 0i; loop { - let _e306 = i_3; - if (_e306 < 8i) { - let _e308 = i_3; - param_86 = _e308; - let _e309 = (*ca_1); - param_87 = _e309; - let _e310 = (*sa_1); - param_88 = _e310; - let _e311 = maxRadius; - param_89 = _e311; - let _e312 = PointShadowOffset_u0028_i1_u003b_f1_u003b_f1_u003b_f1_u003b((¶m_86), (¶m_87), (¶m_88), (¶m_89)); - let _e313 = (*uv_3); - param_90 = _e313; - param_91 = _e312; - let _e314 = (*tile_2); - param_92 = _e314; - let _e315 = (*range_2); - param_93 = _e315; - let _e316 = PointShadowDistance_u0028_vf2_u003b_vf2_u003b_vf2_u003b_f1_u003b((¶m_90), (¶m_91), (¶m_92), (¶m_93)); - stored_1 = _e316; - let _e317 = stored_1; - let _e318 = (*range_2); - if (_e317 >= (_e318 * 0.999f)) { + let _e318 = i_3; + if (_e318 < 8i) { + let _e320 = i_3; + param_93 = _e320; + let _e321 = (*ca_1); + param_94 = _e321; + let _e322 = (*sa_1); + param_95 = _e322; + let _e323 = maxRadius; + param_96 = _e323; + let _e324 = PointShadowOffset_u0028_i1_u003b_f1_u003b_f1_u003b_f1_u003b((¶m_93), (¶m_94), (¶m_95), (¶m_96)); + let _e325 = (*uv_3); + param_97 = _e325; + param_98 = _e324; + let _e326 = (*tile_2); + param_99 = _e326; + let _e327 = (*range_2); + param_100 = _e327; + let _e328 = PointShadowDistance_u0028_vf2_u003b_vf2_u003b_vf2_u003b_f1_u003b((¶m_97), (¶m_98), (¶m_99), (¶m_100)); + stored_1 = _e328; + let _e329 = stored_1; + let _e330 = (*range_2); + if (_e329 >= (_e330 * 0.999f)) { continue; } - let _e321 = stored_1; - let _e322 = (*receiver_1); - if (_e321 >= _e322) { + let _e333 = stored_1; + let _e334 = (*receiver_1); + if (_e333 >= _e334) { continue; } - let _e324 = stored_1; - let _e325 = sum; - sum = (_e325 + _e324); - let _e327 = count; - count = (_e327 + 1f); + let _e336 = stored_1; + let _e337 = sum; + sum = (_e337 + _e336); + let _e339 = count; + count = (_e339 + 1f); continue; } else { break; } continuing { - let _e329 = i_3; - i_3 = (_e329 + 1i); + let _e341 = i_3; + i_3 = (_e341 + 1i); } } - let _e331 = count; - if (_e331 < 0.5f) { + let _e343 = count; + if (_e343 < 0.5f) { return -1f; } - let _e333 = sum; - let _e334 = count; - blocker = max((_e333 / _e334), 0.0001f); - let _e337 = blocker; - (*blockerOut) = _e337; - let _e338 = lightRadius; - let _e339 = (*receiver_1); - let _e340 = blocker; - let _e344 = blocker; - world = ((_e338 * max((_e339 - _e340), 0f)) / _e344); - let _e346 = world; - let _e347 = (*receiver_1); - let _e349 = (*tanHalf); - let _e352 = minRadius; - let _e353 = maxRadius; - return clamp((_e346 / ((2f * _e347) * _e349)), _e352, _e353); + let _e345 = sum; + let _e346 = count; + blocker = max((_e345 / _e346), 0.0001f); + let _e349 = blocker; + (*blockerOut) = _e349; + let _e350 = lightRadius; + let _e351 = (*receiver_1); + let _e352 = blocker; + let _e356 = blocker; + world = ((_e350 * max((_e351 - _e352), 0f)) / _e356); + let _e358 = world; + let _e359 = (*receiver_1); + let _e361 = (*tanHalf); + let _e364 = minRadius; + let _e365 = maxRadius; + return clamp((_e358 / ((2f * _e359) * _e361)), _e364, _e365); } fn FragCoordFromTop_u0028_f1_u003b(rows: ptr) -> vec2 { - var local_6: vec2; - - let _e263 = (*rows); - if (_e263 > 0f) { - let _e266 = gl_FragCoord_1[0u]; - let _e267 = (*rows); - let _e269 = gl_FragCoord_1[1u]; - local_6 = vec2(_e266, (_e267 - _e269)); + var local_10: vec2; + + let _e275 = (*rows); + if (_e275 > 0f) { + let _e278 = gl_FragCoord_1[0u]; + let _e279 = (*rows); + let _e281 = gl_FragCoord_1[1u]; + local_10 = vec2(_e278, (_e279 - _e281)); } else { - let _e272 = gl_FragCoord_1; - local_6 = _e272.xy; + let _e284 = gl_FragCoord_1; + local_10 = _e284.xy; } - let _e274 = local_6; - return _e274; + let _e286 = local_10; + return _e286; } fn PointShadowFactor_u0028_vf3_u003b_vf3_u003b_i1_u003b(world_1: ptr>, normal: ptr>, lightIndex: ptr) -> f32 { - var slot: i32; + var slot_1: i32; var strength: f32; var toLight: vec3; var toLightLength: f32; @@ -17072,292 +19402,292 @@ fn PointShadowFactor_u0028_vf3_u003b_vf3_u003b_i1_u003b(world_1: ptr; var receiver_2: f32; var noise: f32; - var param_94: f32; + var param_101: f32; var angle_1: f32; var ca_2: f32; var sa_2: f32; var tanHalf_1: f32; var blocker_1: f32; var radius_1: f32; - var param_95: vec2; - var param_96: vec2; - var param_97: f32; - var param_98: f32; - var param_99: f32; - var param_100: f32; - var param_101: f32; - var param_102: f32; - var local_7: vec3; - var lit: f32; + var param_102: vec2; var param_103: vec2; - var param_104: vec2; - var param_105: vec2; + var param_104: f32; + var param_105: f32; var param_106: f32; var param_107: f32; - var i_4: i32; - var param_108: i32; + var param_108: f32; var param_109: f32; - var param_110: f32; - var param_111: f32; + var local_11: vec3; + var lit_1: f32; + var param_110: vec2; + var param_111: vec2; var param_112: vec2; - var param_113: vec2; - var param_114: vec2; - var param_115: f32; + var param_113: f32; + var param_114: f32; + var i_4: i32; + var param_115: i32; var param_116: f32; - var phi_2602_: bool; - var phi_2663_: bool; - - let _e315 = (*lightIndex); - let _e319 = point_shadow.slots[_e315][0u]; - slot = i32((_e319 + 0.5f)); - let _e322 = (*lightIndex); - let _e326 = point_shadow.slots[_e322][0u]; - if (_e326 < 0f) { + var param_117: f32; + var param_118: f32; + var param_119: vec2; + var param_120: vec2; + var param_121: vec2; + var param_122: f32; + var param_123: f32; + var phi_2703_: bool; + var phi_2764_: bool; + + let _e327 = (*lightIndex); + let _e331 = point_shadow.slots[_e327][0u]; + slot_1 = i32((_e331 + 0.5f)); + let _e334 = (*lightIndex); + let _e338 = point_shadow.slots[_e334][0u]; + if (_e338 < 0f) { return 1f; } - let _e330 = point_shadow.params[2u]; - strength = _e330; - let _e331 = strength; - if (_e331 <= 0f) { + let _e342 = point_shadow.params[2u]; + strength = _e342; + let _e343 = strength; + if (_e343 <= 0f) { return 1f; } - let _e333 = slot; - let _e336 = point_shadow.lights[_e333]; - let _e338 = (*world_1); - toLight = (_e336.xyz - _e338); - let _e340 = toLight; - toLightLength = max(length(_e340), 0.000001f); - let _e343 = (*normal); - let _e344 = toLight; - let _e345 = toLightLength; - nDotL = max(dot(_e343, (_e344 / vec3(_e345))), 0.15f); - let _e350 = nDotL; - let _e351 = nDotL; - let _e356 = nDotL; - let _e357 = nDotL; - slope = min((sqrt(max((1f - (_e350 * _e351)), 0f)) / (_e356 * _e357)), 8f); - let _e361 = toLightLength; - let _e363 = (*lightIndex); - let _e367 = point_shadow.slots[_e363][2u]; - let _e372 = point_shadow.params3_[1u]; - texel = (((2f * _e361) * max(_e367, 0.0001f)) * _e372); - let _e374 = (*world_1); - let _e375 = (*normal); - let _e376 = texel; - let _e380 = point_shadow.params[3u]; - let _e382 = slope; - origin = (_e374 + (((_e375 * _e376) * _e380) * (1f + _e382))); - let _e386 = origin; - let _e387 = slot; - let _e390 = point_shadow.lights[_e387]; - toFragment = (_e386 - _e390.xyz); - let _e393 = toFragment; - distance_ = length(_e393); - let _e395 = slot; - let _e399 = point_shadow.lights[_e395][3u]; - range_3 = max(_e399, 0.0001f); - let _e401 = distance_; - let _e402 = range_3; - if (_e401 >= _e402) { + let _e345 = slot_1; + let _e348 = point_shadow.lights[_e345]; + let _e350 = (*world_1); + toLight = (_e348.xyz - _e350); + let _e352 = toLight; + toLightLength = max(length(_e352), 0.000001f); + let _e355 = (*normal); + let _e356 = toLight; + let _e357 = toLightLength; + nDotL = max(dot(_e355, (_e356 / vec3(_e357))), 0.15f); + let _e362 = nDotL; + let _e363 = nDotL; + let _e368 = nDotL; + let _e369 = nDotL; + slope = min((sqrt(max((1f - (_e362 * _e363)), 0f)) / (_e368 * _e369)), 8f); + let _e373 = toLightLength; + let _e375 = (*lightIndex); + let _e379 = point_shadow.slots[_e375][2u]; + let _e384 = point_shadow.params3_[1u]; + texel = (((2f * _e373) * max(_e379, 0.0001f)) * _e384); + let _e386 = (*world_1); + let _e387 = (*normal); + let _e388 = texel; + let _e392 = point_shadow.params[3u]; + let _e394 = slope; + origin = (_e386 + (((_e387 * _e388) * _e392) * (1f + _e394))); + let _e398 = origin; + let _e399 = slot_1; + let _e402 = point_shadow.lights[_e399]; + toFragment = (_e398 - _e402.xyz); + let _e405 = toFragment; + distance_ = length(_e405); + let _e407 = slot_1; + let _e411 = point_shadow.lights[_e407][3u]; + range_3 = max(_e411, 0.0001f); + let _e413 = distance_; + let _e414 = range_3; + if (_e413 >= _e414) { return 1f; } face = 0i; - let _e404 = (*lightIndex); - let _e408 = point_shadow.slots[_e404][1u]; - if (_e408 < 0.5f) { - let _e410 = toFragment; - a_2 = abs(_e410); - let _e413 = a_2[0u]; - let _e415 = a_2[1u]; - let _e416 = (_e413 >= _e415); - phi_2602_ = _e416; - if _e416 { - let _e418 = a_2[0u]; - let _e420 = a_2[2u]; - phi_2602_ = (_e418 >= _e420); - } - let _e423 = phi_2602_; - if _e423 { - let _e425 = toFragment[0u]; - face = select(1i, 0i, (_e425 > 0f)); + let _e416 = (*lightIndex); + let _e420 = point_shadow.slots[_e416][1u]; + if (_e420 < 0.5f) { + let _e422 = toFragment; + a_2 = abs(_e422); + let _e425 = a_2[0u]; + let _e427 = a_2[1u]; + let _e428 = (_e425 >= _e427); + phi_2703_ = _e428; + if _e428 { + let _e430 = a_2[0u]; + let _e432 = a_2[2u]; + phi_2703_ = (_e430 >= _e432); + } + let _e435 = phi_2703_; + if _e435 { + let _e437 = toFragment[0u]; + face = select(1i, 0i, (_e437 > 0f)); } else { - let _e429 = a_2[1u]; - let _e431 = a_2[2u]; - if (_e429 >= _e431) { - let _e434 = toFragment[1u]; - face = select(3i, 2i, (_e434 > 0f)); + let _e441 = a_2[1u]; + let _e443 = a_2[2u]; + if (_e441 >= _e443) { + let _e446 = toFragment[1u]; + face = select(3i, 2i, (_e446 > 0f)); } else { - let _e438 = toFragment[2u]; - face = select(5i, 4i, (_e438 > 0f)); + let _e450 = toFragment[2u]; + face = select(5i, 4i, (_e450 > 0f)); } } } - let _e441 = slot; - let _e443 = face; - let _e447 = point_shadow.faces[((_e441 * 6i) + _e443)]; - let _e448 = origin; - clip = (_e447 * vec4(_e448.x, _e448.y, _e448.z, 1f)); - let _e455 = clip[3u]; - if (_e455 <= 0f) { + let _e453 = slot_1; + let _e455 = face; + let _e459 = point_shadow.faces[((_e453 * 6i) + _e455)]; + let _e460 = origin; + clip = (_e459 * vec4(_e460.x, _e460.y, _e460.z, 1f)); + let _e467 = clip[3u]; + if (_e467 <= 0f) { return 1f; } - let _e457 = clip; - let _e460 = clip[3u]; - ndc = (_e457.xy / vec2(_e460)); - let _e464 = ndc[0u]; - let _e466 = (abs(_e464) > 1f); - phi_2663_ = _e466; - if !(_e466) { - let _e469 = ndc[1u]; - phi_2663_ = (abs(_e469) > 1f); - } - let _e473 = phi_2663_; - if _e473 { + let _e469 = clip; + let _e472 = clip[3u]; + ndc = (_e469.xy / vec2(_e472)); + let _e476 = ndc[0u]; + let _e478 = (abs(_e476) > 1f); + phi_2764_ = _e478; + if !(_e478) { + let _e481 = ndc[1u]; + phi_2764_ = (abs(_e481) > 1f); + } + let _e485 = phi_2764_; + if _e485 { return 1f; } - let _e475 = ndc[0u]; - let _e479 = ndc[1u]; - uv_4 = vec2(((_e475 * 0.5f) + 0.5f), (0.5f - (_e479 * 0.5f))); - let _e483 = face; - let _e485 = slot; - tile_3 = vec2(f32(_e483), f32(_e485)); - let _e488 = distance_; - let _e491 = point_shadow.params[1u]; - receiver_2 = (_e488 - _e491); - let _e495 = frag_info.target_origin[0u]; - param_94 = _e495; - let _e496 = FragCoordFromTop_u0028_f1_u003b((¶m_94)); - noise = fract((52.982918f * fract(dot(_e496, vec2(0.06711056f, 0.00583715f))))); - let _e501 = noise; - angle_1 = (_e501 * 6.2831855f); - let _e503 = angle_1; - ca_2 = cos(_e503); - let _e505 = angle_1; - sa_2 = sin(_e505); - let _e507 = (*lightIndex); - let _e511 = point_shadow.slots[_e507][2u]; - tanHalf_1 = max(_e511, 0.0001f); + let _e487 = ndc[0u]; + let _e491 = ndc[1u]; + uv_4 = vec2(((_e487 * 0.5f) + 0.5f), (0.5f - (_e491 * 0.5f))); + let _e495 = face; + let _e497 = slot_1; + tile_3 = vec2(f32(_e495), f32(_e497)); + let _e500 = distance_; + let _e503 = point_shadow.params[1u]; + receiver_2 = (_e500 - _e503); + let _e507 = frag_info.target_origin[0u]; + param_101 = _e507; + let _e508 = FragCoordFromTop_u0028_f1_u003b((¶m_101)); + noise = fract((52.982918f * fract(dot(_e508, vec2(0.06711056f, 0.00583715f))))); + let _e513 = noise; + angle_1 = (_e513 * 6.2831855f); + let _e515 = angle_1; + ca_2 = cos(_e515); + let _e517 = angle_1; + sa_2 = sin(_e517); + let _e519 = (*lightIndex); + let _e523 = point_shadow.slots[_e519][2u]; + tanHalf_1 = max(_e523, 0.0001f); blocker_1 = -1f; - let _e513 = uv_4; - param_95 = _e513; - let _e514 = tile_3; - param_96 = _e514; - let _e515 = range_3; - param_97 = _e515; - let _e516 = receiver_2; - param_98 = _e516; - let _e517 = ca_2; - param_99 = _e517; - let _e518 = sa_2; - param_100 = _e518; - let _e519 = tanHalf_1; - param_101 = _e519; - let _e520 = PointShadowPenumbra_u0028_vf2_u003b_vf2_u003b_f1_u003b_f1_u003b_f1_u003b_f1_u003b_f1_u003b_f1_u003b((¶m_95), (¶m_96), (¶m_97), (¶m_98), (¶m_99), (¶m_100), (¶m_101), (¶m_102)); - let _e521 = param_102; - blocker_1 = _e521; - radius_1 = _e520; - let _e524 = point_shadow.params2_[3u]; - if (_e524 > 0.5f) { + let _e525 = uv_4; + param_102 = _e525; + let _e526 = tile_3; + param_103 = _e526; + let _e527 = range_3; + param_104 = _e527; + let _e528 = receiver_2; + param_105 = _e528; + let _e529 = ca_2; + param_106 = _e529; + let _e530 = sa_2; + param_107 = _e530; + let _e531 = tanHalf_1; + param_108 = _e531; + let _e532 = PointShadowPenumbra_u0028_vf2_u003b_vf2_u003b_f1_u003b_f1_u003b_f1_u003b_f1_u003b_f1_u003b_f1_u003b((¶m_102), (¶m_103), (¶m_104), (¶m_105), (¶m_106), (¶m_107), (¶m_108), (¶m_109)); + let _e533 = param_109; + blocker_1 = _e533; + radius_1 = _e532; + let _e536 = point_shadow.params2_[3u]; + if (_e536 > 0.5f) { g_debug_surface_on = true; - let _e526 = radius_1; - if (_e526 < 0f) { - local_7 = vec3(0f, 0f, 1f); + let _e538 = radius_1; + if (_e538 < 0f) { + local_11 = vec3(0f, 0f, 1f); } else { - let _e528 = radius_1; - let _e531 = point_shadow.params2_[2u]; - let _e535 = blocker_1; - let _e536 = range_3; - local_7 = vec3(clamp((_e528 / max(_e531, 0.000001f)), 0f, 1f), clamp((_e535 / _e536), 0f, 1f), 0f); + let _e540 = radius_1; + let _e543 = point_shadow.params2_[2u]; + let _e547 = blocker_1; + let _e548 = range_3; + local_11 = vec3(clamp((_e540 / max(_e543, 0.000001f)), 0f, 1f), clamp((_e547 / _e548), 0f, 1f), 0f); } - let _e540 = local_7; - g_debug_surface = _e540; + let _e552 = local_11; + g_debug_surface = _e552; } - let _e541 = radius_1; - if (_e541 < 0f) { + let _e553 = radius_1; + if (_e553 < 0f) { return 1f; } - let _e543 = uv_4; - param_103 = _e543; - param_104 = vec2(0f, 0f); - let _e544 = tile_3; - param_105 = _e544; - let _e545 = range_3; - param_106 = _e545; - let _e546 = receiver_2; - param_107 = _e546; - let _e547 = PointShadowTap_u0028_vf2_u003b_vf2_u003b_vf2_u003b_f1_u003b_f1_u003b((¶m_103), (¶m_104), (¶m_105), (¶m_106), (¶m_107)); - lit = _e547; - let _e548 = radius_1; - if (_e548 > 0f) { + let _e555 = uv_4; + param_110 = _e555; + param_111 = vec2(0f, 0f); + let _e556 = tile_3; + param_112 = _e556; + let _e557 = range_3; + param_113 = _e557; + let _e558 = receiver_2; + param_114 = _e558; + let _e559 = PointShadowTap_u0028_vf2_u003b_vf2_u003b_vf2_u003b_f1_u003b_f1_u003b((¶m_110), (¶m_111), (¶m_112), (¶m_113), (¶m_114)); + lit_1 = _e559; + let _e560 = radius_1; + if (_e560 > 0f) { i_4 = 0i; loop { - let _e550 = i_4; - if (_e550 < 8i) { - let _e552 = i_4; - param_108 = _e552; - let _e553 = ca_2; - param_109 = _e553; - let _e554 = sa_2; - param_110 = _e554; - let _e555 = radius_1; - param_111 = _e555; - let _e556 = PointShadowOffset_u0028_i1_u003b_f1_u003b_f1_u003b_f1_u003b((¶m_108), (¶m_109), (¶m_110), (¶m_111)); - let _e557 = uv_4; - param_112 = _e557; - param_113 = _e556; - let _e558 = tile_3; - param_114 = _e558; - let _e559 = range_3; - param_115 = _e559; - let _e560 = receiver_2; - param_116 = _e560; - let _e561 = PointShadowTap_u0028_vf2_u003b_vf2_u003b_vf2_u003b_f1_u003b_f1_u003b((¶m_112), (¶m_113), (¶m_114), (¶m_115), (¶m_116)); - let _e562 = lit; - lit = (_e562 + _e561); + let _e562 = i_4; + if (_e562 < 8i) { + let _e564 = i_4; + param_115 = _e564; + let _e565 = ca_2; + param_116 = _e565; + let _e566 = sa_2; + param_117 = _e566; + let _e567 = radius_1; + param_118 = _e567; + let _e568 = PointShadowOffset_u0028_i1_u003b_f1_u003b_f1_u003b_f1_u003b((¶m_115), (¶m_116), (¶m_117), (¶m_118)); + let _e569 = uv_4; + param_119 = _e569; + param_120 = _e568; + let _e570 = tile_3; + param_121 = _e570; + let _e571 = range_3; + param_122 = _e571; + let _e572 = receiver_2; + param_123 = _e572; + let _e573 = PointShadowTap_u0028_vf2_u003b_vf2_u003b_vf2_u003b_f1_u003b_f1_u003b((¶m_119), (¶m_120), (¶m_121), (¶m_122), (¶m_123)); + let _e574 = lit_1; + lit_1 = (_e574 + _e573); continue; } else { break; } continuing { - let _e564 = i_4; - i_4 = (_e564 + 1i); + let _e576 = i_4; + i_4 = (_e576 + 1i); } } - let _e566 = lit; - lit = (_e566 * 0.11111111f); + let _e578 = lit_1; + lit_1 = (_e578 * 0.11111111f); } - let _e568 = lit; - let _e569 = strength; - return mix(1f, _e568, clamp(_e569, 0f, 1f)); + let _e580 = lit_1; + let _e581 = strength; + return mix(1f, _e580, clamp(_e581, 0f, 1f)); } fn VogelDisc_u0028_i1_u003b_i1_u003b_f1_u003b(i_5: ptr, n_2: ptr, turn: ptr) -> vec2 { var r_3: f32; var theta: f32; - let _e266 = (*i_5); - let _e269 = (*n_2); - r_3 = sqrt(((f32(_e266) + 0.5f) / f32(_e269))); - let _e273 = (*i_5); - let _e276 = (*turn); - theta = ((f32(_e273) * 2.3999631f) + _e276); - let _e278 = r_3; - let _e279 = theta; - let _e281 = theta; - return (vec2(cos(_e279), sin(_e281)) * _e278); + let _e278 = (*i_5); + let _e281 = (*n_2); + r_3 = sqrt(((f32(_e278) + 0.5f) / f32(_e281))); + let _e285 = (*i_5); + let _e288 = (*turn); + theta = ((f32(_e285) * 2.3999631f) + _e288); + let _e290 = r_3; + let _e291 = theta; + let _e293 = theta; + return (vec2(cos(_e291), sin(_e293)) * _e290); } fn ShadowNoise_u0028_() -> f32 { var at_3: vec2; - var param_117: f32; + var param_124: f32; - let _e265 = frag_info.target_origin[0u]; - param_117 = _e265; - let _e266 = FragCoordFromTop_u0028_f1_u003b((¶m_117)); - let _e269 = frag_info.target_origin[3u]; - at_3 = (_e266 + vec2((5.588238f * max(_e269, 0f)))); - let _e274 = at_3; - return fract((52.982918f * fract(dot(_e274, vec2(0.06711056f, 0.00583715f))))); + let _e277 = frag_info.target_origin[0u]; + param_124 = _e277; + let _e278 = FragCoordFromTop_u0028_f1_u003b((¶m_124)); + let _e281 = frag_info.target_origin[3u]; + at_3 = (_e278 + vec2((5.588238f * max(_e281, 0f)))); + let _e286 = at_3; + return fract((52.982918f * fract(dot(_e286, vec2(0.06711056f, 0.00583715f))))); } fn EvsmChebyshev_u0028_vf2_u003b_f1_u003b_f1_u003b_f1_u003b(moments: ptr>, t: ptr, minVariance: ptr, bleed: ptr) -> f32 { @@ -17366,91 +19696,92 @@ fn EvsmChebyshev_u0028_vf2_u003b_f1_u003b_f1_u003b_f1_u003b(moments: ptr) -> vec2 { var d_4: f32; - let _e263 = (*depth); - d_4 = ((2f * clamp(_e263, 0f, 1f)) - 1f); - let _e267 = d_4; - let _e270 = d_4; - return vec2(exp((40f * _e267)), -(exp((-5f * _e270)))); + let _e275 = (*depth); + d_4 = ((2f * clamp(_e275, 0f, 1f)) - 1f); + let _e279 = d_4; + let _e282 = d_4; + return vec2(exp((40f * _e279)), -(exp((-5f * _e282)))); } fn EvsmVisibility_u0028_vf4_u003b_f1_u003b_f1_u003b(moments_1: ptr>, depth_1: ptr, bleed_1: ptr) -> f32 { var warped: vec2; - var param_118: f32; + var param_125: f32; var scale: vec2; var positive: f32; - var param_119: vec2; - var param_120: f32; - var param_121: f32; - var param_122: f32; + var param_126: vec2; + var param_127: f32; + var param_128: f32; + var param_129: f32; var negative: f32; - var param_123: vec2; - var param_124: f32; - var param_125: f32; - var param_126: f32; + var param_130: vec2; + var param_131: f32; + var param_132: f32; + var param_133: f32; - let _e277 = (*depth_1); - param_118 = _e277; - let _e278 = EvsmWarp_u0028_f1_u003b((¶m_118)); - warped = _e278; - let _e279 = warped; - scale = (vec2(0.004f, 0.0005f) * _e279); - let _e282 = scale[0u]; - let _e284 = scale[0u]; - let _e286 = (*moments_1); - param_119 = _e286.xy; - let _e289 = warped[0u]; - param_120 = _e289; - param_121 = (_e282 * _e284); - let _e290 = (*bleed_1); - param_122 = _e290; - let _e291 = EvsmChebyshev_u0028_vf2_u003b_f1_u003b_f1_u003b_f1_u003b((¶m_119), (¶m_120), (¶m_121), (¶m_122)); - positive = _e291; - let _e293 = scale[1u]; - let _e295 = scale[1u]; - let _e297 = (*moments_1); - param_123 = _e297.zw; - let _e300 = warped[1u]; - param_124 = _e300; - param_125 = (_e293 * _e295); - let _e301 = (*bleed_1); - param_126 = _e301; - let _e302 = EvsmChebyshev_u0028_vf2_u003b_f1_u003b_f1_u003b_f1_u003b((¶m_123), (¶m_124), (¶m_125), (¶m_126)); - negative = _e302; - let _e303 = positive; - let _e304 = negative; - return min(_e303, _e304); -} - -fn ShadowFactor_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b_struct_u002d_LightSample_u002d_vf3_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf31_u003b_i1_u003b(s_4: ptr, light_2: ptr, lightIndex_1: ptr) -> f32 { + let _e289 = (*depth_1); + param_125 = _e289; + let _e290 = EvsmWarp_u0028_f1_u003b((¶m_125)); + warped = _e290; + let _e291 = warped; + scale = (vec2(0.004f, 0.0005f) * _e291); + let _e294 = scale[0u]; + let _e296 = scale[0u]; + let _e298 = (*moments_1); + param_126 = _e298.xy; + let _e301 = warped[0u]; + param_127 = _e301; + param_128 = (_e294 * _e296); + let _e302 = (*bleed_1); + param_129 = _e302; + let _e303 = EvsmChebyshev_u0028_vf2_u003b_f1_u003b_f1_u003b_f1_u003b((¶m_126), (¶m_127), (¶m_128), (¶m_129)); + positive = _e303; + let _e305 = scale[1u]; + let _e307 = scale[1u]; + let _e309 = (*moments_1); + param_130 = _e309.zw; + let _e312 = warped[1u]; + param_131 = _e312; + param_132 = (_e305 * _e307); + let _e313 = (*bleed_1); + param_133 = _e313; + let _e314 = EvsmChebyshev_u0028_vf2_u003b_f1_u003b_f1_u003b_f1_u003b((¶m_130), (¶m_131), (¶m_132), (¶m_133)); + negative = _e314; + let _e315 = positive; + let _e316 = negative; + return min(_e315, _e316); +} + +fn ShadowFactor_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b_struct_u002d_LightSample_u002d_vf3_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf31_u003b_i1_u003b(s_5: ptr, light_2: ptr, lightIndex_1: ptr) -> f32 { var strength_1: f32; var cascadeCount: i32; var viewDistance: f32; + var local_12: f32; var cascade: i32; var uv_5: vec2; var projected: vec3; @@ -17461,8 +19792,8 @@ fn ShadowFactor_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf var attempt: i32; var which: i32; var matrix: mat4x4; - var local_8: mat4x4; - var local_9: mat4x4; + var local_13: mat4x4; + var local_14: mat4x4; var axisX: vec3; var axisY: vec3; var rowX: f32; @@ -17476,17 +19807,19 @@ fn ShadowFactor_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf var inTile: vec2; var cosSlope: f32; var bias: f32; - var local_10: f32; - var local_11: f32; + var local_15: f32; + var local_16: f32; var texel_1: vec2; var tileLo: vec2; var tileHi: vec2; + var stored_2: vec4; + var through: vec3; var softness: f32; - var lit_1: f32; + var lit_2: f32; var moments_2: vec4; - var param_127: vec4; - var param_128: f32; - var param_129: f32; + var param_134: vec4; + var param_135: f32; + var param_136: f32; var y_1: i32; var x_1: i32; var occluder: f32; @@ -17497,65 +19830,73 @@ fn ShadowFactor_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf var blockerCount: f32; var i_6: i32; var disc: vec2; - var param_130: i32; - var param_131: i32; - var param_132: f32; + var param_137: i32; + var param_138: i32; + var param_139: f32; var tapBias: f32; var occluder_1: f32; var gap: f32; var radius_2: f32; var i_7: i32; var disc_1: vec2; - var param_133: i32; - var param_134: i32; - var param_135: f32; + var param_140: i32; + var param_141: i32; + var param_142: f32; var tapBias_1: f32; var occluder_2: f32; - var phi_3823_: bool; - var phi_3834_: bool; - var phi_3995_: bool; - var phi_4002_: bool; - var phi_4009_: bool; - - let _e329 = frag_info.shadow_params[3u]; - strength_1 = _e329; - let _e330 = strength_1; - if (_e330 <= 0f) { + var phi_4094_: bool; + var phi_4105_: bool; + var phi_4266_: bool; + var phi_4273_: bool; + var phi_4280_: bool; + + let _e344 = frag_info.shadow_params[3u]; + strength_1 = _e344; + let _e345 = strength_1; + if (_e345 <= 0f) { return 1f; } - let _e332 = (*lightIndex_1); - let _e335 = frag_info.frame_params[2u]; - if (_e332 != i32((_e335 + 0.5f))) { + let _e347 = (*lightIndex_1); + let _e350 = frag_info.frame_params[2u]; + if (_e347 != i32((_e350 + 0.5f))) { return 1f; } - let _e341 = frag_info.shadow_cascades[2u]; - cascadeCount = i32((_e341 + 0.5f)); - let _e344 = v_world_position_1; - let _e346 = frag_info.camera_position; - viewDistance = length((_e344 - _e346.xyz)); + let _e356 = frag_info.shadow_cascades[2u]; + cascadeCount = i32((_e356 + 0.5f)); + let _e359 = Orthographic_u0028_(); + if _e359 { + let _e360 = ViewDepth_u0028_(); + local_12 = _e360; + } else { + let _e361 = v_world_position_1; + let _e363 = frag_info.camera_position; + local_12 = length((_e361 - _e363.xyz)); + } + let _e367 = local_12; + viewDistance = _e367; cascade = 0i; - let _e350 = cascadeCount; - let _e351 = (_e350 > 1i); - phi_3823_ = _e351; - if _e351 { - let _e352 = viewDistance; - let _e355 = frag_info.shadow_cascades[0u]; - phi_3823_ = (_e352 > _e355); - } - let _e358 = phi_3823_; - if _e358 { + let _e368 = cascadeCount; + let _e369 = (_e368 > 1i); + phi_4094_ = _e369; + if _e369 { + let _e370 = viewDistance; + let _e373 = frag_info.shadow_cascades[0u]; + phi_4094_ = (_e370 > _e373); + } + let _e376 = phi_4094_; + if _e376 { cascade = 1i; } - let _e359 = cascadeCount; - let _e360 = (_e359 > 2i); - phi_3834_ = _e360; - if _e360 { - let _e361 = viewDistance; - let _e364 = frag_info.shadow_cascades[1u]; - phi_3834_ = (_e361 > _e364); + let _e377 = cascadeCount; + let _e378 = (_e377 > 2i); + phi_4105_ = _e378; + if _e378 { + let _e379 = viewDistance; + let _e382 = frag_info.shadow_cascades[1u]; + phi_4105_ = (_e379 > _e382); } - let _e367 = phi_3834_; - if _e367 { + let _e385 = phi_4105_; + if _e385 { cascade = 2i; } uv_5 = vec2(0f, 0f); @@ -17566,232 +19907,247 @@ fn ShadowFactor_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf receiverSlope = 0f; attempt = 0i; loop { - let _e368 = attempt; - if (_e368 < 3i) { - let _e370 = cascade; - let _e371 = attempt; - which = (_e370 + _e371); - let _e373 = which; - let _e374 = cascadeCount; - if (_e373 >= _e374) { + let _e386 = attempt; + if (_e386 < 3i) { + let _e388 = cascade; + let _e389 = attempt; + which = (_e388 + _e389); + let _e391 = which; + let _e392 = cascadeCount; + if (_e391 >= _e392) { break; } - let _e376 = which; - if (_e376 == 0i) { - let _e379 = frag_info.shadow_matrix; - local_8 = _e379; + let _e394 = which; + if (_e394 == 0i) { + let _e397 = frag_info.shadow_matrix; + local_13 = _e397; } else { - let _e380 = which; - if (_e380 == 1i) { - let _e383 = frag_info.shadow_matrix_far; - local_9 = _e383; + let _e398 = which; + if (_e398 == 1i) { + let _e401 = frag_info.shadow_matrix_far; + local_14 = _e401; } else { - let _e385 = frag_info.shadow_matrix_farthest; - local_9 = _e385; + let _e403 = frag_info.shadow_matrix_farthest; + local_14 = _e403; } - let _e386 = local_9; - local_8 = _e386; - } - let _e387 = local_8; - matrix = _e387; - let _e390 = matrix[0][0u]; - let _e393 = matrix[1][0u]; - let _e396 = matrix[2][0u]; - axisX = vec3(_e390, _e393, _e396); - let _e400 = matrix[0][1u]; - let _e403 = matrix[1][1u]; - let _e406 = matrix[2][1u]; - axisY = vec3(_e400, _e403, _e406); - let _e408 = axisX; - rowX = max(length(_e408), 0.000001f); - let _e411 = axisY; - rowY = max(length(_e411), 0.000001f); - let _e416 = frag_info.shadow_cascades[3u]; - let _e418 = rowX; - texelMetres = ((2f * _e416) / _e418); - let _e422 = frag_info.shadow_cascades[3u]; - let _e425 = (*s_4).n; + let _e404 = local_14; + local_13 = _e404; + } + let _e405 = local_13; + matrix = _e405; + let _e408 = matrix[0][0u]; + let _e411 = matrix[1][0u]; + let _e414 = matrix[2][0u]; + axisX = vec3(_e408, _e411, _e414); + let _e418 = matrix[0][1u]; + let _e421 = matrix[1][1u]; + let _e424 = matrix[2][1u]; + axisY = vec3(_e418, _e421, _e424); let _e426 = axisX; - let _e429 = rowX; - let _e430 = rowX; - let _e434 = (*s_4).n; - let _e435 = axisY; - let _e438 = rowY; - let _e439 = rowY; - reach = ((3f * _e422) * ((abs(dot(_e425, _e426)) / (_e429 * _e430)) + (abs(dot(_e434, _e435)) / (_e438 * _e439)))); - let _e446 = frag_info.shadow_params[2u]; - let _e447 = texelMetres; - flatOffset = min(_e446, _e447); - let _e449 = v_world_position_1; - let _e451 = (*s_4).n; - let _e452 = flatOffset; - let _e453 = reach; - origin_1 = (_e449 + (_e451 * (_e452 + _e453))); - let _e457 = matrix; - let _e458 = origin_1; - lightSpace = (_e457 * vec4(_e458.x, _e458.y, _e458.z, 1f)); - let _e465 = lightSpace[3u]; - if (_e465 <= 0f) { + rowX = max(length(_e426), 0.000001f); + let _e429 = axisY; + rowY = max(length(_e429), 0.000001f); + let _e434 = frag_info.shadow_cascades[3u]; + let _e436 = rowX; + texelMetres = ((2f * _e434) / _e436); + let _e440 = frag_info.shadow_cascades[3u]; + let _e443 = (*s_5).n; + let _e444 = axisX; + let _e447 = rowX; + let _e448 = rowX; + let _e452 = (*s_5).n; + let _e453 = axisY; + let _e456 = rowY; + let _e457 = rowY; + reach = ((3f * _e440) * ((abs(dot(_e443, _e444)) / (_e447 * _e448)) + (abs(dot(_e452, _e453)) / (_e456 * _e457)))); + let _e464 = frag_info.shadow_params[2u]; + let _e465 = texelMetres; + flatOffset = min(_e464, _e465); + let _e467 = v_world_position_1; + let _e469 = (*s_5).n; + let _e470 = flatOffset; + let _e471 = reach; + origin_1 = (_e467 + (_e469 * (_e470 + _e471))); + let _e475 = matrix; + let _e476 = origin_1; + lightSpace = (_e475 * vec4(_e476.x, _e476.y, _e476.z, 1f)); + let _e483 = lightSpace[3u]; + if (_e483 <= 0f) { continue; } - let _e467 = lightSpace; - let _e470 = lightSpace[3u]; - candidate = (_e467.xyz / vec3(_e470)); - let _e474 = candidate[0u]; - let _e478 = candidate[1u]; - inTile = vec2(((_e474 * 0.5f) + 0.5f), (0.5f - (_e478 * 0.5f))); - let _e483 = inTile[0u]; - let _e484 = (_e483 < 0f); - phi_3995_ = _e484; - if !(_e484) { - let _e487 = inTile[0u]; - phi_3995_ = (_e487 > 1f); - } - let _e490 = phi_3995_; - phi_4002_ = _e490; - if !(_e490) { - let _e493 = inTile[1u]; - phi_4002_ = (_e493 < 0f); - } - let _e496 = phi_4002_; - phi_4009_ = _e496; - if !(_e496) { - let _e499 = inTile[1u]; - phi_4009_ = (_e499 > 1f); - } - let _e502 = phi_4009_; - if _e502 { + let _e485 = lightSpace; + let _e488 = lightSpace[3u]; + candidate = (_e485.xyz / vec3(_e488)); + let _e492 = candidate[0u]; + let _e496 = candidate[1u]; + inTile = vec2(((_e492 * 0.5f) + 0.5f), (0.5f - (_e496 * 0.5f))); + let _e501 = inTile[0u]; + let _e502 = (_e501 < 0f); + phi_4266_ = _e502; + if !(_e502) { + let _e505 = inTile[0u]; + phi_4266_ = (_e505 > 1f); + } + let _e508 = phi_4266_; + phi_4273_ = _e508; + if !(_e508) { + let _e511 = inTile[1u]; + phi_4273_ = (_e511 < 0f); + } + let _e514 = phi_4273_; + phi_4280_ = _e514; + if !(_e514) { + let _e517 = inTile[1u]; + phi_4280_ = (_e517 > 1f); + } + let _e520 = phi_4280_; + if _e520 { continue; } - let _e504 = candidate[2u]; - if (_e504 > 1f) { - let _e506 = which; - let _e507 = cascadeCount; - if (_e506 < (_e507 - 1i)) { + let _e522 = candidate[2u]; + if (_e522 > 1f) { + let _e524 = which; + let _e525 = cascadeCount; + if (_e524 < (_e525 - 1i)) { continue; } candidate[2u] = 1f; } - let _e512 = inTile[0u]; - let _e513 = which; - let _e516 = cascadeCount; - let _e520 = inTile[1u]; - uv_5 = vec2(((_e512 + f32(_e513)) / f32(_e516)), _e520); - let _e522 = candidate; - projected = _e522; - let _e523 = which; - cascade = _e523; - let _e524 = texelMetres; - cascadeTexel = _e524; - let _e527 = matrix[0][2u]; - let _e530 = matrix[1][2u]; - let _e533 = matrix[2][2u]; - cascadeDepth = (1f / max(length(vec3(_e527, _e530, _e533)), 0.000001f)); - let _e539 = (*s_4).n; - let _e542 = matrix[0][2u]; - let _e545 = matrix[1][2u]; - let _e548 = matrix[2][2u]; - let _e552 = cascadeDepth; - cosSlope = clamp((abs(dot(_e539, vec3(_e542, _e545, _e548))) * _e552), 0.1f, 1f); - let _e555 = texelMetres; - let _e556 = cosSlope; - let _e557 = cosSlope; - let _e563 = cosSlope; - let _e565 = cascadeDepth; - receiverSlope = (((_e555 * sqrt(max((1f - (_e556 * _e557)), 0f))) / _e563) / _e565); + let _e530 = inTile[0u]; + let _e531 = which; + let _e534 = cascadeCount; + let _e538 = inTile[1u]; + uv_5 = vec2(((_e530 + f32(_e531)) / f32(_e534)), _e538); + let _e540 = candidate; + projected = _e540; + let _e541 = which; + cascade = _e541; + let _e542 = texelMetres; + cascadeTexel = _e542; + let _e545 = matrix[0][2u]; + let _e548 = matrix[1][2u]; + let _e551 = matrix[2][2u]; + cascadeDepth = (1f / max(length(vec3(_e545, _e548, _e551)), 0.000001f)); + let _e557 = (*s_5).n; + let _e560 = matrix[0][2u]; + let _e563 = matrix[1][2u]; + let _e566 = matrix[2][2u]; + let _e570 = cascadeDepth; + cosSlope = clamp((abs(dot(_e557, vec3(_e560, _e563, _e566))) * _e570), 0.1f, 1f); + let _e573 = texelMetres; + let _e574 = cosSlope; + let _e575 = cosSlope; + let _e581 = cosSlope; + let _e583 = cascadeDepth; + receiverSlope = (((_e573 * sqrt(max((1f - (_e574 * _e575)), 0f))) / _e581) / _e583); found = true; break; } else { break; } continuing { - let _e567 = attempt; - attempt = (_e567 + 1i); + let _e585 = attempt; + attempt = (_e585 + 1i); } } - let _e569 = found; - if !(_e569) { + let _e587 = found; + if !(_e587) { return 1f; } - let _e571 = cascade; - if (_e571 == 0i) { - let _e575 = frag_info.shadow_bias[0u]; - local_10 = _e575; + let _e589 = cascade; + if (_e589 == 0i) { + let _e593 = frag_info.shadow_bias[0u]; + local_15 = _e593; } else { - let _e576 = cascade; - if (_e576 == 1i) { - let _e580 = frag_info.shadow_bias[1u]; - local_11 = _e580; + let _e594 = cascade; + if (_e594 == 1i) { + let _e598 = frag_info.shadow_bias[1u]; + local_16 = _e598; } else { - let _e583 = frag_info.shadow_bias[2u]; - local_11 = _e583; - } - let _e584 = local_11; - local_10 = _e584; - } - let _e585 = local_10; - bias = _e585; - let _e588 = frag_info.shadow_params[0u]; - let _e591 = frag_info.shadow_cascades[3u]; - texel_1 = vec2(_e588, _e591); - let _e593 = cascade; - let _e595 = cascadeCount; - let _e599 = texel_1; - tileLo = (vec2((f32(_e593) / f32(_e595)), 0f) + (_e599 * 0.5f)); - let _e602 = cascade; - let _e605 = cascadeCount; - let _e609 = texel_1; - tileHi = (vec2((f32((_e602 + 1i)) / f32(_e605)), 1f) - (_e609 * 0.5f)); - let _e614 = frag_info.ambient_ground[3u]; - softness = _e614; - lit_1 = 0f; - let _e615 = softness; - if (_e615 < 0f) { - let _e617 = uv_5; - let _e618 = tileLo; - let _e619 = tileHi; - let _e621 = textureSampleLevel(shadow_texture_tex, shadow_texture_smp, clamp(_e617, _e618, _e619), 0f); - moments_2 = _e621; - let _e623 = projected[2u]; - let _e624 = bias; - let _e626 = softness; - let _e630 = moments_2; - param_127 = _e630; - param_128 = (_e623 - _e624); - param_129 = clamp((-(_e626) - 1f), 0f, 0.95f); - let _e631 = EvsmVisibility_u0028_vf4_u003b_f1_u003b_f1_u003b((¶m_127), (¶m_128), (¶m_129)); - lit_1 = _e631; - } else { - let _e632 = softness; - if (_e632 <= 0f) { + let _e601 = frag_info.shadow_bias[2u]; + local_16 = _e601; + } + let _e602 = local_16; + local_15 = _e602; + } + let _e603 = local_15; + bias = _e603; + let _e606 = frag_info.shadow_params[0u]; + let _e609 = frag_info.shadow_cascades[3u]; + texel_1 = vec2(_e606, _e609); + let _e611 = cascade; + let _e613 = cascadeCount; + let _e617 = texel_1; + tileLo = (vec2((f32(_e611) / f32(_e613)), 0f) + (_e617 * 0.5f)); + let _e620 = cascade; + let _e623 = cascadeCount; + let _e627 = texel_1; + tileHi = (vec2((f32((_e620 + 1i)) / f32(_e623)), 1f) - (_e627 * 0.5f)); + let _e632 = frag_info.shadow_bias[3u]; + if (_e632 > 0.5f) { + let _e634 = uv_5; + let _e635 = tileLo; + let _e636 = tileHi; + let _e638 = textureSampleLevel(shadow_texture_tex, shadow_texture_smp, clamp(_e634, _e635, _e636), 0f); + stored_2 = _e638; + let _e640 = stored_2[1u]; + let _e643 = stored_2[2u]; + let _e646 = stored_2[3u]; + through = clamp(vec3((1f - _e640), (1f - _e643), _e646), vec3(0f), vec3(4f)); + let _e651 = through; + let _e652 = strength_1; + light_transmittance = mix(vec3(1f, 1f, 1f), _e651, vec3(clamp(_e652, 0f, 1f))); + } + let _e658 = frag_info.ambient_ground[3u]; + softness = _e658; + lit_2 = 0f; + let _e659 = softness; + if (_e659 < 0f) { + let _e661 = uv_5; + let _e662 = tileLo; + let _e663 = tileHi; + let _e665 = textureSampleLevel(shadow_texture_tex, shadow_texture_smp, clamp(_e661, _e662, _e663), 0f); + moments_2 = _e665; + let _e667 = projected[2u]; + let _e668 = bias; + let _e670 = softness; + let _e674 = moments_2; + param_134 = _e674; + param_135 = (_e667 - _e668); + param_136 = clamp((-(_e670) - 1f), 0f, 0.95f); + let _e675 = EvsmVisibility_u0028_vf4_u003b_f1_u003b_f1_u003b((¶m_134), (¶m_135), (¶m_136)); + lit_2 = _e675; + } else { + let _e676 = softness; + if (_e676 <= 0f) { y_1 = -1i; loop { - let _e634 = y_1; - if (_e634 <= 1i) { + let _e678 = y_1; + if (_e678 <= 1i) { x_1 = -1i; loop { - let _e636 = x_1; - if (_e636 <= 1i) { - let _e638 = uv_5; - let _e639 = x_1; - let _e641 = y_1; - let _e644 = texel_1; - let _e647 = tileLo; - let _e648 = tileHi; - let _e650 = textureSampleLevel(shadow_texture_tex, shadow_texture_smp, clamp((_e638 + (vec2(f32(_e639), f32(_e641)) * _e644)), _e647, _e648), 0f); - occluder = _e650.x; - let _e653 = projected[2u]; - let _e654 = bias; - let _e656 = occluder; - let _e659 = lit_1; - lit_1 = (_e659 + select(1f, 0f, ((_e653 - _e654) > _e656))); + let _e680 = x_1; + if (_e680 <= 1i) { + let _e682 = uv_5; + let _e683 = x_1; + let _e685 = y_1; + let _e688 = texel_1; + let _e691 = tileLo; + let _e692 = tileHi; + let _e694 = textureSampleLevel(shadow_texture_tex, shadow_texture_smp, clamp((_e682 + (vec2(f32(_e683), f32(_e685)) * _e688)), _e691, _e692), 0f); + occluder = _e694.x; + let _e697 = projected[2u]; + let _e698 = bias; + let _e700 = occluder; + let _e703 = lit_2; + lit_2 = (_e703 + select(1f, 0f, ((_e697 - _e698) > _e700))); continue; } else { break; } continuing { - let _e661 = x_1; - x_1 = (_e661 + 1i); + let _e705 = x_1; + x_1 = (_e705 + 1i); } } continue; @@ -17799,145 +20155,145 @@ fn ShadowFactor_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf break; } continuing { - let _e663 = y_1; - y_1 = (_e663 + 1i); + let _e707 = y_1; + y_1 = (_e707 + 1i); } } - let _e665 = lit_1; - lit_1 = (_e665 * 0.11111111f); + let _e709 = lit_2; + lit_2 = (_e709 * 0.11111111f); } else { - let _e667 = softness; - spread = tan(min(_e667, 0.5f)); - let _e670 = ShadowNoise_u0028_(); - turn_1 = (_e670 * 6.2831855f); - let _e672 = spread; - let _e674 = projected[2u]; - let _e676 = cascadeDepth; - let _e678 = cascadeTexel; - searchRadius = clamp((((_e672 * _e674) * _e676) / _e678), 1f, 16f); + let _e711 = softness; + spread = tan(min(_e711, 0.5f)); + let _e714 = ShadowNoise_u0028_(); + turn_1 = (_e714 * 6.2831855f); + let _e716 = spread; + let _e718 = projected[2u]; + let _e720 = cascadeDepth; + let _e722 = cascadeTexel; + searchRadius = clamp((((_e716 * _e718) * _e720) / _e722), 1f, 16f); blockerSum = 0f; blockerCount = 0f; i_6 = 0i; loop { - let _e681 = i_6; - if (_e681 < 16i) { - let _e683 = i_6; - param_130 = _e683; - param_131 = 16i; - let _e684 = turn_1; - param_132 = _e684; - let _e685 = VogelDisc_u0028_i1_u003b_i1_u003b_f1_u003b((¶m_130), (¶m_131), (¶m_132)); - disc = _e685; - let _e686 = bias; - let _e687 = disc; - let _e689 = searchRadius; - let _e693 = receiverSlope; - tapBias = (_e686 + (max(((length(_e687) * _e689) - 1.5f), 0f) * _e693)); - let _e696 = uv_5; - let _e697 = disc; - let _e698 = texel_1; - let _e700 = searchRadius; - let _e703 = tileLo; - let _e704 = tileHi; - let _e706 = textureSampleLevel(shadow_texture_tex, shadow_texture_smp, clamp((_e696 + ((_e697 * _e698) * _e700)), _e703, _e704), 0f); - occluder_1 = _e706.x; - let _e709 = projected[2u]; - let _e710 = tapBias; - let _e712 = occluder_1; - if ((_e709 - _e710) > _e712) { - let _e714 = occluder_1; - let _e715 = blockerSum; - blockerSum = (_e715 + _e714); - let _e717 = blockerCount; - blockerCount = (_e717 + 1f); + let _e725 = i_6; + if (_e725 < 16i) { + let _e727 = i_6; + param_137 = _e727; + param_138 = 16i; + let _e728 = turn_1; + param_139 = _e728; + let _e729 = VogelDisc_u0028_i1_u003b_i1_u003b_f1_u003b((¶m_137), (¶m_138), (¶m_139)); + disc = _e729; + let _e730 = bias; + let _e731 = disc; + let _e733 = searchRadius; + let _e737 = receiverSlope; + tapBias = (_e730 + (max(((length(_e731) * _e733) - 1.5f), 0f) * _e737)); + let _e740 = uv_5; + let _e741 = disc; + let _e742 = texel_1; + let _e744 = searchRadius; + let _e747 = tileLo; + let _e748 = tileHi; + let _e750 = textureSampleLevel(shadow_texture_tex, shadow_texture_smp, clamp((_e740 + ((_e741 * _e742) * _e744)), _e747, _e748), 0f); + occluder_1 = _e750.x; + let _e753 = projected[2u]; + let _e754 = tapBias; + let _e756 = occluder_1; + if ((_e753 - _e754) > _e756) { + let _e758 = occluder_1; + let _e759 = blockerSum; + blockerSum = (_e759 + _e758); + let _e761 = blockerCount; + blockerCount = (_e761 + 1f); } continue; } else { break; } continuing { - let _e719 = i_6; - i_6 = (_e719 + 1i); + let _e763 = i_6; + i_6 = (_e763 + 1i); } } - let _e721 = blockerCount; - if (_e721 <= 0f) { + let _e765 = blockerCount; + if (_e765 <= 0f) { return 1f; } - let _e724 = projected[2u]; - let _e725 = blockerSum; - let _e726 = blockerCount; - let _e730 = cascadeDepth; - gap = (max((_e724 - (_e725 / _e726)), 0f) * _e730); - let _e732 = spread; - let _e733 = gap; - let _e735 = cascadeTexel; - radius_2 = clamp(((_e732 * _e733) / _e735), 1f, 16f); + let _e768 = projected[2u]; + let _e769 = blockerSum; + let _e770 = blockerCount; + let _e774 = cascadeDepth; + gap = (max((_e768 - (_e769 / _e770)), 0f) * _e774); + let _e776 = spread; + let _e777 = gap; + let _e779 = cascadeTexel; + radius_2 = clamp(((_e776 * _e777) / _e779), 1f, 16f); i_7 = 0i; loop { - let _e738 = i_7; - if (_e738 < 16i) { - let _e740 = turn_1; - let _e742 = i_7; - param_133 = _e742; - param_134 = 16i; - param_135 = (_e740 + 1f); - let _e743 = VogelDisc_u0028_i1_u003b_i1_u003b_f1_u003b((¶m_133), (¶m_134), (¶m_135)); - disc_1 = _e743; - let _e744 = bias; - let _e745 = disc_1; - let _e747 = radius_2; - let _e751 = receiverSlope; - tapBias_1 = (_e744 + (max(((length(_e745) * _e747) - 1.5f), 0f) * _e751)); - let _e754 = uv_5; - let _e755 = disc_1; - let _e756 = texel_1; - let _e758 = radius_2; - let _e761 = tileLo; - let _e762 = tileHi; - let _e764 = textureSampleLevel(shadow_texture_tex, shadow_texture_smp, clamp((_e754 + ((_e755 * _e756) * _e758)), _e761, _e762), 0f); - occluder_2 = _e764.x; - let _e767 = projected[2u]; - let _e768 = tapBias_1; - let _e770 = occluder_2; - let _e773 = lit_1; - lit_1 = (_e773 + select(1f, 0f, ((_e767 - _e768) > _e770))); + let _e782 = i_7; + if (_e782 < 16i) { + let _e784 = turn_1; + let _e786 = i_7; + param_140 = _e786; + param_141 = 16i; + param_142 = (_e784 + 1f); + let _e787 = VogelDisc_u0028_i1_u003b_i1_u003b_f1_u003b((¶m_140), (¶m_141), (¶m_142)); + disc_1 = _e787; + let _e788 = bias; + let _e789 = disc_1; + let _e791 = radius_2; + let _e795 = receiverSlope; + tapBias_1 = (_e788 + (max(((length(_e789) * _e791) - 1.5f), 0f) * _e795)); + let _e798 = uv_5; + let _e799 = disc_1; + let _e800 = texel_1; + let _e802 = radius_2; + let _e805 = tileLo; + let _e806 = tileHi; + let _e808 = textureSampleLevel(shadow_texture_tex, shadow_texture_smp, clamp((_e798 + ((_e799 * _e800) * _e802)), _e805, _e806), 0f); + occluder_2 = _e808.x; + let _e811 = projected[2u]; + let _e812 = tapBias_1; + let _e814 = occluder_2; + let _e817 = lit_2; + lit_2 = (_e817 + select(1f, 0f, ((_e811 - _e812) > _e814))); continue; } else { break; } continuing { - let _e775 = i_7; - i_7 = (_e775 + 1i); + let _e819 = i_7; + i_7 = (_e819 + 1i); } } - let _e777 = lit_1; - lit_1 = (_e777 * 0.0625f); + let _e821 = lit_2; + lit_2 = (_e821 * 0.0625f); } } - let _e779 = lit_1; - let _e780 = strength_1; - return mix(1f, _e779, clamp(_e780, 0f, 1f)); + let _e823 = lit_2; + let _e824 = strength_1; + return mix(1f, _e823, clamp(_e824, 0f, 1f)); } -fn LightVisibility_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b_struct_u002d_LightSample_u002d_vf3_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf31_u003b_i1_u003b(s_5: ptr, light_3: ptr, index: ptr) -> f32 { - var param_136: Surface; - var param_137: LightSample; - var param_138: i32; +fn LightVisibility_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b_struct_u002d_LightSample_u002d_vf3_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf31_u003b_i1_u003b(s_6: ptr, light_3: ptr, index: ptr) -> f32 { + var param_143: Surface; + var param_144: LightSample; + var param_145: i32; - let _e267 = (*s_5); - param_136 = _e267; - let _e268 = (*light_3); - param_137 = _e268; - let _e269 = (*index); - param_138 = _e269; - let _e270 = ShadowFactor_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b_struct_u002d_LightSample_u002d_vf3_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf31_u003b_i1_u003b((¶m_136), (¶m_137), (¶m_138)); - return _e270; + let _e279 = (*s_6); + param_143 = _e279; + let _e280 = (*light_3); + param_144 = _e280; + let _e281 = (*index); + param_145 = _e281; + let _e282 = ShadowFactor_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b_struct_u002d_LightSample_u002d_vf3_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf31_u003b_i1_u003b((¶m_143), (¶m_144), (¶m_145)); + return _e282; } fn LightHasShadow_u0028_i1_u003b(index_1: ptr) -> bool { - let _e262 = (*index_1); - return (_e262 < 8i); + let _e274 = (*index_1); + return (_e274 < 8i); } fn PunctualAttenuation_u0028_f1_u003b_f1_u003b(distance_1: ptr, range_4: ptr) -> f32 { @@ -17945,26 +20301,26 @@ fn PunctualAttenuation_u0028_f1_u003b_f1_u003b(distance_1: ptr, r var ratio: f32; var window: f32; - let _e266 = (*distance_1); - let _e267 = (*distance_1); - attenuation = (1f / max((_e266 * _e267), 0.0001f)); - let _e271 = (*range_4); - if (_e271 > 0f) { - let _e273 = (*distance_1); - let _e274 = (*range_4); - ratio = (_e273 / _e274); - let _e276 = ratio; - let _e277 = ratio; - let _e279 = ratio; - let _e281 = ratio; - window = clamp((1f - (((_e276 * _e277) * _e279) * _e281)), 0f, 1f); - let _e285 = window; - let _e286 = window; - let _e288 = attenuation; - attenuation = (_e288 * (_e285 * _e286)); - } - let _e290 = attenuation; - return _e290; + let _e278 = (*distance_1); + let _e279 = (*distance_1); + attenuation = (1f / max((_e278 * _e279), 0.0001f)); + let _e283 = (*range_4); + if (_e283 > 0f) { + let _e285 = (*distance_1); + let _e286 = (*range_4); + ratio = (_e285 / _e286); + let _e288 = ratio; + let _e289 = ratio; + let _e291 = ratio; + let _e293 = ratio; + window = clamp((1f - (((_e288 * _e289) * _e291) * _e293)), 0f, 1f); + let _e297 = window; + let _e298 = window; + let _e300 = attenuation; + attenuation = (_e300 * (_e297 * _e298)); + } + let _e302 = attenuation; + return _e302; } fn RectangleClosestPoint_u0028_vf3_u003b_vf3_u003b_vf3_u003b_vf3_u003b_vf3_u003b(centre: ptr>, halfWidth: ptr>, halfHeight: ptr>, world_2: ptr>, mirror: ptr>) -> vec3 { @@ -17974,82 +20330,82 @@ fn RectangleClosestPoint_u0028_vf3_u003b_vf3_u003b_vf3_u003b_vf3_u003b_vf3_u003b var denom: f32; var onPlane: vec3; var t_1: f32; - var local_12: vec3; + var local_17: vec3; var offset_2: vec3; var wLen2_: f32; var hLen2_: f32; var u: f32; var v_1: f32; - let _e278 = (*halfWidth); - let _e279 = (*halfHeight); - n_3 = cross(_e278, _e279); - let _e281 = n_3; - nLen = length(_e281); - let _e283 = nLen; - if (_e283 < 0.000000000001f) { - let _e285 = (*centre); - return _e285; - } - let _e286 = nLen; - let _e287 = n_3; - n_3 = (_e287 / vec3(_e286)); - let _e290 = (*centre); - let _e291 = (*world_2); - toPlane = (_e290 - _e291); - let _e293 = (*mirror); - let _e294 = n_3; - denom = dot(_e293, _e294); - let _e296 = denom; - if (abs(_e296) < 0.00001f) { - let _e299 = toPlane; - let _e300 = n_3; - let _e301 = toPlane; - let _e302 = n_3; - onPlane = (_e299 - (_e300 * dot(_e301, _e302))); - } else { - let _e306 = toPlane; - let _e307 = n_3; - let _e309 = denom; - t_1 = (dot(_e306, _e307) / _e309); - let _e311 = t_1; - if (_e311 > 0f) { - let _e313 = (*mirror); - let _e314 = t_1; - local_12 = (_e313 * _e314); + let _e290 = (*halfWidth); + let _e291 = (*halfHeight); + n_3 = cross(_e290, _e291); + let _e293 = n_3; + nLen = length(_e293); + let _e295 = nLen; + if (_e295 < 0.000000000001f) { + let _e297 = (*centre); + return _e297; + } + let _e298 = nLen; + let _e299 = n_3; + n_3 = (_e299 / vec3(_e298)); + let _e302 = (*centre); + let _e303 = (*world_2); + toPlane = (_e302 - _e303); + let _e305 = (*mirror); + let _e306 = n_3; + denom = dot(_e305, _e306); + let _e308 = denom; + if (abs(_e308) < 0.00001f) { + let _e311 = toPlane; + let _e312 = n_3; + let _e313 = toPlane; + let _e314 = n_3; + onPlane = (_e311 - (_e312 * dot(_e313, _e314))); + } else { + let _e318 = toPlane; + let _e319 = n_3; + let _e321 = denom; + t_1 = (dot(_e318, _e319) / _e321); + let _e323 = t_1; + if (_e323 > 0f) { + let _e325 = (*mirror); + let _e326 = t_1; + local_17 = (_e325 * _e326); } else { - let _e316 = toPlane; - let _e317 = n_3; - let _e318 = toPlane; - let _e319 = n_3; - local_12 = (_e316 - (_e317 * dot(_e318, _e319))); - } - let _e323 = local_12; - onPlane = _e323; - } - let _e324 = onPlane; - let _e325 = toPlane; - offset_2 = (_e324 - _e325); - let _e327 = (*halfWidth); - let _e328 = (*halfWidth); - wLen2_ = max(dot(_e327, _e328), 0.000000000001f); - let _e331 = (*halfHeight); - let _e332 = (*halfHeight); - hLen2_ = max(dot(_e331, _e332), 0.000000000001f); - let _e335 = offset_2; - let _e336 = (*halfWidth); - let _e338 = wLen2_; - u = clamp((dot(_e335, _e336) / _e338), -1f, 1f); - let _e341 = offset_2; - let _e342 = (*halfHeight); - let _e344 = hLen2_; - v_1 = clamp((dot(_e341, _e342) / _e344), -1f, 1f); - let _e347 = (*centre); + let _e328 = toPlane; + let _e329 = n_3; + let _e330 = toPlane; + let _e331 = n_3; + local_17 = (_e328 - (_e329 * dot(_e330, _e331))); + } + let _e335 = local_17; + onPlane = _e335; + } + let _e336 = onPlane; + let _e337 = toPlane; + offset_2 = (_e336 - _e337); + let _e339 = (*halfWidth); + let _e340 = (*halfWidth); + wLen2_ = max(dot(_e339, _e340), 0.000000000001f); + let _e343 = (*halfHeight); + let _e344 = (*halfHeight); + hLen2_ = max(dot(_e343, _e344), 0.000000000001f); + let _e347 = offset_2; let _e348 = (*halfWidth); - let _e349 = u; - let _e352 = (*halfHeight); - let _e353 = v_1; - return ((_e347 + (_e348 * _e349)) + (_e352 * _e353)); + let _e350 = wLen2_; + u = clamp((dot(_e347, _e348) / _e350), -1f, 1f); + let _e353 = offset_2; + let _e354 = (*halfHeight); + let _e356 = hLen2_; + v_1 = clamp((dot(_e353, _e354) / _e356), -1f, 1f); + let _e359 = (*centre); + let _e360 = (*halfWidth); + let _e361 = u; + let _e364 = (*halfHeight); + let _e365 = v_1; + return ((_e359 + (_e360 * _e361)) + (_e364 * _e365)); } fn LambertEdge_u0028_vf3_u003b_vf3_u003b_vf3_u003b(a_3: ptr>, b_1: ptr>, n_4: ptr>) -> f32 { @@ -18058,34 +20414,34 @@ fn LambertEdge_u0028_vf3_u003b_vf3_u003b_vf3_u003b(a_3: ptr> var angle_2: f32; var axis_1: vec3; var len_2: f32; - var local_13: f32; + var local_18: f32; - let _e270 = (*a_3); - let _e271 = (*a_3); - ua = (_e270 / vec3(max(length(_e271), 0.000000000001f))); - let _e276 = (*b_1); - let _e277 = (*b_1); - ub = (_e276 / vec3(max(length(_e277), 0.000000000001f))); - let _e282 = ua; - let _e283 = ub; - angle_2 = acos(clamp(dot(_e282, _e283), -1f, 1f)); - let _e287 = ua; - let _e288 = ub; - axis_1 = cross(_e287, _e288); - let _e290 = axis_1; - len_2 = length(_e290); - let _e292 = len_2; - if (_e292 > 0.000001f) { - let _e294 = angle_2; - let _e295 = axis_1; - let _e296 = (*n_4); - let _e299 = len_2; - local_13 = ((_e294 * dot(_e295, _e296)) / _e299); - } else { - local_13 = 0f; - } - let _e301 = local_13; - return _e301; + let _e282 = (*a_3); + let _e283 = (*a_3); + ua = (_e282 / vec3(max(length(_e283), 0.000000000001f))); + let _e288 = (*b_1); + let _e289 = (*b_1); + ub = (_e288 / vec3(max(length(_e289), 0.000000000001f))); + let _e294 = ua; + let _e295 = ub; + angle_2 = acos(clamp(dot(_e294, _e295), -1f, 1f)); + let _e299 = ua; + let _e300 = ub; + axis_1 = cross(_e299, _e300); + let _e302 = axis_1; + len_2 = length(_e302); + let _e304 = len_2; + if (_e304 > 0.000001f) { + let _e306 = angle_2; + let _e307 = axis_1; + let _e308 = (*n_4); + let _e311 = len_2; + local_18 = ((_e306 * dot(_e307, _e308)) / _e311); + } else { + local_18 = 0f; + } + let _e313 = local_18; + return _e313; } fn RectangleFormFactor_u0028_vf3_u005b_4_u005d_u003b_vf3_u003b(corners_1: ptr, 4>>, n_5: ptr>) -> f32 { @@ -18095,273 +20451,273 @@ fn RectangleFormFactor_u0028_vf3_u005b_4_u005d_u003b_vf3_u003b(corners_1: ptr; var b_2: vec3; - var local_14: i32; + var local_19: i32; var ha: f32; var hb: f32; var d_5: f32; var q: vec3; - var local_15: f32; + var local_20: f32; var aAbove: bool; var bAbove: bool; - var local_16: f32; - var param_139: vec3; - var param_140: vec3; - var param_141: vec3; - var param_142: vec3; - var param_143: vec3; - var param_144: vec3; + var local_21: f32; + var param_146: vec3; + var param_147: vec3; + var param_148: vec3; + var param_149: vec3; + var param_150: vec3; + var param_151: vec3; total = 0f; exit = vec3(0f, 0f, 0f); entry = vec3(0f, 0f, 0f); i_8 = 0i; loop { - let _e284 = i_8; - if (_e284 < 4i) { - let _e286 = i_8; - let _e288 = (*corners_1)[_e286]; - a_4 = _e288; - let _e289 = i_8; - if (_e289 == 3i) { - local_14 = 0i; + let _e296 = i_8; + if (_e296 < 4i) { + let _e298 = i_8; + let _e300 = (*corners_1)[_e298]; + a_4 = _e300; + let _e301 = i_8; + if (_e301 == 3i) { + local_19 = 0i; } else { - let _e291 = i_8; - local_14 = (_e291 + 1i); - } - let _e293 = local_14; - let _e295 = (*corners_1)[_e293]; - b_2 = _e295; - let _e296 = a_4; - let _e297 = (*n_5); - ha = dot(_e296, _e297); - let _e299 = b_2; - let _e300 = (*n_5); - hb = dot(_e299, _e300); - let _e302 = ha; - let _e303 = hb; - d_5 = (_e302 - _e303); - let _e305 = a_4; - let _e306 = b_2; - let _e307 = a_4; - let _e309 = d_5; - if (abs(_e309) > 0.000000000001f) { - let _e312 = ha; - let _e313 = d_5; - local_15 = (_e312 / _e313); + let _e303 = i_8; + local_19 = (_e303 + 1i); + } + let _e305 = local_19; + let _e307 = (*corners_1)[_e305]; + b_2 = _e307; + let _e308 = a_4; + let _e309 = (*n_5); + ha = dot(_e308, _e309); + let _e311 = b_2; + let _e312 = (*n_5); + hb = dot(_e311, _e312); + let _e314 = ha; + let _e315 = hb; + d_5 = (_e314 - _e315); + let _e317 = a_4; + let _e318 = b_2; + let _e319 = a_4; + let _e321 = d_5; + if (abs(_e321) > 0.000000000001f) { + let _e324 = ha; + let _e325 = d_5; + local_20 = (_e324 / _e325); } else { - local_15 = 0f; - } - let _e315 = local_15; - q = (_e305 + ((_e306 - _e307) * _e315)); - let _e318 = ha; - aAbove = (_e318 > 0f); - let _e320 = hb; - bAbove = (_e320 > 0f); - let _e322 = aAbove; - let _e323 = bAbove; - if (_e322 || _e323) { - let _e325 = aAbove; - let _e326 = a_4; - let _e327 = q; - let _e330 = bAbove; - let _e331 = b_2; - let _e332 = q; - param_139 = select(_e327, _e326, vec3(_e325)); - param_140 = select(_e332, _e331, vec3(_e330)); - let _e335 = (*n_5); - param_141 = _e335; - let _e336 = LambertEdge_u0028_vf3_u003b_vf3_u003b_vf3_u003b((¶m_139), (¶m_140), (¶m_141)); - local_16 = _e336; + local_20 = 0f; + } + let _e327 = local_20; + q = (_e317 + ((_e318 - _e319) * _e327)); + let _e330 = ha; + aAbove = (_e330 > 0f); + let _e332 = hb; + bAbove = (_e332 > 0f); + let _e334 = aAbove; + let _e335 = bAbove; + if (_e334 || _e335) { + let _e337 = aAbove; + let _e338 = a_4; + let _e339 = q; + let _e342 = bAbove; + let _e343 = b_2; + let _e344 = q; + param_146 = select(_e339, _e338, vec3(_e337)); + param_147 = select(_e344, _e343, vec3(_e342)); + let _e347 = (*n_5); + param_148 = _e347; + let _e348 = LambertEdge_u0028_vf3_u003b_vf3_u003b_vf3_u003b((¶m_146), (¶m_147), (¶m_148)); + local_21 = _e348; } else { - local_16 = 0f; - } - let _e337 = local_16; - let _e338 = total; - total = (_e338 + _e337); - let _e340 = aAbove; - let _e341 = bAbove; - let _e344 = q; - let _e345 = exit; - exit = select(_e345, _e344, vec3((_e340 && !(_e341)))); - let _e348 = aAbove; - let _e350 = bAbove; - let _e352 = q; - let _e353 = entry; - entry = select(_e353, _e352, vec3((!(_e348) && _e350))); + local_21 = 0f; + } + let _e349 = local_21; + let _e350 = total; + total = (_e350 + _e349); + let _e352 = aAbove; + let _e353 = bAbove; + let _e356 = q; + let _e357 = exit; + exit = select(_e357, _e356, vec3((_e352 && !(_e353)))); + let _e360 = aAbove; + let _e362 = bAbove; + let _e364 = q; + let _e365 = entry; + entry = select(_e365, _e364, vec3((!(_e360) && _e362))); continue; } else { break; } continuing { - let _e356 = i_8; - i_8 = (_e356 + 1i); + let _e368 = i_8; + i_8 = (_e368 + 1i); } } - let _e358 = exit; - param_142 = _e358; - let _e359 = entry; - param_143 = _e359; - let _e360 = (*n_5); - param_144 = _e360; - let _e361 = LambertEdge_u0028_vf3_u003b_vf3_u003b_vf3_u003b((¶m_142), (¶m_143), (¶m_144)); - let _e362 = total; - total = (_e362 + _e361); - let _e364 = total; - return max((-(_e364) * 0.5f), 0f); + let _e370 = exit; + param_149 = _e370; + let _e371 = entry; + param_150 = _e371; + let _e372 = (*n_5); + param_151 = _e372; + let _e373 = LambertEdge_u0028_vf3_u003b_vf3_u003b_vf3_u003b((¶m_149), (¶m_150), (¶m_151)); + let _e374 = total; + total = (_e374 + _e373); + let _e376 = total; + return max((-(_e376) * 0.5f), 0f); } -fn LightListLane_u0028_vf4_u003b_i1_u003b(four: ptr>, slot_1: ptr) -> f32 { +fn LightListLane_u0028_vf4_u003b_i1_u003b(four: ptr>, slot_2: ptr) -> f32 { var lane: i32; - var local_17: f32; - var local_18: f32; - var local_19: f32; - - let _e267 = (*slot_1); - let _e268 = (*slot_1); - lane = (_e267 - ((_e268 / 4i) * 4i)); - let _e272 = lane; - if (_e272 == 0i) { - let _e275 = (*four)[0u]; - local_17 = _e275; - } else { - let _e276 = lane; - if (_e276 == 1i) { - let _e279 = (*four)[1u]; - local_18 = _e279; + var local_22: f32; + var local_23: f32; + var local_24: f32; + + let _e279 = (*slot_2); + let _e280 = (*slot_2); + lane = (_e279 - ((_e280 / 4i) * 4i)); + let _e284 = lane; + if (_e284 == 0i) { + let _e287 = (*four)[0u]; + local_22 = _e287; + } else { + let _e288 = lane; + if (_e288 == 1i) { + let _e291 = (*four)[1u]; + local_23 = _e291; } else { - let _e280 = lane; - if (_e280 == 2i) { - let _e283 = (*four)[2u]; - local_19 = _e283; + let _e292 = lane; + if (_e292 == 2i) { + let _e295 = (*four)[2u]; + local_24 = _e295; } else { - let _e285 = (*four)[3u]; - local_19 = _e285; + let _e297 = (*four)[3u]; + local_24 = _e297; } - let _e286 = local_19; - local_18 = _e286; + let _e298 = local_24; + local_23 = _e298; } - let _e287 = local_18; - local_17 = _e287; + let _e299 = local_23; + local_22 = _e299; } - let _e288 = local_17; - return _e288; + let _e300 = local_22; + return _e300; } -fn LightListScale_u0028_i1_u003b(slot_2: ptr) -> f32 { - var param_145: vec4; - var param_146: i32; +fn LightListScale_u0028_i1_u003b(slot_3: ptr) -> f32 { + var param_152: vec4; + var param_153: i32; - let _e264 = (*slot_2); - let _e268 = light_list_info.scales[(_e264 / 4i)]; - param_145 = _e268; - let _e269 = (*slot_2); - param_146 = _e269; - let _e270 = LightListLane_u0028_vf4_u003b_i1_u003b((¶m_145), (¶m_146)); - return _e270; + let _e276 = (*slot_3); + let _e280 = light_list_info.scales[(_e276 / 4i)]; + param_152 = _e280; + let _e281 = (*slot_3); + param_153 = _e281; + let _e282 = LightListLane_u0028_vf4_u003b_i1_u003b((¶m_152), (¶m_153)); + return _e282; } fn InSlots_u0028_f1_u003b(row: ptr) -> bool { var a_5: vec4; var b_3: vec4; - let _e266 = light_list_info.slot_rows[0i]; - let _e267 = (*row); - a_5 = abs((_e266 - vec4(_e267))); - let _e273 = light_list_info.slot_rows[1i]; - let _e274 = (*row); - b_3 = abs((_e273 - vec4(_e274))); - let _e279 = a_5[0u]; - let _e281 = a_5[1u]; - let _e284 = a_5[2u]; - let _e286 = a_5[3u]; - let _e290 = b_3[0u]; - let _e292 = b_3[1u]; - let _e295 = b_3[2u]; - let _e297 = b_3[3u]; - return (min(min(min(_e279, _e281), min(_e284, _e286)), min(min(_e290, _e292), min(_e295, _e297))) < 0.5f); -} - -fn LightListRow_u0028_i1_u003b(slot_3: ptr) -> f32 { - var param_147: vec4; - var param_148: i32; - - let _e264 = (*slot_3); - let _e268 = light_list_info.indices[(_e264 / 4i)]; - param_147 = _e268; - let _e269 = (*slot_3); - param_148 = _e269; - let _e270 = LightListLane_u0028_vf4_u003b_i1_u003b((¶m_147), (¶m_148)); - return _e270; + let _e278 = light_list_info.slot_rows[0i]; + let _e279 = (*row); + a_5 = abs((_e278 - vec4(_e279))); + let _e285 = light_list_info.slot_rows[1i]; + let _e286 = (*row); + b_3 = abs((_e285 - vec4(_e286))); + let _e291 = a_5[0u]; + let _e293 = a_5[1u]; + let _e296 = a_5[2u]; + let _e298 = a_5[3u]; + let _e302 = b_3[0u]; + let _e304 = b_3[1u]; + let _e307 = b_3[2u]; + let _e309 = b_3[3u]; + return (min(min(min(_e291, _e293), min(_e296, _e298)), min(min(_e302, _e304), min(_e307, _e309))) < 0.5f); +} + +fn LightListRow_u0028_i1_u003b(slot_4: ptr) -> f32 { + var param_154: vec4; + var param_155: i32; + + let _e276 = (*slot_4); + let _e280 = light_list_info.indices[(_e276 / 4i)]; + param_154 = _e280; + let _e281 = (*slot_4); + param_155 = _e281; + let _e282 = LightListLane_u0028_vf4_u003b_i1_u003b((¶m_154), (¶m_155)); + return _e282; } fn LightListTexel_u0028_f1_u003b_f1_u003b(texel_2: ptr, row_1: ptr) -> vec4 { - let _e263 = (*texel_2); - let _e267 = light_list_info.list[1u]; - let _e269 = (*row_1); - let _e273 = light_list_info.list[2u]; - let _e276 = textureSampleLevel(light_list_texture_tex, light_list_texture_smp, vec2(((_e263 + 0.5f) * _e267), ((_e269 + 0.5f) * _e273)), 0f); - return _e276; + let _e275 = (*texel_2); + let _e279 = light_list_info.list[1u]; + let _e281 = (*row_1); + let _e285 = light_list_info.list[2u]; + let _e288 = textureSampleLevel(light_list_texture_tex, light_list_texture_smp, vec2(((_e275 + 0.5f) * _e279), ((_e281 + 0.5f) * _e285)), 0f); + return _e288; } -fn ClusterRow_u0028_i1_u003b(slot_4: ptr) -> f32 { +fn ClusterRow_u0028_i1_u003b(slot_5: ptr) -> f32 { var entry_1: f32; var row_2: f32; var within: f32; var texel_3: f32; var four_1: vec4; - var param_149: f32; - var param_150: f32; - var param_151: vec4; - var param_152: i32; - - let _e271 = g_cluster_offset; - let _e272 = (*slot_4); - entry_1 = (_e271 + f32(_e272)); - let _e275 = entry_1; - row_2 = floor((_e275 / 16f)); - let _e278 = entry_1; - let _e279 = row_2; - within = (_e278 - (_e279 * 16f)); - let _e282 = within; - texel_3 = floor((_e282 / 4f)); - let _e287 = light_list_info.cluster_depth[3u]; - let _e288 = row_2; - let _e290 = texel_3; - param_149 = _e290; - param_150 = (_e287 + _e288); - let _e291 = LightListTexel_u0028_f1_u003b_f1_u003b((¶m_149), (¶m_150)); - four_1 = _e291; - let _e292 = within; - let _e293 = texel_3; - let _e298 = four_1; - param_151 = _e298; - param_152 = i32(((_e292 - (_e293 * 4f)) + 0.5f)); - let _e299 = LightListLane_u0028_vf4_u003b_i1_u003b((¶m_151), (¶m_152)); - return _e299; + var param_156: f32; + var param_157: f32; + var param_158: vec4; + var param_159: i32; + + let _e283 = g_cluster_offset; + let _e284 = (*slot_5); + entry_1 = (_e283 + f32(_e284)); + let _e287 = entry_1; + row_2 = floor((_e287 / 16f)); + let _e290 = entry_1; + let _e291 = row_2; + within = (_e290 - (_e291 * 16f)); + let _e294 = within; + texel_3 = floor((_e294 / 4f)); + let _e299 = light_list_info.cluster_depth[3u]; + let _e300 = row_2; + let _e302 = texel_3; + param_156 = _e302; + param_157 = (_e299 + _e300); + let _e303 = LightListTexel_u0028_f1_u003b_f1_u003b((¶m_156), (¶m_157)); + four_1 = _e303; + let _e304 = within; + let _e305 = texel_3; + let _e310 = four_1; + param_158 = _e310; + param_159 = i32(((_e304 - (_e305 * 4f)) + 0.5f)); + let _e311 = LightListLane_u0028_vf4_u003b_i1_u003b((¶m_158), (¶m_159)); + return _e311; } fn Clustered_u0028_() -> bool { - let _e263 = light_list_info.cluster_grid[3u]; - return (_e263 > 0.5f); + let _e275 = light_list_info.cluster_grid[3u]; + return (_e275 > 0.5f); } -fn SampleLight_u0028_i1_u003b_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b(index_2: ptr, s_6: ptr) -> LightSample { +fn SampleLight_u0028_i1_u003b_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b(index_2: ptr, s_7: ptr) -> LightSample { var light_4: LightSample; var position: vec4; var color_1: vec4; var direction: vec4; var cone: vec4; - var slot_5: i32; + var slot_6: i32; var clustered: bool; var listRow: f32; - var local_20: f32; - var param_153: i32; - var param_154: i32; + var local_25: f32; + var param_160: i32; + var param_161: i32; var v_2: f32; var u_1: f32; - var local_21: f32; - var param_155: f32; - var param_156: i32; + var local_26: f32; + var param_162: f32; + var param_163: i32; var type_45: f32; var halfWidth_1: vec3; var halfHeight_1: vec3; @@ -18369,310 +20725,310 @@ fn SampleLight_u0028_i1_u003b_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_ var corners_2: array, 4>; var behind: bool; var formFactor: f32; - var local_22: f32; - var param_157: array, 4>; - var param_158: vec3; + var local_27: f32; + var param_164: array, 4>; + var param_165: vec3; var area: f32; var radiance: f32; - var local_23: f32; + var local_28: f32; var distance_2: f32; var ratio_1: f32; var window_1: f32; var mirror_1: vec3; var representative: vec3; - var param_159: vec3; - var param_160: vec3; - var param_161: vec3; - var param_162: vec3; - var param_163: vec3; + var param_166: vec3; + var param_167: vec3; + var param_168: vec3; + var param_169: vec3; + var param_170: vec3; var toPoint: vec3; var pointDistance: f32; - var local_24: vec3; - var param_164: vec3; - var param_165: vec3; - var param_166: f32; - var param_167: array, 4>; + var local_29: vec3; + var param_171: vec3; + var param_172: vec3; + var param_173: f32; + var param_174: array, 4>; var aim: vec3; var attenuation_1: f32; var toLight_1: vec3; var distance_3: f32; - var param_168: f32; - var param_169: f32; + var param_175: f32; + var param_176: f32; var cosAngle: f32; light_4.integrated = 0f; light_4.ltc = vec3(0f, 0f, 0f); - let _e318 = (*index_2); - if (_e318 < 8i) { - let _e320 = (*index_2); - let _e323 = frag_info.light_position[_e320]; - position = _e323; - let _e324 = (*index_2); - let _e327 = frag_info.light_color[_e324]; - color_1 = _e327; - let _e328 = (*index_2); - let _e331 = frag_info.light_direction[_e328]; - direction = _e331; + let _e330 = (*index_2); + if (_e330 < 8i) { let _e332 = (*index_2); - let _e335 = frag_info.light_cone[_e332]; - cone = _e335; - } else { + let _e335 = frag_info.light_position[_e332]; + position = _e335; let _e336 = (*index_2); - slot_5 = (_e336 - 8i); - let _e338 = Clustered_u0028_(); - clustered = _e338; - let _e339 = clustered; - if _e339 { - let _e340 = slot_5; - param_153 = _e340; - let _e341 = ClusterRow_u0028_i1_u003b((¶m_153)); - local_20 = _e341; + let _e339 = frag_info.light_color[_e336]; + color_1 = _e339; + let _e340 = (*index_2); + let _e343 = frag_info.light_direction[_e340]; + direction = _e343; + let _e344 = (*index_2); + let _e347 = frag_info.light_cone[_e344]; + cone = _e347; + } else { + let _e348 = (*index_2); + slot_6 = (_e348 - 8i); + let _e350 = Clustered_u0028_(); + clustered = _e350; + let _e351 = clustered; + if _e351 { + let _e352 = slot_6; + param_160 = _e352; + let _e353 = ClusterRow_u0028_i1_u003b((¶m_160)); + local_25 = _e353; } else { - let _e342 = slot_5; - param_154 = _e342; - let _e343 = LightListRow_u0028_i1_u003b((¶m_154)); - local_20 = _e343; - } - let _e344 = local_20; - listRow = _e344; - let _e345 = listRow; - let _e349 = light_list_info.list[2u]; - v_2 = ((_e345 + 0.5f) * _e349); - let _e353 = light_list_info.list[1u]; - u_1 = _e353; - let _e354 = u_1; - let _e356 = v_2; - let _e358 = textureSampleLevel(light_list_texture_tex, light_list_texture_smp, vec2((0.5f * _e354), _e356), 0f); - position = _e358; - let _e359 = u_1; - let _e361 = v_2; - let _e363 = textureSampleLevel(light_list_texture_tex, light_list_texture_smp, vec2((1.5f * _e359), _e361), 0f); - color_1 = _e363; - let _e364 = u_1; - let _e366 = v_2; - let _e368 = textureSampleLevel(light_list_texture_tex, light_list_texture_smp, vec2((2.5f * _e364), _e366), 0f); - direction = _e368; - let _e369 = u_1; - let _e371 = v_2; - let _e373 = textureSampleLevel(light_list_texture_tex, light_list_texture_smp, vec2((3.5f * _e369), _e371), 0f); - cone = _e373; - let _e374 = clustered; - if _e374 { - let _e375 = listRow; - param_155 = _e375; - let _e376 = InSlots_u0028_f1_u003b((¶m_155)); - local_21 = select(1f, 0f, _e376); + let _e354 = slot_6; + param_161 = _e354; + let _e355 = LightListRow_u0028_i1_u003b((¶m_161)); + local_25 = _e355; + } + let _e356 = local_25; + listRow = _e356; + let _e357 = listRow; + let _e361 = light_list_info.list[2u]; + v_2 = ((_e357 + 0.5f) * _e361); + let _e365 = light_list_info.list[1u]; + u_1 = _e365; + let _e366 = u_1; + let _e368 = v_2; + let _e370 = textureSampleLevel(light_list_texture_tex, light_list_texture_smp, vec2((0.5f * _e366), _e368), 0f); + position = _e370; + let _e371 = u_1; + let _e373 = v_2; + let _e375 = textureSampleLevel(light_list_texture_tex, light_list_texture_smp, vec2((1.5f * _e371), _e373), 0f); + color_1 = _e375; + let _e376 = u_1; + let _e378 = v_2; + let _e380 = textureSampleLevel(light_list_texture_tex, light_list_texture_smp, vec2((2.5f * _e376), _e378), 0f); + direction = _e380; + let _e381 = u_1; + let _e383 = v_2; + let _e385 = textureSampleLevel(light_list_texture_tex, light_list_texture_smp, vec2((3.5f * _e381), _e383), 0f); + cone = _e385; + let _e386 = clustered; + if _e386 { + let _e387 = listRow; + param_162 = _e387; + let _e388 = InSlots_u0028_f1_u003b((¶m_162)); + local_26 = select(1f, 0f, _e388); } else { - let _e378 = slot_5; - param_156 = _e378; - let _e379 = LightListScale_u0028_i1_u003b((¶m_156)); - local_21 = _e379; - } - let _e380 = local_21; - let _e382 = color_1[3u]; - color_1[3u] = (_e382 * _e380); - } - let _e386 = position[3u]; - type_45 = _e386; - let _e387 = type_45; - if (_e387 > 2.5f) { - let _e389 = direction; - halfWidth_1 = _e389.xyz; - let _e391 = cone; - halfHeight_1 = _e391.xyz; - let _e393 = position; - let _e395 = v_world_position_1; - toCentre = (_e393.xyz - _e395); - let _e397 = toCentre; - let _e398 = halfWidth_1; - let _e400 = halfHeight_1; - corners_2[0i] = ((_e397 - _e398) - _e400); - let _e403 = toCentre; - let _e404 = halfWidth_1; - let _e406 = halfHeight_1; - corners_2[1i] = ((_e403 + _e404) - _e406); + let _e390 = slot_6; + param_163 = _e390; + let _e391 = LightListScale_u0028_i1_u003b((¶m_163)); + local_26 = _e391; + } + let _e392 = local_26; + let _e394 = color_1[3u]; + color_1[3u] = (_e394 * _e392); + } + let _e398 = position[3u]; + type_45 = _e398; + let _e399 = type_45; + if (_e399 > 2.5f) { + let _e401 = direction; + halfWidth_1 = _e401.xyz; + let _e403 = cone; + halfHeight_1 = _e403.xyz; + let _e405 = position; + let _e407 = v_world_position_1; + toCentre = (_e405.xyz - _e407); let _e409 = toCentre; let _e410 = halfWidth_1; let _e412 = halfHeight_1; - corners_2[2i] = ((_e409 + _e410) + _e412); + corners_2[0i] = ((_e409 - _e410) - _e412); let _e415 = toCentre; let _e416 = halfWidth_1; let _e418 = halfHeight_1; - corners_2[3i] = ((_e415 - _e416) + _e418); + corners_2[1i] = ((_e415 + _e416) - _e418); let _e421 = toCentre; let _e422 = halfWidth_1; - let _e423 = halfHeight_1; - behind = (dot(_e421, cross(_e422, _e423)) >= 0f); - let _e427 = behind; - if _e427 { - local_22 = 0f; + let _e424 = halfHeight_1; + corners_2[2i] = ((_e421 + _e422) + _e424); + let _e427 = toCentre; + let _e428 = halfWidth_1; + let _e430 = halfHeight_1; + corners_2[3i] = ((_e427 - _e428) + _e430); + let _e433 = toCentre; + let _e434 = halfWidth_1; + let _e435 = halfHeight_1; + behind = (dot(_e433, cross(_e434, _e435)) >= 0f); + let _e439 = behind; + if _e439 { + local_27 = 0f; } else { - let _e428 = corners_2; - param_157 = _e428; - let _e430 = (*s_6).n; - param_158 = _e430; - let _e431 = RectangleFormFactor_u0028_vf3_u005b_4_u005d_u003b_vf3_u003b((¶m_157), (¶m_158)); - local_22 = _e431; - } - let _e432 = local_22; - formFactor = _e432; - let _e433 = halfWidth_1; - let _e434 = halfHeight_1; - area = (length(cross(_e433, _e434)) * 4f); - let _e438 = area; - if (_e438 > 0.000000001f) { - let _e440 = area; - local_23 = (1f / _e440); + let _e440 = corners_2; + param_164 = _e440; + let _e442 = (*s_7).n; + param_165 = _e442; + let _e443 = RectangleFormFactor_u0028_vf3_u005b_4_u005d_u003b_vf3_u003b((¶m_164), (¶m_165)); + local_27 = _e443; + } + let _e444 = local_27; + formFactor = _e444; + let _e445 = halfWidth_1; + let _e446 = halfHeight_1; + area = (length(cross(_e445, _e446)) * 4f); + let _e450 = area; + if (_e450 > 0.000000001f) { + let _e452 = area; + local_28 = (1f / _e452); } else { - local_23 = 0f; - } - let _e442 = local_23; - radiance = _e442; - let _e443 = toCentre; - distance_2 = length(_e443); - let _e446 = direction[3u]; - if (_e446 > 0f) { - let _e448 = distance_2; - let _e450 = direction[3u]; - ratio_1 = (_e448 / _e450); - let _e452 = ratio_1; - let _e453 = ratio_1; - let _e455 = ratio_1; - let _e457 = ratio_1; - window_1 = clamp((1f - (((_e452 * _e453) * _e455) * _e457)), 0f, 1f); - let _e461 = window_1; - let _e462 = window_1; - let _e464 = radiance; - radiance = (_e464 * (_e461 * _e462)); - } - let _e467 = (*s_6).v; - let _e470 = (*s_6).n; - mirror_1 = reflect(-(_e467), _e470); - let _e472 = position; - param_159 = _e472.xyz; - let _e474 = halfWidth_1; - param_160 = _e474; - let _e475 = halfHeight_1; - param_161 = _e475; - let _e476 = v_world_position_1; - param_162 = _e476; - let _e477 = mirror_1; - param_163 = _e477; - let _e478 = RectangleClosestPoint_u0028_vf3_u003b_vf3_u003b_vf3_u003b_vf3_u003b_vf3_u003b((¶m_159), (¶m_160), (¶m_161), (¶m_162), (¶m_163)); - representative = _e478; - let _e479 = representative; - let _e480 = v_world_position_1; - toPoint = (_e479 - _e480); - let _e482 = toPoint; - pointDistance = length(_e482); - let _e484 = pointDistance; - if (_e484 > 0.000001f) { - let _e486 = toPoint; - let _e487 = pointDistance; - local_24 = (_e486 / vec3(_e487)); + local_28 = 0f; + } + let _e454 = local_28; + radiance = _e454; + let _e455 = toCentre; + distance_2 = length(_e455); + let _e458 = direction[3u]; + if (_e458 > 0f) { + let _e460 = distance_2; + let _e462 = direction[3u]; + ratio_1 = (_e460 / _e462); + let _e464 = ratio_1; + let _e465 = ratio_1; + let _e467 = ratio_1; + let _e469 = ratio_1; + window_1 = clamp((1f - (((_e464 * _e465) * _e467) * _e469)), 0f, 1f); + let _e473 = window_1; + let _e474 = window_1; + let _e476 = radiance; + radiance = (_e476 * (_e473 * _e474)); + } + let _e479 = (*s_7).v; + let _e482 = (*s_7).n; + mirror_1 = reflect(-(_e479), _e482); + let _e484 = position; + param_166 = _e484.xyz; + let _e486 = halfWidth_1; + param_167 = _e486; + let _e487 = halfHeight_1; + param_168 = _e487; + let _e488 = v_world_position_1; + param_169 = _e488; + let _e489 = mirror_1; + param_170 = _e489; + let _e490 = RectangleClosestPoint_u0028_vf3_u003b_vf3_u003b_vf3_u003b_vf3_u003b_vf3_u003b((¶m_166), (¶m_167), (¶m_168), (¶m_169), (¶m_170)); + representative = _e490; + let _e491 = representative; + let _e492 = v_world_position_1; + toPoint = (_e491 - _e492); + let _e494 = toPoint; + pointDistance = length(_e494); + let _e496 = pointDistance; + if (_e496 > 0.000001f) { + let _e498 = toPoint; + let _e499 = pointDistance; + local_29 = (_e498 / vec3(_e499)); } else { - let _e491 = (*s_6).n; - local_24 = _e491; - } - let _e492 = local_24; - light_4.l = _e492; - let _e495 = light_4.l; - let _e497 = (*s_6).v; - light_4.h = normalize((_e495 + _e497)); - let _e501 = formFactor; - light_4.n_dot_l = _e501; - let _e504 = (*s_6).n; - let _e506 = light_4.h; - light_4.n_dot_h = max(dot(_e504, _e506), 0f); - let _e511 = (*s_6).v; - let _e513 = light_4.h; - light_4.v_dot_h = max(dot(_e511, _e513), 0f); - let _e517 = color_1; - let _e520 = color_1[3u]; - let _e522 = radiance; - light_4.radiance = ((_e517.xyz * _e520) * _e522); + let _e503 = (*s_7).n; + local_29 = _e503; + } + let _e504 = local_29; + light_4.l = _e504; + let _e507 = light_4.l; + let _e509 = (*s_7).v; + light_4.h = normalize((_e507 + _e509)); + let _e513 = formFactor; + light_4.n_dot_l = _e513; + let _e516 = (*s_7).n; + let _e518 = light_4.h; + light_4.n_dot_h = max(dot(_e516, _e518), 0f); + let _e523 = (*s_7).v; + let _e525 = light_4.h; + light_4.v_dot_h = max(dot(_e523, _e525), 0f); + let _e529 = color_1; + let _e532 = color_1[3u]; + let _e534 = radiance; + light_4.radiance = ((_e529.xyz * _e532) * _e534); light_4.integrated = 1f; - let _e527 = (*s_6).n; - param_164 = _e527; - let _e529 = (*s_6).v; - param_165 = _e529; - let _e531 = (*s_6).roughness; - param_166 = _e531; - let _e532 = corners_2; - param_167 = _e532; - let _e533 = LtcRectangle_u0028_vf3_u003b_vf3_u003b_f1_u003b_vf3_u005b_4_u005d_u003b((¶m_164), (¶m_165), (¶m_166), (¶m_167)); - light_4.ltc = _e533; - let _e535 = corners_2; - g_rect_corners = _e535; - let _e536 = light_4; - return _e536; - } - let _e537 = direction; - aim = normalize(_e537.xyz); + let _e539 = (*s_7).n; + param_171 = _e539; + let _e541 = (*s_7).v; + param_172 = _e541; + let _e543 = (*s_7).roughness; + param_173 = _e543; + let _e544 = corners_2; + param_174 = _e544; + let _e545 = LtcRectangle_u0028_vf3_u003b_vf3_u003b_f1_u003b_vf3_u005b_4_u005d_u003b((¶m_171), (¶m_172), (¶m_173), (¶m_174)); + light_4.ltc = _e545; + let _e547 = corners_2; + g_rect_corners = _e547; + let _e548 = light_4; + return _e548; + } + let _e549 = direction; + aim = normalize(_e549.xyz); attenuation_1 = 1f; - let _e540 = type_45; - if (_e540 < 0.5f) { - let _e542 = aim; - light_4.l = -(_e542); - } else { - let _e545 = position; - let _e547 = v_world_position_1; - toLight_1 = (_e545.xyz - _e547); - let _e549 = toLight_1; - distance_3 = length(_e549); - let _e551 = distance_3; - if (_e551 < 0.000001f) { - let _e554 = (*s_6).n; - light_4.l = _e554; - let _e557 = (*s_6).n; - light_4.h = _e557; + let _e552 = type_45; + if (_e552 < 0.5f) { + let _e554 = aim; + light_4.l = -(_e554); + } else { + let _e557 = position; + let _e559 = v_world_position_1; + toLight_1 = (_e557.xyz - _e559); + let _e561 = toLight_1; + distance_3 = length(_e561); + let _e563 = distance_3; + if (_e563 < 0.000001f) { + let _e566 = (*s_7).n; + light_4.l = _e566; + let _e569 = (*s_7).n; + light_4.h = _e569; light_4.radiance = vec3(0f, 0f, 0f); light_4.n_dot_l = 0f; light_4.n_dot_h = 0f; light_4.v_dot_h = 0f; - let _e563 = light_4; - return _e563; - } - let _e564 = toLight_1; - let _e565 = distance_3; - light_4.l = (_e564 / vec3(_e565)); - let _e569 = distance_3; - param_168 = _e569; - let _e571 = direction[3u]; - param_169 = _e571; - let _e572 = PunctualAttenuation_u0028_f1_u003b_f1_u003b((¶m_168), (¶m_169)); - attenuation_1 = _e572; - let _e573 = type_45; - if (_e573 > 1.5f) { - let _e575 = aim; - let _e577 = light_4.l; - cosAngle = dot(_e575, -(_e577)); - let _e580 = cosAngle; - let _e582 = cone[1u]; - let _e585 = cone[0u]; - let _e587 = cone[1u]; - let _e591 = attenuation_1; - attenuation_1 = (_e591 * clamp(((_e580 - _e582) / (_e585 - _e587)), 0f, 1f)); - } - } - let _e594 = light_4.l; - let _e596 = (*s_6).v; - light_4.h = normalize((_e594 + _e596)); - let _e601 = (*s_6).n; - let _e603 = light_4.l; - light_4.n_dot_l = max(dot(_e601, _e603), 0f); - let _e608 = (*s_6).n; - let _e610 = light_4.h; - light_4.n_dot_h = max(dot(_e608, _e610), 0f); - let _e615 = (*s_6).v; - let _e617 = light_4.h; - light_4.v_dot_h = max(dot(_e615, _e617), 0f); - let _e621 = color_1; - let _e624 = color_1[3u]; - let _e626 = attenuation_1; - light_4.radiance = ((_e621.xyz * _e624) * _e626); - let _e629 = light_4; - return _e629; + let _e575 = light_4; + return _e575; + } + let _e576 = toLight_1; + let _e577 = distance_3; + light_4.l = (_e576 / vec3(_e577)); + let _e581 = distance_3; + param_175 = _e581; + let _e583 = direction[3u]; + param_176 = _e583; + let _e584 = PunctualAttenuation_u0028_f1_u003b_f1_u003b((¶m_175), (¶m_176)); + attenuation_1 = _e584; + let _e585 = type_45; + if (_e585 > 1.5f) { + let _e587 = aim; + let _e589 = light_4.l; + cosAngle = dot(_e587, -(_e589)); + let _e592 = cosAngle; + let _e594 = cone[1u]; + let _e597 = cone[0u]; + let _e599 = cone[1u]; + let _e603 = attenuation_1; + attenuation_1 = (_e603 * clamp(((_e592 - _e594) / (_e597 - _e599)), 0f, 1f)); + } + } + let _e606 = light_4.l; + let _e608 = (*s_7).v; + light_4.h = normalize((_e606 + _e608)); + let _e613 = (*s_7).n; + let _e615 = light_4.l; + light_4.n_dot_l = max(dot(_e613, _e615), 0f); + let _e620 = (*s_7).n; + let _e622 = light_4.h; + light_4.n_dot_h = max(dot(_e620, _e622), 0f); + let _e627 = (*s_7).v; + let _e629 = light_4.h; + light_4.v_dot_h = max(dot(_e627, _e629), 0f); + let _e633 = color_1; + let _e636 = color_1[3u]; + let _e638 = attenuation_1; + light_4.radiance = ((_e633.xyz * _e636) * _e638); + let _e641 = light_4; + return _e641; } fn FindCluster_u0028_vf3_u003b(world_3: ptr>) { @@ -18683,188 +21039,190 @@ fn FindCluster_u0028_vf3_u003b(world_3: ptr>) { var tx: f32; var ty: f32; var tz: f32; - var local_25: f32; + var local_30: f32; var cell: f32; var row_3: f32; var header: vec4; - var param_170: f32; - var param_171: f32; - - let _e276 = light_list_info.cluster_view_projection; - let _e277 = (*world_3); - clip_1 = (_e276 * vec4(_e277.x, _e277.y, _e277.z, 1f)); - let _e283 = clip_1; - let _e286 = clip_1[3u]; - ndc_1 = (_e283.xy / vec2(max(_e286, 0.000001f))); - let _e291 = light_list_info.cluster_grid; - grid = _e291.xyz; - let _e295 = light_list_info.cluster_depth[0u]; - near_1 = _e295; - let _e297 = ndc_1[0u]; - let _e301 = grid[0u]; - let _e305 = grid[0u]; - tx = clamp(floor((((_e297 * 0.5f) + 0.5f) * _e301)), 0f, (_e305 - 1f)); - let _e309 = ndc_1[1u]; - let _e313 = grid[1u]; - let _e317 = grid[1u]; - ty = clamp(floor((((_e309 * 0.5f) + 0.5f) * _e313)), 0f, (_e317 - 1f)); - let _e321 = clip_1[3u]; - let _e322 = near_1; - if (_e321 <= _e322) { - local_25 = 0f; - } else { - let _e325 = clip_1[3u]; - let _e326 = near_1; - let _e331 = light_list_info.cluster_depth[1u]; - let _e335 = grid[2u]; - local_25 = clamp(floor((log((_e325 / _e326)) * _e331)), 0f, (_e335 - 1f)); - } - let _e338 = local_25; - tz = _e338; - let _e339 = tx; - let _e340 = ty; - let _e342 = grid[0u]; - let _e345 = tz; - let _e347 = grid[0u]; - let _e350 = grid[1u]; - cell = ((_e339 + (_e340 * _e342)) + ((_e345 * _e347) * _e350)); - let _e353 = cell; - row_3 = floor((_e353 / 4f)); - let _e356 = cell; - let _e357 = row_3; - let _e362 = light_list_info.cluster_depth[2u]; - let _e363 = row_3; - param_170 = (_e356 - (_e357 * 4f)); - param_171 = (_e362 + _e363); - let _e365 = LightListTexel_u0028_f1_u003b_f1_u003b((¶m_170), (¶m_171)); - header = _e365; - let _e367 = header[0u]; - g_cluster_offset = _e367; - let _e369 = header[1u]; - g_cluster_count = _e369; + var param_177: f32; + var param_178: f32; + + let _e288 = light_list_info.cluster_view_projection; + let _e289 = (*world_3); + clip_1 = (_e288 * vec4(_e289.x, _e289.y, _e289.z, 1f)); + let _e295 = clip_1; + let _e298 = clip_1[3u]; + ndc_1 = (_e295.xy / vec2(max(_e298, 0.000001f))); + let _e303 = light_list_info.cluster_grid; + grid = _e303.xyz; + let _e307 = light_list_info.cluster_depth[0u]; + near_1 = _e307; + let _e309 = ndc_1[0u]; + let _e313 = grid[0u]; + let _e317 = grid[0u]; + tx = clamp(floor((((_e309 * 0.5f) + 0.5f) * _e313)), 0f, (_e317 - 1f)); + let _e321 = ndc_1[1u]; + let _e325 = grid[1u]; + let _e329 = grid[1u]; + ty = clamp(floor((((_e321 * 0.5f) + 0.5f) * _e325)), 0f, (_e329 - 1f)); + let _e333 = clip_1[3u]; + let _e334 = near_1; + if (_e333 <= _e334) { + local_30 = 0f; + } else { + let _e337 = clip_1[3u]; + let _e338 = near_1; + let _e343 = light_list_info.cluster_depth[1u]; + let _e347 = grid[2u]; + local_30 = clamp(floor((log((_e337 / _e338)) * _e343)), 0f, (_e347 - 1f)); + } + let _e350 = local_30; + tz = _e350; + let _e351 = tx; + let _e352 = ty; + let _e354 = grid[0u]; + let _e357 = tz; + let _e359 = grid[0u]; + let _e362 = grid[1u]; + cell = ((_e351 + (_e352 * _e354)) + ((_e357 * _e359) * _e362)); + let _e365 = cell; + row_3 = floor((_e365 / 4f)); + let _e368 = cell; + let _e369 = row_3; + let _e374 = light_list_info.cluster_depth[2u]; + let _e375 = row_3; + param_177 = (_e368 - (_e369 * 4f)); + param_178 = (_e374 + _e375); + let _e377 = LightListTexel_u0028_f1_u003b_f1_u003b((¶m_177), (¶m_178)); + header = _e377; + let _e379 = header[0u]; + g_cluster_offset = _e379; + let _e381 = header[1u]; + g_cluster_count = _e381; return; } fn LightCount_u0028_() -> i32 { var tail: f32; - var param_172: vec3; + var param_179: vec3; - let _e265 = light_list_info.list[0u]; - tail = _e265; - let _e266 = Clustered_u0028_(); - if _e266 { - let _e267 = v_world_position_1; - param_172 = _e267; - FindCluster_u0028_vf3_u003b((¶m_172)); - let _e268 = g_cluster_count; - tail = _e268; + let _e277 = light_list_info.list[0u]; + tail = _e277; + let _e278 = Clustered_u0028_(); + if _e278 { + let _e279 = v_world_position_1; + param_179 = _e279; + FindCluster_u0028_vf3_u003b((¶m_179)); + let _e280 = g_cluster_count; + tail = _e280; } - let _e271 = frag_info.frame_params[1u]; - let _e275 = tail; - return (clamp(i32((_e271 + 0.5f)), 0i, 8i) + clamp(i32((_e275 + 0.5f)), 0i, 24i)); + let _e283 = frag_info.frame_params[1u]; + let _e287 = tail; + return (clamp(i32((_e283 + 0.5f)), 0i, 8i) + clamp(i32((_e287 + 0.5f)), 0i, 24i)); } -fn AccumulateLights_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b(s_7: ptr) -> vec3 { +fn AccumulateLights_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b(s_8: ptr) -> vec3 { var total_1: vec3; var count_1: i32; var i_9: i32; var light_5: LightSample; - var param_173: i32; - var param_174: Surface; + var param_180: i32; + var param_181: Surface; var visibility: f32; - var param_175: i32; - var local_26: f32; - var param_176: Surface; - var param_177: LightSample; - var param_178: i32; - var param_179: vec3; - var param_180: vec3; - var param_181: i32; - var param_182: Surface; - var param_183: LightSample; + var param_182: i32; + var local_31: f32; + var param_183: Surface; + var param_184: LightSample; + var param_185: i32; + var param_186: vec3; + var param_187: vec3; + var param_188: i32; + var param_189: Surface; + var param_190: LightSample; total_1 = vec3(0f, 0f, 0f); - let _e279 = LightCount_u0028_(); - count_1 = _e279; + let _e291 = LightCount_u0028_(); + count_1 = _e291; i_9 = 0i; loop { - let _e280 = i_9; - if (_e280 < 32i) { - let _e282 = i_9; - let _e283 = count_1; - if (_e282 >= _e283) { + let _e292 = i_9; + if (_e292 < 32i) { + let _e294 = i_9; + let _e295 = count_1; + if (_e294 >= _e295) { break; } - let _e285 = i_9; - param_173 = _e285; - let _e286 = (*s_7); - param_174 = _e286; - let _e287 = SampleLight_u0028_i1_u003b_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b((¶m_173), (¶m_174)); - light_5 = _e287; - let _e289 = light_5.n_dot_l; - if (_e289 <= 0f) { + let _e297 = i_9; + param_180 = _e297; + let _e298 = (*s_8); + param_181 = _e298; + let _e299 = SampleLight_u0028_i1_u003b_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b((¶m_180), (¶m_181)); + light_5 = _e299; + let _e301 = light_5.n_dot_l; + if (_e301 <= 0f) { continue; } - let _e291 = i_9; - param_175 = _e291; - let _e292 = LightHasShadow_u0028_i1_u003b((¶m_175)); - if _e292 { - let _e293 = (*s_7); - param_176 = _e293; - let _e294 = light_5; - param_177 = _e294; - let _e295 = i_9; - param_178 = _e295; - let _e296 = LightVisibility_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b_struct_u002d_LightSample_u002d_vf3_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf31_u003b_i1_u003b((¶m_176), (¶m_177), (¶m_178)); - let _e297 = v_world_position_1; - param_179 = _e297; - let _e299 = (*s_7).n; - param_180 = _e299; - let _e300 = i_9; - param_181 = _e300; - let _e301 = PointShadowFactor_u0028_vf3_u003b_vf3_u003b_i1_u003b((¶m_179), (¶m_180), (¶m_181)); - local_26 = (_e296 * _e301); + light_transmittance = vec3(1f, 1f, 1f); + let _e303 = i_9; + param_182 = _e303; + let _e304 = LightHasShadow_u0028_i1_u003b((¶m_182)); + if _e304 { + let _e305 = (*s_8); + param_183 = _e305; + let _e306 = light_5; + param_184 = _e306; + let _e307 = i_9; + param_185 = _e307; + let _e308 = LightVisibility_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b_struct_u002d_LightSample_u002d_vf3_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf31_u003b_i1_u003b((¶m_183), (¶m_184), (¶m_185)); + let _e309 = v_world_position_1; + param_186 = _e309; + let _e311 = (*s_8).n; + param_187 = _e311; + let _e312 = i_9; + param_188 = _e312; + let _e313 = PointShadowFactor_u0028_vf3_u003b_vf3_u003b_i1_u003b((¶m_186), (¶m_187), (¶m_188)); + local_31 = (_e308 * _e313); } else { - local_26 = 1f; + local_31 = 1f; } - let _e303 = local_26; - visibility = _e303; - let _e304 = visibility; - if (_e304 <= 0f) { + let _e315 = local_31; + visibility = _e315; + let _e316 = visibility; + if (_e316 <= 0f) { continue; } - let _e306 = (*s_7); - param_182 = _e306; - let _e307 = light_5; - param_183 = _e307; - let _e308 = ShadeLight_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b_struct_u002d_LightSample_u002d_vf3_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf31_u003b((¶m_182), (¶m_183)); - let _e310 = light_5.radiance; - let _e313 = light_5.n_dot_l; - let _e315 = visibility; - let _e317 = total_1; - total_1 = (_e317 + (((_e308 * _e310) * _e313) * _e315)); + let _e318 = (*s_8); + param_189 = _e318; + let _e319 = light_5; + param_190 = _e319; + let _e320 = ShadeLight_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b_struct_u002d_LightSample_u002d_vf3_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf31_u003b((¶m_189), (¶m_190)); + let _e322 = light_5.radiance; + let _e325 = light_5.n_dot_l; + let _e327 = visibility; + let _e329 = light_transmittance; + let _e331 = total_1; + total_1 = (_e331 + ((((_e320 * _e322) * _e325) * _e327) * _e329)); continue; } else { break; } continuing { - let _e319 = i_9; - i_9 = (_e319 + 1i); + let _e333 = i_9; + i_9 = (_e333 + 1i); } } - let _e321 = total_1; - return _e321; + let _e335 = total_1; + return _e335; } fn SampleLightmap_u0028_() -> vec3 { var texel_4: vec4; - let _e262 = v_lightmap_uv_1; - let _e263 = textureSample(lightmap_texture_tex, lightmap_texture_smp, _e262); - texel_4 = _e263; - let _e264 = texel_4; - let _e267 = texel_4[3u]; - return ((_e264.xyz * _e267) * 8f); + let _e274 = v_lightmap_uv_1; + let _e275 = textureSample(lightmap_texture_tex, lightmap_texture_smp, _e274); + texel_4 = _e275; + let _e276 = texel_4; + let _e279 = texel_4[3u]; + return ((_e276.xyz * _e279) * 8f); } fn SceneColourLevel_u0028_vf2_u003b_i1_u003b(uv_6: ptr>, level: ptr) -> vec3 { @@ -18872,315 +21230,315 @@ fn SceneColourLevel_u0028_vf2_u003b_i1_u003b(uv_6: ptr>, lev var inset_1: vec2; var at_4: vec2; - let _e266 = (*level); - let _e269 = layer_info.scene_levels[_e266]; - rect = _e269; - let _e271 = layer_info.scene_colour; - inset_1 = (_e271.yz * 0.5f); - let _e274 = rect; - let _e276 = (*uv_6); - let _e277 = rect; - let _e280 = inset_1; - let _e281 = rect; - let _e283 = inset_1; - let _e285 = inset_1; - at_4 = (_e274.xy + clamp((_e276 * _e277.zw), _e280, max((_e281.zw - _e283), _e285))); - let _e289 = at_4; - let _e290 = textureSampleLevel(scene_colour_texture_tex, scene_colour_texture_smp, _e289, 0f); - return _e290.xyz; + let _e278 = (*level); + let _e281 = layer_info.scene_levels[_e278]; + rect = _e281; + let _e283 = layer_info.scene_colour; + inset_1 = (_e283.yz * 0.5f); + let _e286 = rect; + let _e288 = (*uv_6); + let _e289 = rect; + let _e292 = inset_1; + let _e293 = rect; + let _e295 = inset_1; + let _e297 = inset_1; + at_4 = (_e286.xy + clamp((_e288 * _e289.zw), _e292, max((_e293.zw - _e295), _e297))); + let _e301 = at_4; + let _e302 = textureSampleLevel(scene_colour_texture_tex, scene_colour_texture_smp, _e301, 0f); + return _e302.xyz; } fn SceneColourAt_u0028_vf3_u003b_f1_u003b(world_4: ptr>, lod: ptr) -> vec3 { var clip_2: vec4; var ndc_2: vec2; - var view: vec2; + var view_1: vec2; var uv_7: vec2; var top: f32; var level_1: f32; var lower: f32; var near_2: vec3; - var param_184: vec2; - var param_185: i32; + var param_191: vec2; + var param_192: i32; var far_1: vec3; - var param_186: vec2; - var param_187: i32; - - let _e277 = layer_info.scene_view_projection; - let _e278 = (*world_4); - clip_2 = (_e277 * vec4(_e278.x, _e278.y, _e278.z, 1f)); - let _e284 = clip_2; - let _e287 = clip_2[3u]; - ndc_2 = (_e284.xy / vec2(max(_e287, 0.000001f))); - let _e292 = ndc_2[0u]; - let _e296 = ndc_2[1u]; - view = clamp(vec2(((_e292 * 0.5f) + 0.5f), (0.5f - (_e296 * 0.5f))), vec2(0f, 0f), vec2(1f, 1f)); - let _e302 = layer_info.scene_viewport; - let _e304 = view; - let _e306 = layer_info.scene_viewport; - uv_7 = (_e302.xy + (_e304 * _e306.zw)); - let _e312 = layer_info.scene_colour[0u]; - top = (_e312 - 1f); - let _e314 = (*lod); - let _e315 = top; - level_1 = clamp(_e314, 0f, _e315); - let _e317 = level_1; - lower = floor(_e317); - let _e319 = lower; - let _e321 = uv_7; - param_184 = _e321; - param_185 = i32(_e319); - let _e322 = SceneColourLevel_u0028_vf2_u003b_i1_u003b((¶m_184), (¶m_185)); - near_2 = _e322; - let _e323 = lower; - let _e325 = top; - let _e328 = uv_7; - param_186 = _e328; - param_187 = i32(min((_e323 + 1f), _e325)); - let _e329 = SceneColourLevel_u0028_vf2_u003b_i1_u003b((¶m_186), (¶m_187)); - far_1 = _e329; - let _e330 = near_2; - let _e331 = far_1; - let _e332 = level_1; - let _e333 = lower; - return mix(_e330, _e331, vec3((_e332 - _e333))); + var param_193: vec2; + var param_194: i32; + + let _e289 = layer_info.scene_view_projection; + let _e290 = (*world_4); + clip_2 = (_e289 * vec4(_e290.x, _e290.y, _e290.z, 1f)); + let _e296 = clip_2; + let _e299 = clip_2[3u]; + ndc_2 = (_e296.xy / vec2(max(_e299, 0.000001f))); + let _e304 = ndc_2[0u]; + let _e308 = ndc_2[1u]; + view_1 = clamp(vec2(((_e304 * 0.5f) + 0.5f), (0.5f - (_e308 * 0.5f))), vec2(0f, 0f), vec2(1f, 1f)); + let _e314 = layer_info.scene_viewport; + let _e316 = view_1; + let _e318 = layer_info.scene_viewport; + uv_7 = (_e314.xy + (_e316 * _e318.zw)); + let _e324 = layer_info.scene_colour[0u]; + top = (_e324 - 1f); + let _e326 = (*lod); + let _e327 = top; + level_1 = clamp(_e326, 0f, _e327); + let _e329 = level_1; + lower = floor(_e329); + let _e331 = lower; + let _e333 = uv_7; + param_191 = _e333; + param_192 = i32(_e331); + let _e334 = SceneColourLevel_u0028_vf2_u003b_i1_u003b((¶m_191), (¶m_192)); + near_2 = _e334; + let _e335 = lower; + let _e337 = top; + let _e340 = uv_7; + param_193 = _e340; + param_194 = i32(min((_e335 + 1f), _e337)); + let _e341 = SceneColourLevel_u0028_vf2_u003b_i1_u003b((¶m_193), (¶m_194)); + far_1 = _e341; + let _e342 = near_2; + let _e343 = far_1; + let _e344 = level_1; + let _e345 = lower; + return mix(_e342, _e343, vec3((_e344 - _e345))); } fn IorInfinite_u0028_() -> bool { - let _e263 = layer_info.coat[2u]; - return (_e263 == 0f); + let _e275 = layer_info.coat[2u]; + return (_e275 == 0f); } fn DispersionSpread_u0028_f1_u003b(ior: ptr) -> f32 { - var local_27: f32; + var local_32: f32; - let _e263 = IorInfinite_u0028_(); - if _e263 { - local_27 = 0f; + let _e275 = IorInfinite_u0028_(); + if _e275 { + local_32 = 0f; } else { - let _e264 = (*ior); - let _e269 = layer_info.transmission[3u]; - local_27 = (((_e264 - 1f) * 0.025f) * _e269); + let _e276 = (*ior); + let _e281 = layer_info.transmission[3u]; + local_32 = (((_e276 - 1f) * 0.025f) * _e281); } - let _e271 = local_27; - return _e271; + let _e283 = local_32; + return _e283; } fn RefractionIor_u0028_() -> f32 { - var local_28: f32; + var local_33: f32; - let _e262 = IorInfinite_u0028_(); - if _e262 { - local_28 = 10000f; + let _e274 = IorInfinite_u0028_(); + if _e274 { + local_33 = 10000f; } else { - let _e265 = layer_info.coat[2u]; - local_28 = max(_e265, 1f); + let _e277 = layer_info.coat[2u]; + local_33 = max(_e277, 1f); } - let _e267 = local_28; - return _e267; + let _e279 = local_33; + return _e279; } -fn SceneBehind_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b(s_8: ptr) -> vec3 { +fn SceneBehind_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b(s_9: ptr) -> vec3 { var ior_1: f32; var spread_1: f32; - var param_188: f32; + var param_195: f32; var width: f32; var lod_1: f32; var thin: bool; var red: vec3; - var local_29: vec3; + var local_34: vec3; var green: vec3; - var local_30: vec3; + var local_35: vec3; var blue: vec3; - var local_31: vec3; - var param_189: vec3; - var param_190: f32; - var param_191: vec3; - var param_192: f32; - var param_193: vec3; - var param_194: f32; + var local_36: vec3; + var param_196: vec3; + var param_197: f32; + var param_198: vec3; + var param_199: f32; + var param_200: vec3; + var param_201: f32; - let _e280 = RefractionIor_u0028_(); - ior_1 = _e280; - let _e281 = ior_1; - param_188 = _e281; - let _e282 = DispersionSpread_u0028_f1_u003b((¶m_188)); - spread_1 = _e282; - let _e286 = layer_info.scene_levels[0i][2u]; - let _e289 = layer_info.scene_colour[1u]; - width = (_e286 / max(_e289, 0.000000001f)); - let _e292 = width; - let _e296 = (*s_8).roughness; - let _e298 = ior_1; - lod_1 = ((log2(max(_e292, 1f)) * _e296) * clamp(((_e298 * 2f) - 2f), 0f, 1f)); - let _e303 = g_thickness; - thin = (_e303 <= 0f); - let _e305 = thin; - if _e305 { - let _e307 = (*s_8).v; - local_29 = -(_e307); - } else { - let _e310 = (*s_8).v; - let _e313 = (*s_8).n; - let _e314 = ior_1; - let _e315 = spread_1; - local_29 = refract(-(_e310), _e313, (1f / max((_e314 - _e315), 1f))); - } - let _e320 = local_29; - red = _e320; - let _e321 = thin; - if _e321 { - let _e323 = (*s_8).v; - local_30 = -(_e323); - } else { - let _e326 = (*s_8).v; - let _e329 = (*s_8).n; - let _e330 = ior_1; - local_30 = refract(-(_e326), _e329, (1f / _e330)); - } - let _e333 = local_30; - green = _e333; - let _e334 = thin; - if _e334 { - let _e336 = (*s_8).v; - local_31 = -(_e336); - } else { - let _e339 = (*s_8).v; - let _e342 = (*s_8).n; - let _e343 = ior_1; - let _e344 = spread_1; - local_31 = refract(-(_e339), _e342, (1f / (_e343 + _e344))); - } - let _e348 = local_31; - blue = _e348; - let _e349 = v_world_position_1; - let _e350 = red; - let _e351 = g_thickness; - param_189 = (_e349 + (_e350 * _e351)); - let _e354 = lod_1; - param_190 = _e354; - let _e355 = SceneColourAt_u0028_vf3_u003b_f1_u003b((¶m_189), (¶m_190)); - let _e357 = v_world_position_1; - let _e358 = green; - let _e359 = g_thickness; - param_191 = (_e357 + (_e358 * _e359)); - let _e362 = lod_1; - param_192 = _e362; - let _e363 = SceneColourAt_u0028_vf3_u003b_f1_u003b((¶m_191), (¶m_192)); - let _e365 = v_world_position_1; - let _e366 = blue; - let _e367 = g_thickness; - param_193 = (_e365 + (_e366 * _e367)); - let _e370 = lod_1; - param_194 = _e370; - let _e371 = SceneColourAt_u0028_vf3_u003b_f1_u003b((¶m_193), (¶m_194)); - return vec3(_e355.x, _e363.y, _e371.z); -} - -fn TransmittedRadiance_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b_f1_u003b(s_9: ptr, levels: ptr) -> vec3 { + let _e292 = RefractionIor_u0028_(); + ior_1 = _e292; + let _e293 = ior_1; + param_195 = _e293; + let _e294 = DispersionSpread_u0028_f1_u003b((¶m_195)); + spread_1 = _e294; + let _e298 = layer_info.scene_levels[0i][2u]; + let _e301 = layer_info.scene_colour[1u]; + width = (_e298 / max(_e301, 0.000000001f)); + let _e304 = width; + let _e308 = (*s_9).roughness; + let _e310 = ior_1; + lod_1 = ((log2(max(_e304, 1f)) * _e308) * clamp(((_e310 * 2f) - 2f), 0f, 1f)); + let _e315 = g_thickness; + thin = (_e315 <= 0f); + let _e317 = thin; + if _e317 { + let _e319 = (*s_9).v; + local_34 = -(_e319); + } else { + let _e322 = (*s_9).v; + let _e325 = (*s_9).n; + let _e326 = ior_1; + let _e327 = spread_1; + local_34 = refract(-(_e322), _e325, (1f / max((_e326 - _e327), 1f))); + } + let _e332 = local_34; + red = _e332; + let _e333 = thin; + if _e333 { + let _e335 = (*s_9).v; + local_35 = -(_e335); + } else { + let _e338 = (*s_9).v; + let _e341 = (*s_9).n; + let _e342 = ior_1; + local_35 = refract(-(_e338), _e341, (1f / _e342)); + } + let _e345 = local_35; + green = _e345; + let _e346 = thin; + if _e346 { + let _e348 = (*s_9).v; + local_36 = -(_e348); + } else { + let _e351 = (*s_9).v; + let _e354 = (*s_9).n; + let _e355 = ior_1; + let _e356 = spread_1; + local_36 = refract(-(_e351), _e354, (1f / (_e355 + _e356))); + } + let _e360 = local_36; + blue = _e360; + let _e361 = v_world_position_1; + let _e362 = red; + let _e363 = g_thickness; + param_196 = (_e361 + (_e362 * _e363)); + let _e366 = lod_1; + param_197 = _e366; + let _e367 = SceneColourAt_u0028_vf3_u003b_f1_u003b((¶m_196), (¶m_197)); + let _e369 = v_world_position_1; + let _e370 = green; + let _e371 = g_thickness; + param_198 = (_e369 + (_e370 * _e371)); + let _e374 = lod_1; + param_199 = _e374; + let _e375 = SceneColourAt_u0028_vf3_u003b_f1_u003b((¶m_198), (¶m_199)); + let _e377 = v_world_position_1; + let _e378 = blue; + let _e379 = g_thickness; + param_200 = (_e377 + (_e378 * _e379)); + let _e382 = lod_1; + param_201 = _e382; + let _e383 = SceneColourAt_u0028_vf3_u003b_f1_u003b((¶m_200), (¶m_201)); + return vec3(_e367.x, _e375.y, _e383.z); +} + +fn TransmittedRadiance_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b_f1_u003b(s_10: ptr, levels: ptr) -> vec3 { var ior_2: f32; var spread_2: f32; - var param_195: f32; + var param_202: f32; var lod_2: f32; var thin_1: bool; var red_1: vec3; - var local_32: vec3; + var local_37: vec3; var green_1: vec3; - var local_33: vec3; + var local_38: vec3; var blue_1: vec3; - var local_34: vec3; + var local_39: vec3; - let _e274 = RefractionIor_u0028_(); - ior_2 = _e274; - let _e275 = ior_2; - param_195 = _e275; - let _e276 = DispersionSpread_u0028_f1_u003b((¶m_195)); - spread_2 = _e276; - let _e278 = (*s_9).roughness; - let _e279 = ior_2; - let _e284 = (*levels); - lod_2 = ((_e278 * clamp(((_e279 * 2f) - 2f), 0f, 1f)) * _e284); - let _e286 = g_thickness; - thin_1 = (_e286 <= 0f); - let _e288 = thin_1; - if _e288 { - let _e290 = (*s_9).v; - local_32 = -(_e290); - } else { - let _e293 = (*s_9).v; - let _e296 = (*s_9).n; - let _e297 = ior_2; - let _e298 = spread_2; - local_32 = refract(-(_e293), _e296, (1f / max((_e297 - _e298), 1f))); - } - let _e303 = local_32; - red_1 = _e303; - let _e304 = thin_1; - if _e304 { - let _e306 = (*s_9).v; - local_33 = -(_e306); - } else { - let _e309 = (*s_9).v; - let _e312 = (*s_9).n; - let _e313 = ior_2; - local_33 = refract(-(_e309), _e312, (1f / _e313)); - } - let _e316 = local_33; - green_1 = _e316; - let _e317 = thin_1; - if _e317 { - let _e319 = (*s_9).v; - local_34 = -(_e319); + let _e286 = RefractionIor_u0028_(); + ior_2 = _e286; + let _e287 = ior_2; + param_202 = _e287; + let _e288 = DispersionSpread_u0028_f1_u003b((¶m_202)); + spread_2 = _e288; + let _e290 = (*s_10).roughness; + let _e291 = ior_2; + let _e296 = (*levels); + lod_2 = ((_e290 * clamp(((_e291 * 2f) - 2f), 0f, 1f)) * _e296); + let _e298 = g_thickness; + thin_1 = (_e298 <= 0f); + let _e300 = thin_1; + if _e300 { + let _e302 = (*s_10).v; + local_37 = -(_e302); } else { - let _e322 = (*s_9).v; - let _e325 = (*s_9).n; - let _e326 = ior_2; - let _e327 = spread_2; - local_34 = refract(-(_e322), _e325, (1f / (_e326 + _e327))); - } - let _e331 = local_34; - blue_1 = _e331; - let _e332 = red_1; - let _e333 = lod_2; - let _e334 = textureSampleLevel(environment_texture_tex, environment_texture_smp, _e332, _e333); - let _e336 = green_1; - let _e337 = lod_2; - let _e338 = textureSampleLevel(environment_texture_tex, environment_texture_smp, _e336, _e337); - let _e340 = blue_1; - let _e341 = lod_2; - let _e342 = textureSampleLevel(environment_texture_tex, environment_texture_smp, _e340, _e341); - return vec3(_e334.x, _e338.y, _e342.z); + let _e305 = (*s_10).v; + let _e308 = (*s_10).n; + let _e309 = ior_2; + let _e310 = spread_2; + local_37 = refract(-(_e305), _e308, (1f / max((_e309 - _e310), 1f))); + } + let _e315 = local_37; + red_1 = _e315; + let _e316 = thin_1; + if _e316 { + let _e318 = (*s_10).v; + local_38 = -(_e318); + } else { + let _e321 = (*s_10).v; + let _e324 = (*s_10).n; + let _e325 = ior_2; + local_38 = refract(-(_e321), _e324, (1f / _e325)); + } + let _e328 = local_38; + green_1 = _e328; + let _e329 = thin_1; + if _e329 { + let _e331 = (*s_10).v; + local_39 = -(_e331); + } else { + let _e334 = (*s_10).v; + let _e337 = (*s_10).n; + let _e338 = ior_2; + let _e339 = spread_2; + local_39 = refract(-(_e334), _e337, (1f / (_e338 + _e339))); + } + let _e343 = local_39; + blue_1 = _e343; + let _e344 = red_1; + let _e345 = lod_2; + let _e346 = textureSampleLevel(environment_texture_tex, environment_texture_smp, _e344, _e345); + let _e348 = green_1; + let _e349 = lod_2; + let _e350 = textureSampleLevel(environment_texture_tex, environment_texture_smp, _e348, _e349); + let _e352 = blue_1; + let _e353 = lod_2; + let _e354 = textureSampleLevel(environment_texture_tex, environment_texture_smp, _e352, _e353); + return vec3(_e346.x, _e350.y, _e354.z); } fn SceneColourBound_u0028_() -> bool { - let _e263 = layer_info.scene_colour[0u]; - return (_e263 > 0f); + let _e275 = layer_info.scene_colour[0u]; + return (_e275 > 0f); } fn EonAlbedo_u0028_vf3_u003b_f1_u003b_f1_u003b(rho_2: ptr>, r_4: ptr, mu_1: ptr) -> vec3 { var e_1: f32; - var param_196: f32; - var param_197: f32; - var param_198: f32; - var param_199: vec3; - var param_200: f32; + var param_203: f32; + var param_204: f32; + var param_205: f32; + var param_206: vec3; + var param_207: f32; - let _e270 = (*mu_1); - param_196 = _e270; - let _e271 = (*r_4); - param_197 = _e271; - let _e272 = FonAlbedo_u0028_f1_u003b_f1_u003b((¶m_196), (¶m_197)); - e_1 = _e272; - let _e273 = (*rho_2); - let _e274 = e_1; - let _e276 = (*r_4); - param_198 = _e276; - let _e277 = FonAverage_u0028_f1_u003b((¶m_198)); - let _e278 = (*rho_2); - param_199 = _e278; - param_200 = _e277; - let _e279 = EonMultiAlbedo_u0028_vf3_u003b_f1_u003b((¶m_199), (¶m_200)); - let _e280 = e_1; - return ((_e273 * _e274) + (_e279 * (1f - _e280))); + let _e282 = (*mu_1); + param_203 = _e282; + let _e283 = (*r_4); + param_204 = _e283; + let _e284 = FonAlbedo_u0028_f1_u003b_f1_u003b((¶m_203), (¶m_204)); + e_1 = _e284; + let _e285 = (*rho_2); + let _e286 = e_1; + let _e288 = (*r_4); + param_205 = _e288; + let _e289 = FonAverage_u0028_f1_u003b((¶m_205)); + let _e290 = (*rho_2); + param_206 = _e290; + param_207 = _e289; + let _e291 = EonMultiAlbedo_u0028_vf3_u003b_f1_u003b((¶m_206), (¶m_207)); + let _e292 = e_1; + return ((_e285 * _e286) + (_e291 * (1f - _e292))); } fn IridescenceSensitivity_u0028_f1_u003b_vf3_u003b(opd: ptr, shift: ptr>) -> vec3 { @@ -19190,30 +21548,30 @@ fn IridescenceSensitivity_u0028_f1_u003b_vf3_u003b(opd: ptr, shif var variance_1: vec3; var xyz: vec3; - let _e268 = (*opd); - phase = ((6.2831855f * _e268) * 0.000000001f); + let _e280 = (*opd); + phase = ((6.2831855f * _e280) * 0.000000001f); val = vec3(0.00000000000054856f, 0.00000000000044201f, 0.00000000000052481f); pos = vec3(1681000f, 1795300f, 2208400f); variance_1 = vec3(4327800000f, 9304600000f, 6612100000f); - let _e271 = val; - let _e272 = variance_1; - let _e276 = pos; - let _e277 = phase; - let _e279 = (*shift); - let _e283 = phase; - let _e284 = phase; - let _e287 = variance_1; - xyz = (((_e271 * sqrt((_e272 * 6.2831855f))) * cos(((_e276 * _e277) + _e279))) * exp((_e287 * -((_e283 * _e284))))); - let _e291 = phase; - let _e294 = (*shift)[0u]; - let _e298 = phase; - let _e300 = phase; - let _e305 = xyz[0u]; - xyz[0u] = (_e305 + ((0.00000001644083f * cos(((2239900f * _e291) + _e294))) * exp(((-4528200000f * _e298) * _e300)))); - let _e308 = xyz; - xyz = (_e308 / vec3(0.00000010685f)); - let _e311 = xyz; - return (mat3x3(vec3(3.2404542f, -0.969266f, 0.0556434f), vec3(-1.5371385f, 1.8760108f, -0.2040259f), vec3(-0.4985314f, 0.041556f, 1.0572252f)) * _e311); + let _e283 = val; + let _e284 = variance_1; + let _e288 = pos; + let _e289 = phase; + let _e291 = (*shift); + let _e295 = phase; + let _e296 = phase; + let _e299 = variance_1; + xyz = (((_e283 * sqrt((_e284 * 6.2831855f))) * cos(((_e288 * _e289) + _e291))) * exp((_e299 * -((_e295 * _e296))))); + let _e303 = phase; + let _e306 = (*shift)[0u]; + let _e310 = phase; + let _e312 = phase; + let _e317 = xyz[0u]; + xyz[0u] = (_e317 + ((0.00000001644083f * cos(((2239900f * _e303) + _e306))) * exp(((-4528200000f * _e310) * _e312)))); + let _e320 = xyz; + xyz = (_e320 / vec3(0.00000010685f)); + let _e323 = xyz; + return (mat3x3(vec3(3.2404542f, -0.969266f, 0.0556434f), vec3(-1.5371385f, 1.8760108f, -0.2040259f), vec3(-0.4985314f, 0.041556f, 1.0572252f)) * _e323); } fn FresnelIridescence_u0028_f1_u003b_f1_u003b_f1_u003b_vf3_u003b(film: ptr, cos1_: ptr, thickness: ptr, base: ptr>) -> vec3 { @@ -19239,312 +21597,291 @@ fn FresnelIridescence_u0028_f1_u003b_f1_u003b_f1_u003b_vf3_u003b(film: ptr; var cm: vec3; var m_1: i32; - var param_201: f32; - var param_202: vec3; - var local_35: vec3; + var param_208: f32; + var param_209: vec3; + var local_40: vec3; - let _e290 = (*film); - let _e291 = (*thickness); - eta2_ = mix(1f, _e290, smoothstep(0f, 0.03f, _e291)); - let _e294 = (*cos1_); - let _e295 = (*cos1_); - let _e298 = eta2_; - let _e299 = eta2_; - sin2Sq = ((1f - (_e294 * _e295)) / (_e298 * _e299)); - let _e302 = sin2Sq; - cos2Sq = (1f - _e302); - let _e304 = cos2Sq; - cos2_ = sqrt(max(_e304, 0f)); - let _e307 = eta2_; - let _e309 = eta2_; - r0_ = ((_e307 - 1f) / (_e309 + 1f)); - let _e312 = r0_; - let _e313 = r0_; - let _e315 = r0_; - let _e316 = r0_; - let _e319 = (*cos1_); - r12_ = ((_e312 * _e313) + ((1f - (_e315 * _e316)) * pow((1f - _e319), 5f))); - let _e324 = r12_; - t121_ = (1f - _e324); - let _e326 = eta2_; - phi12_ = select(0f, 3.1415927f, (_e326 < 1f)); - let _e329 = phi12_; - phi21_ = (3.1415927f - _e329); - let _e331 = (*base); - sqrtBase = sqrt(clamp(_e331, vec3(0f, 0f, 0f), vec3(0.9999f, 0.9999f, 0.9999f))); - let _e334 = sqrtBase; - let _e336 = sqrtBase; - baseIor = ((vec3(1f, 1f, 1f) + _e334) / (vec3(1f, 1f, 1f) - _e336)); - let _e339 = baseIor; - let _e340 = eta2_; - let _e343 = baseIor; - let _e344 = eta2_; - r1_ = ((_e339 - vec3(_e340)) / (_e343 + vec3(_e344))); - let _e348 = r1_; - let _e349 = r1_; - r1_ = (_e349 * _e348); - let _e351 = r1_; - let _e352 = r1_; - let _e354 = cos2_; - r23_ = (_e351 + ((vec3(1f, 1f, 1f) - _e352) * pow((1f - _e354), 5f))); - let _e360 = baseIor[0u]; - let _e361 = eta2_; - let _e365 = baseIor[1u]; - let _e366 = eta2_; - let _e370 = baseIor[2u]; - let _e371 = eta2_; - phi23_ = vec3(select(0f, 3.1415927f, (_e360 < _e361)), select(0f, 3.1415927f, (_e365 < _e366)), select(0f, 3.1415927f, (_e370 < _e371))); - let _e375 = eta2_; - let _e377 = (*thickness); - let _e379 = cos2_; - opd_1 = (((2f * _e375) * _e377) * _e379); - let _e381 = phi21_; - let _e383 = phi23_; - phi = (vec3(_e381) + _e383); - let _e385 = r12_; - let _e386 = r23_; - r123_ = clamp((_e386 * _e385), vec3(0.00001f, 0.00001f, 0.00001f), vec3(0.9999f, 0.9999f, 0.9999f)); - let _e389 = r123_; - rootR123_ = sqrt(_e389); - let _e391 = t121_; - let _e392 = t121_; - let _e394 = r23_; - let _e396 = r123_; - rs = ((_e394 * (_e391 * _e392)) / (vec3(1f, 1f, 1f) - _e396)); - let _e399 = r12_; - let _e401 = rs; - total_2 = (vec3(_e399) + _e401); - let _e403 = rs; + let _e302 = (*film); + let _e303 = (*thickness); + eta2_ = mix(1f, _e302, smoothstep(0f, 0.03f, _e303)); + let _e306 = (*cos1_); + let _e307 = (*cos1_); + let _e310 = eta2_; + let _e311 = eta2_; + sin2Sq = ((1f - (_e306 * _e307)) / (_e310 * _e311)); + let _e314 = sin2Sq; + cos2Sq = (1f - _e314); + let _e316 = cos2Sq; + cos2_ = sqrt(max(_e316, 0f)); + let _e319 = eta2_; + let _e321 = eta2_; + r0_ = ((_e319 - 1f) / (_e321 + 1f)); + let _e324 = r0_; + let _e325 = r0_; + let _e327 = r0_; + let _e328 = r0_; + let _e331 = (*cos1_); + r12_ = ((_e324 * _e325) + ((1f - (_e327 * _e328)) * pow((1f - _e331), 5f))); + let _e336 = r12_; + t121_ = (1f - _e336); + let _e338 = eta2_; + phi12_ = select(0f, 3.1415927f, (_e338 < 1f)); + let _e341 = phi12_; + phi21_ = (3.1415927f - _e341); + let _e343 = (*base); + sqrtBase = sqrt(clamp(_e343, vec3(0f, 0f, 0f), vec3(0.9999f, 0.9999f, 0.9999f))); + let _e346 = sqrtBase; + let _e348 = sqrtBase; + baseIor = ((vec3(1f, 1f, 1f) + _e346) / (vec3(1f, 1f, 1f) - _e348)); + let _e351 = baseIor; + let _e352 = eta2_; + let _e355 = baseIor; + let _e356 = eta2_; + r1_ = ((_e351 - vec3(_e352)) / (_e355 + vec3(_e356))); + let _e360 = r1_; + let _e361 = r1_; + r1_ = (_e361 * _e360); + let _e363 = r1_; + let _e364 = r1_; + let _e366 = cos2_; + r23_ = (_e363 + ((vec3(1f, 1f, 1f) - _e364) * pow((1f - _e366), 5f))); + let _e372 = baseIor[0u]; + let _e373 = eta2_; + let _e377 = baseIor[1u]; + let _e378 = eta2_; + let _e382 = baseIor[2u]; + let _e383 = eta2_; + phi23_ = vec3(select(0f, 3.1415927f, (_e372 < _e373)), select(0f, 3.1415927f, (_e377 < _e378)), select(0f, 3.1415927f, (_e382 < _e383))); + let _e387 = eta2_; + let _e389 = (*thickness); + let _e391 = cos2_; + opd_1 = (((2f * _e387) * _e389) * _e391); + let _e393 = phi21_; + let _e395 = phi23_; + phi = (vec3(_e393) + _e395); + let _e397 = r12_; + let _e398 = r23_; + r123_ = clamp((_e398 * _e397), vec3(0.00001f, 0.00001f, 0.00001f), vec3(0.9999f, 0.9999f, 0.9999f)); + let _e401 = r123_; + rootR123_ = sqrt(_e401); + let _e403 = t121_; let _e404 = t121_; - cm = (_e403 - vec3(_e404)); + let _e406 = r23_; + let _e408 = r123_; + rs = ((_e406 * (_e403 * _e404)) / (vec3(1f, 1f, 1f) - _e408)); + let _e411 = r12_; + let _e413 = rs; + total_2 = (vec3(_e411) + _e413); + let _e415 = rs; + let _e416 = t121_; + cm = (_e415 - vec3(_e416)); m_1 = 1i; loop { - let _e407 = m_1; - if (_e407 <= 2i) { - let _e409 = rootR123_; - let _e410 = cm; - cm = (_e410 * _e409); - let _e412 = cm; - let _e414 = m_1; - let _e416 = opd_1; - let _e418 = m_1; - let _e420 = phi; - param_201 = (f32(_e414) * _e416); - param_202 = (_e420 * f32(_e418)); - let _e422 = IridescenceSensitivity_u0028_f1_u003b_vf3_u003b((¶m_201), (¶m_202)); - let _e424 = total_2; - total_2 = (_e424 + ((_e412 * 2f) * _e422)); + let _e419 = m_1; + if (_e419 <= 2i) { + let _e421 = rootR123_; + let _e422 = cm; + cm = (_e422 * _e421); + let _e424 = cm; + let _e426 = m_1; + let _e428 = opd_1; + let _e430 = m_1; + let _e432 = phi; + param_208 = (f32(_e426) * _e428); + param_209 = (_e432 * f32(_e430)); + let _e434 = IridescenceSensitivity_u0028_f1_u003b_vf3_u003b((¶m_208), (¶m_209)); + let _e436 = total_2; + total_2 = (_e436 + ((_e424 * 2f) * _e434)); continue; } else { break; } continuing { - let _e426 = m_1; - m_1 = (_e426 + 1i); + let _e438 = m_1; + m_1 = (_e438 + 1i); } } - let _e428 = cos2Sq; - if (_e428 < 0f) { - local_35 = vec3(1f, 1f, 1f); + let _e440 = cos2Sq; + if (_e440 < 0f) { + local_40 = vec3(1f, 1f, 1f); } else { - let _e430 = total_2; - local_35 = max(_e430, vec3(0f, 0f, 0f)); + let _e442 = total_2; + local_40 = max(_e442, vec3(0f, 0f, 0f)); } - let _e432 = local_35; - return _e432; + let _e444 = local_40; + return _e444; } -fn ReadIridescence_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b(s_10: ptr) { +fn ReadIridescence_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b(s_11: ptr) { var f0_3: vec3; - var param_203: f32; - var param_204: f32; - var param_205: f32; - var param_206: vec3; - - let _e267 = g_f0_dielectric; - let _e269 = (*s_10).albedo; - let _e271 = (*s_10).metallic; - f0_3 = mix(_e267, _e269, vec3(clamp(_e271, 0f, 1f))); - let _e277 = layer_info.iridescence[1u]; - param_203 = _e277; - let _e279 = (*s_10).n_dot_v; - param_204 = _e279; - let _e282 = layer_info.iridescence[2u]; - param_205 = _e282; - let _e283 = f0_3; - param_206 = _e283; - let _e284 = FresnelIridescence_u0028_f1_u003b_f1_u003b_f1_u003b_vf3_u003b((¶m_203), (¶m_204), (¶m_205), (¶m_206)); - g_irid_fresnel = _e284; + var param_210: f32; + var param_211: f32; + var param_212: f32; + var param_213: vec3; + + let _e279 = g_f0_dielectric; + let _e281 = (*s_11).albedo; + let _e283 = (*s_11).metallic; + f0_3 = mix(_e279, _e281, vec3(clamp(_e283, 0f, 1f))); + let _e289 = layer_info.iridescence[1u]; + param_210 = _e289; + let _e291 = (*s_11).n_dot_v; + param_211 = _e291; + let _e294 = layer_info.iridescence[2u]; + param_212 = _e294; + let _e295 = f0_3; + param_213 = _e295; + let _e296 = FresnelIridescence_u0028_f1_u003b_f1_u003b_f1_u003b_vf3_u003b((¶m_210), (¶m_211), (¶m_212), (¶m_213)); + g_irid_fresnel = _e296; return; } -fn ReadLayersOnMaps_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b(s_11: ptr) { - var param_207: f32; - var param_208: f32; - var param_209: f32; +fn ReadLayersOnMaps_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b(s_12: ptr) { + var param_214: f32; + var param_215: f32; + var param_216: f32; var t_2: vec3; var usable: bool; - var local_36: vec3; + var local_41: vec3; var b_4: vec3; var turn_2: vec2; var along_1: vec3; - var local_37: f32; - var local_38: vec3; - var phi_4683_: bool; - - let _e274 = (*s_11).n_dot_v; - let _e278 = g_sheen_roughness; - param_207 = _e278; - param_208 = sqrt(clamp((1f - _e274), 0f, 1f)); - param_209 = 1f; - let _e279 = LtcUv_u0028_f1_u003b_f1_u003b_f1_u003b((¶m_207), (¶m_208), (¶m_209)); - let _e280 = textureSampleLevel(ltc_texture_tex, ltc_texture_smp, _e279, 0f); - g_sheen_albedo = _e280.z; - let _e283 = g_sheen[0u]; - let _e285 = g_sheen[1u]; - let _e288 = g_sheen[2u]; - let _e290 = g_sheen_albedo; - g_sheen_scale = (1f - (max(max(_e283, _e285), _e288) * _e290)); - let _e293 = v_tangent_1; - let _e296 = (*s_11).n; - let _e298 = (*s_11).n; - let _e299 = v_tangent_1; - t_2 = (_e293.xyz - (_e296 * dot(_e298, _e299.xyz))); - let _e304 = t_2; - let _e305 = t_2; - usable = (dot(_e304, _e305) > 0.000000000001f); - let _e308 = usable; - if _e308 { - let _e309 = t_2; - local_36 = normalize(_e309); - } else { - local_36 = vec3(1f, 0f, 0f); - } - let _e311 = local_36; - t_2 = _e311; - let _e313 = (*s_11).n; - let _e314 = t_2; - let _e317 = v_tangent_1[3u]; - b_4 = (cross(_e313, _e314) * _e317); - let _e319 = gl_FrontFacing_1; - if !(_e319) { + var local_42: f32; + var local_43: vec3; + var phi_5014_: bool; + + let _e286 = (*s_12).n_dot_v; + let _e290 = g_sheen_roughness; + param_214 = _e290; + param_215 = sqrt(clamp((1f - _e286), 0f, 1f)); + param_216 = 1f; + let _e291 = LtcUv_u0028_f1_u003b_f1_u003b_f1_u003b((¶m_214), (¶m_215), (¶m_216)); + let _e292 = textureSampleLevel(ltc_texture_tex, ltc_texture_smp, _e291, 0f); + g_sheen_albedo = _e292.z; + let _e295 = g_sheen[0u]; + let _e297 = g_sheen[1u]; + let _e300 = g_sheen[2u]; + let _e302 = g_sheen_albedo; + g_sheen_scale = (1f - (max(max(_e295, _e297), _e300) * _e302)); + let _e305 = v_tangent_1; + let _e308 = (*s_12).n; + let _e310 = (*s_12).n; + let _e311 = v_tangent_1; + t_2 = (_e305.xyz - (_e308 * dot(_e310, _e311.xyz))); + let _e316 = t_2; + let _e317 = t_2; + usable = (dot(_e316, _e317) > 0.000000000001f); + let _e320 = usable; + if _e320 { let _e321 = t_2; - t_2 = -(_e321); + local_41 = normalize(_e321); + } else { + local_41 = vec3(1f, 0f, 0f); } - let _e324 = layer_info.anisotropy; - turn_2 = _e324.yz; + let _e323 = local_41; + t_2 = _e323; + let _e325 = (*s_12).n; let _e326 = t_2; - let _e328 = turn_2[0u]; - let _e330 = b_4; - let _e332 = turn_2[1u]; - along_1 = ((_e326 * _e328) + (_e330 * _e332)); - let _e335 = usable; - phi_4683_ = _e335; - if _e335 { - let _e336 = along_1; - let _e337 = along_1; - phi_4683_ = (dot(_e336, _e337) > 0.000000000001f); - } - let _e341 = phi_4683_; - if _e341 { - let _e344 = layer_info.anisotropy[0u]; - local_37 = clamp(_e344, 0f, 1f); - } else { - local_37 = 0f; - } - let _e346 = local_37; - g_aniso = _e346; - let _e347 = g_aniso; - if (_e347 > 0f) { + let _e329 = v_tangent_1[3u]; + b_4 = (cross(_e325, _e326) * _e329); + let _e331 = gl_FrontFacing_1; + if !(_e331) { + let _e333 = t_2; + t_2 = -(_e333); + } + let _e336 = layer_info.anisotropy; + turn_2 = _e336.yz; + let _e338 = t_2; + let _e340 = turn_2[0u]; + let _e342 = b_4; + let _e344 = turn_2[1u]; + along_1 = ((_e338 * _e340) + (_e342 * _e344)); + let _e347 = usable; + phi_5014_ = _e347; + if _e347 { + let _e348 = along_1; let _e349 = along_1; - local_38 = normalize(_e349); - } else { - let _e351 = t_2; - local_38 = _e351; + phi_5014_ = (dot(_e348, _e349) > 0.000000000001f); } - let _e352 = local_38; - g_aniso_t = _e352; - let _e354 = (*s_11).n; - let _e355 = g_aniso_t; - g_aniso_b = cross(_e354, _e355); + let _e353 = phi_5014_; + if _e353 { + let _e356 = layer_info.anisotropy[0u]; + local_42 = clamp(_e356, 0f, 1f); + } else { + local_42 = 0f; + } + let _e358 = local_42; + g_aniso = _e358; + let _e359 = g_aniso; + if (_e359 > 0f) { + let _e361 = along_1; + local_43 = normalize(_e361); + } else { + let _e363 = t_2; + local_43 = _e363; + } + let _e364 = local_43; + g_aniso_t = _e364; + let _e366 = (*s_12).n; + let _e367 = g_aniso_t; + g_aniso_b = cross(_e366, _e367); return; } fn MaterialLodBias_u0028_() -> f32 { - let _e263 = frag_info.target_origin[1u]; - return _e263; + let _e275 = frag_info.target_origin[1u]; + return _e275; } -fn MapUv_u0028_i1_u003b(slot_6: ptr) -> vec2 { - var uvw: vec3; - - let _e263 = v_texcoord_1; - uvw = vec3(_e263.x, _e263.y, 1f); - let _e267 = (*slot_6); - let _e271 = layer_info.uv_transform[(_e267 * 2i)]; - let _e273 = uvw; - let _e275 = (*slot_6); - let _e280 = layer_info.uv_transform[((_e275 * 2i) + 1i)]; - let _e282 = uvw; - return vec2(dot(_e271.xyz, _e273), dot(_e280.xyz, _e282)); -} - -fn ApplyMetallicRoughnessMap_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b(s_12: ptr) { +fn ApplyMetallicRoughnessMap_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b(s_13: ptr) { var orm: vec3; - var param_210: i32; - - param_210 = 1i; - let _e264 = MapUv_u0028_i1_u003b((¶m_210)); - let _e265 = MaterialLodBias_u0028_(); - let _e266 = textureSampleBias(metallic_roughness_texture_tex, metallic_roughness_texture_smp, _e264, _e265); - orm = _e266.xyz; - let _e269 = (*s_12).metallic; - let _e271 = orm[2u]; - (*s_12).metallic = clamp((_e269 * _e271), 0f, 1f); - let _e276 = (*s_12).roughness; - let _e278 = orm[1u]; - (*s_12).roughness = clamp((_e276 * _e278), 0.02f, 1f); + var param_217: i32; + + param_217 = 1i; + let _e276 = MapUv_u0028_i1_u003b((¶m_217)); + let _e277 = MaterialLodBias_u0028_(); + let _e278 = textureSampleBias(metallic_roughness_texture_tex, metallic_roughness_texture_smp, _e276, _e277); + orm = _e278.xyz; + let _e281 = (*s_13).metallic; + let _e283 = orm[2u]; + (*s_13).metallic = clamp((_e281 * _e283), 0f, 1f); + let _e288 = (*s_13).roughness; + let _e290 = orm[1u]; + (*s_13).roughness = clamp((_e288 * _e290), 0.02f, 1f); return; } -fn SrgbToLinear_u0028_vf3_u003b(srgb: ptr>) -> vec3 { - let _e262 = (*srgb); - let _e265 = (*srgb); - let _e270 = (*srgb); - return mix((_e262 / vec3(12.92f)), pow(((_e265 + vec3(0.055f, 0.055f, 0.055f)) / vec3(1.055f)), vec3(2.4f, 2.4f, 2.4f)), step(vec3(0.04045f, 0.04045f, 0.04045f), _e270)); -} - -fn ApplyEmissiveMap_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b(s_13: ptr) { +fn ApplyEmissiveMap_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b(s_14: ptr) { var emissive: vec3; - var param_211: i32; - var param_212: vec3; - - param_211 = 4i; - let _e265 = MapUv_u0028_i1_u003b((¶m_211)); - let _e266 = MaterialLodBias_u0028_(); - let _e267 = textureSampleBias(emissive_texture_tex, emissive_texture_smp, _e265, _e266); - param_212 = _e267.xyz; - let _e269 = SrgbToLinear_u0028_vf3_u003b((¶m_212)); - emissive = _e269; - let _e270 = emissive; - let _e272 = frag_info.emissive; - let _e277 = frag_info.material2_[3u]; - (*s_13).emissive = ((_e270 * _e272.xyz) * _e277); + var param_218: i32; + var param_219: vec3; + + param_218 = 4i; + let _e277 = MapUv_u0028_i1_u003b((¶m_218)); + let _e278 = MaterialLodBias_u0028_(); + let _e279 = textureSampleBias(emissive_texture_tex, emissive_texture_smp, _e277, _e278); + param_219 = _e279.xyz; + let _e281 = SrgbToLinear_u0028_vf3_u003b((¶m_219)); + emissive = _e281; + let _e282 = emissive; + let _e284 = frag_info.emissive; + let _e289 = frag_info.material2_[3u]; + (*s_14).emissive = ((_e282 * _e284.xyz) * _e289); return; } -fn ApplyOcclusionMap_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b(s_14: ptr) { +fn ApplyOcclusionMap_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b(s_15: ptr) { var occlusion: f32; - var param_213: i32; - - param_213 = 3i; - let _e264 = MapUv_u0028_i1_u003b((¶m_213)); - let _e265 = MaterialLodBias_u0028_(); - let _e266 = textureSampleBias(occlusion_texture_tex, occlusion_texture_smp, _e264, _e265); - occlusion = _e266.x; - let _e268 = occlusion; - let _e271 = frag_info.material2_[2u]; - (*s_14).occlusion = mix(1f, _e268, clamp(_e271, 0f, 1f)); + var param_220: i32; + + param_220 = 3i; + let _e276 = MapUv_u0028_i1_u003b((¶m_220)); + let _e277 = MaterialLodBias_u0028_(); + let _e278 = textureSampleBias(occlusion_texture_tex, occlusion_texture_smp, _e276, _e277); + occlusion = _e278.x; + let _e280 = occlusion; + let _e283 = frag_info.material2_[2u]; + (*s_15).occlusion = mix(1f, _e280, clamp(_e283, 0f, 1f)); return; } @@ -19552,176 +21889,176 @@ fn MapMatrix_u0028_i1_u003b(slot_7: ptr) -> vec4 { var u_2: vec4; var v_3: vec4; - let _e264 = (*slot_7); - let _e268 = layer_info.uv_transform[(_e264 * 2i)]; - u_2 = _e268; - let _e269 = (*slot_7); - let _e274 = layer_info.uv_transform[((_e269 * 2i) + 1i)]; - v_3 = _e274; - let _e276 = u_2[0u]; - let _e278 = u_2[1u]; - let _e280 = v_3[0u]; - let _e282 = v_3[1u]; - return vec4(_e276, _e278, _e280, _e282); + let _e276 = (*slot_7); + let _e280 = layer_info.uv_transform[(_e276 * 2i)]; + u_2 = _e280; + let _e281 = (*slot_7); + let _e286 = layer_info.uv_transform[((_e281 * 2i) + 1i)]; + v_3 = _e286; + let _e288 = u_2[0u]; + let _e290 = u_2[1u]; + let _e292 = v_3[0u]; + let _e294 = v_3[1u]; + return vec4(_e288, _e290, _e292, _e294); } -fn ApplyNormalMap_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b(s_15: ptr) { +fn ApplyNormalMap_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b(s_16: ptr) { var sampledTexel: vec4; - var param_214: i32; + var param_221: i32; var t_3: vec3; var b_5: vec3; var m_2: vec4; - var param_215: i32; + var param_222: i32; var det: f32; var flip: f32; var front: vec3; - var local_39: vec3; + var local_44: vec3; var turned: vec3; var turns: bool; - var local_40: vec3; - var local_41: vec3; + var local_45: vec3; + var local_46: vec3; var sampled: vec3; - var phi_3502_: bool; - var phi_3509_: bool; - var phi_3516_: bool; - var phi_3523_: bool; + var phi_3766_: bool; + var phi_3773_: bool; + var phi_3780_: bool; + var phi_3787_: bool; - param_214 = 2i; - let _e277 = MapUv_u0028_i1_u003b((¶m_214)); - let _e278 = MaterialLodBias_u0028_(); - let _e279 = textureSampleBias(normal_texture_tex, normal_texture_smp, _e277, _e278); - sampledTexel = _e279; - let _e280 = v_tangent_1; - t_3 = _e280.xyz; - let _e282 = t_3; - let _e284 = (*s_15).n; - let _e286 = (*s_15).n; - let _e287 = t_3; - t_3 = (_e282 - (_e284 * dot(_e286, _e287))); - let _e291 = t_3; - let _e292 = t_3; - if (dot(_e291, _e292) < 0.000000000001f) { + param_221 = 2i; + let _e289 = MapUv_u0028_i1_u003b((¶m_221)); + let _e290 = MaterialLodBias_u0028_(); + let _e291 = textureSampleBias(normal_texture_tex, normal_texture_smp, _e289, _e290); + sampledTexel = _e291; + let _e292 = v_tangent_1; + t_3 = _e292.xyz; + let _e294 = t_3; + let _e296 = (*s_16).n; + let _e298 = (*s_16).n; + let _e299 = t_3; + t_3 = (_e294 - (_e296 * dot(_e298, _e299))); + let _e303 = t_3; + let _e304 = t_3; + if (dot(_e303, _e304) < 0.000000000001f) { return; } - let _e295 = t_3; - t_3 = normalize(_e295); - let _e298 = (*s_15).n; - let _e299 = t_3; - let _e302 = v_tangent_1[3u]; - b_5 = (cross(_e298, _e299) * _e302); - param_215 = 2i; - let _e304 = MapMatrix_u0028_i1_u003b((¶m_215)); - m_2 = _e304; - let _e306 = m_2[0u]; - let _e308 = m_2[3u]; - let _e311 = m_2[1u]; - let _e313 = m_2[2u]; - det = ((_e306 * _e308) - (_e311 * _e313)); - let _e316 = det; - flip = select(1f, -1f, (_e316 < 0f)); - let _e319 = gl_FrontFacing_1; - if _e319 { - let _e320 = b_5; - local_39 = _e320; - } else { - let _e321 = b_5; - local_39 = -(_e321); - } - let _e323 = local_39; - front = _e323; - let _e324 = t_3; - let _e326 = m_2[3u]; - let _e328 = front; - let _e330 = m_2[2u]; - let _e333 = flip; - turned = (((_e324 * _e326) + (_e328 * _e330)) * _e333); - let _e336 = m_2[1u]; - let _e337 = (_e336 != 0f); - phi_3502_ = _e337; - if !(_e337) { - let _e340 = m_2[2u]; - phi_3502_ = (_e340 != 0f); - } - let _e343 = phi_3502_; - phi_3509_ = _e343; - if !(_e343) { - let _e346 = m_2[0u]; - phi_3509_ = (_e346 < 0f); - } - let _e349 = phi_3509_; - phi_3516_ = _e349; + let _e307 = t_3; + t_3 = normalize(_e307); + let _e310 = (*s_16).n; + let _e311 = t_3; + let _e314 = v_tangent_1[3u]; + b_5 = (cross(_e310, _e311) * _e314); + param_222 = 2i; + let _e316 = MapMatrix_u0028_i1_u003b((¶m_222)); + m_2 = _e316; + let _e318 = m_2[0u]; + let _e320 = m_2[3u]; + let _e323 = m_2[1u]; + let _e325 = m_2[2u]; + det = ((_e318 * _e320) - (_e323 * _e325)); + let _e328 = det; + flip = select(1f, -1f, (_e328 < 0f)); + let _e331 = gl_FrontFacing_1; + if _e331 { + let _e332 = b_5; + local_44 = _e332; + } else { + let _e333 = b_5; + local_44 = -(_e333); + } + let _e335 = local_44; + front = _e335; + let _e336 = t_3; + let _e338 = m_2[3u]; + let _e340 = front; + let _e342 = m_2[2u]; + let _e345 = flip; + turned = (((_e336 * _e338) + (_e340 * _e342)) * _e345); + let _e348 = m_2[1u]; + let _e349 = (_e348 != 0f); + phi_3766_ = _e349; if !(_e349) { - let _e352 = m_2[3u]; - phi_3516_ = (_e352 < 0f); - } - let _e355 = phi_3516_; - phi_3523_ = _e355; - if _e355 { - let _e356 = turned; - let _e357 = turned; - phi_3523_ = (dot(_e356, _e357) > 0.000000000001f); - } - let _e361 = phi_3523_; - turns = _e361; - let _e362 = turns; - if _e362 { - let _e363 = turned; - local_40 = normalize(_e363); + let _e352 = m_2[2u]; + phi_3766_ = (_e352 != 0f); + } + let _e355 = phi_3766_; + phi_3773_ = _e355; + if !(_e355) { + let _e358 = m_2[0u]; + phi_3773_ = (_e358 < 0f); + } + let _e361 = phi_3773_; + phi_3780_ = _e361; + if !(_e361) { + let _e364 = m_2[3u]; + phi_3780_ = (_e364 < 0f); + } + let _e367 = phi_3780_; + phi_3787_ = _e367; + if _e367 { + let _e368 = turned; + let _e369 = turned; + phi_3787_ = (dot(_e368, _e369) > 0.000000000001f); + } + let _e373 = phi_3787_; + turns = _e373; + let _e374 = turns; + if _e374 { + let _e375 = turned; + local_45 = normalize(_e375); } else { - let _e365 = t_3; - local_40 = _e365; + let _e377 = t_3; + local_45 = _e377; } - let _e366 = local_40; - t_3 = _e366; - let _e367 = turns; - if _e367 { - let _e369 = (*s_15).n; - let _e370 = t_3; - let _e373 = v_tangent_1[3u]; - let _e375 = flip; - local_41 = ((cross(_e369, _e370) * _e373) * _e375); - } else { - let _e377 = b_5; - local_41 = _e377; - } - let _e378 = local_41; - b_5 = _e378; - let _e379 = gl_FrontFacing_1; - if !(_e379) { - let _e381 = t_3; - t_3 = -(_e381); - } - let _e383 = sampledTexel; - sampled = ((_e383.xyz * 2f) - vec3(1f)); - let _e390 = frag_info.emissive[3u]; - if (_e390 > 0.5f) { - let _e392 = sampled; - let _e394 = sampled; - sampled[2u] = sqrt(max((1f - dot(_e392.xy, _e394.xy)), 0f)); - } - let _e403 = frag_info.material2_[1u]; - let _e404 = sampled; - let _e406 = (_e404.xy * _e403); - sampled[0u] = _e406.x; - sampled[1u] = _e406.y; - let _e411 = t_3; - let _e413 = sampled[0u]; - let _e415 = b_5; - let _e417 = sampled[1u]; - let _e421 = (*s_15).n; - let _e423 = sampled[2u]; - (*s_15).n = normalize((((_e411 * _e413) + (_e415 * _e417)) + (_e421 * _e423))); - let _e429 = (*s_15).n; - let _e431 = (*s_15).v; - (*s_15).n_dot_v = max(dot(_e429, _e431), 0.0001f); + let _e378 = local_45; + t_3 = _e378; + let _e379 = turns; + if _e379 { + let _e381 = (*s_16).n; + let _e382 = t_3; + let _e385 = v_tangent_1[3u]; + let _e387 = flip; + local_46 = ((cross(_e381, _e382) * _e385) * _e387); + } else { + let _e389 = b_5; + local_46 = _e389; + } + let _e390 = local_46; + b_5 = _e390; + let _e391 = gl_FrontFacing_1; + if !(_e391) { + let _e393 = t_3; + t_3 = -(_e393); + } + let _e395 = sampledTexel; + sampled = ((_e395.xyz * 2f) - vec3(1f)); + let _e402 = frag_info.emissive[3u]; + if (_e402 > 0.5f) { + let _e404 = sampled; + let _e406 = sampled; + sampled[2u] = sqrt(max((1f - dot(_e404.xy, _e406.xy)), 0f)); + } + let _e415 = frag_info.material2_[1u]; + let _e416 = sampled; + let _e418 = (_e416.xy * _e415); + sampled[0u] = _e418.x; + sampled[1u] = _e418.y; + let _e423 = t_3; + let _e425 = sampled[0u]; + let _e427 = b_5; + let _e429 = sampled[1u]; + let _e433 = (*s_16).n; + let _e435 = sampled[2u]; + (*s_16).n = normalize((((_e423 * _e425) + (_e427 * _e429)) + (_e433 * _e435))); + let _e441 = (*s_16).n; + let _e443 = (*s_16).v; + (*s_16).n_dot_v = max(dot(_e441, _e443), 0.0001f); return; } fn AtlasTexel_u0028_vf2_u003b(texel_5: ptr>) -> vec4 { - let _e262 = (*texel_5); - let _e266 = irradiance_info.atlas; - let _e269 = textureSampleLevel(irradiance_texture_tex, irradiance_texture_smp, ((_e262 + vec2(0.5f)) * _e266.xy), 0f); - return _e269; + let _e274 = (*texel_5); + let _e278 = irradiance_info.atlas; + let _e281 = textureSampleLevel(irradiance_texture_tex, irradiance_texture_smp, ((_e274 + vec2(0.5f)) * _e278.xy), 0f); + return _e281; } fn TileBilinear_u0028_vf2_u003b_f1_u003b_vf2_u003b(corner: ptr>, interior: ptr, uv_8: ptr>) -> vec4 { @@ -19729,50 +22066,50 @@ fn TileBilinear_u0028_vf2_u003b_f1_u003b_vf2_u003b(corner: ptr; var f_5: vec2; var a_6: vec4; - var param_216: vec2; + var param_223: vec2; var b_6: vec4; - var param_217: vec2; - var c: vec4; - var param_218: vec2; + var param_224: vec2; + var c_1: vec4; + var param_225: vec2; var d_6: vec4; - var param_219: vec2; - - let _e275 = (*uv_8); - let _e276 = (*interior); - at_5 = ((vec2(1f) + (_e275 * _e276)) - vec2(0.5f)); - let _e282 = at_5; - low = floor(_e282); - let _e284 = at_5; - let _e285 = low; - f_5 = (_e284 - _e285); - let _e287 = (*corner); - let _e288 = low; - param_216 = (_e287 + _e288); - let _e290 = AtlasTexel_u0028_vf2_u003b((¶m_216)); - a_6 = _e290; - let _e291 = (*corner); - let _e292 = low; - param_217 = ((_e291 + _e292) + vec2(1f, 0f)); - let _e295 = AtlasTexel_u0028_vf2_u003b((¶m_217)); - b_6 = _e295; - let _e296 = (*corner); + var param_226: vec2; + + let _e287 = (*uv_8); + let _e288 = (*interior); + at_5 = ((vec2(1f) + (_e287 * _e288)) - vec2(0.5f)); + let _e294 = at_5; + low = floor(_e294); + let _e296 = at_5; let _e297 = low; - param_218 = ((_e296 + _e297) + vec2(0f, 1f)); - let _e300 = AtlasTexel_u0028_vf2_u003b((¶m_218)); - c = _e300; - let _e301 = (*corner); - let _e302 = low; - param_219 = ((_e301 + _e302) + vec2(1f, 1f)); - let _e305 = AtlasTexel_u0028_vf2_u003b((¶m_219)); - d_6 = _e305; - let _e306 = a_6; - let _e307 = b_6; - let _e309 = f_5[0u]; - let _e312 = c; - let _e313 = d_6; - let _e315 = f_5[0u]; - let _e319 = f_5[1u]; - return mix(mix(_e306, _e307, vec4(_e309)), mix(_e312, _e313, vec4(_e315)), vec4(_e319)); + f_5 = (_e296 - _e297); + let _e299 = (*corner); + let _e300 = low; + param_223 = (_e299 + _e300); + let _e302 = AtlasTexel_u0028_vf2_u003b((¶m_223)); + a_6 = _e302; + let _e303 = (*corner); + let _e304 = low; + param_224 = ((_e303 + _e304) + vec2(1f, 0f)); + let _e307 = AtlasTexel_u0028_vf2_u003b((¶m_224)); + b_6 = _e307; + let _e308 = (*corner); + let _e309 = low; + param_225 = ((_e308 + _e309) + vec2(0f, 1f)); + let _e312 = AtlasTexel_u0028_vf2_u003b((¶m_225)); + c_1 = _e312; + let _e313 = (*corner); + let _e314 = low; + param_226 = ((_e313 + _e314) + vec2(1f, 1f)); + let _e317 = AtlasTexel_u0028_vf2_u003b((¶m_226)); + d_6 = _e317; + let _e318 = a_6; + let _e319 = b_6; + let _e321 = f_5[0u]; + let _e324 = c_1; + let _e325 = d_6; + let _e327 = f_5[0u]; + let _e331 = f_5[1u]; + return mix(mix(_e318, _e319, vec4(_e321)), mix(_e324, _e325, vec4(_e327)), vec4(_e331)); } fn ProbeOctahedral_u0028_vf3_u003b(direction_1: ptr>) -> vec2 { @@ -19780,32 +22117,32 @@ fn ProbeOctahedral_u0028_vf3_u003b(direction_1: ptr>) -> vec var n_6: vec3; var xy: vec2; - let _e266 = (*direction_1)[0u]; - let _e269 = (*direction_1)[1u]; - let _e273 = (*direction_1)[2u]; - sum_1 = ((abs(_e266) + abs(_e269)) + abs(_e273)); - let _e276 = sum_1; - if (_e276 <= 0f) { + let _e278 = (*direction_1)[0u]; + let _e281 = (*direction_1)[1u]; + let _e285 = (*direction_1)[2u]; + sum_1 = ((abs(_e278) + abs(_e281)) + abs(_e285)); + let _e288 = sum_1; + if (_e288 <= 0f) { return vec2(0.5f, 0.5f); } - let _e278 = (*direction_1); - let _e279 = sum_1; - n_6 = (_e278 / vec3(_e279)); - let _e282 = n_6; - xy = _e282.xy; - let _e285 = n_6[2u]; - if (_e285 < 0f) { - let _e288 = n_6[1u]; - let _e292 = n_6[0u]; - let _e297 = n_6[0u]; - let _e301 = n_6[1u]; - xy = vec2(((1f - abs(_e288)) * select(-1f, 1f, (_e292 >= 0f))), ((1f - abs(_e297)) * select(-1f, 1f, (_e301 >= 0f)))); + let _e290 = (*direction_1); + let _e291 = sum_1; + n_6 = (_e290 / vec3(_e291)); + let _e294 = n_6; + xy = _e294.xy; + let _e297 = n_6[2u]; + if (_e297 < 0f) { + let _e300 = n_6[1u]; + let _e304 = n_6[0u]; + let _e309 = n_6[0u]; + let _e313 = n_6[1u]; + xy = vec2(((1f - abs(_e300)) * select(-1f, 1f, (_e304 >= 0f))), ((1f - abs(_e309)) * select(-1f, 1f, (_e313 >= 0f)))); } - let _e306 = xy; - return ((_e306 * 0.5f) + vec2(0.5f)); + let _e318 = xy; + return ((_e318 * 0.5f) + vec2(0.5f)); } -fn SampleIrradiance_u0028_vf3_u003b_vf3_u003b_vf3_u003b(world_5: ptr>, normal_1: ptr>, view_1: ptr>) -> vec3 { +fn SampleIrradiance_u0028_vf3_u003b_vf3_u003b_vf3_u003b(world_5: ptr>, normal_1: ptr>, view_2: ptr>) -> vec3 { var origin_2: vec3; var spacing: vec3; var counts: vec3; @@ -19827,9 +22164,9 @@ fn SampleIrradiance_u0028_vf3_u003b_vf3_u003b_vf3_u003b(world_5: ptr; var irradianceCorner: vec2; var momentCorner: vec2; - var param_220: vec2; + var param_227: vec2; var trilinear: vec3; - var weight_2: f32; + var weight_3: f32; var probePosition: vec3; var toProbe: vec3; var distance_4: f32; @@ -19837,433 +22174,475 @@ fn SampleIrradiance_u0028_vf3_u003b_vf3_u003b_vf3_u003b(world_5: ptr; - var param_221: vec3; - var param_222: vec2; - var param_223: f32; - var param_224: vec2; + var param_228: vec3; + var param_229: vec2; + var param_230: f32; + var param_231: vec2; var chebyshev: f32; var variance_2: f32; var difference: f32; - var param_225: vec3; - var param_226: vec2; - var param_227: f32; - var param_228: vec2; - var local_42: vec3; - - let _e308 = irradiance_info.origin; - origin_2 = _e308.xyz; - let _e311 = irradiance_info.spacing; - spacing = _e311.xyz; - let _e314 = irradiance_info.counts; - counts = _e314.xyz; - let _e318 = irradiance_info.tiles[0u]; - irradianceTile = _e318; - let _e321 = irradiance_info.tiles[1u]; - depthTile = _e321; - let _e324 = irradiance_info.tiles[2u]; - columns = _e324; - let _e327 = irradiance_info.tiles[3u]; - momentsTop = _e327; - let _e328 = (*normal_1); - unit = normalize(_e328); - let _e330 = (*world_5); - let _e331 = unit; - let _e334 = irradiance_info.spacing[3u]; - let _e337 = (*view_1); - let _e340 = irradiance_info.counts[3u]; - biased = ((_e330 + (_e331 * _e334)) + (_e337 * _e340)); - let _e343 = biased; - let _e344 = origin_2; - let _e346 = spacing; - grid_1 = ((_e343 - _e344) / _e346); - let _e348 = grid_1; - let _e350 = counts; - base_1 = clamp(floor(_e348), vec3(0f, 0f, 0f), (_e350 - vec3(2f))); - let _e354 = grid_1; - let _e355 = base_1; - f_6 = clamp((_e354 - _e355), vec3(0f, 0f, 0f), vec3(1f, 1f, 1f)); + var param_232: vec3; + var param_233: vec2; + var param_234: f32; + var param_235: vec2; + var local_47: vec3; + + let _e320 = irradiance_info.origin; + origin_2 = _e320.xyz; + let _e323 = irradiance_info.spacing; + spacing = _e323.xyz; + let _e326 = irradiance_info.counts; + counts = _e326.xyz; + let _e330 = irradiance_info.tiles[0u]; + irradianceTile = _e330; + let _e333 = irradiance_info.tiles[1u]; + depthTile = _e333; + let _e336 = irradiance_info.tiles[2u]; + columns = _e336; + let _e339 = irradiance_info.tiles[3u]; + momentsTop = _e339; + let _e340 = (*normal_1); + unit = normalize(_e340); + let _e342 = (*world_5); + let _e343 = unit; + let _e346 = irradiance_info.spacing[3u]; + let _e349 = (*view_2); + let _e352 = irradiance_info.counts[3u]; + biased = ((_e342 + (_e343 * _e346)) + (_e349 * _e352)); + let _e355 = biased; + let _e356 = origin_2; + let _e358 = spacing; + grid_1 = ((_e355 - _e356) / _e358); + let _e360 = grid_1; + let _e362 = counts; + base_1 = clamp(floor(_e360), vec3(0f, 0f, 0f), (_e362 - vec3(2f))); + let _e366 = grid_1; + let _e367 = base_1; + f_6 = clamp((_e366 - _e367), vec3(0f, 0f, 0f), vec3(1f, 1f, 1f)); total_3 = vec3(0f, 0f, 0f); weights = 0f; corner_1 = 0i; loop { - let _e358 = corner_1; - if (_e358 < 8i) { - let _e360 = corner_1; - let _e363 = corner_1; - let _e368 = corner_1; - offset_3 = vec3(f32((_e360 & 1i)), f32(((_e363 >> bitcast(1i)) & 1i)), f32(((_e368 >> bitcast(2i)) & 1i))); - let _e374 = base_1; - let _e375 = offset_3; - cell_1 = (_e374 + _e375); - let _e378 = cell_1[2u]; - let _e380 = counts[1u]; - let _e383 = cell_1[1u]; - let _e386 = counts[0u]; - let _e389 = cell_1[0u]; - probe = ((((_e378 * _e380) + _e383) * _e386) + _e389); - let _e391 = probe; - let _e392 = columns; - let _e397 = probe; - let _e398 = columns; - tile_4 = vec2((_e391 - (floor((_e391 / _e392)) * _e392)), floor((_e397 / _e398))); - let _e402 = tile_4; - let _e403 = irradianceTile; - irradianceCorner = (_e402 * (_e403 + 2f)); - let _e407 = tile_4[0u]; - let _e408 = depthTile; - let _e411 = momentsTop; - let _e413 = tile_4[1u]; - let _e414 = depthTile; - momentCorner = vec2((_e407 * (_e408 + 2f)), (_e411 + (_e413 * (_e414 + 2f)))); - let _e419 = irradianceCorner; - param_220 = (_e419 + vec2(1f)); - let _e422 = AtlasTexel_u0028_vf2_u003b((¶m_220)); - if (_e422.w < 0.5f) { + let _e370 = corner_1; + if (_e370 < 8i) { + let _e372 = corner_1; + let _e375 = corner_1; + let _e380 = corner_1; + offset_3 = vec3(f32((_e372 & 1i)), f32(((_e375 >> bitcast(1i)) & 1i)), f32(((_e380 >> bitcast(2i)) & 1i))); + let _e386 = base_1; + let _e387 = offset_3; + cell_1 = (_e386 + _e387); + let _e390 = cell_1[2u]; + let _e392 = counts[1u]; + let _e395 = cell_1[1u]; + let _e398 = counts[0u]; + let _e401 = cell_1[0u]; + probe = ((((_e390 * _e392) + _e395) * _e398) + _e401); + let _e403 = probe; + let _e404 = columns; + let _e409 = probe; + let _e410 = columns; + tile_4 = vec2((_e403 - (floor((_e403 / _e404)) * _e404)), floor((_e409 / _e410))); + let _e414 = tile_4; + let _e415 = irradianceTile; + irradianceCorner = (_e414 * (_e415 + 2f)); + let _e419 = tile_4[0u]; + let _e420 = depthTile; + let _e423 = momentsTop; + let _e425 = tile_4[1u]; + let _e426 = depthTile; + momentCorner = vec2((_e419 * (_e420 + 2f)), (_e423 + (_e425 * (_e426 + 2f)))); + let _e431 = irradianceCorner; + param_227 = (_e431 + vec2(1f)); + let _e434 = AtlasTexel_u0028_vf2_u003b((¶m_227)); + if (_e434.w < 0.5f) { continue; } - let _e425 = f_6; - let _e427 = f_6; - let _e428 = offset_3; - trilinear = mix((vec3(1f, 1f, 1f) - _e425), _e427, _e428); - let _e431 = trilinear[0u]; - let _e433 = trilinear[1u]; - let _e436 = trilinear[2u]; - weight_2 = max(((_e431 * _e433) * _e436), 0.001f); - let _e439 = origin_2; - let _e440 = spacing; - let _e441 = cell_1; - probePosition = (_e439 + (_e440 * _e441)); - let _e444 = probePosition; - let _e445 = biased; - toProbe = (_e444 - _e445); - let _e447 = toProbe; - distance_4 = length(_e447); - let _e449 = distance_4; - if (_e449 > 0.000001f) { - let _e451 = toProbe; - let _e452 = distance_4; - direction_2 = (_e451 / vec3(_e452)); - let _e455 = unit; - let _e456 = probePosition; - let _e457 = (*world_5); - facing = ((dot(_e455, normalize((_e456 - _e457))) * 0.5f) + 0.5f); - let _e463 = facing; - let _e464 = facing; - probeWeight = ((_e463 * _e464) + 0.2f); - let _e467 = direction_2; - param_221 = -(_e467); - let _e469 = ProbeOctahedral_u0028_vf3_u003b((¶m_221)); - let _e470 = momentCorner; - param_222 = _e470; - let _e471 = depthTile; - param_223 = _e471; - param_224 = _e469; - let _e472 = TileBilinear_u0028_vf2_u003b_f1_u003b_vf2_u003b((¶m_222), (¶m_223), (¶m_224)); - moments_3 = _e472.xy; + let _e437 = f_6; + let _e439 = f_6; + let _e440 = offset_3; + trilinear = mix((vec3(1f, 1f, 1f) - _e437), _e439, _e440); + let _e443 = trilinear[0u]; + let _e445 = trilinear[1u]; + let _e448 = trilinear[2u]; + weight_3 = max(((_e443 * _e445) * _e448), 0.001f); + let _e451 = origin_2; + let _e452 = spacing; + let _e453 = cell_1; + probePosition = (_e451 + (_e452 * _e453)); + let _e456 = probePosition; + let _e457 = biased; + toProbe = (_e456 - _e457); + let _e459 = toProbe; + distance_4 = length(_e459); + let _e461 = distance_4; + if (_e461 > 0.000001f) { + let _e463 = toProbe; + let _e464 = distance_4; + direction_2 = (_e463 / vec3(_e464)); + let _e467 = unit; + let _e468 = probePosition; + let _e469 = (*world_5); + facing = ((dot(_e467, normalize((_e468 - _e469))) * 0.5f) + 0.5f); + let _e475 = facing; + let _e476 = facing; + probeWeight = ((_e475 * _e476) + 0.2f); + let _e479 = direction_2; + param_228 = -(_e479); + let _e481 = ProbeOctahedral_u0028_vf3_u003b((¶m_228)); + let _e482 = momentCorner; + param_229 = _e482; + let _e483 = depthTile; + param_230 = _e483; + param_231 = _e481; + let _e484 = TileBilinear_u0028_vf2_u003b_f1_u003b_vf2_u003b((¶m_229), (¶m_230), (¶m_231)); + moments_3 = _e484.xy; chebyshev = 1f; - let _e474 = distance_4; - let _e476 = moments_3[0u]; - if (_e474 > _e476) { - let _e479 = moments_3[1u]; - let _e481 = moments_3[0u]; - let _e483 = moments_3[0u]; - variance_2 = max((_e479 - (_e481 * _e483)), 0.000001f); - let _e487 = distance_4; - let _e489 = moments_3[0u]; - difference = (_e487 - _e489); - let _e491 = variance_2; - let _e492 = variance_2; - let _e493 = difference; - let _e494 = difference; - chebyshev = (_e491 / (_e492 + (_e493 * _e494))); - let _e498 = chebyshev; - let _e499 = chebyshev; - let _e501 = chebyshev; - chebyshev = ((_e498 * _e499) * _e501); + let _e486 = distance_4; + let _e488 = moments_3[0u]; + if (_e486 > _e488) { + let _e491 = moments_3[1u]; + let _e493 = moments_3[0u]; + let _e495 = moments_3[0u]; + variance_2 = max((_e491 - (_e493 * _e495)), 0.000001f); + let _e499 = distance_4; + let _e501 = moments_3[0u]; + difference = (_e499 - _e501); + let _e503 = variance_2; + let _e504 = variance_2; + let _e505 = difference; + let _e506 = difference; + chebyshev = (_e503 / (_e504 + (_e505 * _e506))); + let _e510 = chebyshev; + let _e511 = chebyshev; + let _e513 = chebyshev; + chebyshev = ((_e510 * _e511) * _e513); } - let _e503 = probeWeight; - let _e504 = chebyshev; - probeWeight = max((_e503 * max(_e504, 0.05f)), 0.000001f); - let _e508 = probeWeight; - if (_e508 < 0.2f) { - let _e510 = probeWeight; - let _e511 = probeWeight; - let _e514 = probeWeight; - probeWeight = (_e514 * ((_e510 * _e511) * 25f)); + let _e515 = probeWeight; + let _e516 = chebyshev; + probeWeight = max((_e515 * max(_e516, 0.05f)), 0.000001f); + let _e520 = probeWeight; + if (_e520 < 0.2f) { + let _e522 = probeWeight; + let _e523 = probeWeight; + let _e526 = probeWeight; + probeWeight = (_e526 * ((_e522 * _e523) * 25f)); } - let _e516 = probeWeight; - let _e517 = weight_2; - weight_2 = (_e517 * _e516); - } - let _e519 = unit; - param_225 = _e519; - let _e520 = ProbeOctahedral_u0028_vf3_u003b((¶m_225)); - let _e521 = irradianceCorner; - param_226 = _e521; - let _e522 = irradianceTile; - param_227 = _e522; - param_228 = _e520; - let _e523 = TileBilinear_u0028_vf2_u003b_f1_u003b_vf2_u003b((¶m_226), (¶m_227), (¶m_228)); - let _e525 = weight_2; - let _e527 = total_3; - total_3 = (_e527 + (_e523.xyz * _e525)); - let _e529 = weight_2; - let _e530 = weights; - weights = (_e530 + _e529); + let _e528 = probeWeight; + let _e529 = weight_3; + weight_3 = (_e529 * _e528); + } + let _e531 = unit; + param_232 = _e531; + let _e532 = ProbeOctahedral_u0028_vf3_u003b((¶m_232)); + let _e533 = irradianceCorner; + param_233 = _e533; + let _e534 = irradianceTile; + param_234 = _e534; + param_235 = _e532; + let _e535 = TileBilinear_u0028_vf2_u003b_f1_u003b_vf2_u003b((¶m_233), (¶m_234), (¶m_235)); + let _e537 = weight_3; + let _e539 = total_3; + total_3 = (_e539 + (_e535.xyz * _e537)); + let _e541 = weight_3; + let _e542 = weights; + weights = (_e542 + _e541); continue; } else { break; } continuing { - let _e532 = corner_1; - corner_1 = (_e532 + 1i); + let _e544 = corner_1; + corner_1 = (_e544 + 1i); } } - let _e534 = weights; - if (_e534 > 0f) { - let _e536 = total_3; - let _e537 = weights; - local_42 = (_e536 / vec3(_e537)); + let _e546 = weights; + if (_e546 > 0f) { + let _e548 = total_3; + let _e549 = weights; + local_47 = (_e548 / vec3(_e549)); } else { - local_42 = vec3(0f, 0f, 0f); + local_47 = vec3(0f, 0f, 0f); } - let _e540 = local_42; - return _e540; + let _e552 = local_47; + return _e552; } fn IrradianceEnabled_u0028_() -> bool { - let _e263 = irradiance_info.origin[3u]; - return (_e263 > 0.5f); -} - -fn ApplyCommonMaps_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b(s_16: ptr) { - var param_229: vec3; - var param_230: vec3; - var param_231: vec3; - var param_232: Surface; - var param_233: Surface; - var param_234: Surface; - - let _e268 = IrradianceEnabled_u0028_(); - if _e268 { - let _e269 = v_world_position_1; - param_229 = _e269; - let _e271 = (*s_16).n; - param_230 = _e271; - let _e273 = (*s_16).v; - param_231 = _e273; - let _e274 = SampleIrradiance_u0028_vf3_u003b_vf3_u003b_vf3_u003b((¶m_229), (¶m_230), (¶m_231)); - let _e277 = frag_info.material[2u]; - (*s_16).ambient = (_e274 * _e277); - } - let _e280 = (*s_16); - param_232 = _e280; - ApplyNormalMap_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b((¶m_232)); - let _e281 = param_232; - (*s_16) = _e281; - let _e282 = (*s_16); - param_233 = _e282; - ApplyOcclusionMap_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b((¶m_233)); - let _e283 = param_233; - (*s_16) = _e283; - let _e284 = (*s_16); - param_234 = _e284; - ApplyEmissiveMap_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b((¶m_234)); - let _e285 = param_234; - (*s_16) = _e285; + let _e275 = irradiance_info.origin[3u]; + return (_e275 > 0.5f); +} + +fn ApplyCommonMaps_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b(s_17: ptr) { + var param_236: vec3; + var param_237: vec3; + var param_238: vec3; + var param_239: Surface; + var param_240: Surface; + var param_241: Surface; + + let _e280 = IrradianceEnabled_u0028_(); + if _e280 { + let _e281 = v_world_position_1; + param_236 = _e281; + let _e283 = (*s_17).n; + param_237 = _e283; + let _e285 = (*s_17).v; + param_238 = _e285; + let _e286 = SampleIrradiance_u0028_vf3_u003b_vf3_u003b_vf3_u003b((¶m_236), (¶m_237), (¶m_238)); + let _e289 = frag_info.material[2u]; + (*s_17).ambient = (_e286 * _e289); + } + let _e292 = (*s_17); + param_239 = _e292; + ApplyNormalMap_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b((¶m_239)); + let _e293 = param_239; + (*s_17) = _e293; + let _e294 = (*s_17); + param_240 = _e294; + ApplyOcclusionMap_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b((¶m_240)); + let _e295 = param_240; + (*s_17) = _e295; + let _e296 = (*s_17); + param_241 = _e296; + ApplyEmissiveMap_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b((¶m_241)); + let _e297 = param_241; + (*s_17) = _e297; return; } -fn ReadLayers_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b(s_17: ptr) { +fn ReadLayers_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b(s_18: ptr) { var coatTexel: vec4; var sheenTexel: vec4; - var param_235: vec3; + var param_242: vec3; var ior_3: f32; var r_5: f32; - var local_43: f32; + var local_48: f32; + var bent: vec3; var distance_5: f32; - var local_44: vec3; + var local_49: vec3; var fc: f32; - let _e271 = v_texcoord_1; - let _e272 = MaterialLodBias_u0028_(); - let _e273 = textureSampleBias(coat_texture_tex, coat_texture_smp, _e271, _e272); - coatTexel = _e273; - let _e274 = v_texcoord_1; - let _e275 = MaterialLodBias_u0028_(); - let _e276 = textureSampleBias(sheen_texture_tex, sheen_texture_smp, _e274, _e275); - sheenTexel = _e276; - let _e278 = layer_info.sheen; - let _e280 = sheenTexel; - param_235 = _e280.xyz; - let _e282 = SrgbToLinear_u0028_vf3_u003b((¶m_235)); - g_sheen = (_e278.xyz * _e282); - let _e286 = layer_info.sheen[3u]; - let _e288 = sheenTexel[3u]; - g_sheen_roughness = clamp((_e286 * _e288), 0.07f, 1f); - let _e293 = layer_info.coat[2u]; - ior_3 = max(_e293, 1f); - let _e295 = IorInfinite_u0028_(); - if _e295 { - local_43 = 1f; - } else { - let _e296 = ior_3; - let _e298 = ior_3; - local_43 = ((_e296 - 1f) / (_e298 + 1f)); - } - let _e301 = local_43; - r_5 = _e301; - let _e302 = r_5; - let _e303 = r_5; - let _e307 = layer_info.specular; - let _e313 = layer_info.specular[3u]; - g_f0_dielectric = (min((vec3((_e302 * _e303)) * _e307.xyz), vec3(1f, 1f, 1f)) * _e313); - let _e317 = layer_info.specular[3u]; - g_f90_ = _e317; - let _e320 = layer_info.coat[0u]; - let _e322 = coatTexel[0u]; - g_coat = clamp((_e320 * _e322), 0f, 1f); - let _e327 = layer_info.coat[1u]; - let _e329 = coatTexel[1u]; - g_coat_roughness = clamp((_e327 * _e329), 0.02f, 1f); - let _e334 = layer_info.transmission[0u]; - let _e336 = coatTexel[2u]; - g_transmission = clamp((_e334 * _e336), 0f, 1f); - let _e341 = layer_info.transmission[1u]; - let _e343 = coatTexel[3u]; - g_thickness = max((_e341 * _e343), 0f); - let _e348 = layer_info.transmission[2u]; - distance_5 = _e348; - let _e349 = distance_5; - if (_e349 > 0f) { - let _e352 = layer_info.attenuation; - let _e355 = g_thickness; - let _e356 = distance_5; - local_44 = pow(max(_e352.xyz, vec3(0.0001f, 0.0001f, 0.0001f)), vec3((_e355 / _e356))); - } else { - local_44 = vec3(1f, 1f, 1f); - } - let _e360 = local_44; - g_transmittance = _e360; - let _e363 = layer_info.iridescence[0u]; - g_iridescence = clamp(_e363, 0f, 1f); - let _e366 = (*s_17).n; - g_coat_n = _e366; - let _e368 = (*s_17).n; - let _e370 = (*s_17).v; - g_coat_n_dot_v = max(dot(_e368, _e370), 0.0001f); - let _e373 = g_coat_n_dot_v; - fc = (0.04f + (0.96f * pow((1f - _e373), 5f))); - let _e378 = g_coat; - let _e379 = fc; - g_coat_through = (1f - (_e378 * _e379)); + let _e284 = v_texcoord_1; + let _e285 = MaterialLodBias_u0028_(); + let _e286 = textureSampleBias(coat_texture_tex, coat_texture_smp, _e284, _e285); + coatTexel = _e286; + let _e287 = v_texcoord_1; + let _e288 = MaterialLodBias_u0028_(); + let _e289 = textureSampleBias(sheen_texture_tex, sheen_texture_smp, _e287, _e288); + sheenTexel = _e289; + let _e291 = layer_info.sheen; + let _e293 = sheenTexel; + param_242 = _e293.xyz; + let _e295 = SrgbToLinear_u0028_vf3_u003b((¶m_242)); + g_sheen = (_e291.xyz * _e295); + let _e299 = layer_info.sheen[3u]; + let _e301 = sheenTexel[3u]; + g_sheen_roughness = clamp((_e299 * _e301), 0.07f, 1f); + let _e306 = layer_info.coat[2u]; + ior_3 = max(_e306, 1f); + let _e308 = IorInfinite_u0028_(); + if _e308 { + local_48 = 1f; + } else { + let _e309 = ior_3; + let _e311 = ior_3; + local_48 = ((_e309 - 1f) / (_e311 + 1f)); + } + let _e314 = local_48; + r_5 = _e314; + let _e315 = r_5; + let _e316 = r_5; + let _e320 = layer_info.specular; + let _e326 = layer_info.specular[3u]; + g_f0_dielectric = (min((vec3((_e315 * _e316)) * _e320.xyz), vec3(1f, 1f, 1f)) * _e326); + let _e330 = layer_info.specular[3u]; + g_f90_ = _e330; + let _e333 = layer_info.coat[0u]; + let _e335 = coatTexel[0u]; + g_coat = clamp((_e333 * _e335), 0f, 1f); + let _e340 = layer_info.coat[1u]; + let _e342 = coatTexel[1u]; + g_coat_roughness = clamp((_e340 * _e342), 0.02f, 1f); + let _e347 = layer_info.transmission[0u]; + let _e349 = coatTexel[2u]; + g_transmission = clamp((_e347 * _e349), 0f, 1f); + let _e354 = layer_info.transmission[1u]; + let _e356 = coatTexel[3u]; + g_thickness = max((_e354 * _e356), 0f); + let _e361 = layer_info.attenuation[3u]; + let _e363 = g_thickness; + if ((_e361 > 0.5f) && (_e363 > 0f)) { + let _e367 = (*s_18).v; + let _e370 = (*s_18).n; + let _e371 = RefractionIor_u0028_(); + bent = refract(-(_e367), _e370, (1f / _e371)); + let _e374 = g_thickness; + let _e376 = (*s_18).n; + let _e377 = bent; + let _e382 = g_thickness; + g_thickness = max((_e374 * max(-(dot(_e376, _e377)), 0f)), (0.0001f * _e382)); + } + let _e387 = layer_info.transmission[2u]; + distance_5 = _e387; + let _e388 = distance_5; + if (_e388 > 0f) { + let _e391 = layer_info.attenuation; + let _e394 = g_thickness; + let _e395 = distance_5; + local_49 = pow(max(_e391.xyz, vec3(0.0001f, 0.0001f, 0.0001f)), vec3((_e394 / _e395))); + } else { + local_49 = vec3(1f, 1f, 1f); + } + let _e399 = local_49; + g_transmittance = _e399; + let _e402 = layer_info.iridescence[0u]; + g_iridescence = clamp(_e402, 0f, 1f); + let _e405 = (*s_18).n; + g_coat_n = _e405; + let _e407 = (*s_18).n; + let _e409 = (*s_18).v; + g_coat_n_dot_v = max(dot(_e407, _e409), 0.0001f); + let _e412 = g_coat_n_dot_v; + fc = (0.04f + (0.96f * pow((1f - _e412), 5f))); + let _e417 = g_coat; + let _e418 = fc; + g_coat_through = (1f - (_e417 * _e418)); return; } +fn TowardsEye_u0028_() -> vec3 { + var local_50: vec3; + + let _e274 = Orthographic_u0028_(); + if _e274 { + let _e276 = fog_info.forward; + local_50 = -(_e276.xyz); + } else { + let _e280 = fog_info.eye; + let _e282 = v_world_position_1; + local_50 = normalize((_e280.xyz - _e282)); + } + let _e285 = local_50; + return _e285; +} + fn ReadSurface_u0028_() -> Surface { var texel_6: vec4; - var param_236: i32; - var s_18: Surface; - var param_237: vec3; - var param_238: vec3; + var param_243: i32; + var s_19: Surface; + var param_244: vec3; + var param_245: vec3; var cutoff: f32; + var edge: f32; var anchored: vec3; var noise_1: f32; - param_236 = 0i; - let _e269 = MapUv_u0028_i1_u003b((¶m_236)); - let _e270 = MaterialLodBias_u0028_(); - let _e271 = textureSampleBias(base_color_texture_tex, base_color_texture_smp, _e269, _e270); - texel_6 = _e271; - let _e272 = texel_6; - param_237 = _e272.xyz; - let _e274 = SrgbToLinear_u0028_vf3_u003b((¶m_237)); - let _e276 = frag_info.base_color; - param_238 = _e276.xyz; - let _e278 = SrgbToLinear_u0028_vf3_u003b((¶m_238)); - let _e280 = v_color_1; - s_18.albedo = ((_e274 * _e278) * _e280.xyz); - let _e285 = texel_6[3u]; - let _e288 = frag_info.base_color[3u]; - let _e291 = v_color_1[3u]; - s_18.alpha = ((_e285 * _e288) * _e291); - let _e295 = s_18.albedo; - g_albedo = _e295; - let _e298 = frag_info.material2_[0u]; - cutoff = _e298; - let _e299 = cutoff; - if (_e299 >= 0f) { - let _e302 = s_18.alpha; - let _e303 = cutoff; - if (_e302 < _e303) { - discard; - } + param_243 = 0i; + let _e282 = MapUv_u0028_i1_u003b((¶m_243)); + let _e283 = MaterialLodBias_u0028_(); + let _e284 = textureSampleBias(base_color_texture_tex, base_color_texture_smp, _e282, _e283); + texel_6 = _e284; + let _e285 = texel_6; + param_244 = _e285.xyz; + let _e287 = SrgbToLinear_u0028_vf3_u003b((¶m_244)); + let _e289 = frag_info.base_color; + param_245 = _e289.xyz; + let _e291 = SrgbToLinear_u0028_vf3_u003b((¶m_245)); + let _e293 = v_color_1; + s_19.albedo = ((_e287 * _e291) * _e293.xyz); + let _e298 = texel_6[3u]; + let _e301 = frag_info.base_color[3u]; + let _e304 = v_color_1[3u]; + s_19.alpha = ((_e298 * _e301) * _e304); + let _e308 = s_19.albedo; + g_albedo = _e308; + let _e311 = frag_info.material2_[0u]; + cutoff = _e311; + let _e312 = cutoff; + if (_e312 > 1f) { + let _e314 = cutoff; + edge = (_e314 - 1f); + let _e317 = s_19.alpha; + let _e318 = edge; + let _e321 = s_19.alpha; + let _e322 = fwidth(_e321); + s_19.alpha = clamp((((_e317 - _e318) / max(_e322, 0.0001f)) + 0.5f), 0f, 1f); } else { - let _e305 = cutoff; - if (_e305 < -1.5f) { - let _e307 = v_world_position_1; - anchored = floor((_e307 * 16f)); - let _e310 = anchored; - noise_1 = fract((sin(dot(_e310, vec3(12.9898f, 78.233f, 37.719f))) * 43758.547f)); - let _e316 = s_18.alpha; - let _e317 = noise_1; - if (_e316 < _e317) { + let _e328 = cutoff; + if (_e328 >= 0f) { + let _e331 = s_19.alpha; + let _e332 = cutoff; + if (_e331 < _e332) { discard; } + s_19.alpha = 1f; + } else { + let _e335 = cutoff; + if (_e335 < -1.5f) { + let _e337 = v_world_position_1; + anchored = floor((_e337 * 16f)); + let _e340 = anchored; + noise_1 = fract((sin(dot(_e340, vec3(12.9898f, 78.233f, 37.719f))) * 43758.547f)); + let _e346 = s_19.alpha; + let _e347 = noise_1; + if (_e346 < _e347) { + discard; + } + } } } - let _e319 = cutoff; - let _e321 = cutoff; - g_premultiply = ((_e319 < 0f) && (_e321 > -0.75f)); - let _e324 = v_normal_1; - s_18.n = normalize(_e324); - let _e327 = gl_FrontFacing_1; - if !(_e327) { - let _e330 = s_18.n; - s_18.n = -(_e330); - } - let _e334 = frag_info.camera_position; - let _e336 = v_world_position_1; - s_18.v = normalize((_e334.xyz - _e336)); - let _e341 = s_18.n; - let _e343 = s_18.v; - s_18.n_dot_v = max(dot(_e341, _e343), 0.0001f); - let _e349 = frag_info.material[0u]; - s_18.metallic = clamp(_e349, 0f, 1f); - let _e354 = frag_info.material[1u]; - s_18.roughness = clamp(_e354, 0.02f, 1f); - let _e358 = frag_info.ambient_ground; - let _e361 = frag_info.ambient_sky; - let _e365 = s_18.n[1u]; - let _e372 = frag_info.material[2u]; - s_18.ambient = (mix(_e358.xyz, _e361.xyz, vec3(((_e365 * 0.5f) + 0.5f))) * _e372); - let _e377 = frag_info.frame_params[0u]; - s_18.exposure = max(_e377, 0f); - s_18.occlusion = 1f; - s_18.emissive = vec3(0f, 0f, 0f); - let _e382 = s_18; - return _e382; + let _e349 = cutoff; + let _e351 = cutoff; + g_premultiply = ((_e349 < 0f) && (_e351 > -0.75f)); + let _e354 = v_normal_1; + s_19.n = normalize(_e354); + let _e357 = gl_FrontFacing_1; + if !(_e357) { + let _e360 = s_19.n; + s_19.n = -(_e360); + } + let _e363 = TowardsEye_u0028_(); + s_19.v = _e363; + let _e366 = s_19.n; + let _e368 = s_19.v; + s_19.n_dot_v = max(dot(_e366, _e368), 0.0001f); + let _e374 = frag_info.material[0u]; + s_19.metallic = clamp(_e374, 0f, 1f); + let _e379 = frag_info.material[1u]; + s_19.roughness = clamp(_e379, 0.02f, 1f); + let _e383 = frag_info.ambient_ground; + let _e386 = frag_info.ambient_sky; + let _e390 = s_19.n[1u]; + let _e397 = frag_info.material[2u]; + s_19.ambient = (mix(_e383.xyz, _e386.xyz, vec3(((_e390 * 0.5f) + 0.5f))) * _e397); + let _e402 = frag_info.frame_params[0u]; + s_19.exposure = max(_e402, 0f); + s_19.occlusion = 1f; + s_19.emissive = vec3(0f, 0f, 0f); + let _e407 = s_19; + return _e407; } fn main_1() { - var s_19: Surface; - var param_239: Surface; - var param_240: Surface; - var param_241: Surface; - var param_242: Surface; - var param_243: Surface; + var s_20: Surface; + var param_246: Surface; + var param_247: Surface; + var param_248: Surface; + var param_249: Surface; + var param_250: Surface; var metallic: f32; var diffuseColor_1: vec3; - var param_244: vec3; - var param_245: f32; - var param_246: f32; + var param_251: vec3; + var param_252: f32; + var param_253: f32; var ambient: vec3; var levels_1: f32; var coatAmbient: vec3; var sheenIncoming: vec3; var f0_4: vec3; var f90_2: f32; - var bent: vec3; + var bent_1: vec3; var across: vec3; var anisoN: vec3; var bend: f32; @@ -20272,39 +22651,45 @@ fn main_1() { var irradiance: vec3; var prefiltered: vec3; var ab_4: vec2; - var param_247: f32; - var param_248: f32; + var param_254: f32; + var param_255: f32; var single_1: vec3; var specular_1: vec3; var missed: f32; var average_2: vec3; var multiple: vec3; var reflects: vec3; - var through: vec3; - var local_45: vec3; - var param_249: Surface; - var param_250: f32; + var through_1: vec3; + var local_51: vec3; + var param_256: Surface; + var param_257: f32; var coatPrefiltered: vec3; var coatAb: vec2; - var param_251: f32; - var param_252: f32; + var param_258: f32; + var param_259: f32; var sceneAb: vec2; - var param_253: f32; - var param_254: f32; + var param_260: f32; + var param_261: f32; var sceneReflects: vec3; - var param_255: Surface; + var passes: vec3; + var param_262: Surface; var sheenAmbient: vec3; - var param_256: Surface; - var param_257: vec3; - var param_258: f32; - var param_259: f32; + var lit_3: vec3; + var param_263: Surface; + var param_264: Surface; + var param_265: vec3; + var param_266: vec3; + var param_267: f32; + var param_268: f32; g_albedo = vec3(0f, 0f, 0f); g_debug_surface = vec3(0f, 0f, 0f); g_debug_surface_on = false; g_premultiply = false; + g_pass_through = 0f; g_cluster_offset = 0f; g_cluster_count = 0f; + light_transmittance = vec3(1f, 1f, 1f); g_f0_dielectric = vec3(0.04f, 0.04f, 0.04f); g_f90_ = 1f; g_coat = 0f; @@ -20324,254 +22709,281 @@ fn main_1() { g_transmittance = vec3(1f, 1f, 1f); g_iridescence = 0f; g_irid_fresnel = vec3(0.04f, 0.04f, 0.04f); - let _e313 = ReadSurface_u0028_(); - s_19 = _e313; - let _e314 = s_19; - param_239 = _e314; - ReadLayers_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b((¶m_239)); - let _e315 = s_19; - param_240 = _e315; - ApplyCommonMaps_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b((¶m_240)); - let _e316 = param_240; - s_19 = _e316; - let _e317 = s_19; - param_241 = _e317; - ApplyMetallicRoughnessMap_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b((¶m_241)); - let _e318 = param_241; - s_19 = _e318; - let _e319 = s_19; - param_242 = _e319; - ReadLayersOnMaps_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b((¶m_242)); - let _e320 = s_19; - param_243 = _e320; - ReadIridescence_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b((¶m_243)); - let _e322 = s_19.metallic; - metallic = clamp(_e322, 0f, 1f); - let _e325 = s_19.albedo; - let _e326 = metallic; - diffuseColor_1 = (_e325 * (1f - _e326)); - let _e329 = EonDiffuse_u0028_(); - if _e329 { - let _e330 = diffuseColor_1; - param_244 = _e330; - let _e332 = s_19.roughness; - param_245 = _e332; - let _e334 = s_19.n_dot_v; - param_246 = _e334; - let _e335 = EonAlbedo_u0028_vf3_u003b_f1_u003b_f1_u003b((¶m_244), (¶m_245), (¶m_246)); - diffuseColor_1 = _e335; - } - let _e336 = diffuseColor_1; - let _e338 = s_19.ambient; - let _e341 = s_19.occlusion; - ambient = ((_e336 * _e338) * _e341); - let _e343 = SceneColourBound_u0028_(); - let _e344 = g_transmittance; - let _e347 = g_transmission; - let _e350 = ambient; - ambient = (_e350 * mix(vec3(1f, 1f, 1f), select(_e344, vec3(0f, 0f, 0f), vec3(_e343)), vec3(_e347))); - let _e354 = frag_info.frame_params[3u]; - levels_1 = _e354; + g_pane = false; + let _e329 = ReadSurface_u0028_(); + s_20 = _e329; + let _e330 = s_20; + param_246 = _e330; + ReadLayers_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b((¶m_246)); + let _e331 = s_20; + param_247 = _e331; + ApplyCommonMaps_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b((¶m_247)); + let _e332 = param_247; + s_20 = _e332; + let _e333 = s_20; + param_248 = _e333; + ApplyMetallicRoughnessMap_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b((¶m_248)); + let _e334 = param_248; + s_20 = _e334; + let _e335 = s_20; + param_249 = _e335; + ReadLayersOnMaps_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b((¶m_249)); + let _e336 = s_20; + param_250 = _e336; + ReadIridescence_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b((¶m_250)); + let _e338 = s_20.metallic; + metallic = clamp(_e338, 0f, 1f); + let _e341 = s_20.albedo; + let _e342 = metallic; + diffuseColor_1 = (_e341 * (1f - _e342)); + let _e345 = EonDiffuse_u0028_(); + if _e345 { + let _e346 = diffuseColor_1; + param_251 = _e346; + let _e348 = s_20.roughness; + param_252 = _e348; + let _e350 = s_20.n_dot_v; + param_253 = _e350; + let _e351 = EonAlbedo_u0028_vf3_u003b_f1_u003b_f1_u003b((¶m_251), (¶m_252), (¶m_253)); + diffuseColor_1 = _e351; + } + let _e352 = diffuseColor_1; + let _e354 = s_20.ambient; + let _e357 = s_20.occlusion; + ambient = ((_e352 * _e354) * _e357); + let _e359 = g_premultiply; + let _e360 = g_transmission; + let _e363 = g_thickness; + g_pane = ((_e359 && (_e360 > 0f)) && (_e363 <= 0f)); + let _e366 = SceneColourBound_u0028_(); + let _e367 = g_pane; + let _e369 = g_transmittance; + let _e372 = g_transmission; + let _e375 = ambient; + ambient = (_e375 * mix(vec3(1f, 1f, 1f), select(_e369, vec3(0f, 0f, 0f), vec3((_e366 || _e367))), vec3(_e372))); + let _e379 = frag_info.frame_params[3u]; + levels_1 = _e379; coatAmbient = vec3(0f, 0f, 0f); - let _e356 = s_19.ambient; - sheenIncoming = _e356; - let _e357 = levels_1; - if (_e357 > 0f) { - let _e359 = g_f0_dielectric; - let _e361 = s_19.albedo; - let _e362 = metallic; - f0_4 = mix(_e359, _e361, vec3(_e362)); - let _e365 = g_f90_; - let _e366 = metallic; - f90_2 = mix(_e365, 1f, _e366); - let _e369 = s_19.n; - bent = _e369; - let _e370 = g_aniso; - if (_e370 > 0f) { - let _e372 = g_aniso_t; - let _e374 = s_19.v; - across = cross(_e372, _e374); - let _e376 = across; - let _e377 = g_aniso_t; - anisoN = cross(_e376, _e377); - let _e379 = g_aniso; - let _e381 = s_19.roughness; - bend = (1f - (_e379 * (1f - _e381))); - let _e385 = bend; - let _e386 = bend; - let _e388 = bend; - let _e390 = bend; - bend4_ = (((_e385 * _e386) * _e388) * _e390); - let _e392 = anisoN; - let _e394 = s_19.n; - let _e395 = bend4_; - bent = normalize(mix(_e392, _e394, vec3(_e395))); - } - let _e400 = s_19.v; - let _e402 = bent; - reflected = reflect(-(_e400), _e402); - let _e405 = s_19.n; - let _e406 = levels_1; - let _e407 = textureSampleLevel(environment_texture_tex, environment_texture_smp, _e405, _e406); - irradiance = _e407.xyz; - let _e409 = reflected; - let _e411 = s_19.roughness; - let _e412 = levels_1; - let _e414 = textureSampleLevel(environment_texture_tex, environment_texture_smp, _e409, (_e411 * _e412)); - prefiltered = _e414.xyz; - let _e417 = s_19.roughness; - param_247 = _e417; - let _e419 = s_19.n_dot_v; - param_248 = _e419; - let _e420 = EnvBrdf_u0028_f1_u003b_f1_u003b((¶m_247), (¶m_248)); - ab_4 = _e420; - let _e421 = f0_4; - let _e422 = g_irid_fresnel; - let _e423 = g_iridescence; - let _e427 = ab_4[0u]; - let _e429 = f90_2; - let _e431 = ab_4[1u]; - single_1 = ((mix(_e421, _e422, vec3(_e423)) * _e427) + vec3((_e429 * _e431))); - let _e435 = prefiltered; - let _e436 = single_1; - specular_1 = (_e435 * _e436); - let _e438 = EnergyCompensation_u0028_(); - if _e438 { - let _e440 = ab_4[0u]; - let _e442 = ab_4[1u]; - missed = (1f - (_e440 + _e442)); - let _e445 = f0_4; - let _e446 = f0_4; - average_2 = (_e445 + ((vec3(1f, 1f, 1f) - _e446) / vec3(21f))); - let _e451 = single_1; - let _e452 = average_2; - let _e454 = missed; - let _e455 = average_2; - multiple = ((_e451 * _e452) / (vec3(1f, 1f, 1f) - (_e455 * _e454))); - let _e459 = multiple; - let _e460 = missed; - let _e462 = irradiance; - let _e464 = specular_1; - specular_1 = (_e464 + ((_e459 * _e460) * _e462)); - } - let _e466 = diffuseColor_1; - let _e467 = irradiance; - let _e469 = specular_1; - let _e473 = frag_info.material[2u]; - let _e476 = s_19.occlusion; - ambient = ((((_e466 * _e467) + _e469) * _e473) * _e476); - let _e478 = g_f0_dielectric; - let _e479 = g_irid_fresnel; - let _e480 = g_iridescence; - let _e484 = ab_4[0u]; - let _e486 = g_f90_; - let _e488 = ab_4[1u]; - reflects = ((mix(_e478, _e479, vec3(_e480)) * _e484) + vec3((_e486 * _e488))); - let _e492 = SceneColourBound_u0028_(); - if _e492 { - local_45 = vec3(0f, 0f, 0f); + let _e381 = s_20.ambient; + sheenIncoming = _e381; + let _e382 = levels_1; + if (_e382 > 0f) { + let _e384 = g_f0_dielectric; + let _e386 = s_20.albedo; + let _e387 = metallic; + f0_4 = mix(_e384, _e386, vec3(_e387)); + let _e390 = g_f90_; + let _e391 = metallic; + f90_2 = mix(_e390, 1f, _e391); + let _e394 = s_20.n; + bent_1 = _e394; + let _e395 = g_aniso; + if (_e395 > 0f) { + let _e397 = g_aniso_t; + let _e399 = s_20.v; + across = cross(_e397, _e399); + let _e401 = across; + let _e402 = g_aniso_t; + anisoN = cross(_e401, _e402); + let _e404 = g_aniso; + let _e406 = s_20.roughness; + bend = (1f - (_e404 * (1f - _e406))); + let _e410 = bend; + let _e411 = bend; + let _e413 = bend; + let _e415 = bend; + bend4_ = (((_e410 * _e411) * _e413) * _e415); + let _e417 = anisoN; + let _e419 = s_20.n; + let _e420 = bend4_; + bent_1 = normalize(mix(_e417, _e419, vec3(_e420))); + } + let _e425 = s_20.v; + let _e427 = bent_1; + reflected = reflect(-(_e425), _e427); + let _e430 = s_20.n; + let _e431 = levels_1; + let _e432 = textureSampleLevel(environment_texture_tex, environment_texture_smp, _e430, _e431); + irradiance = _e432.xyz; + let _e434 = reflected; + let _e436 = s_20.roughness; + let _e437 = levels_1; + let _e439 = textureSampleLevel(environment_texture_tex, environment_texture_smp, _e434, (_e436 * _e437)); + prefiltered = _e439.xyz; + let _e442 = s_20.roughness; + param_254 = _e442; + let _e444 = s_20.n_dot_v; + param_255 = _e444; + let _e445 = EnvBrdf_u0028_f1_u003b_f1_u003b((¶m_254), (¶m_255)); + ab_4 = _e445; + let _e446 = f0_4; + let _e447 = g_irid_fresnel; + let _e448 = g_iridescence; + let _e452 = ab_4[0u]; + let _e454 = f90_2; + let _e456 = ab_4[1u]; + single_1 = ((mix(_e446, _e447, vec3(_e448)) * _e452) + vec3((_e454 * _e456))); + let _e460 = prefiltered; + let _e461 = single_1; + specular_1 = (_e460 * _e461); + let _e463 = EnergyCompensation_u0028_(); + if _e463 { + let _e465 = ab_4[0u]; + let _e467 = ab_4[1u]; + missed = (1f - (_e465 + _e467)); + let _e470 = f0_4; + let _e471 = f0_4; + average_2 = (_e470 + ((vec3(1f, 1f, 1f) - _e471) / vec3(21f))); + let _e476 = single_1; + let _e477 = average_2; + let _e479 = missed; + let _e480 = average_2; + multiple = ((_e476 * _e477) / (vec3(1f, 1f, 1f) - (_e480 * _e479))); + let _e484 = multiple; + let _e485 = missed; + let _e487 = irradiance; + let _e489 = specular_1; + specular_1 = (_e489 + ((_e484 * _e485) * _e487)); + } + let _e491 = diffuseColor_1; + let _e492 = irradiance; + let _e494 = specular_1; + let _e498 = frag_info.material[2u]; + let _e501 = s_20.occlusion; + ambient = ((((_e491 * _e492) + _e494) * _e498) * _e501); + let _e503 = g_f0_dielectric; + let _e504 = g_irid_fresnel; + let _e505 = g_iridescence; + let _e509 = ab_4[0u]; + let _e511 = g_f90_; + let _e513 = ab_4[1u]; + reflects = ((mix(_e503, _e504, vec3(_e505)) * _e509) + vec3((_e511 * _e513))); + let _e517 = SceneColourBound_u0028_(); + let _e518 = g_pane; + if (_e517 || _e518) { + local_51 = vec3(0f, 0f, 0f); } else { - let _e493 = s_19; - param_249 = _e493; - let _e494 = levels_1; - param_250 = _e494; - let _e495 = TransmittedRadiance_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b_f1_u003b((¶m_249), (¶m_250)); - let _e496 = g_transmittance; - let _e498 = reflects; - local_45 = ((_e495 * _e496) * (vec3(1f, 1f, 1f) - min(_e498, vec3(1f, 1f, 1f)))); - } - let _e502 = local_45; - through = _e502; - let _e503 = diffuseColor_1; - let _e504 = through; - let _e505 = irradiance; - let _e508 = g_transmission; - let _e512 = frag_info.material[2u]; - let _e515 = s_19.occlusion; - let _e517 = ambient; - ambient = (_e517 + ((((_e503 * (_e504 - _e505)) * _e508) * _e512) * _e515)); - let _e520 = s_19.v; - let _e522 = g_coat_n; - let _e524 = g_coat_roughness; - let _e525 = levels_1; - let _e527 = textureSampleLevel(environment_texture_tex, environment_texture_smp, reflect(-(_e520), _e522), (_e524 * _e525)); - coatPrefiltered = _e527.xyz; - let _e529 = g_coat_roughness; - param_251 = _e529; - let _e530 = g_coat_n_dot_v; - param_252 = _e530; - let _e531 = EnvBrdf_u0028_f1_u003b_f1_u003b((¶m_251), (¶m_252)); - coatAb = _e531; - let _e532 = coatPrefiltered; - let _e534 = coatAb[0u]; - let _e537 = coatAb[1u]; - let _e540 = g_coat; - let _e544 = frag_info.material[2u]; - let _e547 = s_19.occlusion; - coatAmbient = ((((_e532 * ((0.04f * _e534) + _e537)) * _e540) * _e544) * _e547); - let _e550 = s_19.n; - let _e551 = g_sheen_roughness; + let _e520 = s_20; + param_256 = _e520; + let _e521 = levels_1; + param_257 = _e521; + let _e522 = TransmittedRadiance_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b_f1_u003b((¶m_256), (¶m_257)); + let _e523 = g_transmittance; + let _e525 = reflects; + local_51 = ((_e522 * _e523) * (vec3(1f, 1f, 1f) - min(_e525, vec3(1f, 1f, 1f)))); + } + let _e529 = local_51; + through_1 = _e529; + let _e530 = diffuseColor_1; + let _e531 = through_1; + let _e532 = irradiance; + let _e535 = g_transmission; + let _e539 = frag_info.material[2u]; + let _e542 = s_20.occlusion; + let _e544 = ambient; + ambient = (_e544 + ((((_e530 * (_e531 - _e532)) * _e535) * _e539) * _e542)); + let _e547 = s_20.v; + let _e549 = g_coat_n; + let _e551 = g_coat_roughness; let _e552 = levels_1; - let _e554 = textureSampleLevel(environment_texture_tex, environment_texture_smp, _e550, (_e551 * _e552)); - let _e558 = frag_info.material[2u]; - sheenIncoming = (_e554.xyz * _e558); - } - let _e560 = SceneColourBound_u0028_(); - if _e560 { - let _e562 = s_19.roughness; - param_253 = _e562; - let _e564 = s_19.n_dot_v; - param_254 = _e564; - let _e565 = EnvBrdf_u0028_f1_u003b_f1_u003b((¶m_253), (¶m_254)); - sceneAb = _e565; - let _e566 = g_f0_dielectric; - let _e567 = g_irid_fresnel; - let _e568 = g_iridescence; - let _e572 = sceneAb[0u]; - let _e574 = g_f90_; - let _e576 = sceneAb[1u]; - sceneReflects = ((mix(_e566, _e567, vec3(_e568)) * _e572) + vec3((_e574 * _e576))); - let _e580 = diffuseColor_1; - let _e581 = s_19; - param_255 = _e581; - let _e582 = SceneBehind_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b((¶m_255)); - let _e584 = g_transmittance; - let _e586 = sceneReflects; - let _e590 = g_transmission; - let _e592 = ambient; - ambient = (_e592 + ((((_e580 * _e582) * _e584) * (vec3(1f, 1f, 1f) - min(_e586, vec3(1f, 1f, 1f)))) * _e590)); - } - let _e594 = diffuseColor_1; - let _e595 = SampleLightmap_u0028_(); - let _e598 = s_19.occlusion; - let _e600 = ambient; - ambient = (_e600 + ((_e594 * _e595) * _e598)); - let _e602 = g_sheen; - let _e603 = g_sheen_albedo; - let _e605 = sheenIncoming; - let _e608 = s_19.occlusion; - sheenAmbient = (((_e602 * _e603) * _e605) * _e608); - let _e610 = s_19; - param_256 = _e610; - let _e611 = AccumulateLights_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b((¶m_256)); - let _e613 = s_19.occlusion; - let _e615 = ambient; - let _e616 = g_sheen_scale; - let _e618 = sheenAmbient; - let _e621 = s_19.emissive; - let _e623 = g_coat_through; - let _e626 = coatAmbient; - param_257 = (((_e611 * _e613) + ((((_e615 * _e616) + _e618) + _e621) * _e623)) + _e626); - let _e629 = s_19.alpha; - param_258 = _e629; - let _e631 = s_19.roughness; - param_259 = _e631; - WriteSurface_u0028_vf3_u003b_f1_u003b_f1_u003b((¶m_257), (¶m_258), (¶m_259)); + let _e554 = textureSampleLevel(environment_texture_tex, environment_texture_smp, reflect(-(_e547), _e549), (_e551 * _e552)); + coatPrefiltered = _e554.xyz; + let _e556 = g_coat_roughness; + param_258 = _e556; + let _e557 = g_coat_n_dot_v; + param_259 = _e557; + let _e558 = EnvBrdf_u0028_f1_u003b_f1_u003b((¶m_258), (¶m_259)); + coatAb = _e558; + let _e559 = coatPrefiltered; + let _e561 = coatAb[0u]; + let _e564 = coatAb[1u]; + let _e567 = g_coat; + let _e571 = frag_info.material[2u]; + let _e574 = s_20.occlusion; + coatAmbient = ((((_e559 * ((0.04f * _e561) + _e564)) * _e567) * _e571) * _e574); + let _e577 = s_20.n; + let _e578 = g_sheen_roughness; + let _e579 = levels_1; + let _e581 = textureSampleLevel(environment_texture_tex, environment_texture_smp, _e577, (_e578 * _e579)); + let _e585 = frag_info.material[2u]; + sheenIncoming = (_e581.xyz * _e585); + } + let _e588 = s_20.roughness; + param_260 = _e588; + let _e590 = s_20.n_dot_v; + param_261 = _e590; + let _e591 = EnvBrdf_u0028_f1_u003b_f1_u003b((¶m_260), (¶m_261)); + sceneAb = _e591; + let _e592 = g_f0_dielectric; + let _e593 = g_irid_fresnel; + let _e594 = g_iridescence; + let _e598 = sceneAb[0u]; + let _e600 = g_f90_; + let _e602 = sceneAb[1u]; + sceneReflects = ((mix(_e592, _e593, vec3(_e594)) * _e598) + vec3((_e600 * _e602))); + let _e606 = diffuseColor_1; + let _e607 = g_transmittance; + let _e609 = sceneReflects; + let _e613 = g_transmission; + passes = (((_e606 * _e607) * (vec3(1f, 1f, 1f) - min(_e609, vec3(1f, 1f, 1f)))) * _e613); + let _e615 = g_pane; + if _e615 { + let _e617 = passes[0u]; + let _e619 = passes[1u]; + let _e622 = passes[2u]; + g_pass_through = clamp((((_e617 + _e619) + _e622) / 3f), 0f, 1f); + } else { + let _e626 = SceneColourBound_u0028_(); + if _e626 { + let _e627 = s_20; + param_262 = _e627; + let _e628 = SceneBehind_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b((¶m_262)); + let _e629 = passes; + let _e631 = ambient; + ambient = (_e631 + (_e628 * _e629)); + } + } + let _e633 = diffuseColor_1; + let _e634 = SampleLightmap_u0028_(); + let _e637 = s_20.occlusion; + let _e639 = ambient; + ambient = (_e639 + ((_e633 * _e634) * _e637)); + let _e641 = g_sheen; + let _e642 = g_sheen_albedo; + let _e644 = sheenIncoming; + let _e647 = s_20.occlusion; + sheenAmbient = (((_e641 * _e642) * _e644) * _e647); + let _e649 = s_20; + param_263 = _e649; + let _e650 = AccumulateLights_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b((¶m_263)); + let _e652 = s_20.occlusion; + let _e654 = ambient; + let _e655 = g_sheen_scale; + let _e657 = sheenAmbient; + let _e660 = s_20.emissive; + let _e662 = g_coat_through; + let _e665 = coatAmbient; + lit_3 = (((_e650 * _e652) + ((((_e654 * _e655) + _e657) + _e660) * _e662)) + _e665); + let _e667 = s_20; + param_264 = _e667; + let _e668 = lit_3; + param_265 = _e668; + let _e669 = WriteDebugView_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b_vf3_u003b((¶m_264), (¶m_265)); + if _e669 { + return; + } + let _e670 = lit_3; + param_266 = _e670; + let _e672 = s_20.alpha; + param_267 = _e672; + let _e674 = s_20.roughness; + param_268 = _e674; + WriteSurface_u0028_vf3_u003b_f1_u003b_f1_u003b((¶m_266), (¶m_267), (¶m_268)); return; } @fragment -fn main(@location(6) v_world_position: vec3, @location(2) v_normal: vec3, @builtin(front_facing) gl_FrontFacing: bool, @builtin(position) gl_FragCoord: vec4, @location(0) v_color: vec4, @location(1) v_lightmap_uv: vec2, @location(3) v_tangent: vec4, @location(4) v_texcoord: vec2) -> FragmentOutput { +fn main(@location(7) v_world_position: vec3, @location(3) v_normal: vec3, @builtin(front_facing) gl_FrontFacing: bool, @builtin(position) gl_FragCoord: vec4, @location(0) v_color: vec4, @location(2) v_lightmap_uv: vec2, @location(4) v_tangent: vec4, @location(5) v_texcoord: vec2) -> FragmentOutput { v_world_position_1 = v_world_position; v_normal_1 = v_normal; gl_FrontFacing_1 = gl_FrontFacing; @@ -20593,7 +23005,7 @@ fn main(@location(6) v_world_position: vec3, @location(2) v_normal: vec3[ WebGpuBlockMember(name: 'fog', offsetInBytes: 0, sizeInBytes: 16), WebGpuBlockMember(name: 'eye', offsetInBytes: 16, sizeInBytes: 16), @@ -20602,13 +23014,18 @@ fn main(@location(6) v_world_position: vec3, @location(2) v_normal: vec3[ WebGpuBlockMember( name: 'light_position', @@ -20705,6 +23122,11 @@ fn main(@location(6) v_world_position: vec3, @location(2) v_normal: vec3, eye: vec4, forward: vec4, + projection: vec4, } struct LightListInfo { @@ -21060,6 +23483,7 @@ struct FragInfo { ambient_ground: vec4, shadow_bias: vec4, target_origin: vec4, + debug_view: vec4, } struct PointShadow { @@ -21089,11 +23513,13 @@ var g_albedo: vec3; var g_debug_surface: vec3; var g_debug_surface_on: bool; var g_premultiply: bool; +var g_pass_through: f32; var g_cluster_offset: f32; var g_cluster_count: f32; -var v_world_position_1: vec3; +var light_transmittance: vec3; @group(1) @binding(0) var fog_info: FogInfo; +var v_world_position_1: vec3; var frag_albedo: vec4; var frag_surface: vec4; var v_normal_1: vec3; @@ -21158,10 +23584,10 @@ var shadow_texture_tex: texture_2d; var shadow_texture_smp: sampler; fn ViewDepth_u0028_() -> f32 { - let _e177 = v_world_position_1; - let _e179 = fog_info.eye; - let _e183 = fog_info.forward; - return dot((_e177 - _e179.xyz), _e183.xyz); + let _e188 = v_world_position_1; + let _e190 = fog_info.eye; + let _e194 = fog_info.forward; + return dot((_e188 - _e190.xyz), _e194.xyz); } fn WeightedBlendedWeight_u0028_f1_u003b(alpha: ptr) -> f32 { @@ -21170,22 +23596,22 @@ fn WeightedBlendedWeight_u0028_f1_u003b(alpha: ptr) -> f32 { var far: f32; var far3_: f32; - let _e182 = ViewDepth_u0028_(); - z = abs(_e182); - let _e184 = z; - near = (_e184 / 5f); - let _e186 = z; - far = (_e186 / 200f); - let _e188 = far; - let _e189 = far; - let _e191 = far; - far3_ = ((_e188 * _e189) * _e191); - let _e193 = (*alpha); - let _e194 = near; - let _e195 = near; - let _e198 = far3_; - let _e199 = far3_; - return (_e193 * clamp((10f / ((0.00001f + (_e194 * _e195)) + (_e198 * _e199))), 0.01f, 3000f)); + let _e193 = ViewDepth_u0028_(); + z = abs(_e193); + let _e195 = z; + near = (_e195 / 5f); + let _e197 = z; + far = (_e197 / 200f); + let _e199 = far; + let _e200 = far; + let _e202 = far; + far3_ = ((_e199 * _e200) * _e202); + let _e204 = (*alpha); + let _e205 = near; + let _e206 = near; + let _e209 = far3_; + let _e210 = far3_; + return (_e204 * clamp((10f / ((0.00001f + (_e205 * _e206)) + (_e209 * _e210))), 0.01f, 3000f)); } fn WriteWeightedBlended_u0028_() { @@ -21197,39 +23623,39 @@ fn WriteWeightedBlended_u0028_() { var revealage: bool; var local: vec4; - let _e186 = fog_info.forward[3u]; - mode = _e186; - let _e187 = mode; - if (_e187 > 0.5f) { - let _e190 = frag_color[3u]; - alpha_1 = _e190; - let _e191 = alpha_1; - param = _e191; - let _e192 = WeightedBlendedWeight_u0028_f1_u003b((¶m)); - weight = _e192; - let _e193 = frag_color; - let _e195 = weight; - let _e196 = (_e193.xyz * _e195); - let _e197 = alpha_1; - let _e198 = weight; - accumulate = vec4(_e196.x, _e196.y, _e196.z, (_e197 * _e198)); - let _e204 = mode; - let _e206 = mode; - revealage = ((_e204 > 1.5f) && (_e206 < 2.5f)); - let _e209 = revealage; - if _e209 { - let _e210 = alpha_1; - local = vec4(_e210); + let _e197 = fog_info.forward[3u]; + mode = _e197; + let _e198 = mode; + if (_e198 > 0.5f) { + let _e201 = frag_color[3u]; + alpha_1 = _e201; + let _e202 = alpha_1; + param = _e202; + let _e203 = WeightedBlendedWeight_u0028_f1_u003b((¶m)); + weight = _e203; + let _e204 = frag_color; + let _e206 = weight; + let _e207 = (_e204.xyz * _e206); + let _e208 = alpha_1; + let _e209 = weight; + accumulate = vec4(_e207.x, _e207.y, _e207.z, (_e208 * _e209)); + let _e215 = mode; + let _e217 = mode; + revealage = ((_e215 > 1.5f) && (_e217 < 2.5f)); + let _e220 = revealage; + if _e220 { + let _e221 = alpha_1; + local = vec4(_e221); } else { - let _e212 = accumulate; - local = _e212; + let _e223 = accumulate; + local = _e223; } - let _e213 = local; - frag_color = _e213; - let _e214 = mode; - if (_e214 > 2.5f) { - let _e216 = alpha_1; - frag_surface = vec4(_e216); + let _e224 = local; + frag_color = _e224; + let _e225 = mode; + if (_e225 > 2.5f) { + let _e227 = alpha_1; + frag_surface = vec4(_e227); } } return; @@ -21238,29 +23664,29 @@ fn WriteWeightedBlended_u0028_() { fn EncodeOctahedral_u0028_vf3_u003b(n: ptr>) -> vec2 { var e: vec2; - let _e180 = (*n)[0u]; - let _e183 = (*n)[1u]; - let _e187 = (*n)[2u]; - let _e190 = (*n); - (*n) = (_e190 / vec3(((abs(_e180) + abs(_e183)) + abs(_e187)))); - let _e193 = (*n); - e = _e193.xy; - let _e196 = (*n)[2u]; - if (_e196 < 0f) { - let _e198 = (*n); - let _e204 = (*n)[0u]; - let _e208 = (*n)[1u]; - e = ((vec2(1f) - abs(_e198.yx)) * vec2(select(-1f, 1f, (_e204 >= 0f)), select(-1f, 1f, (_e208 >= 0f)))); + let _e191 = (*n)[0u]; + let _e194 = (*n)[1u]; + let _e198 = (*n)[2u]; + let _e201 = (*n); + (*n) = (_e201 / vec3(((abs(_e191) + abs(_e194)) + abs(_e198)))); + let _e204 = (*n); + e = _e204.xy; + let _e207 = (*n)[2u]; + if (_e207 < 0f) { + let _e209 = (*n); + let _e215 = (*n)[0u]; + let _e219 = (*n)[1u]; + e = ((vec2(1f) - abs(_e209.yx)) * vec2(select(-1f, 1f, (_e215 >= 0f)), select(-1f, 1f, (_e219 >= 0f)))); } - let _e213 = e; - return ((_e213 * 0.5f) + vec2(0.5f)); + let _e224 = e; + return ((_e224 * 0.5f) + vec2(0.5f)); } fn LinearToSrgb_u0028_vf3_u003b(linear: ptr>) -> vec3 { - let _e178 = (*linear); - let _e180 = (*linear); - let _e184 = (*linear); - return mix((_e178 * 12.92f), ((pow(_e180, vec3(0.41666666f, 0.41666666f, 0.41666666f)) * 1.055f) - vec3(0.055f, 0.055f, 0.055f)), step(vec3(0.0031308f, 0.0031308f, 0.0031308f), _e184)); + let _e189 = (*linear); + let _e191 = (*linear); + let _e195 = (*linear); + return mix((_e189 * 12.92f), ((pow(_e191, vec3(0.41666666f, 0.41666666f, 0.41666666f)) * 1.055f) - vec3(0.055f, 0.055f, 0.055f)), step(vec3(0.0031308f, 0.0031308f, 0.0031308f), _e195)); } fn WriteSurfaceGeometry_u0028_f1_u003b(roughness: ptr) { @@ -21268,57 +23694,96 @@ fn WriteSurfaceGeometry_u0028_f1_u003b(roughness: ptr) { var geometric: vec3; var param_2: vec3; - let _e181 = g_albedo; - param_1 = clamp(_e181, vec3(0f, 0f, 0f), vec3(1f, 1f, 1f)); - let _e183 = LinearToSrgb_u0028_vf3_u003b((¶m_1)); - frag_albedo = vec4(_e183.x, _e183.y, _e183.z, 1f); - let _e188 = g_debug_surface_on; - if _e188 { - let _e189 = g_debug_surface; - let _e190 = ViewDepth_u0028_(); - frag_surface = vec4(_e189.x, _e189.y, _e189.z, _e190); + let _e192 = g_albedo; + param_1 = clamp(_e192, vec3(0f, 0f, 0f), vec3(1f, 1f, 1f)); + let _e194 = LinearToSrgb_u0028_vf3_u003b((¶m_1)); + frag_albedo = vec4(_e194.x, _e194.y, _e194.z, 1f); + let _e199 = g_debug_surface_on; + if _e199 { + let _e200 = g_debug_surface; + let _e201 = ViewDepth_u0028_(); + frag_surface = vec4(_e200.x, _e200.y, _e200.z, _e201); return; } - let _e195 = v_normal_1; - geometric = normalize(_e195); - let _e197 = gl_FrontFacing_1; - if !(_e197) { - let _e199 = geometric; - geometric = -(_e199); - } - let _e201 = geometric; - param_2 = _e201; - let _e202 = EncodeOctahedral_u0028_vf3_u003b((¶m_2)); - let _e203 = (*roughness); - let _e205 = ViewDepth_u0028_(); - frag_surface = vec4(_e202.x, _e202.y, clamp(_e203, 0f, 1f), _e205); + let _e206 = v_normal_1; + geometric = normalize(_e206); + let _e208 = gl_FrontFacing_1; + if !(_e208) { + let _e210 = geometric; + geometric = -(_e210); + } + let _e212 = geometric; + param_2 = _e212; + let _e213 = EncodeOctahedral_u0028_vf3_u003b((¶m_2)); + let _e214 = (*roughness); + let _e216 = ViewDepth_u0028_(); + frag_surface = vec4(_e213.x, _e213.y, clamp(_e214, 0f, 1f), _e216); return; } +fn Orthographic_u0028_() -> bool { + let _e190 = fog_info.projection[0u]; + return (_e190 > 0.5f); +} + fn EyeDistance_u0028_() -> f32 { - let _e177 = v_world_position_1; - let _e179 = fog_info.eye; - return distance(_e177, _e179.xyz); + var local_1: f32; + + let _e189 = Orthographic_u0028_(); + if _e189 { + let _e190 = ViewDepth_u0028_(); + local_1 = max(_e190, 0f); + } else { + let _e192 = v_world_position_1; + let _e194 = fog_info.eye; + local_1 = distance(_e192, _e194.xyz); + } + let _e197 = local_1; + return _e197; } fn ApplyFog_u0028_vf3_u003b(color: ptr>) -> vec3 { var density: f32; var d: f32; + var falloff: f32; + var k: f32; + var local_2: f32; - let _e182 = fog_info.fog[3u]; - density = _e182; - let _e183 = density; - if (_e183 <= 0f) { - let _e185 = (*color); - return _e185; + let _e196 = fog_info.fog[3u]; + density = _e196; + let _e197 = density; + if (_e197 <= 0f) { + let _e199 = (*color); + return _e199; + } + let _e200 = EyeDistance_u0028_(); + d = _e200; + let _e203 = fog_info.eye[3u]; + falloff = _e203; + let _e204 = falloff; + if (_e204 > 0f) { + let _e206 = falloff; + let _e208 = v_world_position_1[1u]; + let _e211 = fog_info.eye[1u]; + k = (_e206 * (_e208 - _e211)); + let _e214 = k; + if (abs(_e214) > 0.001f) { + let _e217 = k; + let _e221 = k; + local_2 = ((1f - exp(-(_e217))) / _e221); + } else { + let _e223 = k; + local_2 = (1f - (0.5f * _e223)); + } + let _e226 = local_2; + let _e227 = density; + density = (_e227 * _e226); } - let _e186 = EyeDistance_u0028_(); - d = _e186; - let _e188 = fog_info.fog; - let _e190 = (*color); - let _e191 = density; - let _e193 = d; - return mix(_e188.xyz, _e190, vec3(clamp(exp((-(_e191) * _e193)), 0f, 1f))); + let _e230 = fog_info.fog; + let _e232 = (*color); + let _e233 = density; + let _e235 = d; + return mix(_e230.xyz, _e232, vec3(clamp(exp((-(_e233) * _e235)), 0f, 1f))); } fn WriteSurface_u0028_vf3_u003b_f1_u003b_f1_u003b(linearColor: ptr>, alpha_2: ptr, roughness_1: ptr) { @@ -21326,77 +23791,218 @@ fn WriteSurface_u0028_vf3_u003b_f1_u003b_f1_u003b(linearColor: ptr; var param_4: f32; - let _e183 = g_premultiply; - let _e184 = (*alpha_2); - weight_1 = select(1f, _e184, _e183); - let _e186 = (*linearColor); - param_3 = _e186; - let _e187 = ApplyFog_u0028_vf3_u003b((¶m_3)); - let _e188 = weight_1; - let _e189 = (_e187 * _e188); - let _e190 = (*alpha_2); - frag_color = vec4(_e189.x, _e189.y, _e189.z, _e190); - let _e195 = (*roughness_1); - param_4 = _e195; + let _e194 = g_premultiply; + let _e195 = (*alpha_2); + weight_1 = select(1f, _e195, _e194); + let _e197 = (*linearColor); + param_3 = _e197; + let _e198 = ApplyFog_u0028_vf3_u003b((¶m_3)); + let _e199 = weight_1; + let _e200 = (_e198 * _e199); + let _e201 = (*alpha_2); + let _e202 = g_pass_through; + frag_color = vec4(_e200.x, _e200.y, _e200.z, (_e201 * (1f - _e202))); + let _e209 = (*roughness_1); + param_4 = _e209; WriteSurfaceGeometry_u0028_f1_u003b((¶m_4)); WriteWeightedBlended_u0028_(); return; } -fn ShadeLight_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b_struct_u002d_LightSample_u002d_vf3_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf31_u003b(s: ptr, light: ptr) -> vec3 { +fn SrgbToLinear_u0028_vf3_u003b(srgb: ptr>) -> vec3 { + let _e189 = (*srgb); + let _e192 = (*srgb); + let _e197 = (*srgb); + return mix((_e189 / vec3(12.92f)), pow(((_e192 + vec3(0.055f, 0.055f, 0.055f)) / vec3(1.055f)), vec3(2.4f, 2.4f, 2.4f)), step(vec3(0.04045f, 0.04045f, 0.04045f), _e197)); +} + +fn NonFinite_u0028_vf3_u003b(c: ptr>) -> bool { + var phi_2562_: bool; + + let _e189 = (*c); + let _e190 = (*c); + let _e192 = any((_e189 != _e190)); + phi_2562_ = _e192; + if !(_e192) { + let _e194 = (*c); + phi_2562_ = any((abs(_e194) > vec3(300000000000000000000000000000000000000f, 300000000000000000000000000000000000000f, 300000000000000000000000000000000000000f))); + } + let _e199 = phi_2562_; + return _e199; +} + +fn WriteDebugView_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b_vf3_u003b(s: ptr, lit: ptr>) -> bool { + var view: f32; + var code: i32; + var shown: vec3; + var param_5: vec3; + var param_6: vec3; + var grey: f32; + var param_7: vec3; + var param_8: vec3; + var local_3: vec3; + var weight_2: f32; + var local_4: f32; + var param_9: vec3; + var param_10: f32; + + let _e205 = frag_info.debug_view[0u]; + view = _e205; + let _e206 = view; + if (_e206 < 0.5f) { + return false; + } + let _e209 = gl_FragCoord_1[0u]; + let _e212 = frag_info.debug_view[1u]; + let _e215 = frag_info.debug_view[2u]; + if (_e209 < (_e212 * _e215)) { + return false; + } + let _e218 = view; + code = i32((_e218 + 0.5f)); + shown = vec3(0f, 0f, 0f); + let _e221 = code; + if (_e221 == 1i) { + let _e224 = (*s).albedo; + param_5 = clamp(_e224, vec3(0f, 0f, 0f), vec3(1f, 1f, 1f)); + let _e226 = LinearToSrgb_u0028_vf3_u003b((¶m_5)); + shown = _e226; + } else { + let _e227 = code; + if (_e227 == 2i) { + let _e230 = (*s).n; + shown = ((_e230 * 0.5f) + vec3(0.5f, 0.5f, 0.5f)); + } else { + let _e233 = code; + if (_e233 == 3i) { + let _e236 = (*s).roughness; + shown = vec3(clamp(_e236, 0f, 1f)); + } else { + let _e239 = code; + if (_e239 == 4i) { + let _e242 = (*s).metallic; + shown = vec3(clamp(_e242, 0f, 1f)); + } else { + let _e245 = code; + if (_e245 == 5i) { + let _e248 = (*s).occlusion; + shown = vec3(clamp(_e248, 0f, 1f)); + } else { + let _e251 = code; + if (_e251 == 6i) { + let _e254 = (*s).emissive; + param_6 = clamp(_e254, vec3(0f, 0f, 0f), vec3(1f, 1f, 1f)); + let _e256 = LinearToSrgb_u0028_vf3_u003b((¶m_6)); + shown = _e256; + } else { + let _e257 = code; + if (_e257 == 7i) { + let _e259 = v_texcoord_1; + let _e260 = fract(_e259); + shown = vec3(_e260.x, _e260.y, 0f); + } else { + let _e264 = code; + if (_e264 == 8i) { + let _e266 = (*lit); + param_7 = clamp(_e266, vec3(0f, 0f, 0f), vec3(1f, 1f, 1f)); + let _e268 = LinearToSrgb_u0028_vf3_u003b((¶m_7)); + grey = dot(_e268, vec3(0.2126f, 0.7152f, 0.0722f)); + let _e270 = (*lit); + param_8 = _e270; + let _e271 = NonFinite_u0028_vf3_u003b((¶m_8)); + if _e271 { + local_3 = vec3(1f, 0f, 1f); + } else { + let _e272 = grey; + local_3 = vec3((_e272 * 0.5f)); + } + let _e275 = local_3; + shown = _e275; + } + } + } + } + } + } + } + } + let _e276 = g_premultiply; + if _e276 { + let _e278 = (*s).alpha; + local_4 = _e278; + } else { + local_4 = 1f; + } + let _e279 = local_4; + weight_2 = _e279; + let _e280 = shown; + param_9 = _e280; + let _e281 = SrgbToLinear_u0028_vf3_u003b((¶m_9)); + let _e282 = weight_2; + let _e283 = (_e281 * _e282); + let _e285 = (*s).alpha; + frag_color = vec4(_e283.x, _e283.y, _e283.z, _e285); + let _e291 = (*s).roughness; + param_10 = _e291; + WriteSurfaceGeometry_u0028_f1_u003b((¶m_10)); + WriteWeightedBlended_u0028_(); + return true; +} + +fn ShadeLight_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b_struct_u002d_LightSample_u002d_vf3_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf31_u003b(s_1: ptr, light: ptr) -> vec3 { var bands: f32; var quantized: f32; var fraction: f32; var ramp: f32; - let _e184 = (*s).roughness; - bands = mix(5f, 2f, _e184); - let _e187 = (*light).n_dot_l; - let _e188 = bands; - let _e191 = bands; - quantized = (floor((_e187 * _e188)) / _e191); - let _e194 = (*light).n_dot_l; - let _e195 = bands; - fraction = fract((_e194 * _e195)); - let _e198 = fraction; - let _e200 = bands; - let _e202 = quantized; - quantized = (_e202 + (smoothstep(0.85f, 1f, _e198) / _e200)); - let _e204 = quantized; - let _e206 = (*light).n_dot_l; - ramp = (_e204 / max(_e206, 0.001f)); - let _e210 = (*s).albedo; - let _e211 = ramp; - return (_e210 * _e211); + let _e195 = (*s_1).roughness; + bands = mix(5f, 2f, _e195); + let _e198 = (*light).n_dot_l; + let _e199 = bands; + let _e202 = bands; + quantized = (floor((_e198 * _e199)) / _e202); + let _e205 = (*light).n_dot_l; + let _e206 = bands; + fraction = fract((_e205 * _e206)); + let _e209 = fraction; + let _e211 = bands; + let _e213 = quantized; + quantized = (_e213 + (smoothstep(0.85f, 1f, _e209) / _e211)); + let _e215 = quantized; + let _e217 = (*light).n_dot_l; + ramp = (_e215 / max(_e217, 0.001f)); + let _e221 = (*s_1).albedo; + let _e222 = ramp; + return (_e221 * _e222); } fn PointShadowDiskTap_u0028_i1_u003b(i: ptr) -> vec2 { - let _e178 = (*i); - if (_e178 == 0i) { + let _e189 = (*i); + if (_e189 == 0i) { return vec2(-0.94201624f, -0.39906216f); } - let _e180 = (*i); - if (_e180 == 1i) { + let _e191 = (*i); + if (_e191 == 1i) { return vec2(0.9455861f, -0.76890725f); } - let _e182 = (*i); - if (_e182 == 2i) { + let _e193 = (*i); + if (_e193 == 2i) { return vec2(-0.0941841f, -0.9293887f); } - let _e184 = (*i); - if (_e184 == 3i) { + let _e195 = (*i); + if (_e195 == 3i) { return vec2(0.34495938f, 0.2938776f); } - let _e186 = (*i); - if (_e186 == 4i) { + let _e197 = (*i); + if (_e197 == 4i) { return vec2(-0.9158858f, 0.45771432f); } - let _e188 = (*i); - if (_e188 == 5i) { + let _e199 = (*i); + if (_e199 == 5i) { return vec2(-0.8154423f, -0.87912464f); } - let _e190 = (*i); - if (_e190 == 6i) { + let _e201 = (*i); + if (_e201 == 6i) { return vec2(-0.38277543f, 0.27676845f); } return vec2(0.974844f, 0.7564838f); @@ -21404,208 +24010,208 @@ fn PointShadowDiskTap_u0028_i1_u003b(i: ptr) -> vec2 { fn PointShadowOffset_u0028_i1_u003b_f1_u003b_f1_u003b_f1_u003b(i_1: ptr, ca: ptr, sa: ptr, radius: ptr) -> vec2 { var p: vec2; - var param_5: i32; - - let _e183 = (*i_1); - param_5 = _e183; - let _e184 = PointShadowDiskTap_u0028_i1_u003b((¶m_5)); - p = _e184; - let _e186 = p[0u]; - let _e187 = (*ca); - let _e190 = p[1u]; - let _e191 = (*sa); - let _e195 = p[0u]; - let _e196 = (*sa); - let _e199 = p[1u]; - let _e200 = (*ca); - let _e204 = (*radius); - return (vec2(((_e186 * _e187) - (_e190 * _e191)), ((_e195 * _e196) + (_e199 * _e200))) * _e204); + var param_11: i32; + + let _e194 = (*i_1); + param_11 = _e194; + let _e195 = PointShadowDiskTap_u0028_i1_u003b((¶m_11)); + p = _e195; + let _e197 = p[0u]; + let _e198 = (*ca); + let _e201 = p[1u]; + let _e202 = (*sa); + let _e206 = p[0u]; + let _e207 = (*sa); + let _e210 = p[1u]; + let _e211 = (*ca); + let _e215 = (*radius); + return (vec2(((_e197 * _e198) - (_e201 * _e202)), ((_e206 * _e207) + (_e210 * _e211))) * _e215); } fn PointShadowDistance_u0028_vf2_u003b_vf2_u003b_vf2_u003b_f1_u003b(uv: ptr>, offset: ptr>, tile: ptr>, range: ptr) -> f32 { var inset: f32; - var local_1: vec2; + var local_5: vec2; var atlas: vec2; - let _e186 = point_shadow.params[0u]; - inset = _e186; - let _e187 = (*uv); - let _e188 = (*offset); - let _e190 = inset; - let _e191 = inset; - local_1 = clamp((_e187 + _e188), vec2(_e190), vec2((1f - _e191))); - let _e196 = local_1; - let _e197 = (*tile); - atlas = ((_e196 + _e197) * vec2(0.16666667f, 0.16666667f)); - let _e202 = point_shadow.params3_[0u]; - if (_e202 > 0.5f) { - let _e205 = atlas[1u]; - atlas[1u] = (1f - _e205); - } - let _e208 = atlas; - let _e209 = textureSampleLevel(point_shadow_texture_tex, point_shadow_texture_smp, _e208, 0f); - let _e211 = atlas; - let _e212 = textureSampleLevel(point_shadow_static_texture_tex, point_shadow_static_texture_smp, _e211, 0f); - let _e215 = (*range); - return (min(_e209.x, _e212.x) * _e215); + let _e197 = point_shadow.params[0u]; + inset = _e197; + let _e198 = (*uv); + let _e199 = (*offset); + let _e201 = inset; + let _e202 = inset; + local_5 = clamp((_e198 + _e199), vec2(_e201), vec2((1f - _e202))); + let _e207 = local_5; + let _e208 = (*tile); + atlas = ((_e207 + _e208) * vec2(0.16666667f, 0.16666667f)); + let _e213 = point_shadow.params3_[0u]; + if (_e213 > 0.5f) { + let _e216 = atlas[1u]; + atlas[1u] = (1f - _e216); + } + let _e219 = atlas; + let _e220 = textureSampleLevel(point_shadow_texture_tex, point_shadow_texture_smp, _e219, 0f); + let _e222 = atlas; + let _e223 = textureSampleLevel(point_shadow_static_texture_tex, point_shadow_static_texture_smp, _e222, 0f); + let _e226 = (*range); + return (min(_e220.x, _e223.x) * _e226); } fn PointShadowTap_u0028_vf2_u003b_vf2_u003b_vf2_u003b_f1_u003b_f1_u003b(uv_1: ptr>, offset_1: ptr>, tile_1: ptr>, range_1: ptr, receiver: ptr) -> f32 { var stored: f32; - var param_6: vec2; - var param_7: vec2; - var param_8: vec2; - var param_9: f32; + var param_12: vec2; + var param_13: vec2; + var param_14: vec2; + var param_15: f32; - let _e187 = (*uv_1); - param_6 = _e187; - let _e188 = (*offset_1); - param_7 = _e188; - let _e189 = (*tile_1); - param_8 = _e189; - let _e190 = (*range_1); - param_9 = _e190; - let _e191 = PointShadowDistance_u0028_vf2_u003b_vf2_u003b_vf2_u003b_f1_u003b((¶m_6), (¶m_7), (¶m_8), (¶m_9)); - stored = _e191; - let _e192 = stored; - let _e193 = (*range_1); - if (_e192 >= (_e193 * 0.999f)) { + let _e198 = (*uv_1); + param_12 = _e198; + let _e199 = (*offset_1); + param_13 = _e199; + let _e200 = (*tile_1); + param_14 = _e200; + let _e201 = (*range_1); + param_15 = _e201; + let _e202 = PointShadowDistance_u0028_vf2_u003b_vf2_u003b_vf2_u003b_f1_u003b((¶m_12), (¶m_13), (¶m_14), (¶m_15)); + stored = _e202; + let _e203 = stored; + let _e204 = (*range_1); + if (_e203 >= (_e204 * 0.999f)) { return 1f; } - let _e196 = (*receiver); - let _e197 = stored; - return select(1f, 0f, (_e196 > _e197)); + let _e207 = (*receiver); + let _e208 = stored; + return select(1f, 0f, (_e207 > _e208)); } fn PointShadowPenumbra_u0028_vf2_u003b_vf2_u003b_f1_u003b_f1_u003b_f1_u003b_f1_u003b_f1_u003b_f1_u003b(uv_2: ptr>, tile_2: ptr>, range_2: ptr, receiver_1: ptr, ca_1: ptr, sa_1: ptr, tanHalf: ptr, blockerOut: ptr) -> f32 { var lightRadius: f32; var minRadius: f32; var maxRadius: f32; - var param_10: vec2; - var param_11: vec2; - var param_12: vec2; - var param_13: f32; + var param_16: vec2; + var param_17: vec2; + var param_18: vec2; + var param_19: f32; var sum: f32; var count: f32; var i_2: i32; var stored_1: f32; - var param_14: i32; - var param_15: f32; - var param_16: f32; - var param_17: f32; - var param_18: vec2; - var param_19: vec2; - var param_20: vec2; + var param_20: i32; var param_21: f32; + var param_22: f32; + var param_23: f32; + var param_24: vec2; + var param_25: vec2; + var param_26: vec2; + var param_27: f32; var blocker: f32; var world: f32; (*blockerOut) = -1f; - let _e208 = point_shadow.params2_[1u]; - lightRadius = _e208; - let _e211 = point_shadow.params2_[0u]; - minRadius = _e211; - let _e214 = point_shadow.params2_[2u]; - maxRadius = _e214; - let _e215 = lightRadius; - if (_e215 <= 0f) { - let _e217 = (*uv_2); - param_10 = _e217; - param_11 = vec2(0f, 0f); - let _e218 = (*tile_2); - param_12 = _e218; - let _e219 = (*range_2); - param_13 = _e219; - let _e220 = PointShadowDistance_u0028_vf2_u003b_vf2_u003b_vf2_u003b_f1_u003b((¶m_10), (¶m_11), (¶m_12), (¶m_13)); - (*blockerOut) = _e220; - let _e221 = minRadius; - return _e221; + let _e219 = point_shadow.params2_[1u]; + lightRadius = _e219; + let _e222 = point_shadow.params2_[0u]; + minRadius = _e222; + let _e225 = point_shadow.params2_[2u]; + maxRadius = _e225; + let _e226 = lightRadius; + if (_e226 <= 0f) { + let _e228 = (*uv_2); + param_16 = _e228; + param_17 = vec2(0f, 0f); + let _e229 = (*tile_2); + param_18 = _e229; + let _e230 = (*range_2); + param_19 = _e230; + let _e231 = PointShadowDistance_u0028_vf2_u003b_vf2_u003b_vf2_u003b_f1_u003b((¶m_16), (¶m_17), (¶m_18), (¶m_19)); + (*blockerOut) = _e231; + let _e232 = minRadius; + return _e232; } sum = 0f; count = 0f; i_2 = 0i; loop { - let _e222 = i_2; - if (_e222 < 8i) { - let _e224 = i_2; - param_14 = _e224; - let _e225 = (*ca_1); - param_15 = _e225; - let _e226 = (*sa_1); - param_16 = _e226; - let _e227 = maxRadius; - param_17 = _e227; - let _e228 = PointShadowOffset_u0028_i1_u003b_f1_u003b_f1_u003b_f1_u003b((¶m_14), (¶m_15), (¶m_16), (¶m_17)); - let _e229 = (*uv_2); - param_18 = _e229; - param_19 = _e228; - let _e230 = (*tile_2); - param_20 = _e230; - let _e231 = (*range_2); - param_21 = _e231; - let _e232 = PointShadowDistance_u0028_vf2_u003b_vf2_u003b_vf2_u003b_f1_u003b((¶m_18), (¶m_19), (¶m_20), (¶m_21)); - stored_1 = _e232; - let _e233 = stored_1; - let _e234 = (*range_2); - if (_e233 >= (_e234 * 0.999f)) { + let _e233 = i_2; + if (_e233 < 8i) { + let _e235 = i_2; + param_20 = _e235; + let _e236 = (*ca_1); + param_21 = _e236; + let _e237 = (*sa_1); + param_22 = _e237; + let _e238 = maxRadius; + param_23 = _e238; + let _e239 = PointShadowOffset_u0028_i1_u003b_f1_u003b_f1_u003b_f1_u003b((¶m_20), (¶m_21), (¶m_22), (¶m_23)); + let _e240 = (*uv_2); + param_24 = _e240; + param_25 = _e239; + let _e241 = (*tile_2); + param_26 = _e241; + let _e242 = (*range_2); + param_27 = _e242; + let _e243 = PointShadowDistance_u0028_vf2_u003b_vf2_u003b_vf2_u003b_f1_u003b((¶m_24), (¶m_25), (¶m_26), (¶m_27)); + stored_1 = _e243; + let _e244 = stored_1; + let _e245 = (*range_2); + if (_e244 >= (_e245 * 0.999f)) { continue; } - let _e237 = stored_1; - let _e238 = (*receiver_1); - if (_e237 >= _e238) { + let _e248 = stored_1; + let _e249 = (*receiver_1); + if (_e248 >= _e249) { continue; } - let _e240 = stored_1; - let _e241 = sum; - sum = (_e241 + _e240); - let _e243 = count; - count = (_e243 + 1f); + let _e251 = stored_1; + let _e252 = sum; + sum = (_e252 + _e251); + let _e254 = count; + count = (_e254 + 1f); continue; } else { break; } continuing { - let _e245 = i_2; - i_2 = (_e245 + 1i); + let _e256 = i_2; + i_2 = (_e256 + 1i); } } - let _e247 = count; - if (_e247 < 0.5f) { + let _e258 = count; + if (_e258 < 0.5f) { return -1f; } - let _e249 = sum; - let _e250 = count; - blocker = max((_e249 / _e250), 0.0001f); - let _e253 = blocker; - (*blockerOut) = _e253; - let _e254 = lightRadius; - let _e255 = (*receiver_1); - let _e256 = blocker; - let _e260 = blocker; - world = ((_e254 * max((_e255 - _e256), 0f)) / _e260); - let _e262 = world; - let _e263 = (*receiver_1); - let _e265 = (*tanHalf); - let _e268 = minRadius; - let _e269 = maxRadius; - return clamp((_e262 / ((2f * _e263) * _e265)), _e268, _e269); + let _e260 = sum; + let _e261 = count; + blocker = max((_e260 / _e261), 0.0001f); + let _e264 = blocker; + (*blockerOut) = _e264; + let _e265 = lightRadius; + let _e266 = (*receiver_1); + let _e267 = blocker; + let _e271 = blocker; + world = ((_e265 * max((_e266 - _e267), 0f)) / _e271); + let _e273 = world; + let _e274 = (*receiver_1); + let _e276 = (*tanHalf); + let _e279 = minRadius; + let _e280 = maxRadius; + return clamp((_e273 / ((2f * _e274) * _e276)), _e279, _e280); } fn FragCoordFromTop_u0028_f1_u003b(rows: ptr) -> vec2 { - var local_2: vec2; + var local_6: vec2; - let _e179 = (*rows); - if (_e179 > 0f) { - let _e182 = gl_FragCoord_1[0u]; - let _e183 = (*rows); - let _e185 = gl_FragCoord_1[1u]; - local_2 = vec2(_e182, (_e183 - _e185)); + let _e190 = (*rows); + if (_e190 > 0f) { + let _e193 = gl_FragCoord_1[0u]; + let _e194 = (*rows); + let _e196 = gl_FragCoord_1[1u]; + local_6 = vec2(_e193, (_e194 - _e196)); } else { - let _e188 = gl_FragCoord_1; - local_2 = _e188.xy; + let _e199 = gl_FragCoord_1; + local_6 = _e199.xy; } - let _e190 = local_2; - return _e190; + let _e201 = local_6; + return _e201; } fn PointShadowFactor_u0028_vf3_u003b_vf3_u003b_i1_u003b(world_1: ptr>, normal: ptr>, lightIndex: ptr) -> f32 { @@ -21628,292 +24234,292 @@ fn PointShadowFactor_u0028_vf3_u003b_vf3_u003b_i1_u003b(world_1: ptr; var receiver_2: f32; var noise: f32; - var param_22: f32; + var param_28: f32; var angle: f32; var ca_2: f32; var sa_2: f32; var tanHalf_1: f32; var blocker_1: f32; var radius_1: f32; - var param_23: vec2; - var param_24: vec2; - var param_25: f32; - var param_26: f32; - var param_27: f32; - var param_28: f32; - var param_29: f32; - var param_30: f32; - var local_3: vec3; - var lit: f32; - var param_31: vec2; - var param_32: vec2; - var param_33: vec2; + var param_29: vec2; + var param_30: vec2; + var param_31: f32; + var param_32: f32; + var param_33: f32; var param_34: f32; var param_35: f32; + var param_36: f32; + var local_7: vec3; + var lit_1: f32; + var param_37: vec2; + var param_38: vec2; + var param_39: vec2; + var param_40: f32; + var param_41: f32; var i_3: i32; - var param_36: i32; - var param_37: f32; - var param_38: f32; - var param_39: f32; - var param_40: vec2; - var param_41: vec2; - var param_42: vec2; + var param_42: i32; var param_43: f32; var param_44: f32; - var phi_2149_: bool; - var phi_2210_: bool; - - let _e231 = (*lightIndex); - let _e235 = point_shadow.slots[_e231][0u]; - slot = i32((_e235 + 0.5f)); - let _e238 = (*lightIndex); - let _e242 = point_shadow.slots[_e238][0u]; - if (_e242 < 0f) { + var param_45: f32; + var param_46: vec2; + var param_47: vec2; + var param_48: vec2; + var param_49: f32; + var param_50: f32; + var phi_2249_: bool; + var phi_2310_: bool; + + let _e242 = (*lightIndex); + let _e246 = point_shadow.slots[_e242][0u]; + slot = i32((_e246 + 0.5f)); + let _e249 = (*lightIndex); + let _e253 = point_shadow.slots[_e249][0u]; + if (_e253 < 0f) { return 1f; } - let _e246 = point_shadow.params[2u]; - strength = _e246; - let _e247 = strength; - if (_e247 <= 0f) { + let _e257 = point_shadow.params[2u]; + strength = _e257; + let _e258 = strength; + if (_e258 <= 0f) { return 1f; } - let _e249 = slot; - let _e252 = point_shadow.lights[_e249]; - let _e254 = (*world_1); - toLight = (_e252.xyz - _e254); - let _e256 = toLight; - toLightLength = max(length(_e256), 0.000001f); - let _e259 = (*normal); - let _e260 = toLight; - let _e261 = toLightLength; - nDotL = max(dot(_e259, (_e260 / vec3(_e261))), 0.15f); - let _e266 = nDotL; - let _e267 = nDotL; - let _e272 = nDotL; - let _e273 = nDotL; - slope = min((sqrt(max((1f - (_e266 * _e267)), 0f)) / (_e272 * _e273)), 8f); - let _e277 = toLightLength; - let _e279 = (*lightIndex); - let _e283 = point_shadow.slots[_e279][2u]; - let _e288 = point_shadow.params3_[1u]; - texel = (((2f * _e277) * max(_e283, 0.0001f)) * _e288); - let _e290 = (*world_1); - let _e291 = (*normal); - let _e292 = texel; - let _e296 = point_shadow.params[3u]; - let _e298 = slope; - origin = (_e290 + (((_e291 * _e292) * _e296) * (1f + _e298))); - let _e302 = origin; - let _e303 = slot; - let _e306 = point_shadow.lights[_e303]; - toFragment = (_e302 - _e306.xyz); - let _e309 = toFragment; - distance_ = length(_e309); - let _e311 = slot; - let _e315 = point_shadow.lights[_e311][3u]; - range_3 = max(_e315, 0.0001f); - let _e317 = distance_; - let _e318 = range_3; - if (_e317 >= _e318) { + let _e260 = slot; + let _e263 = point_shadow.lights[_e260]; + let _e265 = (*world_1); + toLight = (_e263.xyz - _e265); + let _e267 = toLight; + toLightLength = max(length(_e267), 0.000001f); + let _e270 = (*normal); + let _e271 = toLight; + let _e272 = toLightLength; + nDotL = max(dot(_e270, (_e271 / vec3(_e272))), 0.15f); + let _e277 = nDotL; + let _e278 = nDotL; + let _e283 = nDotL; + let _e284 = nDotL; + slope = min((sqrt(max((1f - (_e277 * _e278)), 0f)) / (_e283 * _e284)), 8f); + let _e288 = toLightLength; + let _e290 = (*lightIndex); + let _e294 = point_shadow.slots[_e290][2u]; + let _e299 = point_shadow.params3_[1u]; + texel = (((2f * _e288) * max(_e294, 0.0001f)) * _e299); + let _e301 = (*world_1); + let _e302 = (*normal); + let _e303 = texel; + let _e307 = point_shadow.params[3u]; + let _e309 = slope; + origin = (_e301 + (((_e302 * _e303) * _e307) * (1f + _e309))); + let _e313 = origin; + let _e314 = slot; + let _e317 = point_shadow.lights[_e314]; + toFragment = (_e313 - _e317.xyz); + let _e320 = toFragment; + distance_ = length(_e320); + let _e322 = slot; + let _e326 = point_shadow.lights[_e322][3u]; + range_3 = max(_e326, 0.0001f); + let _e328 = distance_; + let _e329 = range_3; + if (_e328 >= _e329) { return 1f; } face = 0i; - let _e320 = (*lightIndex); - let _e324 = point_shadow.slots[_e320][1u]; - if (_e324 < 0.5f) { - let _e326 = toFragment; - a = abs(_e326); - let _e329 = a[0u]; - let _e331 = a[1u]; - let _e332 = (_e329 >= _e331); - phi_2149_ = _e332; - if _e332 { - let _e334 = a[0u]; - let _e336 = a[2u]; - phi_2149_ = (_e334 >= _e336); - } - let _e339 = phi_2149_; - if _e339 { - let _e341 = toFragment[0u]; - face = select(1i, 0i, (_e341 > 0f)); - } else { - let _e345 = a[1u]; + let _e331 = (*lightIndex); + let _e335 = point_shadow.slots[_e331][1u]; + if (_e335 < 0.5f) { + let _e337 = toFragment; + a = abs(_e337); + let _e340 = a[0u]; + let _e342 = a[1u]; + let _e343 = (_e340 >= _e342); + phi_2249_ = _e343; + if _e343 { + let _e345 = a[0u]; let _e347 = a[2u]; - if (_e345 >= _e347) { - let _e350 = toFragment[1u]; - face = select(3i, 2i, (_e350 > 0f)); + phi_2249_ = (_e345 >= _e347); + } + let _e350 = phi_2249_; + if _e350 { + let _e352 = toFragment[0u]; + face = select(1i, 0i, (_e352 > 0f)); + } else { + let _e356 = a[1u]; + let _e358 = a[2u]; + if (_e356 >= _e358) { + let _e361 = toFragment[1u]; + face = select(3i, 2i, (_e361 > 0f)); } else { - let _e354 = toFragment[2u]; - face = select(5i, 4i, (_e354 > 0f)); + let _e365 = toFragment[2u]; + face = select(5i, 4i, (_e365 > 0f)); } } } - let _e357 = slot; - let _e359 = face; - let _e363 = point_shadow.faces[((_e357 * 6i) + _e359)]; - let _e364 = origin; - clip = (_e363 * vec4(_e364.x, _e364.y, _e364.z, 1f)); - let _e371 = clip[3u]; - if (_e371 <= 0f) { + let _e368 = slot; + let _e370 = face; + let _e374 = point_shadow.faces[((_e368 * 6i) + _e370)]; + let _e375 = origin; + clip = (_e374 * vec4(_e375.x, _e375.y, _e375.z, 1f)); + let _e382 = clip[3u]; + if (_e382 <= 0f) { return 1f; } - let _e373 = clip; - let _e376 = clip[3u]; - ndc = (_e373.xy / vec2(_e376)); - let _e380 = ndc[0u]; - let _e382 = (abs(_e380) > 1f); - phi_2210_ = _e382; - if !(_e382) { - let _e385 = ndc[1u]; - phi_2210_ = (abs(_e385) > 1f); - } - let _e389 = phi_2210_; - if _e389 { + let _e384 = clip; + let _e387 = clip[3u]; + ndc = (_e384.xy / vec2(_e387)); + let _e391 = ndc[0u]; + let _e393 = (abs(_e391) > 1f); + phi_2310_ = _e393; + if !(_e393) { + let _e396 = ndc[1u]; + phi_2310_ = (abs(_e396) > 1f); + } + let _e400 = phi_2310_; + if _e400 { return 1f; } - let _e391 = ndc[0u]; - let _e395 = ndc[1u]; - uv_3 = vec2(((_e391 * 0.5f) + 0.5f), (0.5f - (_e395 * 0.5f))); - let _e399 = face; - let _e401 = slot; - tile_3 = vec2(f32(_e399), f32(_e401)); - let _e404 = distance_; - let _e407 = point_shadow.params[1u]; - receiver_2 = (_e404 - _e407); - let _e411 = frag_info.target_origin[0u]; - param_22 = _e411; - let _e412 = FragCoordFromTop_u0028_f1_u003b((¶m_22)); - noise = fract((52.982918f * fract(dot(_e412, vec2(0.06711056f, 0.00583715f))))); - let _e417 = noise; - angle = (_e417 * 6.2831855f); - let _e419 = angle; - ca_2 = cos(_e419); - let _e421 = angle; - sa_2 = sin(_e421); - let _e423 = (*lightIndex); - let _e427 = point_shadow.slots[_e423][2u]; - tanHalf_1 = max(_e427, 0.0001f); + let _e402 = ndc[0u]; + let _e406 = ndc[1u]; + uv_3 = vec2(((_e402 * 0.5f) + 0.5f), (0.5f - (_e406 * 0.5f))); + let _e410 = face; + let _e412 = slot; + tile_3 = vec2(f32(_e410), f32(_e412)); + let _e415 = distance_; + let _e418 = point_shadow.params[1u]; + receiver_2 = (_e415 - _e418); + let _e422 = frag_info.target_origin[0u]; + param_28 = _e422; + let _e423 = FragCoordFromTop_u0028_f1_u003b((¶m_28)); + noise = fract((52.982918f * fract(dot(_e423, vec2(0.06711056f, 0.00583715f))))); + let _e428 = noise; + angle = (_e428 * 6.2831855f); + let _e430 = angle; + ca_2 = cos(_e430); + let _e432 = angle; + sa_2 = sin(_e432); + let _e434 = (*lightIndex); + let _e438 = point_shadow.slots[_e434][2u]; + tanHalf_1 = max(_e438, 0.0001f); blocker_1 = -1f; - let _e429 = uv_3; - param_23 = _e429; - let _e430 = tile_3; - param_24 = _e430; - let _e431 = range_3; - param_25 = _e431; - let _e432 = receiver_2; - param_26 = _e432; - let _e433 = ca_2; - param_27 = _e433; - let _e434 = sa_2; - param_28 = _e434; - let _e435 = tanHalf_1; - param_29 = _e435; - let _e436 = PointShadowPenumbra_u0028_vf2_u003b_vf2_u003b_f1_u003b_f1_u003b_f1_u003b_f1_u003b_f1_u003b_f1_u003b((¶m_23), (¶m_24), (¶m_25), (¶m_26), (¶m_27), (¶m_28), (¶m_29), (¶m_30)); - let _e437 = param_30; - blocker_1 = _e437; - radius_1 = _e436; - let _e440 = point_shadow.params2_[3u]; - if (_e440 > 0.5f) { + let _e440 = uv_3; + param_29 = _e440; + let _e441 = tile_3; + param_30 = _e441; + let _e442 = range_3; + param_31 = _e442; + let _e443 = receiver_2; + param_32 = _e443; + let _e444 = ca_2; + param_33 = _e444; + let _e445 = sa_2; + param_34 = _e445; + let _e446 = tanHalf_1; + param_35 = _e446; + let _e447 = PointShadowPenumbra_u0028_vf2_u003b_vf2_u003b_f1_u003b_f1_u003b_f1_u003b_f1_u003b_f1_u003b_f1_u003b((¶m_29), (¶m_30), (¶m_31), (¶m_32), (¶m_33), (¶m_34), (¶m_35), (¶m_36)); + let _e448 = param_36; + blocker_1 = _e448; + radius_1 = _e447; + let _e451 = point_shadow.params2_[3u]; + if (_e451 > 0.5f) { g_debug_surface_on = true; - let _e442 = radius_1; - if (_e442 < 0f) { - local_3 = vec3(0f, 0f, 1f); + let _e453 = radius_1; + if (_e453 < 0f) { + local_7 = vec3(0f, 0f, 1f); } else { - let _e444 = radius_1; - let _e447 = point_shadow.params2_[2u]; - let _e451 = blocker_1; - let _e452 = range_3; - local_3 = vec3(clamp((_e444 / max(_e447, 0.000001f)), 0f, 1f), clamp((_e451 / _e452), 0f, 1f), 0f); + let _e455 = radius_1; + let _e458 = point_shadow.params2_[2u]; + let _e462 = blocker_1; + let _e463 = range_3; + local_7 = vec3(clamp((_e455 / max(_e458, 0.000001f)), 0f, 1f), clamp((_e462 / _e463), 0f, 1f), 0f); } - let _e456 = local_3; - g_debug_surface = _e456; + let _e467 = local_7; + g_debug_surface = _e467; } - let _e457 = radius_1; - if (_e457 < 0f) { + let _e468 = radius_1; + if (_e468 < 0f) { return 1f; } - let _e459 = uv_3; - param_31 = _e459; - param_32 = vec2(0f, 0f); - let _e460 = tile_3; - param_33 = _e460; - let _e461 = range_3; - param_34 = _e461; - let _e462 = receiver_2; - param_35 = _e462; - let _e463 = PointShadowTap_u0028_vf2_u003b_vf2_u003b_vf2_u003b_f1_u003b_f1_u003b((¶m_31), (¶m_32), (¶m_33), (¶m_34), (¶m_35)); - lit = _e463; - let _e464 = radius_1; - if (_e464 > 0f) { + let _e470 = uv_3; + param_37 = _e470; + param_38 = vec2(0f, 0f); + let _e471 = tile_3; + param_39 = _e471; + let _e472 = range_3; + param_40 = _e472; + let _e473 = receiver_2; + param_41 = _e473; + let _e474 = PointShadowTap_u0028_vf2_u003b_vf2_u003b_vf2_u003b_f1_u003b_f1_u003b((¶m_37), (¶m_38), (¶m_39), (¶m_40), (¶m_41)); + lit_1 = _e474; + let _e475 = radius_1; + if (_e475 > 0f) { i_3 = 0i; loop { - let _e466 = i_3; - if (_e466 < 8i) { - let _e468 = i_3; - param_36 = _e468; - let _e469 = ca_2; - param_37 = _e469; - let _e470 = sa_2; - param_38 = _e470; - let _e471 = radius_1; - param_39 = _e471; - let _e472 = PointShadowOffset_u0028_i1_u003b_f1_u003b_f1_u003b_f1_u003b((¶m_36), (¶m_37), (¶m_38), (¶m_39)); - let _e473 = uv_3; - param_40 = _e473; - param_41 = _e472; - let _e474 = tile_3; - param_42 = _e474; - let _e475 = range_3; - param_43 = _e475; - let _e476 = receiver_2; - param_44 = _e476; - let _e477 = PointShadowTap_u0028_vf2_u003b_vf2_u003b_vf2_u003b_f1_u003b_f1_u003b((¶m_40), (¶m_41), (¶m_42), (¶m_43), (¶m_44)); - let _e478 = lit; - lit = (_e478 + _e477); + let _e477 = i_3; + if (_e477 < 8i) { + let _e479 = i_3; + param_42 = _e479; + let _e480 = ca_2; + param_43 = _e480; + let _e481 = sa_2; + param_44 = _e481; + let _e482 = radius_1; + param_45 = _e482; + let _e483 = PointShadowOffset_u0028_i1_u003b_f1_u003b_f1_u003b_f1_u003b((¶m_42), (¶m_43), (¶m_44), (¶m_45)); + let _e484 = uv_3; + param_46 = _e484; + param_47 = _e483; + let _e485 = tile_3; + param_48 = _e485; + let _e486 = range_3; + param_49 = _e486; + let _e487 = receiver_2; + param_50 = _e487; + let _e488 = PointShadowTap_u0028_vf2_u003b_vf2_u003b_vf2_u003b_f1_u003b_f1_u003b((¶m_46), (¶m_47), (¶m_48), (¶m_49), (¶m_50)); + let _e489 = lit_1; + lit_1 = (_e489 + _e488); continue; } else { break; } continuing { - let _e480 = i_3; - i_3 = (_e480 + 1i); + let _e491 = i_3; + i_3 = (_e491 + 1i); } } - let _e482 = lit; - lit = (_e482 * 0.11111111f); + let _e493 = lit_1; + lit_1 = (_e493 * 0.11111111f); } - let _e484 = lit; - let _e485 = strength; - return mix(1f, _e484, clamp(_e485, 0f, 1f)); + let _e495 = lit_1; + let _e496 = strength; + return mix(1f, _e495, clamp(_e496, 0f, 1f)); } fn VogelDisc_u0028_i1_u003b_i1_u003b_f1_u003b(i_4: ptr, n_1: ptr, turn: ptr) -> vec2 { var r: f32; var theta: f32; - let _e182 = (*i_4); - let _e185 = (*n_1); - r = sqrt(((f32(_e182) + 0.5f) / f32(_e185))); - let _e189 = (*i_4); - let _e192 = (*turn); - theta = ((f32(_e189) * 2.3999631f) + _e192); - let _e194 = r; - let _e195 = theta; - let _e197 = theta; - return (vec2(cos(_e195), sin(_e197)) * _e194); + let _e193 = (*i_4); + let _e196 = (*n_1); + r = sqrt(((f32(_e193) + 0.5f) / f32(_e196))); + let _e200 = (*i_4); + let _e203 = (*turn); + theta = ((f32(_e200) * 2.3999631f) + _e203); + let _e205 = r; + let _e206 = theta; + let _e208 = theta; + return (vec2(cos(_e206), sin(_e208)) * _e205); } fn ShadowNoise_u0028_() -> f32 { var at: vec2; - var param_45: f32; + var param_51: f32; - let _e181 = frag_info.target_origin[0u]; - param_45 = _e181; - let _e182 = FragCoordFromTop_u0028_f1_u003b((¶m_45)); - let _e185 = frag_info.target_origin[3u]; - at = (_e182 + vec2((5.588238f * max(_e185, 0f)))); - let _e190 = at; - return fract((52.982918f * fract(dot(_e190, vec2(0.06711056f, 0.00583715f))))); + let _e192 = frag_info.target_origin[0u]; + param_51 = _e192; + let _e193 = FragCoordFromTop_u0028_f1_u003b((¶m_51)); + let _e196 = frag_info.target_origin[3u]; + at = (_e193 + vec2((5.588238f * max(_e196, 0f)))); + let _e201 = at; + return fract((52.982918f * fract(dot(_e201, vec2(0.06711056f, 0.00583715f))))); } fn EvsmChebyshev_u0028_vf2_u003b_f1_u003b_f1_u003b_f1_u003b(moments: ptr>, t: ptr, minVariance: ptr, bleed: ptr) -> f32 { @@ -21922,91 +24528,92 @@ fn EvsmChebyshev_u0028_vf2_u003b_f1_u003b_f1_u003b_f1_u003b(moments: ptr) -> vec2 { var d_2: f32; - let _e179 = (*depth); - d_2 = ((2f * clamp(_e179, 0f, 1f)) - 1f); - let _e183 = d_2; - let _e186 = d_2; - return vec2(exp((40f * _e183)), -(exp((-5f * _e186)))); + let _e190 = (*depth); + d_2 = ((2f * clamp(_e190, 0f, 1f)) - 1f); + let _e194 = d_2; + let _e197 = d_2; + return vec2(exp((40f * _e194)), -(exp((-5f * _e197)))); } fn EvsmVisibility_u0028_vf4_u003b_f1_u003b_f1_u003b(moments_1: ptr>, depth_1: ptr, bleed_1: ptr) -> f32 { var warped: vec2; - var param_46: f32; + var param_52: f32; var scale: vec2; var positive: f32; - var param_47: vec2; - var param_48: f32; - var param_49: f32; - var param_50: f32; - var negative: f32; - var param_51: vec2; - var param_52: f32; - var param_53: f32; + var param_53: vec2; var param_54: f32; + var param_55: f32; + var param_56: f32; + var negative: f32; + var param_57: vec2; + var param_58: f32; + var param_59: f32; + var param_60: f32; - let _e193 = (*depth_1); - param_46 = _e193; - let _e194 = EvsmWarp_u0028_f1_u003b((¶m_46)); - warped = _e194; - let _e195 = warped; - scale = (vec2(0.004f, 0.0005f) * _e195); - let _e198 = scale[0u]; - let _e200 = scale[0u]; - let _e202 = (*moments_1); - param_47 = _e202.xy; - let _e205 = warped[0u]; - param_48 = _e205; - param_49 = (_e198 * _e200); - let _e206 = (*bleed_1); - param_50 = _e206; - let _e207 = EvsmChebyshev_u0028_vf2_u003b_f1_u003b_f1_u003b_f1_u003b((¶m_47), (¶m_48), (¶m_49), (¶m_50)); - positive = _e207; - let _e209 = scale[1u]; - let _e211 = scale[1u]; + let _e204 = (*depth_1); + param_52 = _e204; + let _e205 = EvsmWarp_u0028_f1_u003b((¶m_52)); + warped = _e205; + let _e206 = warped; + scale = (vec2(0.004f, 0.0005f) * _e206); + let _e209 = scale[0u]; + let _e211 = scale[0u]; let _e213 = (*moments_1); - param_51 = _e213.zw; - let _e216 = warped[1u]; - param_52 = _e216; - param_53 = (_e209 * _e211); + param_53 = _e213.xy; + let _e216 = warped[0u]; + param_54 = _e216; + param_55 = (_e209 * _e211); let _e217 = (*bleed_1); - param_54 = _e217; - let _e218 = EvsmChebyshev_u0028_vf2_u003b_f1_u003b_f1_u003b_f1_u003b((¶m_51), (¶m_52), (¶m_53), (¶m_54)); - negative = _e218; - let _e219 = positive; - let _e220 = negative; - return min(_e219, _e220); -} - -fn ShadowFactor_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b_struct_u002d_LightSample_u002d_vf3_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf31_u003b_i1_u003b(s_1: ptr, light_1: ptr, lightIndex_1: ptr) -> f32 { + param_56 = _e217; + let _e218 = EvsmChebyshev_u0028_vf2_u003b_f1_u003b_f1_u003b_f1_u003b((¶m_53), (¶m_54), (¶m_55), (¶m_56)); + positive = _e218; + let _e220 = scale[1u]; + let _e222 = scale[1u]; + let _e224 = (*moments_1); + param_57 = _e224.zw; + let _e227 = warped[1u]; + param_58 = _e227; + param_59 = (_e220 * _e222); + let _e228 = (*bleed_1); + param_60 = _e228; + let _e229 = EvsmChebyshev_u0028_vf2_u003b_f1_u003b_f1_u003b_f1_u003b((¶m_57), (¶m_58), (¶m_59), (¶m_60)); + negative = _e229; + let _e230 = positive; + let _e231 = negative; + return min(_e230, _e231); +} + +fn ShadowFactor_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b_struct_u002d_LightSample_u002d_vf3_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf31_u003b_i1_u003b(s_2: ptr, light_1: ptr, lightIndex_1: ptr) -> f32 { var strength_1: f32; var cascadeCount: i32; var viewDistance: f32; + var local_8: f32; var cascade: i32; var uv_4: vec2; var projected: vec3; @@ -22017,8 +24624,8 @@ fn ShadowFactor_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf var attempt: i32; var which: i32; var matrix: mat4x4; - var local_4: mat4x4; - var local_5: mat4x4; + var local_9: mat4x4; + var local_10: mat4x4; var axisX: vec3; var axisY: vec3; var rowX: f32; @@ -22032,17 +24639,19 @@ fn ShadowFactor_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf var inTile: vec2; var cosSlope: f32; var bias: f32; - var local_6: f32; - var local_7: f32; + var local_11: f32; + var local_12: f32; var texel_1: vec2; var tileLo: vec2; var tileHi: vec2; + var stored_2: vec4; + var through: vec3; var softness: f32; - var lit_1: f32; + var lit_2: f32; var moments_2: vec4; - var param_55: vec4; - var param_56: f32; - var param_57: f32; + var param_61: vec4; + var param_62: f32; + var param_63: f32; var y: i32; var x: i32; var occluder: f32; @@ -22053,65 +24662,73 @@ fn ShadowFactor_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf var blockerCount: f32; var i_5: i32; var disc: vec2; - var param_58: i32; - var param_59: i32; - var param_60: f32; + var param_64: i32; + var param_65: i32; + var param_66: f32; var tapBias: f32; var occluder_1: f32; var gap: f32; var radius_2: f32; var i_6: i32; var disc_1: vec2; - var param_61: i32; - var param_62: i32; - var param_63: f32; + var param_67: i32; + var param_68: i32; + var param_69: f32; var tapBias_1: f32; var occluder_2: f32; - var phi_3269_: bool; - var phi_3280_: bool; - var phi_3441_: bool; - var phi_3448_: bool; - var phi_3455_: bool; - - let _e245 = frag_info.shadow_params[3u]; - strength_1 = _e245; - let _e246 = strength_1; - if (_e246 <= 0f) { + var phi_3538_: bool; + var phi_3549_: bool; + var phi_3710_: bool; + var phi_3717_: bool; + var phi_3724_: bool; + + let _e259 = frag_info.shadow_params[3u]; + strength_1 = _e259; + let _e260 = strength_1; + if (_e260 <= 0f) { return 1f; } - let _e248 = (*lightIndex_1); - let _e251 = frag_info.frame_params[2u]; - if (_e248 != i32((_e251 + 0.5f))) { + let _e262 = (*lightIndex_1); + let _e265 = frag_info.frame_params[2u]; + if (_e262 != i32((_e265 + 0.5f))) { return 1f; } - let _e257 = frag_info.shadow_cascades[2u]; - cascadeCount = i32((_e257 + 0.5f)); - let _e260 = v_world_position_1; - let _e262 = frag_info.camera_position; - viewDistance = length((_e260 - _e262.xyz)); - cascade = 0i; - let _e266 = cascadeCount; - let _e267 = (_e266 > 1i); - phi_3269_ = _e267; - if _e267 { - let _e268 = viewDistance; - let _e271 = frag_info.shadow_cascades[0u]; - phi_3269_ = (_e268 > _e271); - } - let _e274 = phi_3269_; + let _e271 = frag_info.shadow_cascades[2u]; + cascadeCount = i32((_e271 + 0.5f)); + let _e274 = Orthographic_u0028_(); if _e274 { + let _e275 = ViewDepth_u0028_(); + local_8 = _e275; + } else { + let _e276 = v_world_position_1; + let _e278 = frag_info.camera_position; + local_8 = length((_e276 - _e278.xyz)); + } + let _e282 = local_8; + viewDistance = _e282; + cascade = 0i; + let _e283 = cascadeCount; + let _e284 = (_e283 > 1i); + phi_3538_ = _e284; + if _e284 { + let _e285 = viewDistance; + let _e288 = frag_info.shadow_cascades[0u]; + phi_3538_ = (_e285 > _e288); + } + let _e291 = phi_3538_; + if _e291 { cascade = 1i; } - let _e275 = cascadeCount; - let _e276 = (_e275 > 2i); - phi_3280_ = _e276; - if _e276 { - let _e277 = viewDistance; - let _e280 = frag_info.shadow_cascades[1u]; - phi_3280_ = (_e277 > _e280); + let _e292 = cascadeCount; + let _e293 = (_e292 > 2i); + phi_3549_ = _e293; + if _e293 { + let _e294 = viewDistance; + let _e297 = frag_info.shadow_cascades[1u]; + phi_3549_ = (_e294 > _e297); } - let _e283 = phi_3280_; - if _e283 { + let _e300 = phi_3549_; + if _e300 { cascade = 2i; } uv_4 = vec2(0f, 0f); @@ -22122,232 +24739,247 @@ fn ShadowFactor_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf receiverSlope = 0f; attempt = 0i; loop { - let _e284 = attempt; - if (_e284 < 3i) { - let _e286 = cascade; - let _e287 = attempt; - which = (_e286 + _e287); - let _e289 = which; - let _e290 = cascadeCount; - if (_e289 >= _e290) { + let _e301 = attempt; + if (_e301 < 3i) { + let _e303 = cascade; + let _e304 = attempt; + which = (_e303 + _e304); + let _e306 = which; + let _e307 = cascadeCount; + if (_e306 >= _e307) { break; } - let _e292 = which; - if (_e292 == 0i) { - let _e295 = frag_info.shadow_matrix; - local_4 = _e295; + let _e309 = which; + if (_e309 == 0i) { + let _e312 = frag_info.shadow_matrix; + local_9 = _e312; } else { - let _e296 = which; - if (_e296 == 1i) { - let _e299 = frag_info.shadow_matrix_far; - local_5 = _e299; + let _e313 = which; + if (_e313 == 1i) { + let _e316 = frag_info.shadow_matrix_far; + local_10 = _e316; } else { - let _e301 = frag_info.shadow_matrix_farthest; - local_5 = _e301; + let _e318 = frag_info.shadow_matrix_farthest; + local_10 = _e318; } - let _e302 = local_5; - local_4 = _e302; - } - let _e303 = local_4; - matrix = _e303; - let _e306 = matrix[0][0u]; - let _e309 = matrix[1][0u]; - let _e312 = matrix[2][0u]; - axisX = vec3(_e306, _e309, _e312); - let _e316 = matrix[0][1u]; - let _e319 = matrix[1][1u]; - let _e322 = matrix[2][1u]; - axisY = vec3(_e316, _e319, _e322); - let _e324 = axisX; - rowX = max(length(_e324), 0.000001f); - let _e327 = axisY; - rowY = max(length(_e327), 0.000001f); - let _e332 = frag_info.shadow_cascades[3u]; - let _e334 = rowX; - texelMetres = ((2f * _e332) / _e334); - let _e338 = frag_info.shadow_cascades[3u]; - let _e341 = (*s_1).n; - let _e342 = axisX; - let _e345 = rowX; - let _e346 = rowX; - let _e350 = (*s_1).n; - let _e351 = axisY; - let _e354 = rowY; - let _e355 = rowY; - reach = ((3f * _e338) * ((abs(dot(_e341, _e342)) / (_e345 * _e346)) + (abs(dot(_e350, _e351)) / (_e354 * _e355)))); - let _e362 = frag_info.shadow_params[2u]; - let _e363 = texelMetres; - flatOffset = min(_e362, _e363); - let _e365 = v_world_position_1; - let _e367 = (*s_1).n; - let _e368 = flatOffset; - let _e369 = reach; - origin_1 = (_e365 + (_e367 * (_e368 + _e369))); - let _e373 = matrix; - let _e374 = origin_1; - lightSpace = (_e373 * vec4(_e374.x, _e374.y, _e374.z, 1f)); - let _e381 = lightSpace[3u]; - if (_e381 <= 0f) { + let _e319 = local_10; + local_9 = _e319; + } + let _e320 = local_9; + matrix = _e320; + let _e323 = matrix[0][0u]; + let _e326 = matrix[1][0u]; + let _e329 = matrix[2][0u]; + axisX = vec3(_e323, _e326, _e329); + let _e333 = matrix[0][1u]; + let _e336 = matrix[1][1u]; + let _e339 = matrix[2][1u]; + axisY = vec3(_e333, _e336, _e339); + let _e341 = axisX; + rowX = max(length(_e341), 0.000001f); + let _e344 = axisY; + rowY = max(length(_e344), 0.000001f); + let _e349 = frag_info.shadow_cascades[3u]; + let _e351 = rowX; + texelMetres = ((2f * _e349) / _e351); + let _e355 = frag_info.shadow_cascades[3u]; + let _e358 = (*s_2).n; + let _e359 = axisX; + let _e362 = rowX; + let _e363 = rowX; + let _e367 = (*s_2).n; + let _e368 = axisY; + let _e371 = rowY; + let _e372 = rowY; + reach = ((3f * _e355) * ((abs(dot(_e358, _e359)) / (_e362 * _e363)) + (abs(dot(_e367, _e368)) / (_e371 * _e372)))); + let _e379 = frag_info.shadow_params[2u]; + let _e380 = texelMetres; + flatOffset = min(_e379, _e380); + let _e382 = v_world_position_1; + let _e384 = (*s_2).n; + let _e385 = flatOffset; + let _e386 = reach; + origin_1 = (_e382 + (_e384 * (_e385 + _e386))); + let _e390 = matrix; + let _e391 = origin_1; + lightSpace = (_e390 * vec4(_e391.x, _e391.y, _e391.z, 1f)); + let _e398 = lightSpace[3u]; + if (_e398 <= 0f) { continue; } - let _e383 = lightSpace; - let _e386 = lightSpace[3u]; - candidate = (_e383.xyz / vec3(_e386)); - let _e390 = candidate[0u]; - let _e394 = candidate[1u]; - inTile = vec2(((_e390 * 0.5f) + 0.5f), (0.5f - (_e394 * 0.5f))); - let _e399 = inTile[0u]; - let _e400 = (_e399 < 0f); - phi_3441_ = _e400; - if !(_e400) { - let _e403 = inTile[0u]; - phi_3441_ = (_e403 > 1f); - } - let _e406 = phi_3441_; - phi_3448_ = _e406; - if !(_e406) { - let _e409 = inTile[1u]; - phi_3448_ = (_e409 < 0f); - } - let _e412 = phi_3448_; - phi_3455_ = _e412; - if !(_e412) { - let _e415 = inTile[1u]; - phi_3455_ = (_e415 > 1f); - } - let _e418 = phi_3455_; - if _e418 { + let _e400 = lightSpace; + let _e403 = lightSpace[3u]; + candidate = (_e400.xyz / vec3(_e403)); + let _e407 = candidate[0u]; + let _e411 = candidate[1u]; + inTile = vec2(((_e407 * 0.5f) + 0.5f), (0.5f - (_e411 * 0.5f))); + let _e416 = inTile[0u]; + let _e417 = (_e416 < 0f); + phi_3710_ = _e417; + if !(_e417) { + let _e420 = inTile[0u]; + phi_3710_ = (_e420 > 1f); + } + let _e423 = phi_3710_; + phi_3717_ = _e423; + if !(_e423) { + let _e426 = inTile[1u]; + phi_3717_ = (_e426 < 0f); + } + let _e429 = phi_3717_; + phi_3724_ = _e429; + if !(_e429) { + let _e432 = inTile[1u]; + phi_3724_ = (_e432 > 1f); + } + let _e435 = phi_3724_; + if _e435 { continue; } - let _e420 = candidate[2u]; - if (_e420 > 1f) { - let _e422 = which; - let _e423 = cascadeCount; - if (_e422 < (_e423 - 1i)) { + let _e437 = candidate[2u]; + if (_e437 > 1f) { + let _e439 = which; + let _e440 = cascadeCount; + if (_e439 < (_e440 - 1i)) { continue; } candidate[2u] = 1f; } - let _e428 = inTile[0u]; - let _e429 = which; - let _e432 = cascadeCount; - let _e436 = inTile[1u]; - uv_4 = vec2(((_e428 + f32(_e429)) / f32(_e432)), _e436); - let _e438 = candidate; - projected = _e438; - let _e439 = which; - cascade = _e439; - let _e440 = texelMetres; - cascadeTexel = _e440; - let _e443 = matrix[0][2u]; - let _e446 = matrix[1][2u]; - let _e449 = matrix[2][2u]; - cascadeDepth = (1f / max(length(vec3(_e443, _e446, _e449)), 0.000001f)); - let _e455 = (*s_1).n; - let _e458 = matrix[0][2u]; - let _e461 = matrix[1][2u]; - let _e464 = matrix[2][2u]; - let _e468 = cascadeDepth; - cosSlope = clamp((abs(dot(_e455, vec3(_e458, _e461, _e464))) * _e468), 0.1f, 1f); - let _e471 = texelMetres; - let _e472 = cosSlope; - let _e473 = cosSlope; - let _e479 = cosSlope; - let _e481 = cascadeDepth; - receiverSlope = (((_e471 * sqrt(max((1f - (_e472 * _e473)), 0f))) / _e479) / _e481); + let _e445 = inTile[0u]; + let _e446 = which; + let _e449 = cascadeCount; + let _e453 = inTile[1u]; + uv_4 = vec2(((_e445 + f32(_e446)) / f32(_e449)), _e453); + let _e455 = candidate; + projected = _e455; + let _e456 = which; + cascade = _e456; + let _e457 = texelMetres; + cascadeTexel = _e457; + let _e460 = matrix[0][2u]; + let _e463 = matrix[1][2u]; + let _e466 = matrix[2][2u]; + cascadeDepth = (1f / max(length(vec3(_e460, _e463, _e466)), 0.000001f)); + let _e472 = (*s_2).n; + let _e475 = matrix[0][2u]; + let _e478 = matrix[1][2u]; + let _e481 = matrix[2][2u]; + let _e485 = cascadeDepth; + cosSlope = clamp((abs(dot(_e472, vec3(_e475, _e478, _e481))) * _e485), 0.1f, 1f); + let _e488 = texelMetres; + let _e489 = cosSlope; + let _e490 = cosSlope; + let _e496 = cosSlope; + let _e498 = cascadeDepth; + receiverSlope = (((_e488 * sqrt(max((1f - (_e489 * _e490)), 0f))) / _e496) / _e498); found = true; break; } else { break; } continuing { - let _e483 = attempt; - attempt = (_e483 + 1i); + let _e500 = attempt; + attempt = (_e500 + 1i); } } - let _e485 = found; - if !(_e485) { + let _e502 = found; + if !(_e502) { return 1f; } - let _e487 = cascade; - if (_e487 == 0i) { - let _e491 = frag_info.shadow_bias[0u]; - local_6 = _e491; + let _e504 = cascade; + if (_e504 == 0i) { + let _e508 = frag_info.shadow_bias[0u]; + local_11 = _e508; } else { - let _e492 = cascade; - if (_e492 == 1i) { - let _e496 = frag_info.shadow_bias[1u]; - local_7 = _e496; + let _e509 = cascade; + if (_e509 == 1i) { + let _e513 = frag_info.shadow_bias[1u]; + local_12 = _e513; } else { - let _e499 = frag_info.shadow_bias[2u]; - local_7 = _e499; - } - let _e500 = local_7; - local_6 = _e500; - } - let _e501 = local_6; - bias = _e501; - let _e504 = frag_info.shadow_params[0u]; - let _e507 = frag_info.shadow_cascades[3u]; - texel_1 = vec2(_e504, _e507); - let _e509 = cascade; - let _e511 = cascadeCount; - let _e515 = texel_1; - tileLo = (vec2((f32(_e509) / f32(_e511)), 0f) + (_e515 * 0.5f)); - let _e518 = cascade; - let _e521 = cascadeCount; - let _e525 = texel_1; - tileHi = (vec2((f32((_e518 + 1i)) / f32(_e521)), 1f) - (_e525 * 0.5f)); - let _e530 = frag_info.ambient_ground[3u]; - softness = _e530; - lit_1 = 0f; - let _e531 = softness; - if (_e531 < 0f) { - let _e533 = uv_4; - let _e534 = tileLo; - let _e535 = tileHi; - let _e537 = textureSampleLevel(shadow_texture_tex, shadow_texture_smp, clamp(_e533, _e534, _e535), 0f); - moments_2 = _e537; - let _e539 = projected[2u]; - let _e540 = bias; - let _e542 = softness; - let _e546 = moments_2; - param_55 = _e546; - param_56 = (_e539 - _e540); - param_57 = clamp((-(_e542) - 1f), 0f, 0.95f); - let _e547 = EvsmVisibility_u0028_vf4_u003b_f1_u003b_f1_u003b((¶m_55), (¶m_56), (¶m_57)); - lit_1 = _e547; - } else { - let _e548 = softness; - if (_e548 <= 0f) { + let _e516 = frag_info.shadow_bias[2u]; + local_12 = _e516; + } + let _e517 = local_12; + local_11 = _e517; + } + let _e518 = local_11; + bias = _e518; + let _e521 = frag_info.shadow_params[0u]; + let _e524 = frag_info.shadow_cascades[3u]; + texel_1 = vec2(_e521, _e524); + let _e526 = cascade; + let _e528 = cascadeCount; + let _e532 = texel_1; + tileLo = (vec2((f32(_e526) / f32(_e528)), 0f) + (_e532 * 0.5f)); + let _e535 = cascade; + let _e538 = cascadeCount; + let _e542 = texel_1; + tileHi = (vec2((f32((_e535 + 1i)) / f32(_e538)), 1f) - (_e542 * 0.5f)); + let _e547 = frag_info.shadow_bias[3u]; + if (_e547 > 0.5f) { + let _e549 = uv_4; + let _e550 = tileLo; + let _e551 = tileHi; + let _e553 = textureSampleLevel(shadow_texture_tex, shadow_texture_smp, clamp(_e549, _e550, _e551), 0f); + stored_2 = _e553; + let _e555 = stored_2[1u]; + let _e558 = stored_2[2u]; + let _e561 = stored_2[3u]; + through = clamp(vec3((1f - _e555), (1f - _e558), _e561), vec3(0f), vec3(4f)); + let _e566 = through; + let _e567 = strength_1; + light_transmittance = mix(vec3(1f, 1f, 1f), _e566, vec3(clamp(_e567, 0f, 1f))); + } + let _e573 = frag_info.ambient_ground[3u]; + softness = _e573; + lit_2 = 0f; + let _e574 = softness; + if (_e574 < 0f) { + let _e576 = uv_4; + let _e577 = tileLo; + let _e578 = tileHi; + let _e580 = textureSampleLevel(shadow_texture_tex, shadow_texture_smp, clamp(_e576, _e577, _e578), 0f); + moments_2 = _e580; + let _e582 = projected[2u]; + let _e583 = bias; + let _e585 = softness; + let _e589 = moments_2; + param_61 = _e589; + param_62 = (_e582 - _e583); + param_63 = clamp((-(_e585) - 1f), 0f, 0.95f); + let _e590 = EvsmVisibility_u0028_vf4_u003b_f1_u003b_f1_u003b((¶m_61), (¶m_62), (¶m_63)); + lit_2 = _e590; + } else { + let _e591 = softness; + if (_e591 <= 0f) { y = -1i; loop { - let _e550 = y; - if (_e550 <= 1i) { + let _e593 = y; + if (_e593 <= 1i) { x = -1i; loop { - let _e552 = x; - if (_e552 <= 1i) { - let _e554 = uv_4; - let _e555 = x; - let _e557 = y; - let _e560 = texel_1; - let _e563 = tileLo; - let _e564 = tileHi; - let _e566 = textureSampleLevel(shadow_texture_tex, shadow_texture_smp, clamp((_e554 + (vec2(f32(_e555), f32(_e557)) * _e560)), _e563, _e564), 0f); - occluder = _e566.x; - let _e569 = projected[2u]; - let _e570 = bias; - let _e572 = occluder; - let _e575 = lit_1; - lit_1 = (_e575 + select(1f, 0f, ((_e569 - _e570) > _e572))); + let _e595 = x; + if (_e595 <= 1i) { + let _e597 = uv_4; + let _e598 = x; + let _e600 = y; + let _e603 = texel_1; + let _e606 = tileLo; + let _e607 = tileHi; + let _e609 = textureSampleLevel(shadow_texture_tex, shadow_texture_smp, clamp((_e597 + (vec2(f32(_e598), f32(_e600)) * _e603)), _e606, _e607), 0f); + occluder = _e609.x; + let _e612 = projected[2u]; + let _e613 = bias; + let _e615 = occluder; + let _e618 = lit_2; + lit_2 = (_e618 + select(1f, 0f, ((_e612 - _e613) > _e615))); continue; } else { break; } continuing { - let _e577 = x; - x = (_e577 + 1i); + let _e620 = x; + x = (_e620 + 1i); } } continue; @@ -22355,145 +24987,145 @@ fn ShadowFactor_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf break; } continuing { - let _e579 = y; - y = (_e579 + 1i); + let _e622 = y; + y = (_e622 + 1i); } } - let _e581 = lit_1; - lit_1 = (_e581 * 0.11111111f); + let _e624 = lit_2; + lit_2 = (_e624 * 0.11111111f); } else { - let _e583 = softness; - spread = tan(min(_e583, 0.5f)); - let _e586 = ShadowNoise_u0028_(); - turn_1 = (_e586 * 6.2831855f); - let _e588 = spread; - let _e590 = projected[2u]; - let _e592 = cascadeDepth; - let _e594 = cascadeTexel; - searchRadius = clamp((((_e588 * _e590) * _e592) / _e594), 1f, 16f); + let _e626 = softness; + spread = tan(min(_e626, 0.5f)); + let _e629 = ShadowNoise_u0028_(); + turn_1 = (_e629 * 6.2831855f); + let _e631 = spread; + let _e633 = projected[2u]; + let _e635 = cascadeDepth; + let _e637 = cascadeTexel; + searchRadius = clamp((((_e631 * _e633) * _e635) / _e637), 1f, 16f); blockerSum = 0f; blockerCount = 0f; i_5 = 0i; loop { - let _e597 = i_5; - if (_e597 < 16i) { - let _e599 = i_5; - param_58 = _e599; - param_59 = 16i; - let _e600 = turn_1; - param_60 = _e600; - let _e601 = VogelDisc_u0028_i1_u003b_i1_u003b_f1_u003b((¶m_58), (¶m_59), (¶m_60)); - disc = _e601; - let _e602 = bias; - let _e603 = disc; - let _e605 = searchRadius; - let _e609 = receiverSlope; - tapBias = (_e602 + (max(((length(_e603) * _e605) - 1.5f), 0f) * _e609)); - let _e612 = uv_4; - let _e613 = disc; - let _e614 = texel_1; - let _e616 = searchRadius; - let _e619 = tileLo; - let _e620 = tileHi; - let _e622 = textureSampleLevel(shadow_texture_tex, shadow_texture_smp, clamp((_e612 + ((_e613 * _e614) * _e616)), _e619, _e620), 0f); - occluder_1 = _e622.x; - let _e625 = projected[2u]; - let _e626 = tapBias; - let _e628 = occluder_1; - if ((_e625 - _e626) > _e628) { - let _e630 = occluder_1; - let _e631 = blockerSum; - blockerSum = (_e631 + _e630); - let _e633 = blockerCount; - blockerCount = (_e633 + 1f); + let _e640 = i_5; + if (_e640 < 16i) { + let _e642 = i_5; + param_64 = _e642; + param_65 = 16i; + let _e643 = turn_1; + param_66 = _e643; + let _e644 = VogelDisc_u0028_i1_u003b_i1_u003b_f1_u003b((¶m_64), (¶m_65), (¶m_66)); + disc = _e644; + let _e645 = bias; + let _e646 = disc; + let _e648 = searchRadius; + let _e652 = receiverSlope; + tapBias = (_e645 + (max(((length(_e646) * _e648) - 1.5f), 0f) * _e652)); + let _e655 = uv_4; + let _e656 = disc; + let _e657 = texel_1; + let _e659 = searchRadius; + let _e662 = tileLo; + let _e663 = tileHi; + let _e665 = textureSampleLevel(shadow_texture_tex, shadow_texture_smp, clamp((_e655 + ((_e656 * _e657) * _e659)), _e662, _e663), 0f); + occluder_1 = _e665.x; + let _e668 = projected[2u]; + let _e669 = tapBias; + let _e671 = occluder_1; + if ((_e668 - _e669) > _e671) { + let _e673 = occluder_1; + let _e674 = blockerSum; + blockerSum = (_e674 + _e673); + let _e676 = blockerCount; + blockerCount = (_e676 + 1f); } continue; } else { break; } continuing { - let _e635 = i_5; - i_5 = (_e635 + 1i); + let _e678 = i_5; + i_5 = (_e678 + 1i); } } - let _e637 = blockerCount; - if (_e637 <= 0f) { + let _e680 = blockerCount; + if (_e680 <= 0f) { return 1f; } - let _e640 = projected[2u]; - let _e641 = blockerSum; - let _e642 = blockerCount; - let _e646 = cascadeDepth; - gap = (max((_e640 - (_e641 / _e642)), 0f) * _e646); - let _e648 = spread; - let _e649 = gap; - let _e651 = cascadeTexel; - radius_2 = clamp(((_e648 * _e649) / _e651), 1f, 16f); + let _e683 = projected[2u]; + let _e684 = blockerSum; + let _e685 = blockerCount; + let _e689 = cascadeDepth; + gap = (max((_e683 - (_e684 / _e685)), 0f) * _e689); + let _e691 = spread; + let _e692 = gap; + let _e694 = cascadeTexel; + radius_2 = clamp(((_e691 * _e692) / _e694), 1f, 16f); i_6 = 0i; loop { - let _e654 = i_6; - if (_e654 < 16i) { - let _e656 = turn_1; - let _e658 = i_6; - param_61 = _e658; - param_62 = 16i; - param_63 = (_e656 + 1f); - let _e659 = VogelDisc_u0028_i1_u003b_i1_u003b_f1_u003b((¶m_61), (¶m_62), (¶m_63)); - disc_1 = _e659; - let _e660 = bias; - let _e661 = disc_1; - let _e663 = radius_2; - let _e667 = receiverSlope; - tapBias_1 = (_e660 + (max(((length(_e661) * _e663) - 1.5f), 0f) * _e667)); - let _e670 = uv_4; - let _e671 = disc_1; - let _e672 = texel_1; - let _e674 = radius_2; - let _e677 = tileLo; - let _e678 = tileHi; - let _e680 = textureSampleLevel(shadow_texture_tex, shadow_texture_smp, clamp((_e670 + ((_e671 * _e672) * _e674)), _e677, _e678), 0f); - occluder_2 = _e680.x; - let _e683 = projected[2u]; - let _e684 = tapBias_1; - let _e686 = occluder_2; - let _e689 = lit_1; - lit_1 = (_e689 + select(1f, 0f, ((_e683 - _e684) > _e686))); + let _e697 = i_6; + if (_e697 < 16i) { + let _e699 = turn_1; + let _e701 = i_6; + param_67 = _e701; + param_68 = 16i; + param_69 = (_e699 + 1f); + let _e702 = VogelDisc_u0028_i1_u003b_i1_u003b_f1_u003b((¶m_67), (¶m_68), (¶m_69)); + disc_1 = _e702; + let _e703 = bias; + let _e704 = disc_1; + let _e706 = radius_2; + let _e710 = receiverSlope; + tapBias_1 = (_e703 + (max(((length(_e704) * _e706) - 1.5f), 0f) * _e710)); + let _e713 = uv_4; + let _e714 = disc_1; + let _e715 = texel_1; + let _e717 = radius_2; + let _e720 = tileLo; + let _e721 = tileHi; + let _e723 = textureSampleLevel(shadow_texture_tex, shadow_texture_smp, clamp((_e713 + ((_e714 * _e715) * _e717)), _e720, _e721), 0f); + occluder_2 = _e723.x; + let _e726 = projected[2u]; + let _e727 = tapBias_1; + let _e729 = occluder_2; + let _e732 = lit_2; + lit_2 = (_e732 + select(1f, 0f, ((_e726 - _e727) > _e729))); continue; } else { break; } continuing { - let _e691 = i_6; - i_6 = (_e691 + 1i); + let _e734 = i_6; + i_6 = (_e734 + 1i); } } - let _e693 = lit_1; - lit_1 = (_e693 * 0.0625f); + let _e736 = lit_2; + lit_2 = (_e736 * 0.0625f); } } - let _e695 = lit_1; - let _e696 = strength_1; - return mix(1f, _e695, clamp(_e696, 0f, 1f)); + let _e738 = lit_2; + let _e739 = strength_1; + return mix(1f, _e738, clamp(_e739, 0f, 1f)); } -fn LightVisibility_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b_struct_u002d_LightSample_u002d_vf3_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf31_u003b_i1_u003b(s_2: ptr, light_2: ptr, index: ptr) -> f32 { - var param_64: Surface; - var param_65: LightSample; - var param_66: i32; +fn LightVisibility_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b_struct_u002d_LightSample_u002d_vf3_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf31_u003b_i1_u003b(s_3: ptr, light_2: ptr, index: ptr) -> f32 { + var param_70: Surface; + var param_71: LightSample; + var param_72: i32; - let _e183 = (*s_2); - param_64 = _e183; - let _e184 = (*light_2); - param_65 = _e184; - let _e185 = (*index); - param_66 = _e185; - let _e186 = ShadowFactor_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b_struct_u002d_LightSample_u002d_vf3_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf31_u003b_i1_u003b((¶m_64), (¶m_65), (¶m_66)); - return _e186; + let _e194 = (*s_3); + param_70 = _e194; + let _e195 = (*light_2); + param_71 = _e195; + let _e196 = (*index); + param_72 = _e196; + let _e197 = ShadowFactor_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b_struct_u002d_LightSample_u002d_vf3_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf31_u003b_i1_u003b((¶m_70), (¶m_71), (¶m_72)); + return _e197; } fn LightHasShadow_u0028_i1_u003b(index_1: ptr) -> bool { - let _e178 = (*index_1); - return (_e178 < 8i); + let _e189 = (*index_1); + return (_e189 < 8i); } fn PunctualAttenuation_u0028_f1_u003b_f1_u003b(distance_1: ptr, range_4: ptr) -> f32 { @@ -22501,26 +25133,26 @@ fn PunctualAttenuation_u0028_f1_u003b_f1_u003b(distance_1: ptr, r var ratio: f32; var window: f32; - let _e182 = (*distance_1); - let _e183 = (*distance_1); - attenuation = (1f / max((_e182 * _e183), 0.0001f)); - let _e187 = (*range_4); - if (_e187 > 0f) { - let _e189 = (*distance_1); - let _e190 = (*range_4); - ratio = (_e189 / _e190); - let _e192 = ratio; - let _e193 = ratio; - let _e195 = ratio; - let _e197 = ratio; - window = clamp((1f - (((_e192 * _e193) * _e195) * _e197)), 0f, 1f); - let _e201 = window; - let _e202 = window; - let _e204 = attenuation; - attenuation = (_e204 * (_e201 * _e202)); - } - let _e206 = attenuation; - return _e206; + let _e193 = (*distance_1); + let _e194 = (*distance_1); + attenuation = (1f / max((_e193 * _e194), 0.0001f)); + let _e198 = (*range_4); + if (_e198 > 0f) { + let _e200 = (*distance_1); + let _e201 = (*range_4); + ratio = (_e200 / _e201); + let _e203 = ratio; + let _e204 = ratio; + let _e206 = ratio; + let _e208 = ratio; + window = clamp((1f - (((_e203 * _e204) * _e206) * _e208)), 0f, 1f); + let _e212 = window; + let _e213 = window; + let _e215 = attenuation; + attenuation = (_e215 * (_e212 * _e213)); + } + let _e217 = attenuation; + return _e217; } fn RectangleClosestPoint_u0028_vf3_u003b_vf3_u003b_vf3_u003b_vf3_u003b_vf3_u003b(centre: ptr>, halfWidth: ptr>, halfHeight: ptr>, world_2: ptr>, mirror: ptr>) -> vec3 { @@ -22530,82 +25162,82 @@ fn RectangleClosestPoint_u0028_vf3_u003b_vf3_u003b_vf3_u003b_vf3_u003b_vf3_u003b var denom: f32; var onPlane: vec3; var t_1: f32; - var local_8: vec3; + var local_13: vec3; var offset_2: vec3; var wLen2_: f32; var hLen2_: f32; var u: f32; var v: f32; - let _e194 = (*halfWidth); - let _e195 = (*halfHeight); - n_2 = cross(_e194, _e195); - let _e197 = n_2; - nLen = length(_e197); - let _e199 = nLen; - if (_e199 < 0.000000000001f) { - let _e201 = (*centre); - return _e201; - } - let _e202 = nLen; - let _e203 = n_2; - n_2 = (_e203 / vec3(_e202)); - let _e206 = (*centre); - let _e207 = (*world_2); - toPlane = (_e206 - _e207); - let _e209 = (*mirror); - let _e210 = n_2; - denom = dot(_e209, _e210); - let _e212 = denom; - if (abs(_e212) < 0.00001f) { - let _e215 = toPlane; - let _e216 = n_2; - let _e217 = toPlane; - let _e218 = n_2; - onPlane = (_e215 - (_e216 * dot(_e217, _e218))); - } else { - let _e222 = toPlane; - let _e223 = n_2; - let _e225 = denom; - t_1 = (dot(_e222, _e223) / _e225); - let _e227 = t_1; - if (_e227 > 0f) { - let _e229 = (*mirror); - let _e230 = t_1; - local_8 = (_e229 * _e230); + let _e205 = (*halfWidth); + let _e206 = (*halfHeight); + n_2 = cross(_e205, _e206); + let _e208 = n_2; + nLen = length(_e208); + let _e210 = nLen; + if (_e210 < 0.000000000001f) { + let _e212 = (*centre); + return _e212; + } + let _e213 = nLen; + let _e214 = n_2; + n_2 = (_e214 / vec3(_e213)); + let _e217 = (*centre); + let _e218 = (*world_2); + toPlane = (_e217 - _e218); + let _e220 = (*mirror); + let _e221 = n_2; + denom = dot(_e220, _e221); + let _e223 = denom; + if (abs(_e223) < 0.00001f) { + let _e226 = toPlane; + let _e227 = n_2; + let _e228 = toPlane; + let _e229 = n_2; + onPlane = (_e226 - (_e227 * dot(_e228, _e229))); + } else { + let _e233 = toPlane; + let _e234 = n_2; + let _e236 = denom; + t_1 = (dot(_e233, _e234) / _e236); + let _e238 = t_1; + if (_e238 > 0f) { + let _e240 = (*mirror); + let _e241 = t_1; + local_13 = (_e240 * _e241); } else { - let _e232 = toPlane; - let _e233 = n_2; - let _e234 = toPlane; - let _e235 = n_2; - local_8 = (_e232 - (_e233 * dot(_e234, _e235))); - } - let _e239 = local_8; - onPlane = _e239; - } - let _e240 = onPlane; - let _e241 = toPlane; - offset_2 = (_e240 - _e241); - let _e243 = (*halfWidth); - let _e244 = (*halfWidth); - wLen2_ = max(dot(_e243, _e244), 0.000000000001f); - let _e247 = (*halfHeight); - let _e248 = (*halfHeight); - hLen2_ = max(dot(_e247, _e248), 0.000000000001f); - let _e251 = offset_2; - let _e252 = (*halfWidth); - let _e254 = wLen2_; - u = clamp((dot(_e251, _e252) / _e254), -1f, 1f); - let _e257 = offset_2; + let _e243 = toPlane; + let _e244 = n_2; + let _e245 = toPlane; + let _e246 = n_2; + local_13 = (_e243 - (_e244 * dot(_e245, _e246))); + } + let _e250 = local_13; + onPlane = _e250; + } + let _e251 = onPlane; + let _e252 = toPlane; + offset_2 = (_e251 - _e252); + let _e254 = (*halfWidth); + let _e255 = (*halfWidth); + wLen2_ = max(dot(_e254, _e255), 0.000000000001f); let _e258 = (*halfHeight); - let _e260 = hLen2_; - v = clamp((dot(_e257, _e258) / _e260), -1f, 1f); - let _e263 = (*centre); - let _e264 = (*halfWidth); - let _e265 = u; - let _e268 = (*halfHeight); - let _e269 = v; - return ((_e263 + (_e264 * _e265)) + (_e268 * _e269)); + let _e259 = (*halfHeight); + hLen2_ = max(dot(_e258, _e259), 0.000000000001f); + let _e262 = offset_2; + let _e263 = (*halfWidth); + let _e265 = wLen2_; + u = clamp((dot(_e262, _e263) / _e265), -1f, 1f); + let _e268 = offset_2; + let _e269 = (*halfHeight); + let _e271 = hLen2_; + v = clamp((dot(_e268, _e269) / _e271), -1f, 1f); + let _e274 = (*centre); + let _e275 = (*halfWidth); + let _e276 = u; + let _e279 = (*halfHeight); + let _e280 = v; + return ((_e274 + (_e275 * _e276)) + (_e279 * _e280)); } fn LambertEdge_u0028_vf3_u003b_vf3_u003b_vf3_u003b(a_1: ptr>, b: ptr>, n_3: ptr>) -> f32 { @@ -22614,34 +25246,34 @@ fn LambertEdge_u0028_vf3_u003b_vf3_u003b_vf3_u003b(a_1: ptr> var angle_1: f32; var axis: vec3; var len: f32; - var local_9: f32; + var local_14: f32; - let _e186 = (*a_1); - let _e187 = (*a_1); - ua = (_e186 / vec3(max(length(_e187), 0.000000000001f))); - let _e192 = (*b); - let _e193 = (*b); - ub = (_e192 / vec3(max(length(_e193), 0.000000000001f))); - let _e198 = ua; - let _e199 = ub; - angle_1 = acos(clamp(dot(_e198, _e199), -1f, 1f)); - let _e203 = ua; - let _e204 = ub; - axis = cross(_e203, _e204); - let _e206 = axis; - len = length(_e206); - let _e208 = len; - if (_e208 > 0.000001f) { - let _e210 = angle_1; - let _e211 = axis; - let _e212 = (*n_3); - let _e215 = len; - local_9 = ((_e210 * dot(_e211, _e212)) / _e215); - } else { - local_9 = 0f; - } - let _e217 = local_9; - return _e217; + let _e197 = (*a_1); + let _e198 = (*a_1); + ua = (_e197 / vec3(max(length(_e198), 0.000000000001f))); + let _e203 = (*b); + let _e204 = (*b); + ub = (_e203 / vec3(max(length(_e204), 0.000000000001f))); + let _e209 = ua; + let _e210 = ub; + angle_1 = acos(clamp(dot(_e209, _e210), -1f, 1f)); + let _e214 = ua; + let _e215 = ub; + axis = cross(_e214, _e215); + let _e217 = axis; + len = length(_e217); + let _e219 = len; + if (_e219 > 0.000001f) { + let _e221 = angle_1; + let _e222 = axis; + let _e223 = (*n_3); + let _e226 = len; + local_14 = ((_e221 * dot(_e222, _e223)) / _e226); + } else { + local_14 = 0f; + } + let _e228 = local_14; + return _e228; } fn RectangleFormFactor_u0028_vf3_u005b_4_u005d_u003b_vf3_u003b(corners: ptr, 4>>, n_4: ptr>) -> f32 { @@ -22651,212 +25283,212 @@ fn RectangleFormFactor_u0028_vf3_u005b_4_u005d_u003b_vf3_u003b(corners: ptr; var b_1: vec3; - var local_10: i32; + var local_15: i32; var ha: f32; var hb: f32; var d_3: f32; var q: vec3; - var local_11: f32; + var local_16: f32; var aAbove: bool; var bAbove: bool; - var local_12: f32; - var param_67: vec3; - var param_68: vec3; - var param_69: vec3; - var param_70: vec3; - var param_71: vec3; - var param_72: vec3; + var local_17: f32; + var param_73: vec3; + var param_74: vec3; + var param_75: vec3; + var param_76: vec3; + var param_77: vec3; + var param_78: vec3; total = 0f; exit = vec3(0f, 0f, 0f); entry = vec3(0f, 0f, 0f); i_7 = 0i; loop { - let _e200 = i_7; - if (_e200 < 4i) { - let _e202 = i_7; - let _e204 = (*corners)[_e202]; - a_2 = _e204; - let _e205 = i_7; - if (_e205 == 3i) { - local_10 = 0i; + let _e211 = i_7; + if (_e211 < 4i) { + let _e213 = i_7; + let _e215 = (*corners)[_e213]; + a_2 = _e215; + let _e216 = i_7; + if (_e216 == 3i) { + local_15 = 0i; } else { - let _e207 = i_7; - local_10 = (_e207 + 1i); - } - let _e209 = local_10; - let _e211 = (*corners)[_e209]; - b_1 = _e211; - let _e212 = a_2; - let _e213 = (*n_4); - ha = dot(_e212, _e213); - let _e215 = b_1; - let _e216 = (*n_4); - hb = dot(_e215, _e216); - let _e218 = ha; - let _e219 = hb; - d_3 = (_e218 - _e219); - let _e221 = a_2; - let _e222 = b_1; + let _e218 = i_7; + local_15 = (_e218 + 1i); + } + let _e220 = local_15; + let _e222 = (*corners)[_e220]; + b_1 = _e222; let _e223 = a_2; - let _e225 = d_3; - if (abs(_e225) > 0.000000000001f) { - let _e228 = ha; - let _e229 = d_3; - local_11 = (_e228 / _e229); + let _e224 = (*n_4); + ha = dot(_e223, _e224); + let _e226 = b_1; + let _e227 = (*n_4); + hb = dot(_e226, _e227); + let _e229 = ha; + let _e230 = hb; + d_3 = (_e229 - _e230); + let _e232 = a_2; + let _e233 = b_1; + let _e234 = a_2; + let _e236 = d_3; + if (abs(_e236) > 0.000000000001f) { + let _e239 = ha; + let _e240 = d_3; + local_16 = (_e239 / _e240); } else { - local_11 = 0f; - } - let _e231 = local_11; - q = (_e221 + ((_e222 - _e223) * _e231)); - let _e234 = ha; - aAbove = (_e234 > 0f); - let _e236 = hb; - bAbove = (_e236 > 0f); - let _e238 = aAbove; - let _e239 = bAbove; - if (_e238 || _e239) { - let _e241 = aAbove; - let _e242 = a_2; - let _e243 = q; - let _e246 = bAbove; - let _e247 = b_1; - let _e248 = q; - param_67 = select(_e243, _e242, vec3(_e241)); - param_68 = select(_e248, _e247, vec3(_e246)); - let _e251 = (*n_4); - param_69 = _e251; - let _e252 = LambertEdge_u0028_vf3_u003b_vf3_u003b_vf3_u003b((¶m_67), (¶m_68), (¶m_69)); - local_12 = _e252; + local_16 = 0f; + } + let _e242 = local_16; + q = (_e232 + ((_e233 - _e234) * _e242)); + let _e245 = ha; + aAbove = (_e245 > 0f); + let _e247 = hb; + bAbove = (_e247 > 0f); + let _e249 = aAbove; + let _e250 = bAbove; + if (_e249 || _e250) { + let _e252 = aAbove; + let _e253 = a_2; + let _e254 = q; + let _e257 = bAbove; + let _e258 = b_1; + let _e259 = q; + param_73 = select(_e254, _e253, vec3(_e252)); + param_74 = select(_e259, _e258, vec3(_e257)); + let _e262 = (*n_4); + param_75 = _e262; + let _e263 = LambertEdge_u0028_vf3_u003b_vf3_u003b_vf3_u003b((¶m_73), (¶m_74), (¶m_75)); + local_17 = _e263; } else { - local_12 = 0f; - } - let _e253 = local_12; - let _e254 = total; - total = (_e254 + _e253); - let _e256 = aAbove; - let _e257 = bAbove; - let _e260 = q; - let _e261 = exit; - exit = select(_e261, _e260, vec3((_e256 && !(_e257)))); - let _e264 = aAbove; - let _e266 = bAbove; - let _e268 = q; - let _e269 = entry; - entry = select(_e269, _e268, vec3((!(_e264) && _e266))); + local_17 = 0f; + } + let _e264 = local_17; + let _e265 = total; + total = (_e265 + _e264); + let _e267 = aAbove; + let _e268 = bAbove; + let _e271 = q; + let _e272 = exit; + exit = select(_e272, _e271, vec3((_e267 && !(_e268)))); + let _e275 = aAbove; + let _e277 = bAbove; + let _e279 = q; + let _e280 = entry; + entry = select(_e280, _e279, vec3((!(_e275) && _e277))); continue; } else { break; } continuing { - let _e272 = i_7; - i_7 = (_e272 + 1i); + let _e283 = i_7; + i_7 = (_e283 + 1i); } } - let _e274 = exit; - param_70 = _e274; - let _e275 = entry; - param_71 = _e275; - let _e276 = (*n_4); - param_72 = _e276; - let _e277 = LambertEdge_u0028_vf3_u003b_vf3_u003b_vf3_u003b((¶m_70), (¶m_71), (¶m_72)); - let _e278 = total; - total = (_e278 + _e277); - let _e280 = total; - return max((-(_e280) * 0.5f), 0f); + let _e285 = exit; + param_76 = _e285; + let _e286 = entry; + param_77 = _e286; + let _e287 = (*n_4); + param_78 = _e287; + let _e288 = LambertEdge_u0028_vf3_u003b_vf3_u003b_vf3_u003b((¶m_76), (¶m_77), (¶m_78)); + let _e289 = total; + total = (_e289 + _e288); + let _e291 = total; + return max((-(_e291) * 0.5f), 0f); } fn LightListLane_u0028_vf4_u003b_i1_u003b(four: ptr>, slot_1: ptr) -> f32 { var lane: i32; - var local_13: f32; - var local_14: f32; - var local_15: f32; + var local_18: f32; + var local_19: f32; + var local_20: f32; - let _e183 = (*slot_1); - let _e184 = (*slot_1); - lane = (_e183 - ((_e184 / 4i) * 4i)); - let _e188 = lane; - if (_e188 == 0i) { - let _e191 = (*four)[0u]; - local_13 = _e191; + let _e194 = (*slot_1); + let _e195 = (*slot_1); + lane = (_e194 - ((_e195 / 4i) * 4i)); + let _e199 = lane; + if (_e199 == 0i) { + let _e202 = (*four)[0u]; + local_18 = _e202; } else { - let _e192 = lane; - if (_e192 == 1i) { - let _e195 = (*four)[1u]; - local_14 = _e195; + let _e203 = lane; + if (_e203 == 1i) { + let _e206 = (*four)[1u]; + local_19 = _e206; } else { - let _e196 = lane; - if (_e196 == 2i) { - let _e199 = (*four)[2u]; - local_15 = _e199; + let _e207 = lane; + if (_e207 == 2i) { + let _e210 = (*four)[2u]; + local_20 = _e210; } else { - let _e201 = (*four)[3u]; - local_15 = _e201; + let _e212 = (*four)[3u]; + local_20 = _e212; } - let _e202 = local_15; - local_14 = _e202; + let _e213 = local_20; + local_19 = _e213; } - let _e203 = local_14; - local_13 = _e203; + let _e214 = local_19; + local_18 = _e214; } - let _e204 = local_13; - return _e204; + let _e215 = local_18; + return _e215; } fn LightListScale_u0028_i1_u003b(slot_2: ptr) -> f32 { - var param_73: vec4; - var param_74: i32; + var param_79: vec4; + var param_80: i32; - let _e180 = (*slot_2); - let _e184 = light_list_info.scales[(_e180 / 4i)]; - param_73 = _e184; - let _e185 = (*slot_2); - param_74 = _e185; - let _e186 = LightListLane_u0028_vf4_u003b_i1_u003b((¶m_73), (¶m_74)); - return _e186; + let _e191 = (*slot_2); + let _e195 = light_list_info.scales[(_e191 / 4i)]; + param_79 = _e195; + let _e196 = (*slot_2); + param_80 = _e196; + let _e197 = LightListLane_u0028_vf4_u003b_i1_u003b((¶m_79), (¶m_80)); + return _e197; } fn InSlots_u0028_f1_u003b(row: ptr) -> bool { var a_3: vec4; var b_2: vec4; - let _e182 = light_list_info.slot_rows[0i]; - let _e183 = (*row); - a_3 = abs((_e182 - vec4(_e183))); - let _e189 = light_list_info.slot_rows[1i]; - let _e190 = (*row); - b_2 = abs((_e189 - vec4(_e190))); - let _e195 = a_3[0u]; - let _e197 = a_3[1u]; - let _e200 = a_3[2u]; - let _e202 = a_3[3u]; - let _e206 = b_2[0u]; - let _e208 = b_2[1u]; - let _e211 = b_2[2u]; - let _e213 = b_2[3u]; - return (min(min(min(_e195, _e197), min(_e200, _e202)), min(min(_e206, _e208), min(_e211, _e213))) < 0.5f); + let _e193 = light_list_info.slot_rows[0i]; + let _e194 = (*row); + a_3 = abs((_e193 - vec4(_e194))); + let _e200 = light_list_info.slot_rows[1i]; + let _e201 = (*row); + b_2 = abs((_e200 - vec4(_e201))); + let _e206 = a_3[0u]; + let _e208 = a_3[1u]; + let _e211 = a_3[2u]; + let _e213 = a_3[3u]; + let _e217 = b_2[0u]; + let _e219 = b_2[1u]; + let _e222 = b_2[2u]; + let _e224 = b_2[3u]; + return (min(min(min(_e206, _e208), min(_e211, _e213)), min(min(_e217, _e219), min(_e222, _e224))) < 0.5f); } fn LightListRow_u0028_i1_u003b(slot_3: ptr) -> f32 { - var param_75: vec4; - var param_76: i32; + var param_81: vec4; + var param_82: i32; - let _e180 = (*slot_3); - let _e184 = light_list_info.indices[(_e180 / 4i)]; - param_75 = _e184; - let _e185 = (*slot_3); - param_76 = _e185; - let _e186 = LightListLane_u0028_vf4_u003b_i1_u003b((¶m_75), (¶m_76)); - return _e186; + let _e191 = (*slot_3); + let _e195 = light_list_info.indices[(_e191 / 4i)]; + param_81 = _e195; + let _e196 = (*slot_3); + param_82 = _e196; + let _e197 = LightListLane_u0028_vf4_u003b_i1_u003b((¶m_81), (¶m_82)); + return _e197; } fn LightListTexel_u0028_f1_u003b_f1_u003b(texel_2: ptr, row_1: ptr) -> vec4 { - let _e179 = (*texel_2); - let _e183 = light_list_info.list[1u]; - let _e185 = (*row_1); - let _e189 = light_list_info.list[2u]; - let _e192 = textureSampleLevel(light_list_texture_tex, light_list_texture_smp, vec2(((_e179 + 0.5f) * _e183), ((_e185 + 0.5f) * _e189)), 0f); - return _e192; + let _e190 = (*texel_2); + let _e194 = light_list_info.list[1u]; + let _e196 = (*row_1); + let _e200 = light_list_info.list[2u]; + let _e203 = textureSampleLevel(light_list_texture_tex, light_list_texture_smp, vec2(((_e190 + 0.5f) * _e194), ((_e196 + 0.5f) * _e200)), 0f); + return _e203; } fn ClusterRow_u0028_i1_u003b(slot_4: ptr) -> f32 { @@ -22865,43 +25497,43 @@ fn ClusterRow_u0028_i1_u003b(slot_4: ptr) -> f32 { var within: f32; var texel_3: f32; var four_1: vec4; - var param_77: f32; - var param_78: f32; - var param_79: vec4; - var param_80: i32; + var param_83: f32; + var param_84: f32; + var param_85: vec4; + var param_86: i32; - let _e187 = g_cluster_offset; - let _e188 = (*slot_4); - entry_1 = (_e187 + f32(_e188)); - let _e191 = entry_1; - row_2 = floor((_e191 / 16f)); - let _e194 = entry_1; - let _e195 = row_2; - within = (_e194 - (_e195 * 16f)); - let _e198 = within; - texel_3 = floor((_e198 / 4f)); - let _e203 = light_list_info.cluster_depth[3u]; - let _e204 = row_2; - let _e206 = texel_3; - param_77 = _e206; - param_78 = (_e203 + _e204); - let _e207 = LightListTexel_u0028_f1_u003b_f1_u003b((¶m_77), (¶m_78)); - four_1 = _e207; - let _e208 = within; - let _e209 = texel_3; - let _e214 = four_1; - param_79 = _e214; - param_80 = i32(((_e208 - (_e209 * 4f)) + 0.5f)); - let _e215 = LightListLane_u0028_vf4_u003b_i1_u003b((¶m_79), (¶m_80)); - return _e215; + let _e198 = g_cluster_offset; + let _e199 = (*slot_4); + entry_1 = (_e198 + f32(_e199)); + let _e202 = entry_1; + row_2 = floor((_e202 / 16f)); + let _e205 = entry_1; + let _e206 = row_2; + within = (_e205 - (_e206 * 16f)); + let _e209 = within; + texel_3 = floor((_e209 / 4f)); + let _e214 = light_list_info.cluster_depth[3u]; + let _e215 = row_2; + let _e217 = texel_3; + param_83 = _e217; + param_84 = (_e214 + _e215); + let _e218 = LightListTexel_u0028_f1_u003b_f1_u003b((¶m_83), (¶m_84)); + four_1 = _e218; + let _e219 = within; + let _e220 = texel_3; + let _e225 = four_1; + param_85 = _e225; + param_86 = i32(((_e219 - (_e220 * 4f)) + 0.5f)); + let _e226 = LightListLane_u0028_vf4_u003b_i1_u003b((¶m_85), (¶m_86)); + return _e226; } fn Clustered_u0028_() -> bool { - let _e179 = light_list_info.cluster_grid[3u]; - return (_e179 > 0.5f); + let _e190 = light_list_info.cluster_grid[3u]; + return (_e190 > 0.5f); } -fn SampleLight_u0028_i1_u003b_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b(index_2: ptr, s_3: ptr) -> LightSample { +fn SampleLight_u0028_i1_u003b_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b(index_2: ptr, s_4: ptr) -> LightSample { var light_3: LightSample; var position: vec4; var color_1: vec4; @@ -22910,14 +25542,14 @@ fn SampleLight_u0028_i1_u003b_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_ var slot_5: i32; var clustered: bool; var listRow: f32; - var local_16: f32; - var param_81: i32; - var param_82: i32; + var local_21: f32; + var param_87: i32; + var param_88: i32; var v_1: f32; var u_1: f32; - var local_17: f32; - var param_83: f32; - var param_84: i32; + var local_22: f32; + var param_89: f32; + var param_90: i32; var type_39: f32; var halfWidth_1: vec3; var halfHeight_1: vec3; @@ -22925,293 +25557,293 @@ fn SampleLight_u0028_i1_u003b_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_ var corners_1: array, 4>; var behind: bool; var formFactor: f32; - var local_18: f32; - var param_85: array, 4>; - var param_86: vec3; + var local_23: f32; + var param_91: array, 4>; + var param_92: vec3; var area: f32; var radiance: f32; - var local_19: f32; + var local_24: f32; var distance_2: f32; var ratio_1: f32; var window_1: f32; var mirror_1: vec3; var representative: vec3; - var param_87: vec3; - var param_88: vec3; - var param_89: vec3; - var param_90: vec3; - var param_91: vec3; + var param_93: vec3; + var param_94: vec3; + var param_95: vec3; + var param_96: vec3; + var param_97: vec3; var toPoint: vec3; var pointDistance: f32; - var local_20: vec3; + var local_25: vec3; var aim: vec3; var attenuation_1: f32; var toLight_1: vec3; var distance_3: f32; - var param_92: f32; - var param_93: f32; + var param_98: f32; + var param_99: f32; var cosAngle: f32; light_3.integrated = 0f; light_3.ltc = vec3(0f, 0f, 0f); - let _e230 = (*index_2); - if (_e230 < 8i) { - let _e232 = (*index_2); - let _e235 = frag_info.light_position[_e232]; - position = _e235; - let _e236 = (*index_2); - let _e239 = frag_info.light_color[_e236]; - color_1 = _e239; - let _e240 = (*index_2); - let _e243 = frag_info.light_direction[_e240]; - direction = _e243; - let _e244 = (*index_2); - let _e247 = frag_info.light_cone[_e244]; - cone = _e247; - } else { - let _e248 = (*index_2); - slot_5 = (_e248 - 8i); - let _e250 = Clustered_u0028_(); - clustered = _e250; - let _e251 = clustered; - if _e251 { - let _e252 = slot_5; - param_81 = _e252; - let _e253 = ClusterRow_u0028_i1_u003b((¶m_81)); - local_16 = _e253; + let _e241 = (*index_2); + if (_e241 < 8i) { + let _e243 = (*index_2); + let _e246 = frag_info.light_position[_e243]; + position = _e246; + let _e247 = (*index_2); + let _e250 = frag_info.light_color[_e247]; + color_1 = _e250; + let _e251 = (*index_2); + let _e254 = frag_info.light_direction[_e251]; + direction = _e254; + let _e255 = (*index_2); + let _e258 = frag_info.light_cone[_e255]; + cone = _e258; + } else { + let _e259 = (*index_2); + slot_5 = (_e259 - 8i); + let _e261 = Clustered_u0028_(); + clustered = _e261; + let _e262 = clustered; + if _e262 { + let _e263 = slot_5; + param_87 = _e263; + let _e264 = ClusterRow_u0028_i1_u003b((¶m_87)); + local_21 = _e264; } else { - let _e254 = slot_5; - param_82 = _e254; - let _e255 = LightListRow_u0028_i1_u003b((¶m_82)); - local_16 = _e255; - } - let _e256 = local_16; - listRow = _e256; - let _e257 = listRow; - let _e261 = light_list_info.list[2u]; - v_1 = ((_e257 + 0.5f) * _e261); - let _e265 = light_list_info.list[1u]; - u_1 = _e265; - let _e266 = u_1; - let _e268 = v_1; - let _e270 = textureSampleLevel(light_list_texture_tex, light_list_texture_smp, vec2((0.5f * _e266), _e268), 0f); - position = _e270; - let _e271 = u_1; - let _e273 = v_1; - let _e275 = textureSampleLevel(light_list_texture_tex, light_list_texture_smp, vec2((1.5f * _e271), _e273), 0f); - color_1 = _e275; - let _e276 = u_1; - let _e278 = v_1; - let _e280 = textureSampleLevel(light_list_texture_tex, light_list_texture_smp, vec2((2.5f * _e276), _e278), 0f); - direction = _e280; - let _e281 = u_1; - let _e283 = v_1; - let _e285 = textureSampleLevel(light_list_texture_tex, light_list_texture_smp, vec2((3.5f * _e281), _e283), 0f); - cone = _e285; - let _e286 = clustered; - if _e286 { - let _e287 = listRow; - param_83 = _e287; - let _e288 = InSlots_u0028_f1_u003b((¶m_83)); - local_17 = select(1f, 0f, _e288); + let _e265 = slot_5; + param_88 = _e265; + let _e266 = LightListRow_u0028_i1_u003b((¶m_88)); + local_21 = _e266; + } + let _e267 = local_21; + listRow = _e267; + let _e268 = listRow; + let _e272 = light_list_info.list[2u]; + v_1 = ((_e268 + 0.5f) * _e272); + let _e276 = light_list_info.list[1u]; + u_1 = _e276; + let _e277 = u_1; + let _e279 = v_1; + let _e281 = textureSampleLevel(light_list_texture_tex, light_list_texture_smp, vec2((0.5f * _e277), _e279), 0f); + position = _e281; + let _e282 = u_1; + let _e284 = v_1; + let _e286 = textureSampleLevel(light_list_texture_tex, light_list_texture_smp, vec2((1.5f * _e282), _e284), 0f); + color_1 = _e286; + let _e287 = u_1; + let _e289 = v_1; + let _e291 = textureSampleLevel(light_list_texture_tex, light_list_texture_smp, vec2((2.5f * _e287), _e289), 0f); + direction = _e291; + let _e292 = u_1; + let _e294 = v_1; + let _e296 = textureSampleLevel(light_list_texture_tex, light_list_texture_smp, vec2((3.5f * _e292), _e294), 0f); + cone = _e296; + let _e297 = clustered; + if _e297 { + let _e298 = listRow; + param_89 = _e298; + let _e299 = InSlots_u0028_f1_u003b((¶m_89)); + local_22 = select(1f, 0f, _e299); } else { - let _e290 = slot_5; - param_84 = _e290; - let _e291 = LightListScale_u0028_i1_u003b((¶m_84)); - local_17 = _e291; - } - let _e292 = local_17; - let _e294 = color_1[3u]; - color_1[3u] = (_e294 * _e292); - } - let _e298 = position[3u]; - type_39 = _e298; - let _e299 = type_39; - if (_e299 > 2.5f) { - let _e301 = direction; - halfWidth_1 = _e301.xyz; - let _e303 = cone; - halfHeight_1 = _e303.xyz; - let _e305 = position; - let _e307 = v_world_position_1; - toCentre = (_e305.xyz - _e307); - let _e309 = toCentre; - let _e310 = halfWidth_1; - let _e312 = halfHeight_1; - corners_1[0i] = ((_e309 - _e310) - _e312); - let _e315 = toCentre; - let _e316 = halfWidth_1; - let _e318 = halfHeight_1; - corners_1[1i] = ((_e315 + _e316) - _e318); - let _e321 = toCentre; - let _e322 = halfWidth_1; - let _e324 = halfHeight_1; - corners_1[2i] = ((_e321 + _e322) + _e324); - let _e327 = toCentre; - let _e328 = halfWidth_1; - let _e330 = halfHeight_1; - corners_1[3i] = ((_e327 - _e328) + _e330); - let _e333 = toCentre; - let _e334 = halfWidth_1; + let _e301 = slot_5; + param_90 = _e301; + let _e302 = LightListScale_u0028_i1_u003b((¶m_90)); + local_22 = _e302; + } + let _e303 = local_22; + let _e305 = color_1[3u]; + color_1[3u] = (_e305 * _e303); + } + let _e309 = position[3u]; + type_39 = _e309; + let _e310 = type_39; + if (_e310 > 2.5f) { + let _e312 = direction; + halfWidth_1 = _e312.xyz; + let _e314 = cone; + halfHeight_1 = _e314.xyz; + let _e316 = position; + let _e318 = v_world_position_1; + toCentre = (_e316.xyz - _e318); + let _e320 = toCentre; + let _e321 = halfWidth_1; + let _e323 = halfHeight_1; + corners_1[0i] = ((_e320 - _e321) - _e323); + let _e326 = toCentre; + let _e327 = halfWidth_1; + let _e329 = halfHeight_1; + corners_1[1i] = ((_e326 + _e327) - _e329); + let _e332 = toCentre; + let _e333 = halfWidth_1; let _e335 = halfHeight_1; - behind = (dot(_e333, cross(_e334, _e335)) >= 0f); - let _e339 = behind; - if _e339 { - local_18 = 0f; - } else { - let _e340 = corners_1; - param_85 = _e340; - let _e342 = (*s_3).n; - param_86 = _e342; - let _e343 = RectangleFormFactor_u0028_vf3_u005b_4_u005d_u003b_vf3_u003b((¶m_85), (¶m_86)); - local_18 = _e343; - } - let _e344 = local_18; - formFactor = _e344; + corners_1[2i] = ((_e332 + _e333) + _e335); + let _e338 = toCentre; + let _e339 = halfWidth_1; + let _e341 = halfHeight_1; + corners_1[3i] = ((_e338 - _e339) + _e341); + let _e344 = toCentre; let _e345 = halfWidth_1; let _e346 = halfHeight_1; - area = (length(cross(_e345, _e346)) * 4f); - let _e350 = area; - if (_e350 > 0.000000001f) { - let _e352 = area; - local_19 = (1f / _e352); + behind = (dot(_e344, cross(_e345, _e346)) >= 0f); + let _e350 = behind; + if _e350 { + local_23 = 0f; } else { - local_19 = 0f; - } - let _e354 = local_19; - radiance = _e354; - let _e355 = toCentre; - distance_2 = length(_e355); - let _e358 = direction[3u]; - if (_e358 > 0f) { - let _e360 = distance_2; - let _e362 = direction[3u]; - ratio_1 = (_e360 / _e362); - let _e364 = ratio_1; - let _e365 = ratio_1; - let _e367 = ratio_1; - let _e369 = ratio_1; - window_1 = clamp((1f - (((_e364 * _e365) * _e367) * _e369)), 0f, 1f); - let _e373 = window_1; - let _e374 = window_1; - let _e376 = radiance; - radiance = (_e376 * (_e373 * _e374)); - } - let _e379 = (*s_3).v; - let _e382 = (*s_3).n; - mirror_1 = reflect(-(_e379), _e382); - let _e384 = position; - param_87 = _e384.xyz; - let _e386 = halfWidth_1; - param_88 = _e386; - let _e387 = halfHeight_1; - param_89 = _e387; - let _e388 = v_world_position_1; - param_90 = _e388; - let _e389 = mirror_1; - param_91 = _e389; - let _e390 = RectangleClosestPoint_u0028_vf3_u003b_vf3_u003b_vf3_u003b_vf3_u003b_vf3_u003b((¶m_87), (¶m_88), (¶m_89), (¶m_90), (¶m_91)); - representative = _e390; - let _e391 = representative; - let _e392 = v_world_position_1; - toPoint = (_e391 - _e392); - let _e394 = toPoint; - pointDistance = length(_e394); - let _e396 = pointDistance; - if (_e396 > 0.000001f) { - let _e398 = toPoint; - let _e399 = pointDistance; - local_20 = (_e398 / vec3(_e399)); + let _e351 = corners_1; + param_91 = _e351; + let _e353 = (*s_4).n; + param_92 = _e353; + let _e354 = RectangleFormFactor_u0028_vf3_u005b_4_u005d_u003b_vf3_u003b((¶m_91), (¶m_92)); + local_23 = _e354; + } + let _e355 = local_23; + formFactor = _e355; + let _e356 = halfWidth_1; + let _e357 = halfHeight_1; + area = (length(cross(_e356, _e357)) * 4f); + let _e361 = area; + if (_e361 > 0.000000001f) { + let _e363 = area; + local_24 = (1f / _e363); + } else { + local_24 = 0f; + } + let _e365 = local_24; + radiance = _e365; + let _e366 = toCentre; + distance_2 = length(_e366); + let _e369 = direction[3u]; + if (_e369 > 0f) { + let _e371 = distance_2; + let _e373 = direction[3u]; + ratio_1 = (_e371 / _e373); + let _e375 = ratio_1; + let _e376 = ratio_1; + let _e378 = ratio_1; + let _e380 = ratio_1; + window_1 = clamp((1f - (((_e375 * _e376) * _e378) * _e380)), 0f, 1f); + let _e384 = window_1; + let _e385 = window_1; + let _e387 = radiance; + radiance = (_e387 * (_e384 * _e385)); + } + let _e390 = (*s_4).v; + let _e393 = (*s_4).n; + mirror_1 = reflect(-(_e390), _e393); + let _e395 = position; + param_93 = _e395.xyz; + let _e397 = halfWidth_1; + param_94 = _e397; + let _e398 = halfHeight_1; + param_95 = _e398; + let _e399 = v_world_position_1; + param_96 = _e399; + let _e400 = mirror_1; + param_97 = _e400; + let _e401 = RectangleClosestPoint_u0028_vf3_u003b_vf3_u003b_vf3_u003b_vf3_u003b_vf3_u003b((¶m_93), (¶m_94), (¶m_95), (¶m_96), (¶m_97)); + representative = _e401; + let _e402 = representative; + let _e403 = v_world_position_1; + toPoint = (_e402 - _e403); + let _e405 = toPoint; + pointDistance = length(_e405); + let _e407 = pointDistance; + if (_e407 > 0.000001f) { + let _e409 = toPoint; + let _e410 = pointDistance; + local_25 = (_e409 / vec3(_e410)); } else { - let _e403 = (*s_3).n; - local_20 = _e403; - } - let _e404 = local_20; - light_3.l = _e404; - let _e407 = light_3.l; - let _e409 = (*s_3).v; - light_3.h = normalize((_e407 + _e409)); - let _e413 = formFactor; - light_3.n_dot_l = _e413; - let _e416 = (*s_3).n; - let _e418 = light_3.h; - light_3.n_dot_h = max(dot(_e416, _e418), 0f); - let _e423 = (*s_3).v; - let _e425 = light_3.h; - light_3.v_dot_h = max(dot(_e423, _e425), 0f); - let _e429 = color_1; - let _e432 = color_1[3u]; - let _e434 = radiance; - light_3.radiance = ((_e429.xyz * _e432) * _e434); - let _e437 = light_3; - return _e437; - } - let _e438 = direction; - aim = normalize(_e438.xyz); + let _e414 = (*s_4).n; + local_25 = _e414; + } + let _e415 = local_25; + light_3.l = _e415; + let _e418 = light_3.l; + let _e420 = (*s_4).v; + light_3.h = normalize((_e418 + _e420)); + let _e424 = formFactor; + light_3.n_dot_l = _e424; + let _e427 = (*s_4).n; + let _e429 = light_3.h; + light_3.n_dot_h = max(dot(_e427, _e429), 0f); + let _e434 = (*s_4).v; + let _e436 = light_3.h; + light_3.v_dot_h = max(dot(_e434, _e436), 0f); + let _e440 = color_1; + let _e443 = color_1[3u]; + let _e445 = radiance; + light_3.radiance = ((_e440.xyz * _e443) * _e445); + let _e448 = light_3; + return _e448; + } + let _e449 = direction; + aim = normalize(_e449.xyz); attenuation_1 = 1f; - let _e441 = type_39; - if (_e441 < 0.5f) { - let _e443 = aim; - light_3.l = -(_e443); - } else { - let _e446 = position; - let _e448 = v_world_position_1; - toLight_1 = (_e446.xyz - _e448); - let _e450 = toLight_1; - distance_3 = length(_e450); - let _e452 = distance_3; - if (_e452 < 0.000001f) { - let _e455 = (*s_3).n; - light_3.l = _e455; - let _e458 = (*s_3).n; - light_3.h = _e458; + let _e452 = type_39; + if (_e452 < 0.5f) { + let _e454 = aim; + light_3.l = -(_e454); + } else { + let _e457 = position; + let _e459 = v_world_position_1; + toLight_1 = (_e457.xyz - _e459); + let _e461 = toLight_1; + distance_3 = length(_e461); + let _e463 = distance_3; + if (_e463 < 0.000001f) { + let _e466 = (*s_4).n; + light_3.l = _e466; + let _e469 = (*s_4).n; + light_3.h = _e469; light_3.radiance = vec3(0f, 0f, 0f); light_3.n_dot_l = 0f; light_3.n_dot_h = 0f; light_3.v_dot_h = 0f; - let _e464 = light_3; - return _e464; - } - let _e465 = toLight_1; - let _e466 = distance_3; - light_3.l = (_e465 / vec3(_e466)); - let _e470 = distance_3; - param_92 = _e470; - let _e472 = direction[3u]; - param_93 = _e472; - let _e473 = PunctualAttenuation_u0028_f1_u003b_f1_u003b((¶m_92), (¶m_93)); - attenuation_1 = _e473; - let _e474 = type_39; - if (_e474 > 1.5f) { - let _e476 = aim; - let _e478 = light_3.l; - cosAngle = dot(_e476, -(_e478)); - let _e481 = cosAngle; - let _e483 = cone[1u]; - let _e486 = cone[0u]; - let _e488 = cone[1u]; - let _e492 = attenuation_1; - attenuation_1 = (_e492 * clamp(((_e481 - _e483) / (_e486 - _e488)), 0f, 1f)); - } - } - let _e495 = light_3.l; - let _e497 = (*s_3).v; - light_3.h = normalize((_e495 + _e497)); - let _e502 = (*s_3).n; - let _e504 = light_3.l; - light_3.n_dot_l = max(dot(_e502, _e504), 0f); - let _e509 = (*s_3).n; - let _e511 = light_3.h; - light_3.n_dot_h = max(dot(_e509, _e511), 0f); - let _e516 = (*s_3).v; - let _e518 = light_3.h; - light_3.v_dot_h = max(dot(_e516, _e518), 0f); - let _e522 = color_1; - let _e525 = color_1[3u]; - let _e527 = attenuation_1; - light_3.radiance = ((_e522.xyz * _e525) * _e527); - let _e530 = light_3; - return _e530; + let _e475 = light_3; + return _e475; + } + let _e476 = toLight_1; + let _e477 = distance_3; + light_3.l = (_e476 / vec3(_e477)); + let _e481 = distance_3; + param_98 = _e481; + let _e483 = direction[3u]; + param_99 = _e483; + let _e484 = PunctualAttenuation_u0028_f1_u003b_f1_u003b((¶m_98), (¶m_99)); + attenuation_1 = _e484; + let _e485 = type_39; + if (_e485 > 1.5f) { + let _e487 = aim; + let _e489 = light_3.l; + cosAngle = dot(_e487, -(_e489)); + let _e492 = cosAngle; + let _e494 = cone[1u]; + let _e497 = cone[0u]; + let _e499 = cone[1u]; + let _e503 = attenuation_1; + attenuation_1 = (_e503 * clamp(((_e492 - _e494) / (_e497 - _e499)), 0f, 1f)); + } + } + let _e506 = light_3.l; + let _e508 = (*s_4).v; + light_3.h = normalize((_e506 + _e508)); + let _e513 = (*s_4).n; + let _e515 = light_3.l; + light_3.n_dot_l = max(dot(_e513, _e515), 0f); + let _e520 = (*s_4).n; + let _e522 = light_3.h; + light_3.n_dot_h = max(dot(_e520, _e522), 0f); + let _e527 = (*s_4).v; + let _e529 = light_3.h; + light_3.v_dot_h = max(dot(_e527, _e529), 0f); + let _e533 = color_1; + let _e536 = color_1[3u]; + let _e538 = attenuation_1; + light_3.radiance = ((_e533.xyz * _e536) * _e538); + let _e541 = light_3; + return _e541; } fn FindCluster_u0028_vf3_u003b(world_3: ptr>) { @@ -23222,312 +25854,307 @@ fn FindCluster_u0028_vf3_u003b(world_3: ptr>) { var tx: f32; var ty: f32; var tz: f32; - var local_21: f32; + var local_26: f32; var cell: f32; var row_3: f32; var header: vec4; - var param_94: f32; - var param_95: f32; + var param_100: f32; + var param_101: f32; - let _e192 = light_list_info.cluster_view_projection; - let _e193 = (*world_3); - clip_1 = (_e192 * vec4(_e193.x, _e193.y, _e193.z, 1f)); - let _e199 = clip_1; - let _e202 = clip_1[3u]; - ndc_1 = (_e199.xy / vec2(max(_e202, 0.000001f))); - let _e207 = light_list_info.cluster_grid; - grid = _e207.xyz; - let _e211 = light_list_info.cluster_depth[0u]; - near_1 = _e211; - let _e213 = ndc_1[0u]; - let _e217 = grid[0u]; - let _e221 = grid[0u]; - tx = clamp(floor((((_e213 * 0.5f) + 0.5f) * _e217)), 0f, (_e221 - 1f)); - let _e225 = ndc_1[1u]; - let _e229 = grid[1u]; - let _e233 = grid[1u]; - ty = clamp(floor((((_e225 * 0.5f) + 0.5f) * _e229)), 0f, (_e233 - 1f)); - let _e237 = clip_1[3u]; - let _e238 = near_1; - if (_e237 <= _e238) { - local_21 = 0f; - } else { - let _e241 = clip_1[3u]; - let _e242 = near_1; - let _e247 = light_list_info.cluster_depth[1u]; - let _e251 = grid[2u]; - local_21 = clamp(floor((log((_e241 / _e242)) * _e247)), 0f, (_e251 - 1f)); - } - let _e254 = local_21; - tz = _e254; - let _e255 = tx; - let _e256 = ty; - let _e258 = grid[0u]; - let _e261 = tz; - let _e263 = grid[0u]; - let _e266 = grid[1u]; - cell = ((_e255 + (_e256 * _e258)) + ((_e261 * _e263) * _e266)); - let _e269 = cell; - row_3 = floor((_e269 / 4f)); - let _e272 = cell; - let _e273 = row_3; - let _e278 = light_list_info.cluster_depth[2u]; - let _e279 = row_3; - param_94 = (_e272 - (_e273 * 4f)); - param_95 = (_e278 + _e279); - let _e281 = LightListTexel_u0028_f1_u003b_f1_u003b((¶m_94), (¶m_95)); - header = _e281; - let _e283 = header[0u]; - g_cluster_offset = _e283; - let _e285 = header[1u]; - g_cluster_count = _e285; + let _e203 = light_list_info.cluster_view_projection; + let _e204 = (*world_3); + clip_1 = (_e203 * vec4(_e204.x, _e204.y, _e204.z, 1f)); + let _e210 = clip_1; + let _e213 = clip_1[3u]; + ndc_1 = (_e210.xy / vec2(max(_e213, 0.000001f))); + let _e218 = light_list_info.cluster_grid; + grid = _e218.xyz; + let _e222 = light_list_info.cluster_depth[0u]; + near_1 = _e222; + let _e224 = ndc_1[0u]; + let _e228 = grid[0u]; + let _e232 = grid[0u]; + tx = clamp(floor((((_e224 * 0.5f) + 0.5f) * _e228)), 0f, (_e232 - 1f)); + let _e236 = ndc_1[1u]; + let _e240 = grid[1u]; + let _e244 = grid[1u]; + ty = clamp(floor((((_e236 * 0.5f) + 0.5f) * _e240)), 0f, (_e244 - 1f)); + let _e248 = clip_1[3u]; + let _e249 = near_1; + if (_e248 <= _e249) { + local_26 = 0f; + } else { + let _e252 = clip_1[3u]; + let _e253 = near_1; + let _e258 = light_list_info.cluster_depth[1u]; + let _e262 = grid[2u]; + local_26 = clamp(floor((log((_e252 / _e253)) * _e258)), 0f, (_e262 - 1f)); + } + let _e265 = local_26; + tz = _e265; + let _e266 = tx; + let _e267 = ty; + let _e269 = grid[0u]; + let _e272 = tz; + let _e274 = grid[0u]; + let _e277 = grid[1u]; + cell = ((_e266 + (_e267 * _e269)) + ((_e272 * _e274) * _e277)); + let _e280 = cell; + row_3 = floor((_e280 / 4f)); + let _e283 = cell; + let _e284 = row_3; + let _e289 = light_list_info.cluster_depth[2u]; + let _e290 = row_3; + param_100 = (_e283 - (_e284 * 4f)); + param_101 = (_e289 + _e290); + let _e292 = LightListTexel_u0028_f1_u003b_f1_u003b((¶m_100), (¶m_101)); + header = _e292; + let _e294 = header[0u]; + g_cluster_offset = _e294; + let _e296 = header[1u]; + g_cluster_count = _e296; return; } fn LightCount_u0028_() -> i32 { var tail: f32; - var param_96: vec3; + var param_102: vec3; - let _e181 = light_list_info.list[0u]; - tail = _e181; - let _e182 = Clustered_u0028_(); - if _e182 { - let _e183 = v_world_position_1; - param_96 = _e183; - FindCluster_u0028_vf3_u003b((¶m_96)); - let _e184 = g_cluster_count; - tail = _e184; + let _e192 = light_list_info.list[0u]; + tail = _e192; + let _e193 = Clustered_u0028_(); + if _e193 { + let _e194 = v_world_position_1; + param_102 = _e194; + FindCluster_u0028_vf3_u003b((¶m_102)); + let _e195 = g_cluster_count; + tail = _e195; } - let _e187 = frag_info.frame_params[1u]; - let _e191 = tail; - return (clamp(i32((_e187 + 0.5f)), 0i, 8i) + clamp(i32((_e191 + 0.5f)), 0i, 24i)); + let _e198 = frag_info.frame_params[1u]; + let _e202 = tail; + return (clamp(i32((_e198 + 0.5f)), 0i, 8i) + clamp(i32((_e202 + 0.5f)), 0i, 24i)); } -fn AccumulateLights_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b(s_4: ptr) -> vec3 { +fn AccumulateLights_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b(s_5: ptr) -> vec3 { var total_1: vec3; var count_1: i32; var i_8: i32; var light_4: LightSample; - var param_97: i32; - var param_98: Surface; + var param_103: i32; + var param_104: Surface; var visibility: f32; - var param_99: i32; - var local_22: f32; - var param_100: Surface; - var param_101: LightSample; - var param_102: i32; - var param_103: vec3; - var param_104: vec3; var param_105: i32; + var local_27: f32; var param_106: Surface; var param_107: LightSample; + var param_108: i32; + var param_109: vec3; + var param_110: vec3; + var param_111: i32; + var param_112: Surface; + var param_113: LightSample; total_1 = vec3(0f, 0f, 0f); - let _e195 = LightCount_u0028_(); - count_1 = _e195; + let _e206 = LightCount_u0028_(); + count_1 = _e206; i_8 = 0i; loop { - let _e196 = i_8; - if (_e196 < 32i) { - let _e198 = i_8; - let _e199 = count_1; - if (_e198 >= _e199) { + let _e207 = i_8; + if (_e207 < 32i) { + let _e209 = i_8; + let _e210 = count_1; + if (_e209 >= _e210) { break; } - let _e201 = i_8; - param_97 = _e201; - let _e202 = (*s_4); - param_98 = _e202; - let _e203 = SampleLight_u0028_i1_u003b_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b((¶m_97), (¶m_98)); - light_4 = _e203; - let _e205 = light_4.n_dot_l; - if (_e205 <= 0f) { + let _e212 = i_8; + param_103 = _e212; + let _e213 = (*s_5); + param_104 = _e213; + let _e214 = SampleLight_u0028_i1_u003b_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b((¶m_103), (¶m_104)); + light_4 = _e214; + let _e216 = light_4.n_dot_l; + if (_e216 <= 0f) { continue; } - let _e207 = i_8; - param_99 = _e207; - let _e208 = LightHasShadow_u0028_i1_u003b((¶m_99)); - if _e208 { - let _e209 = (*s_4); - param_100 = _e209; - let _e210 = light_4; - param_101 = _e210; - let _e211 = i_8; - param_102 = _e211; - let _e212 = LightVisibility_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b_struct_u002d_LightSample_u002d_vf3_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf31_u003b_i1_u003b((¶m_100), (¶m_101), (¶m_102)); - let _e213 = v_world_position_1; - param_103 = _e213; - let _e215 = (*s_4).n; - param_104 = _e215; - let _e216 = i_8; - param_105 = _e216; - let _e217 = PointShadowFactor_u0028_vf3_u003b_vf3_u003b_i1_u003b((¶m_103), (¶m_104), (¶m_105)); - local_22 = (_e212 * _e217); + light_transmittance = vec3(1f, 1f, 1f); + let _e218 = i_8; + param_105 = _e218; + let _e219 = LightHasShadow_u0028_i1_u003b((¶m_105)); + if _e219 { + let _e220 = (*s_5); + param_106 = _e220; + let _e221 = light_4; + param_107 = _e221; + let _e222 = i_8; + param_108 = _e222; + let _e223 = LightVisibility_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b_struct_u002d_LightSample_u002d_vf3_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf31_u003b_i1_u003b((¶m_106), (¶m_107), (¶m_108)); + let _e224 = v_world_position_1; + param_109 = _e224; + let _e226 = (*s_5).n; + param_110 = _e226; + let _e227 = i_8; + param_111 = _e227; + let _e228 = PointShadowFactor_u0028_vf3_u003b_vf3_u003b_i1_u003b((¶m_109), (¶m_110), (¶m_111)); + local_27 = (_e223 * _e228); } else { - local_22 = 1f; + local_27 = 1f; } - let _e219 = local_22; - visibility = _e219; - let _e220 = visibility; - if (_e220 <= 0f) { + let _e230 = local_27; + visibility = _e230; + let _e231 = visibility; + if (_e231 <= 0f) { continue; } - let _e222 = (*s_4); - param_106 = _e222; - let _e223 = light_4; - param_107 = _e223; - let _e224 = ShadeLight_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b_struct_u002d_LightSample_u002d_vf3_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf31_u003b((¶m_106), (¶m_107)); - let _e226 = light_4.radiance; - let _e229 = light_4.n_dot_l; - let _e231 = visibility; - let _e233 = total_1; - total_1 = (_e233 + (((_e224 * _e226) * _e229) * _e231)); + let _e233 = (*s_5); + param_112 = _e233; + let _e234 = light_4; + param_113 = _e234; + let _e235 = ShadeLight_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b_struct_u002d_LightSample_u002d_vf3_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf31_u003b((¶m_112), (¶m_113)); + let _e237 = light_4.radiance; + let _e240 = light_4.n_dot_l; + let _e242 = visibility; + let _e244 = light_transmittance; + let _e246 = total_1; + total_1 = (_e246 + ((((_e235 * _e237) * _e240) * _e242) * _e244)); continue; } else { break; } continuing { - let _e235 = i_8; - i_8 = (_e235 + 1i); + let _e248 = i_8; + i_8 = (_e248 + 1i); } } - let _e237 = total_1; - return _e237; + let _e250 = total_1; + return _e250; } fn SampleLightmap_u0028_() -> vec3 { var texel_4: vec4; - let _e178 = v_lightmap_uv_1; - let _e179 = textureSample(lightmap_texture_tex, lightmap_texture_smp, _e178); - texel_4 = _e179; - let _e180 = texel_4; - let _e183 = texel_4[3u]; - return ((_e180.xyz * _e183) * 8f); + let _e189 = v_lightmap_uv_1; + let _e190 = textureSample(lightmap_texture_tex, lightmap_texture_smp, _e189); + texel_4 = _e190; + let _e191 = texel_4; + let _e194 = texel_4[3u]; + return ((_e191.xyz * _e194) * 8f); } fn MaterialLodBias_u0028_() -> f32 { - let _e179 = frag_info.target_origin[1u]; - return _e179; + let _e190 = frag_info.target_origin[1u]; + return _e190; } -fn ApplyMetallicRoughnessMap_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b(s_5: ptr) { +fn ApplyMetallicRoughnessMap_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b(s_6: ptr) { var orm: vec3; - let _e179 = v_texcoord_1; - let _e180 = MaterialLodBias_u0028_(); - let _e181 = textureSampleBias(metallic_roughness_texture_tex, metallic_roughness_texture_smp, _e179, _e180); - orm = _e181.xyz; - let _e184 = (*s_5).metallic; - let _e186 = orm[2u]; - (*s_5).metallic = clamp((_e184 * _e186), 0f, 1f); - let _e191 = (*s_5).roughness; - let _e193 = orm[1u]; - (*s_5).roughness = clamp((_e191 * _e193), 0.02f, 1f); + let _e190 = v_texcoord_1; + let _e191 = MaterialLodBias_u0028_(); + let _e192 = textureSampleBias(metallic_roughness_texture_tex, metallic_roughness_texture_smp, _e190, _e191); + orm = _e192.xyz; + let _e195 = (*s_6).metallic; + let _e197 = orm[2u]; + (*s_6).metallic = clamp((_e195 * _e197), 0f, 1f); + let _e202 = (*s_6).roughness; + let _e204 = orm[1u]; + (*s_6).roughness = clamp((_e202 * _e204), 0.02f, 1f); return; } -fn SrgbToLinear_u0028_vf3_u003b(srgb: ptr>) -> vec3 { - let _e178 = (*srgb); - let _e181 = (*srgb); - let _e186 = (*srgb); - return mix((_e178 / vec3(12.92f)), pow(((_e181 + vec3(0.055f, 0.055f, 0.055f)) / vec3(1.055f)), vec3(2.4f, 2.4f, 2.4f)), step(vec3(0.04045f, 0.04045f, 0.04045f), _e186)); -} - -fn ApplyEmissiveMap_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b(s_6: ptr) { +fn ApplyEmissiveMap_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b(s_7: ptr) { var emissive: vec3; - var param_108: vec3; + var param_114: vec3; - let _e180 = v_texcoord_1; - let _e181 = MaterialLodBias_u0028_(); - let _e182 = textureSampleBias(emissive_texture_tex, emissive_texture_smp, _e180, _e181); - param_108 = _e182.xyz; - let _e184 = SrgbToLinear_u0028_vf3_u003b((¶m_108)); - emissive = _e184; - let _e185 = emissive; - let _e187 = frag_info.emissive; - let _e192 = frag_info.material2_[3u]; - (*s_6).emissive = ((_e185 * _e187.xyz) * _e192); + let _e191 = v_texcoord_1; + let _e192 = MaterialLodBias_u0028_(); + let _e193 = textureSampleBias(emissive_texture_tex, emissive_texture_smp, _e191, _e192); + param_114 = _e193.xyz; + let _e195 = SrgbToLinear_u0028_vf3_u003b((¶m_114)); + emissive = _e195; + let _e196 = emissive; + let _e198 = frag_info.emissive; + let _e203 = frag_info.material2_[3u]; + (*s_7).emissive = ((_e196 * _e198.xyz) * _e203); return; } -fn ApplyOcclusionMap_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b(s_7: ptr) { +fn ApplyOcclusionMap_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b(s_8: ptr) { var occlusion: f32; - let _e179 = v_texcoord_1; - let _e180 = MaterialLodBias_u0028_(); - let _e181 = textureSampleBias(occlusion_texture_tex, occlusion_texture_smp, _e179, _e180); - occlusion = _e181.x; - let _e183 = occlusion; - let _e186 = frag_info.material2_[2u]; - (*s_7).occlusion = mix(1f, _e183, clamp(_e186, 0f, 1f)); + let _e190 = v_texcoord_1; + let _e191 = MaterialLodBias_u0028_(); + let _e192 = textureSampleBias(occlusion_texture_tex, occlusion_texture_smp, _e190, _e191); + occlusion = _e192.x; + let _e194 = occlusion; + let _e197 = frag_info.material2_[2u]; + (*s_8).occlusion = mix(1f, _e194, clamp(_e197, 0f, 1f)); return; } -fn ApplyNormalMap_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b(s_8: ptr) { +fn ApplyNormalMap_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b(s_9: ptr) { var sampledTexel: vec4; var t_2: vec3; var b_3: vec3; var sampled: vec3; - let _e182 = v_texcoord_1; - let _e183 = MaterialLodBias_u0028_(); - let _e184 = textureSampleBias(normal_texture_tex, normal_texture_smp, _e182, _e183); - sampledTexel = _e184; - let _e185 = v_tangent_1; - t_2 = _e185.xyz; - let _e187 = t_2; - let _e189 = (*s_8).n; - let _e191 = (*s_8).n; - let _e192 = t_2; - t_2 = (_e187 - (_e189 * dot(_e191, _e192))); - let _e196 = t_2; - let _e197 = t_2; - if (dot(_e196, _e197) < 0.000000000001f) { + let _e193 = v_texcoord_1; + let _e194 = MaterialLodBias_u0028_(); + let _e195 = textureSampleBias(normal_texture_tex, normal_texture_smp, _e193, _e194); + sampledTexel = _e195; + let _e196 = v_tangent_1; + t_2 = _e196.xyz; + let _e198 = t_2; + let _e200 = (*s_9).n; + let _e202 = (*s_9).n; + let _e203 = t_2; + t_2 = (_e198 - (_e200 * dot(_e202, _e203))); + let _e207 = t_2; + let _e208 = t_2; + if (dot(_e207, _e208) < 0.000000000001f) { return; } - let _e200 = t_2; - t_2 = normalize(_e200); - let _e203 = (*s_8).n; - let _e204 = t_2; - let _e207 = v_tangent_1[3u]; - b_3 = (cross(_e203, _e204) * _e207); - let _e209 = gl_FrontFacing_1; - if !(_e209) { - let _e211 = t_2; - t_2 = -(_e211); - } - let _e213 = sampledTexel; - sampled = ((_e213.xyz * 2f) - vec3(1f)); - let _e220 = frag_info.emissive[3u]; - if (_e220 > 0.5f) { - let _e222 = sampled; - let _e224 = sampled; - sampled[2u] = sqrt(max((1f - dot(_e222.xy, _e224.xy)), 0f)); - } - let _e233 = frag_info.material2_[1u]; - let _e234 = sampled; - let _e236 = (_e234.xy * _e233); - sampled[0u] = _e236.x; - sampled[1u] = _e236.y; - let _e241 = t_2; - let _e243 = sampled[0u]; - let _e245 = b_3; - let _e247 = sampled[1u]; - let _e251 = (*s_8).n; - let _e253 = sampled[2u]; - (*s_8).n = normalize((((_e241 * _e243) + (_e245 * _e247)) + (_e251 * _e253))); - let _e259 = (*s_8).n; - let _e261 = (*s_8).v; - (*s_8).n_dot_v = max(dot(_e259, _e261), 0.0001f); + let _e211 = t_2; + t_2 = normalize(_e211); + let _e214 = (*s_9).n; + let _e215 = t_2; + let _e218 = v_tangent_1[3u]; + b_3 = (cross(_e214, _e215) * _e218); + let _e220 = gl_FrontFacing_1; + if !(_e220) { + let _e222 = t_2; + t_2 = -(_e222); + } + let _e224 = sampledTexel; + sampled = ((_e224.xyz * 2f) - vec3(1f)); + let _e231 = frag_info.emissive[3u]; + if (_e231 > 0.5f) { + let _e233 = sampled; + let _e235 = sampled; + sampled[2u] = sqrt(max((1f - dot(_e233.xy, _e235.xy)), 0f)); + } + let _e244 = frag_info.material2_[1u]; + let _e245 = sampled; + let _e247 = (_e245.xy * _e244); + sampled[0u] = _e247.x; + sampled[1u] = _e247.y; + let _e252 = t_2; + let _e254 = sampled[0u]; + let _e256 = b_3; + let _e258 = sampled[1u]; + let _e262 = (*s_9).n; + let _e264 = sampled[2u]; + (*s_9).n = normalize((((_e252 * _e254) + (_e256 * _e258)) + (_e262 * _e264))); + let _e270 = (*s_9).n; + let _e272 = (*s_9).v; + (*s_9).n_dot_v = max(dot(_e270, _e272), 0.0001f); return; } fn AtlasTexel_u0028_vf2_u003b(texel_5: ptr>) -> vec4 { - let _e178 = (*texel_5); - let _e182 = irradiance_info.atlas; - let _e185 = textureSampleLevel(irradiance_texture_tex, irradiance_texture_smp, ((_e178 + vec2(0.5f)) * _e182.xy), 0f); - return _e185; + let _e189 = (*texel_5); + let _e193 = irradiance_info.atlas; + let _e196 = textureSampleLevel(irradiance_texture_tex, irradiance_texture_smp, ((_e189 + vec2(0.5f)) * _e193.xy), 0f); + return _e196; } fn TileBilinear_u0028_vf2_u003b_f1_u003b_vf2_u003b(corner: ptr>, interior: ptr, uv_5: ptr>) -> vec4 { @@ -23535,50 +26162,50 @@ fn TileBilinear_u0028_vf2_u003b_f1_u003b_vf2_u003b(corner: ptr; var f: vec2; var a_4: vec4; - var param_109: vec2; + var param_115: vec2; var b_4: vec4; - var param_110: vec2; - var c: vec4; - var param_111: vec2; + var param_116: vec2; + var c_1: vec4; + var param_117: vec2; var d_4: vec4; - var param_112: vec2; + var param_118: vec2; - let _e191 = (*uv_5); - let _e192 = (*interior); - at_1 = ((vec2(1f) + (_e191 * _e192)) - vec2(0.5f)); - let _e198 = at_1; - low = floor(_e198); - let _e200 = at_1; - let _e201 = low; - f = (_e200 - _e201); - let _e203 = (*corner); - let _e204 = low; - param_109 = (_e203 + _e204); - let _e206 = AtlasTexel_u0028_vf2_u003b((¶m_109)); - a_4 = _e206; - let _e207 = (*corner); - let _e208 = low; - param_110 = ((_e207 + _e208) + vec2(1f, 0f)); - let _e211 = AtlasTexel_u0028_vf2_u003b((¶m_110)); - b_4 = _e211; - let _e212 = (*corner); - let _e213 = low; - param_111 = ((_e212 + _e213) + vec2(0f, 1f)); - let _e216 = AtlasTexel_u0028_vf2_u003b((¶m_111)); - c = _e216; - let _e217 = (*corner); - let _e218 = low; - param_112 = ((_e217 + _e218) + vec2(1f, 1f)); - let _e221 = AtlasTexel_u0028_vf2_u003b((¶m_112)); - d_4 = _e221; - let _e222 = a_4; - let _e223 = b_4; - let _e225 = f[0u]; - let _e228 = c; - let _e229 = d_4; - let _e231 = f[0u]; - let _e235 = f[1u]; - return mix(mix(_e222, _e223, vec4(_e225)), mix(_e228, _e229, vec4(_e231)), vec4(_e235)); + let _e202 = (*uv_5); + let _e203 = (*interior); + at_1 = ((vec2(1f) + (_e202 * _e203)) - vec2(0.5f)); + let _e209 = at_1; + low = floor(_e209); + let _e211 = at_1; + let _e212 = low; + f = (_e211 - _e212); + let _e214 = (*corner); + let _e215 = low; + param_115 = (_e214 + _e215); + let _e217 = AtlasTexel_u0028_vf2_u003b((¶m_115)); + a_4 = _e217; + let _e218 = (*corner); + let _e219 = low; + param_116 = ((_e218 + _e219) + vec2(1f, 0f)); + let _e222 = AtlasTexel_u0028_vf2_u003b((¶m_116)); + b_4 = _e222; + let _e223 = (*corner); + let _e224 = low; + param_117 = ((_e223 + _e224) + vec2(0f, 1f)); + let _e227 = AtlasTexel_u0028_vf2_u003b((¶m_117)); + c_1 = _e227; + let _e228 = (*corner); + let _e229 = low; + param_118 = ((_e228 + _e229) + vec2(1f, 1f)); + let _e232 = AtlasTexel_u0028_vf2_u003b((¶m_118)); + d_4 = _e232; + let _e233 = a_4; + let _e234 = b_4; + let _e236 = f[0u]; + let _e239 = c_1; + let _e240 = d_4; + let _e242 = f[0u]; + let _e246 = f[1u]; + return mix(mix(_e233, _e234, vec4(_e236)), mix(_e239, _e240, vec4(_e242)), vec4(_e246)); } fn ProbeOctahedral_u0028_vf3_u003b(direction_1: ptr>) -> vec2 { @@ -23586,32 +26213,32 @@ fn ProbeOctahedral_u0028_vf3_u003b(direction_1: ptr>) -> vec var n_5: vec3; var xy: vec2; - let _e182 = (*direction_1)[0u]; - let _e185 = (*direction_1)[1u]; - let _e189 = (*direction_1)[2u]; - sum_1 = ((abs(_e182) + abs(_e185)) + abs(_e189)); - let _e192 = sum_1; - if (_e192 <= 0f) { + let _e193 = (*direction_1)[0u]; + let _e196 = (*direction_1)[1u]; + let _e200 = (*direction_1)[2u]; + sum_1 = ((abs(_e193) + abs(_e196)) + abs(_e200)); + let _e203 = sum_1; + if (_e203 <= 0f) { return vec2(0.5f, 0.5f); } - let _e194 = (*direction_1); - let _e195 = sum_1; - n_5 = (_e194 / vec3(_e195)); - let _e198 = n_5; - xy = _e198.xy; - let _e201 = n_5[2u]; - if (_e201 < 0f) { - let _e204 = n_5[1u]; - let _e208 = n_5[0u]; - let _e213 = n_5[0u]; - let _e217 = n_5[1u]; - xy = vec2(((1f - abs(_e204)) * select(-1f, 1f, (_e208 >= 0f))), ((1f - abs(_e213)) * select(-1f, 1f, (_e217 >= 0f)))); - } - let _e222 = xy; - return ((_e222 * 0.5f) + vec2(0.5f)); + let _e205 = (*direction_1); + let _e206 = sum_1; + n_5 = (_e205 / vec3(_e206)); + let _e209 = n_5; + xy = _e209.xy; + let _e212 = n_5[2u]; + if (_e212 < 0f) { + let _e215 = n_5[1u]; + let _e219 = n_5[0u]; + let _e224 = n_5[0u]; + let _e228 = n_5[1u]; + xy = vec2(((1f - abs(_e215)) * select(-1f, 1f, (_e219 >= 0f))), ((1f - abs(_e224)) * select(-1f, 1f, (_e228 >= 0f)))); + } + let _e233 = xy; + return ((_e233 * 0.5f) + vec2(0.5f)); } -fn SampleIrradiance_u0028_vf3_u003b_vf3_u003b_vf3_u003b(world_4: ptr>, normal_1: ptr>, view: ptr>) -> vec3 { +fn SampleIrradiance_u0028_vf3_u003b_vf3_u003b_vf3_u003b(world_4: ptr>, normal_1: ptr>, view_1: ptr>) -> vec3 { var origin_2: vec3; var spacing: vec3; var counts: vec3; @@ -23633,9 +26260,9 @@ fn SampleIrradiance_u0028_vf3_u003b_vf3_u003b_vf3_u003b(world_4: ptr; var irradianceCorner: vec2; var momentCorner: vec2; - var param_113: vec2; + var param_119: vec2; var trilinear: vec3; - var weight_2: f32; + var weight_3: f32; var probePosition: vec3; var toProbe: vec3; var distance_4: f32; @@ -23643,381 +26270,424 @@ fn SampleIrradiance_u0028_vf3_u003b_vf3_u003b_vf3_u003b(world_4: ptr; - var param_114: vec3; - var param_115: vec2; - var param_116: f32; - var param_117: vec2; + var param_120: vec3; + var param_121: vec2; + var param_122: f32; + var param_123: vec2; var chebyshev: f32; var variance_1: f32; var difference: f32; - var param_118: vec3; - var param_119: vec2; - var param_120: f32; - var param_121: vec2; - var local_23: vec3; - - let _e224 = irradiance_info.origin; - origin_2 = _e224.xyz; - let _e227 = irradiance_info.spacing; - spacing = _e227.xyz; - let _e230 = irradiance_info.counts; - counts = _e230.xyz; - let _e234 = irradiance_info.tiles[0u]; - irradianceTile = _e234; - let _e237 = irradiance_info.tiles[1u]; - depthTile = _e237; - let _e240 = irradiance_info.tiles[2u]; - columns = _e240; - let _e243 = irradiance_info.tiles[3u]; - momentsTop = _e243; - let _e244 = (*normal_1); - unit = normalize(_e244); - let _e246 = (*world_4); - let _e247 = unit; - let _e250 = irradiance_info.spacing[3u]; - let _e253 = (*view); - let _e256 = irradiance_info.counts[3u]; - biased = ((_e246 + (_e247 * _e250)) + (_e253 * _e256)); - let _e259 = biased; - let _e260 = origin_2; - let _e262 = spacing; - grid_1 = ((_e259 - _e260) / _e262); - let _e264 = grid_1; - let _e266 = counts; - base = clamp(floor(_e264), vec3(0f, 0f, 0f), (_e266 - vec3(2f))); - let _e270 = grid_1; - let _e271 = base; - f_1 = clamp((_e270 - _e271), vec3(0f, 0f, 0f), vec3(1f, 1f, 1f)); + var param_124: vec3; + var param_125: vec2; + var param_126: f32; + var param_127: vec2; + var local_28: vec3; + + let _e235 = irradiance_info.origin; + origin_2 = _e235.xyz; + let _e238 = irradiance_info.spacing; + spacing = _e238.xyz; + let _e241 = irradiance_info.counts; + counts = _e241.xyz; + let _e245 = irradiance_info.tiles[0u]; + irradianceTile = _e245; + let _e248 = irradiance_info.tiles[1u]; + depthTile = _e248; + let _e251 = irradiance_info.tiles[2u]; + columns = _e251; + let _e254 = irradiance_info.tiles[3u]; + momentsTop = _e254; + let _e255 = (*normal_1); + unit = normalize(_e255); + let _e257 = (*world_4); + let _e258 = unit; + let _e261 = irradiance_info.spacing[3u]; + let _e264 = (*view_1); + let _e267 = irradiance_info.counts[3u]; + biased = ((_e257 + (_e258 * _e261)) + (_e264 * _e267)); + let _e270 = biased; + let _e271 = origin_2; + let _e273 = spacing; + grid_1 = ((_e270 - _e271) / _e273); + let _e275 = grid_1; + let _e277 = counts; + base = clamp(floor(_e275), vec3(0f, 0f, 0f), (_e277 - vec3(2f))); + let _e281 = grid_1; + let _e282 = base; + f_1 = clamp((_e281 - _e282), vec3(0f, 0f, 0f), vec3(1f, 1f, 1f)); total_2 = vec3(0f, 0f, 0f); weights = 0f; corner_1 = 0i; loop { - let _e274 = corner_1; - if (_e274 < 8i) { - let _e276 = corner_1; - let _e279 = corner_1; - let _e284 = corner_1; - offset_3 = vec3(f32((_e276 & 1i)), f32(((_e279 >> bitcast(1i)) & 1i)), f32(((_e284 >> bitcast(2i)) & 1i))); - let _e290 = base; - let _e291 = offset_3; - cell_1 = (_e290 + _e291); - let _e294 = cell_1[2u]; - let _e296 = counts[1u]; - let _e299 = cell_1[1u]; - let _e302 = counts[0u]; - let _e305 = cell_1[0u]; - probe = ((((_e294 * _e296) + _e299) * _e302) + _e305); - let _e307 = probe; - let _e308 = columns; - let _e313 = probe; - let _e314 = columns; - tile_4 = vec2((_e307 - (floor((_e307 / _e308)) * _e308)), floor((_e313 / _e314))); - let _e318 = tile_4; - let _e319 = irradianceTile; - irradianceCorner = (_e318 * (_e319 + 2f)); - let _e323 = tile_4[0u]; - let _e324 = depthTile; - let _e327 = momentsTop; - let _e329 = tile_4[1u]; - let _e330 = depthTile; - momentCorner = vec2((_e323 * (_e324 + 2f)), (_e327 + (_e329 * (_e330 + 2f)))); - let _e335 = irradianceCorner; - param_113 = (_e335 + vec2(1f)); - let _e338 = AtlasTexel_u0028_vf2_u003b((¶m_113)); - if (_e338.w < 0.5f) { + let _e285 = corner_1; + if (_e285 < 8i) { + let _e287 = corner_1; + let _e290 = corner_1; + let _e295 = corner_1; + offset_3 = vec3(f32((_e287 & 1i)), f32(((_e290 >> bitcast(1i)) & 1i)), f32(((_e295 >> bitcast(2i)) & 1i))); + let _e301 = base; + let _e302 = offset_3; + cell_1 = (_e301 + _e302); + let _e305 = cell_1[2u]; + let _e307 = counts[1u]; + let _e310 = cell_1[1u]; + let _e313 = counts[0u]; + let _e316 = cell_1[0u]; + probe = ((((_e305 * _e307) + _e310) * _e313) + _e316); + let _e318 = probe; + let _e319 = columns; + let _e324 = probe; + let _e325 = columns; + tile_4 = vec2((_e318 - (floor((_e318 / _e319)) * _e319)), floor((_e324 / _e325))); + let _e329 = tile_4; + let _e330 = irradianceTile; + irradianceCorner = (_e329 * (_e330 + 2f)); + let _e334 = tile_4[0u]; + let _e335 = depthTile; + let _e338 = momentsTop; + let _e340 = tile_4[1u]; + let _e341 = depthTile; + momentCorner = vec2((_e334 * (_e335 + 2f)), (_e338 + (_e340 * (_e341 + 2f)))); + let _e346 = irradianceCorner; + param_119 = (_e346 + vec2(1f)); + let _e349 = AtlasTexel_u0028_vf2_u003b((¶m_119)); + if (_e349.w < 0.5f) { continue; } - let _e341 = f_1; - let _e343 = f_1; - let _e344 = offset_3; - trilinear = mix((vec3(1f, 1f, 1f) - _e341), _e343, _e344); - let _e347 = trilinear[0u]; - let _e349 = trilinear[1u]; - let _e352 = trilinear[2u]; - weight_2 = max(((_e347 * _e349) * _e352), 0.001f); - let _e355 = origin_2; - let _e356 = spacing; - let _e357 = cell_1; - probePosition = (_e355 + (_e356 * _e357)); - let _e360 = probePosition; - let _e361 = biased; - toProbe = (_e360 - _e361); - let _e363 = toProbe; - distance_4 = length(_e363); - let _e365 = distance_4; - if (_e365 > 0.000001f) { - let _e367 = toProbe; - let _e368 = distance_4; - direction_2 = (_e367 / vec3(_e368)); - let _e371 = unit; - let _e372 = probePosition; - let _e373 = (*world_4); - facing = ((dot(_e371, normalize((_e372 - _e373))) * 0.5f) + 0.5f); - let _e379 = facing; - let _e380 = facing; - probeWeight = ((_e379 * _e380) + 0.2f); - let _e383 = direction_2; - param_114 = -(_e383); - let _e385 = ProbeOctahedral_u0028_vf3_u003b((¶m_114)); - let _e386 = momentCorner; - param_115 = _e386; - let _e387 = depthTile; - param_116 = _e387; - param_117 = _e385; - let _e388 = TileBilinear_u0028_vf2_u003b_f1_u003b_vf2_u003b((¶m_115), (¶m_116), (¶m_117)); - moments_3 = _e388.xy; + let _e352 = f_1; + let _e354 = f_1; + let _e355 = offset_3; + trilinear = mix((vec3(1f, 1f, 1f) - _e352), _e354, _e355); + let _e358 = trilinear[0u]; + let _e360 = trilinear[1u]; + let _e363 = trilinear[2u]; + weight_3 = max(((_e358 * _e360) * _e363), 0.001f); + let _e366 = origin_2; + let _e367 = spacing; + let _e368 = cell_1; + probePosition = (_e366 + (_e367 * _e368)); + let _e371 = probePosition; + let _e372 = biased; + toProbe = (_e371 - _e372); + let _e374 = toProbe; + distance_4 = length(_e374); + let _e376 = distance_4; + if (_e376 > 0.000001f) { + let _e378 = toProbe; + let _e379 = distance_4; + direction_2 = (_e378 / vec3(_e379)); + let _e382 = unit; + let _e383 = probePosition; + let _e384 = (*world_4); + facing = ((dot(_e382, normalize((_e383 - _e384))) * 0.5f) + 0.5f); + let _e390 = facing; + let _e391 = facing; + probeWeight = ((_e390 * _e391) + 0.2f); + let _e394 = direction_2; + param_120 = -(_e394); + let _e396 = ProbeOctahedral_u0028_vf3_u003b((¶m_120)); + let _e397 = momentCorner; + param_121 = _e397; + let _e398 = depthTile; + param_122 = _e398; + param_123 = _e396; + let _e399 = TileBilinear_u0028_vf2_u003b_f1_u003b_vf2_u003b((¶m_121), (¶m_122), (¶m_123)); + moments_3 = _e399.xy; chebyshev = 1f; - let _e390 = distance_4; - let _e392 = moments_3[0u]; - if (_e390 > _e392) { - let _e395 = moments_3[1u]; - let _e397 = moments_3[0u]; - let _e399 = moments_3[0u]; - variance_1 = max((_e395 - (_e397 * _e399)), 0.000001f); - let _e403 = distance_4; - let _e405 = moments_3[0u]; - difference = (_e403 - _e405); - let _e407 = variance_1; - let _e408 = variance_1; - let _e409 = difference; - let _e410 = difference; - chebyshev = (_e407 / (_e408 + (_e409 * _e410))); - let _e414 = chebyshev; - let _e415 = chebyshev; - let _e417 = chebyshev; - chebyshev = ((_e414 * _e415) * _e417); + let _e401 = distance_4; + let _e403 = moments_3[0u]; + if (_e401 > _e403) { + let _e406 = moments_3[1u]; + let _e408 = moments_3[0u]; + let _e410 = moments_3[0u]; + variance_1 = max((_e406 - (_e408 * _e410)), 0.000001f); + let _e414 = distance_4; + let _e416 = moments_3[0u]; + difference = (_e414 - _e416); + let _e418 = variance_1; + let _e419 = variance_1; + let _e420 = difference; + let _e421 = difference; + chebyshev = (_e418 / (_e419 + (_e420 * _e421))); + let _e425 = chebyshev; + let _e426 = chebyshev; + let _e428 = chebyshev; + chebyshev = ((_e425 * _e426) * _e428); } - let _e419 = probeWeight; - let _e420 = chebyshev; - probeWeight = max((_e419 * max(_e420, 0.05f)), 0.000001f); - let _e424 = probeWeight; - if (_e424 < 0.2f) { - let _e426 = probeWeight; - let _e427 = probeWeight; - let _e430 = probeWeight; - probeWeight = (_e430 * ((_e426 * _e427) * 25f)); + let _e430 = probeWeight; + let _e431 = chebyshev; + probeWeight = max((_e430 * max(_e431, 0.05f)), 0.000001f); + let _e435 = probeWeight; + if (_e435 < 0.2f) { + let _e437 = probeWeight; + let _e438 = probeWeight; + let _e441 = probeWeight; + probeWeight = (_e441 * ((_e437 * _e438) * 25f)); } - let _e432 = probeWeight; - let _e433 = weight_2; - weight_2 = (_e433 * _e432); - } - let _e435 = unit; - param_118 = _e435; - let _e436 = ProbeOctahedral_u0028_vf3_u003b((¶m_118)); - let _e437 = irradianceCorner; - param_119 = _e437; - let _e438 = irradianceTile; - param_120 = _e438; - param_121 = _e436; - let _e439 = TileBilinear_u0028_vf2_u003b_f1_u003b_vf2_u003b((¶m_119), (¶m_120), (¶m_121)); - let _e441 = weight_2; - let _e443 = total_2; - total_2 = (_e443 + (_e439.xyz * _e441)); - let _e445 = weight_2; - let _e446 = weights; - weights = (_e446 + _e445); + let _e443 = probeWeight; + let _e444 = weight_3; + weight_3 = (_e444 * _e443); + } + let _e446 = unit; + param_124 = _e446; + let _e447 = ProbeOctahedral_u0028_vf3_u003b((¶m_124)); + let _e448 = irradianceCorner; + param_125 = _e448; + let _e449 = irradianceTile; + param_126 = _e449; + param_127 = _e447; + let _e450 = TileBilinear_u0028_vf2_u003b_f1_u003b_vf2_u003b((¶m_125), (¶m_126), (¶m_127)); + let _e452 = weight_3; + let _e454 = total_2; + total_2 = (_e454 + (_e450.xyz * _e452)); + let _e456 = weight_3; + let _e457 = weights; + weights = (_e457 + _e456); continue; } else { break; } continuing { - let _e448 = corner_1; - corner_1 = (_e448 + 1i); + let _e459 = corner_1; + corner_1 = (_e459 + 1i); } } - let _e450 = weights; - if (_e450 > 0f) { - let _e452 = total_2; - let _e453 = weights; - local_23 = (_e452 / vec3(_e453)); + let _e461 = weights; + if (_e461 > 0f) { + let _e463 = total_2; + let _e464 = weights; + local_28 = (_e463 / vec3(_e464)); } else { - local_23 = vec3(0f, 0f, 0f); + local_28 = vec3(0f, 0f, 0f); } - let _e456 = local_23; - return _e456; + let _e467 = local_28; + return _e467; } fn IrradianceEnabled_u0028_() -> bool { - let _e179 = irradiance_info.origin[3u]; - return (_e179 > 0.5f); + let _e190 = irradiance_info.origin[3u]; + return (_e190 > 0.5f); } -fn ApplyCommonMaps_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b(s_9: ptr) { - var param_122: vec3; - var param_123: vec3; - var param_124: vec3; - var param_125: Surface; - var param_126: Surface; - var param_127: Surface; - - let _e184 = IrradianceEnabled_u0028_(); - if _e184 { - let _e185 = v_world_position_1; - param_122 = _e185; - let _e187 = (*s_9).n; - param_123 = _e187; - let _e189 = (*s_9).v; - param_124 = _e189; - let _e190 = SampleIrradiance_u0028_vf3_u003b_vf3_u003b_vf3_u003b((¶m_122), (¶m_123), (¶m_124)); - let _e193 = frag_info.material[2u]; - (*s_9).ambient = (_e190 * _e193); - } - let _e196 = (*s_9); - param_125 = _e196; - ApplyNormalMap_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b((¶m_125)); - let _e197 = param_125; - (*s_9) = _e197; - let _e198 = (*s_9); - param_126 = _e198; - ApplyOcclusionMap_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b((¶m_126)); - let _e199 = param_126; - (*s_9) = _e199; - let _e200 = (*s_9); - param_127 = _e200; - ApplyEmissiveMap_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b((¶m_127)); - let _e201 = param_127; - (*s_9) = _e201; +fn ApplyCommonMaps_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b(s_10: ptr) { + var param_128: vec3; + var param_129: vec3; + var param_130: vec3; + var param_131: Surface; + var param_132: Surface; + var param_133: Surface; + + let _e195 = IrradianceEnabled_u0028_(); + if _e195 { + let _e196 = v_world_position_1; + param_128 = _e196; + let _e198 = (*s_10).n; + param_129 = _e198; + let _e200 = (*s_10).v; + param_130 = _e200; + let _e201 = SampleIrradiance_u0028_vf3_u003b_vf3_u003b_vf3_u003b((¶m_128), (¶m_129), (¶m_130)); + let _e204 = frag_info.material[2u]; + (*s_10).ambient = (_e201 * _e204); + } + let _e207 = (*s_10); + param_131 = _e207; + ApplyNormalMap_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b((¶m_131)); + let _e208 = param_131; + (*s_10) = _e208; + let _e209 = (*s_10); + param_132 = _e209; + ApplyOcclusionMap_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b((¶m_132)); + let _e210 = param_132; + (*s_10) = _e210; + let _e211 = (*s_10); + param_133 = _e211; + ApplyEmissiveMap_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b((¶m_133)); + let _e212 = param_133; + (*s_10) = _e212; return; } +fn TowardsEye_u0028_() -> vec3 { + var local_29: vec3; + + let _e189 = Orthographic_u0028_(); + if _e189 { + let _e191 = fog_info.forward; + local_29 = -(_e191.xyz); + } else { + let _e195 = fog_info.eye; + let _e197 = v_world_position_1; + local_29 = normalize((_e195.xyz - _e197)); + } + let _e200 = local_29; + return _e200; +} + fn ReadSurface_u0028_() -> Surface { var texel_6: vec4; - var s_10: Surface; - var param_128: vec3; - var param_129: vec3; + var s_11: Surface; + var param_134: vec3; + var param_135: vec3; var cutoff: f32; + var edge: f32; var anchored: vec3; var noise_1: f32; - let _e184 = v_texcoord_1; - let _e185 = MaterialLodBias_u0028_(); - let _e186 = textureSampleBias(base_color_texture_tex, base_color_texture_smp, _e184, _e185); - texel_6 = _e186; - let _e187 = texel_6; - param_128 = _e187.xyz; - let _e189 = SrgbToLinear_u0028_vf3_u003b((¶m_128)); - let _e191 = frag_info.base_color; - param_129 = _e191.xyz; - let _e193 = SrgbToLinear_u0028_vf3_u003b((¶m_129)); - let _e195 = v_color_1; - s_10.albedo = ((_e189 * _e193) * _e195.xyz); - let _e200 = texel_6[3u]; - let _e203 = frag_info.base_color[3u]; - let _e206 = v_color_1[3u]; - s_10.alpha = ((_e200 * _e203) * _e206); - let _e210 = s_10.albedo; - g_albedo = _e210; - let _e213 = frag_info.material2_[0u]; - cutoff = _e213; - let _e214 = cutoff; - if (_e214 >= 0f) { - let _e217 = s_10.alpha; - let _e218 = cutoff; - if (_e217 < _e218) { - discard; - } + let _e196 = v_texcoord_1; + let _e197 = MaterialLodBias_u0028_(); + let _e198 = textureSampleBias(base_color_texture_tex, base_color_texture_smp, _e196, _e197); + texel_6 = _e198; + let _e199 = texel_6; + param_134 = _e199.xyz; + let _e201 = SrgbToLinear_u0028_vf3_u003b((¶m_134)); + let _e203 = frag_info.base_color; + param_135 = _e203.xyz; + let _e205 = SrgbToLinear_u0028_vf3_u003b((¶m_135)); + let _e207 = v_color_1; + s_11.albedo = ((_e201 * _e205) * _e207.xyz); + let _e212 = texel_6[3u]; + let _e215 = frag_info.base_color[3u]; + let _e218 = v_color_1[3u]; + s_11.alpha = ((_e212 * _e215) * _e218); + let _e222 = s_11.albedo; + g_albedo = _e222; + let _e225 = frag_info.material2_[0u]; + cutoff = _e225; + let _e226 = cutoff; + if (_e226 > 1f) { + let _e228 = cutoff; + edge = (_e228 - 1f); + let _e231 = s_11.alpha; + let _e232 = edge; + let _e235 = s_11.alpha; + let _e236 = fwidth(_e235); + s_11.alpha = clamp((((_e231 - _e232) / max(_e236, 0.0001f)) + 0.5f), 0f, 1f); } else { - let _e220 = cutoff; - if (_e220 < -1.5f) { - let _e222 = v_world_position_1; - anchored = floor((_e222 * 16f)); - let _e225 = anchored; - noise_1 = fract((sin(dot(_e225, vec3(12.9898f, 78.233f, 37.719f))) * 43758.547f)); - let _e231 = s_10.alpha; - let _e232 = noise_1; - if (_e231 < _e232) { + let _e242 = cutoff; + if (_e242 >= 0f) { + let _e245 = s_11.alpha; + let _e246 = cutoff; + if (_e245 < _e246) { discard; } + s_11.alpha = 1f; + } else { + let _e249 = cutoff; + if (_e249 < -1.5f) { + let _e251 = v_world_position_1; + anchored = floor((_e251 * 16f)); + let _e254 = anchored; + noise_1 = fract((sin(dot(_e254, vec3(12.9898f, 78.233f, 37.719f))) * 43758.547f)); + let _e260 = s_11.alpha; + let _e261 = noise_1; + if (_e260 < _e261) { + discard; + } + } } } - let _e234 = cutoff; - let _e236 = cutoff; - g_premultiply = ((_e234 < 0f) && (_e236 > -0.75f)); - let _e239 = v_normal_1; - s_10.n = normalize(_e239); - let _e242 = gl_FrontFacing_1; - if !(_e242) { - let _e245 = s_10.n; - s_10.n = -(_e245); - } - let _e249 = frag_info.camera_position; - let _e251 = v_world_position_1; - s_10.v = normalize((_e249.xyz - _e251)); - let _e256 = s_10.n; - let _e258 = s_10.v; - s_10.n_dot_v = max(dot(_e256, _e258), 0.0001f); - let _e264 = frag_info.material[0u]; - s_10.metallic = clamp(_e264, 0f, 1f); - let _e269 = frag_info.material[1u]; - s_10.roughness = clamp(_e269, 0.02f, 1f); - let _e273 = frag_info.ambient_ground; - let _e276 = frag_info.ambient_sky; - let _e280 = s_10.n[1u]; - let _e287 = frag_info.material[2u]; - s_10.ambient = (mix(_e273.xyz, _e276.xyz, vec3(((_e280 * 0.5f) + 0.5f))) * _e287); - let _e292 = frag_info.frame_params[0u]; - s_10.exposure = max(_e292, 0f); - s_10.occlusion = 1f; - s_10.emissive = vec3(0f, 0f, 0f); - let _e297 = s_10; - return _e297; + let _e263 = cutoff; + let _e265 = cutoff; + g_premultiply = ((_e263 < 0f) && (_e265 > -0.75f)); + let _e268 = v_normal_1; + s_11.n = normalize(_e268); + let _e271 = gl_FrontFacing_1; + if !(_e271) { + let _e274 = s_11.n; + s_11.n = -(_e274); + } + let _e277 = TowardsEye_u0028_(); + s_11.v = _e277; + let _e280 = s_11.n; + let _e282 = s_11.v; + s_11.n_dot_v = max(dot(_e280, _e282), 0.0001f); + let _e288 = frag_info.material[0u]; + s_11.metallic = clamp(_e288, 0f, 1f); + let _e293 = frag_info.material[1u]; + s_11.roughness = clamp(_e293, 0.02f, 1f); + let _e297 = frag_info.ambient_ground; + let _e300 = frag_info.ambient_sky; + let _e304 = s_11.n[1u]; + let _e311 = frag_info.material[2u]; + s_11.ambient = (mix(_e297.xyz, _e300.xyz, vec3(((_e304 * 0.5f) + 0.5f))) * _e311); + let _e316 = frag_info.frame_params[0u]; + s_11.exposure = max(_e316, 0f); + s_11.occlusion = 1f; + s_11.emissive = vec3(0f, 0f, 0f); + let _e321 = s_11; + return _e321; } fn main_1() { - var s_11: Surface; - var param_130: Surface; - var param_131: Surface; + var s_12: Surface; + var param_136: Surface; + var param_137: Surface; var rim: f32; var ambient: vec3; - var param_132: Surface; - var param_133: vec3; - var param_134: f32; - var param_135: f32; + var lit_3: vec3; + var param_138: Surface; + var param_139: Surface; + var param_140: vec3; + var param_141: vec3; + var param_142: f32; + var param_143: f32; g_albedo = vec3(0f, 0f, 0f); g_debug_surface = vec3(0f, 0f, 0f); g_debug_surface_on = false; g_premultiply = false; + g_pass_through = 0f; g_cluster_offset = 0f; g_cluster_count = 0f; - let _e186 = ReadSurface_u0028_(); - s_11 = _e186; - let _e187 = s_11; - param_130 = _e187; - ApplyCommonMaps_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b((¶m_130)); - let _e188 = param_130; - s_11 = _e188; - let _e189 = s_11; - param_131 = _e189; - ApplyMetallicRoughnessMap_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b((¶m_131)); - let _e190 = param_131; - s_11 = _e190; - let _e192 = s_11.n_dot_v; - let _e197 = frag_info.material[3u]; - rim = (pow((1f - _e192), 3f) * _e197); - let _e200 = s_11.albedo; - let _e202 = s_11.ambient; - let _e203 = SampleLightmap_u0028_(); - let _e207 = s_11.occlusion; - ambient = ((_e200 * (_e202 + _e203)) * _e207); - let _e209 = s_11; - param_132 = _e209; - let _e210 = AccumulateLights_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b((¶m_132)); - let _e212 = s_11.occlusion; - let _e214 = ambient; - let _e216 = rim; - let _e221 = s_11.emissive; - param_133 = ((((_e210 * _e212) + _e214) + vec3((_e216 * 0.35f))) + _e221); - let _e224 = s_11.alpha; - param_134 = _e224; - let _e226 = s_11.roughness; - param_135 = _e226; - WriteSurface_u0028_vf3_u003b_f1_u003b_f1_u003b((¶m_133), (¶m_134), (¶m_135)); + light_transmittance = vec3(1f, 1f, 1f); + let _e200 = ReadSurface_u0028_(); + s_12 = _e200; + let _e201 = s_12; + param_136 = _e201; + ApplyCommonMaps_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b((¶m_136)); + let _e202 = param_136; + s_12 = _e202; + let _e203 = s_12; + param_137 = _e203; + ApplyMetallicRoughnessMap_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b((¶m_137)); + let _e204 = param_137; + s_12 = _e204; + let _e206 = s_12.n_dot_v; + let _e211 = frag_info.material[3u]; + rim = (pow((1f - _e206), 3f) * _e211); + let _e214 = s_12.albedo; + let _e216 = s_12.ambient; + let _e217 = SampleLightmap_u0028_(); + let _e221 = s_12.occlusion; + ambient = ((_e214 * (_e216 + _e217)) * _e221); + let _e223 = s_12; + param_138 = _e223; + let _e224 = AccumulateLights_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b((¶m_138)); + let _e226 = s_12.occlusion; + let _e228 = ambient; + let _e230 = rim; + let _e235 = s_12.emissive; + lit_3 = ((((_e224 * _e226) + _e228) + vec3((_e230 * 0.35f))) + _e235); + let _e237 = s_12; + param_139 = _e237; + let _e238 = lit_3; + param_140 = _e238; + let _e239 = WriteDebugView_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b_vf3_u003b((¶m_139), (¶m_140)); + if _e239 { + return; + } + let _e240 = lit_3; + param_141 = _e240; + let _e242 = s_12.alpha; + param_142 = _e242; + let _e244 = s_12.roughness; + param_143 = _e244; + WriteSurface_u0028_vf3_u003b_f1_u003b_f1_u003b((¶m_141), (¶m_142), (¶m_143)); return; } @fragment -fn main(@location(6) v_world_position: vec3, @location(2) v_normal: vec3, @builtin(front_facing) gl_FrontFacing: bool, @builtin(position) gl_FragCoord: vec4, @location(4) v_texcoord: vec2, @location(0) v_color: vec4, @location(1) v_lightmap_uv: vec2, @location(3) v_tangent: vec4) -> FragmentOutput { +fn main(@location(7) v_world_position: vec3, @location(3) v_normal: vec3, @builtin(front_facing) gl_FrontFacing: bool, @builtin(position) gl_FragCoord: vec4, @location(5) v_texcoord: vec2, @location(0) v_color: vec4, @location(2) v_lightmap_uv: vec2, @location(4) v_tangent: vec4) -> FragmentOutput { v_world_position_1 = v_world_position; v_normal_1 = v_normal; gl_FrontFacing_1 = gl_FrontFacing; @@ -24039,7 +26709,7 @@ fn main(@location(6) v_world_position: vec3, @location(2) v_normal: vec3[ WebGpuBlockMember(name: 'fog', offsetInBytes: 0, sizeInBytes: 16), WebGpuBlockMember(name: 'eye', offsetInBytes: 16, sizeInBytes: 16), @@ -24048,13 +26718,18 @@ fn main(@location(6) v_world_position: vec3, @location(2) v_normal: vec3[ WebGpuBlockMember( name: 'light_position', @@ -24151,6 +26826,11 @@ fn main(@location(6) v_world_position: vec3, @location(2) v_normal: vec3, eye: vec4, forward: vec4, + projection: vec4, } struct FragmentOutput { @@ -24362,6 +27043,7 @@ var g_albedo: vec3; var g_debug_surface: vec3; var g_debug_surface_on: bool; var g_premultiply: bool; +var g_pass_through: f32; var v_world_position_1: vec3; @group(1) @binding(0) var fog_info: FogInfo; @@ -24378,36 +27060,36 @@ var v_lightmap_uv_1: vec2; fn EncodeOctahedral_u0028_vf3_u003b(n: ptr>) -> vec2 { var e: vec2; - let _e44 = (*n)[0u]; - let _e47 = (*n)[1u]; - let _e51 = (*n)[2u]; - let _e54 = (*n); - (*n) = (_e54 / vec3(((abs(_e44) + abs(_e47)) + abs(_e51)))); - let _e57 = (*n); - e = _e57.xy; - let _e60 = (*n)[2u]; - if (_e60 < 0f) { - let _e62 = (*n); - let _e68 = (*n)[0u]; - let _e72 = (*n)[1u]; - e = ((vec2(1f) - abs(_e62.yx)) * vec2(select(-1f, 1f, (_e68 >= 0f)), select(-1f, 1f, (_e72 >= 0f)))); + let _e45 = (*n)[0u]; + let _e48 = (*n)[1u]; + let _e52 = (*n)[2u]; + let _e55 = (*n); + (*n) = (_e55 / vec3(((abs(_e45) + abs(_e48)) + abs(_e52)))); + let _e58 = (*n); + e = _e58.xy; + let _e61 = (*n)[2u]; + if (_e61 < 0f) { + let _e63 = (*n); + let _e69 = (*n)[0u]; + let _e73 = (*n)[1u]; + e = ((vec2(1f) - abs(_e63.yx)) * vec2(select(-1f, 1f, (_e69 >= 0f)), select(-1f, 1f, (_e73 >= 0f)))); } - let _e77 = e; - return ((_e77 * 0.5f) + vec2(0.5f)); + let _e78 = e; + return ((_e78 * 0.5f) + vec2(0.5f)); } fn ViewDepth_u0028_() -> f32 { - let _e41 = v_world_position_1; - let _e43 = fog_info.eye; - let _e47 = fog_info.forward; - return dot((_e41 - _e43.xyz), _e47.xyz); + let _e42 = v_world_position_1; + let _e44 = fog_info.eye; + let _e48 = fog_info.forward; + return dot((_e42 - _e44.xyz), _e48.xyz); } fn LinearToSrgb_u0028_vf3_u003b(linear: ptr>) -> vec3 { - let _e42 = (*linear); - let _e44 = (*linear); - let _e48 = (*linear); - return mix((_e42 * 12.92f), ((pow(_e44, vec3(0.41666666f, 0.41666666f, 0.41666666f)) * 1.055f) - vec3(0.055f, 0.055f, 0.055f)), step(vec3(0.0031308f, 0.0031308f, 0.0031308f), _e48)); + let _e43 = (*linear); + let _e45 = (*linear); + let _e49 = (*linear); + return mix((_e43 * 12.92f), ((pow(_e45, vec3(0.41666666f, 0.41666666f, 0.41666666f)) * 1.055f) - vec3(0.055f, 0.055f, 0.055f)), step(vec3(0.0031308f, 0.0031308f, 0.0031308f), _e49)); } fn WriteSurfaceGeometry_u0028_f1_u003b(roughness: ptr) { @@ -24415,49 +27097,49 @@ fn WriteSurfaceGeometry_u0028_f1_u003b(roughness: ptr) { var geometric: vec3; var param_1: vec3; - let _e45 = g_albedo; - param = clamp(_e45, vec3(0f, 0f, 0f), vec3(1f, 1f, 1f)); - let _e47 = LinearToSrgb_u0028_vf3_u003b((¶m)); - frag_albedo = vec4(_e47.x, _e47.y, _e47.z, 1f); - let _e52 = g_debug_surface_on; - if _e52 { - let _e53 = g_debug_surface; - let _e54 = ViewDepth_u0028_(); - frag_surface = vec4(_e53.x, _e53.y, _e53.z, _e54); + let _e46 = g_albedo; + param = clamp(_e46, vec3(0f, 0f, 0f), vec3(1f, 1f, 1f)); + let _e48 = LinearToSrgb_u0028_vf3_u003b((¶m)); + frag_albedo = vec4(_e48.x, _e48.y, _e48.z, 1f); + let _e53 = g_debug_surface_on; + if _e53 { + let _e54 = g_debug_surface; + let _e55 = ViewDepth_u0028_(); + frag_surface = vec4(_e54.x, _e54.y, _e54.z, _e55); return; } - let _e59 = v_normal_1; - geometric = normalize(_e59); - let _e61 = gl_FrontFacing_1; - if !(_e61) { - let _e63 = geometric; - geometric = -(_e63); - } - let _e65 = geometric; - param_1 = _e65; - let _e66 = EncodeOctahedral_u0028_vf3_u003b((¶m_1)); - let _e67 = (*roughness); - let _e69 = ViewDepth_u0028_(); - frag_surface = vec4(_e66.x, _e66.y, clamp(_e67, 0f, 1f), _e69); + let _e60 = v_normal_1; + geometric = normalize(_e60); + let _e62 = gl_FrontFacing_1; + if !(_e62) { + let _e64 = geometric; + geometric = -(_e64); + } + let _e66 = geometric; + param_1 = _e66; + let _e67 = EncodeOctahedral_u0028_vf3_u003b((¶m_1)); + let _e68 = (*roughness); + let _e70 = ViewDepth_u0028_(); + frag_surface = vec4(_e67.x, _e67.y, clamp(_e68, 0f, 1f), _e70); return; } fn SrgbToLinear_u0028_vf3_u003b(srgb: ptr>) -> vec3 { - let _e42 = (*srgb); - let _e45 = (*srgb); - let _e50 = (*srgb); - return mix((_e42 / vec3(12.92f)), pow(((_e45 + vec3(0.055f, 0.055f, 0.055f)) / vec3(1.055f)), vec3(2.4f, 2.4f, 2.4f)), step(vec3(0.04045f, 0.04045f, 0.04045f), _e50)); + let _e43 = (*srgb); + let _e46 = (*srgb); + let _e51 = (*srgb); + return mix((_e43 / vec3(12.92f)), pow(((_e46 + vec3(0.055f, 0.055f, 0.055f)) / vec3(1.055f)), vec3(2.4f, 2.4f, 2.4f)), step(vec3(0.04045f, 0.04045f, 0.04045f), _e51)); } fn WriteDisplayColor_u0028_vf3_u003b_f1_u003b(displayColor: ptr>, alpha: ptr) { var param_2: vec3; var param_3: f32; - let _e45 = (*displayColor); - param_2 = _e45; - let _e46 = SrgbToLinear_u0028_vf3_u003b((¶m_2)); - let _e47 = (*alpha); - frag_color = vec4(_e46.x, _e46.y, _e46.z, _e47); + let _e46 = (*displayColor); + param_2 = _e46; + let _e47 = SrgbToLinear_u0028_vf3_u003b((¶m_2)); + let _e48 = (*alpha); + frag_color = vec4(_e47.x, _e47.y, _e47.z, _e48); param_3 = 1f; WriteSurfaceGeometry_u0028_f1_u003b((¶m_3)); return; @@ -24472,17 +27154,18 @@ fn main_1() { g_debug_surface = vec3(0f, 0f, 0f); g_debug_surface_on = false; g_premultiply = false; - let _e44 = v_normal_1; - n_1 = normalize(_e44); - let _e46 = n_1; - param_4 = ((_e46 * 0.5f) + vec3(0.5f, 0.5f, 0.5f)); + g_pass_through = 0f; + let _e45 = v_normal_1; + n_1 = normalize(_e45); + let _e47 = n_1; + param_4 = ((_e47 * 0.5f) + vec3(0.5f, 0.5f, 0.5f)); param_5 = 1f; WriteDisplayColor_u0028_vf3_u003b_f1_u003b((¶m_4), (¶m_5)); return; } @fragment -fn main(@location(6) v_world_position: vec3, @location(2) v_normal: vec3, @builtin(front_facing) gl_FrontFacing: bool, @location(4) v_texcoord: vec2, @location(3) v_tangent: vec4, @location(0) v_color: vec4, @location(1) v_lightmap_uv: vec2) -> FragmentOutput { +fn main(@location(7) v_world_position: vec3, @location(3) v_normal: vec3, @builtin(front_facing) gl_FrontFacing: bool, @location(5) v_texcoord: vec2, @location(4) v_tangent: vec4, @location(0) v_color: vec4, @location(2) v_lightmap_uv: vec2) -> FragmentOutput { v_world_position_1 = v_world_position; v_normal_1 = v_normal; gl_FrontFacing_1 = gl_FrontFacing; @@ -24503,7 +27186,7 @@ fn main(@location(6) v_world_position: vec3, @location(2) v_normal: vec3[ WebGpuBlockMember(name: 'fog', offsetInBytes: 0, sizeInBytes: 16), WebGpuBlockMember(name: 'eye', offsetInBytes: 16, sizeInBytes: 16), @@ -24512,6 +27195,11 @@ fn main(@location(6) v_world_position: vec3, @location(2) v_normal: vec3) -> @location(0) vec4 { +fn main(@location(6) v_uv: vec2) -> @location(0) vec4 { v_uv_1 = v_uv; main_1(); let _e3 = frag_color; @@ -24812,7 +27500,7 @@ fn main_1() { } @fragment -fn main(@location(5) v_uv: vec2) -> @location(0) vec4 { +fn main(@location(6) v_uv: vec2) -> @location(0) vec4 { v_uv_1 = v_uv; main_1(); let _e3 = frag_color; @@ -24933,7 +27621,7 @@ fn main_1() { } @fragment -fn main(@location(5) v_uv: vec2) -> @location(0) vec4 { +fn main(@location(6) v_uv: vec2) -> @location(0) vec4 { v_uv_1 = v_uv; main_1(); let _e3 = frag_color; @@ -24983,17 +27671,18 @@ struct CompositeInfo { gamma: vec4, gain: vec4, contact: vec4, + lens: vec4, } var gl_FragCoord_1: vec4; @group(1) @binding(1) var frag_coord_info: FragCoordInfo; +@group(1) @binding(0) +var composite_info: CompositeInfo; @group(1) @binding(12) var lut_texture_tex: texture_2d; @group(1) @binding(13) var lut_texture_smp: sampler; -@group(1) @binding(0) -var composite_info: CompositeInfo; @group(1) @binding(8) var display_texture_tex: texture_2d; @group(1) @binding(9) @@ -25461,25 +28150,68 @@ fn TonemapBy_u0028_vf3_u003b_i1_u003b(color_9: ptr>, curve: return _e175; } -fn main_1() { - var dispersion: f32; +fn Distort_u0028_vf2_u003b_f1_u003b(at_2: ptr>, k: ptr) -> vec2 { + var aspect: f32; var fromCentre: vec2; - var step_uv: vec2; - var scene: vec4; - var bloom: vec3; - var half_texel: vec2; - var occlusion: vec4; + var onFrame: vec2; + var r2_: f32; + var border: f32; + var local_2: f32; + + let _e153 = composite_info.look_more[3u]; + aspect = max(_e153, 0.0001f); + let _e155 = (*at_2); + fromCentre = (_e155 - vec2(0.5f, 0.5f)); + let _e157 = fromCentre; + let _e158 = aspect; + onFrame = (_e157 * vec2(_e158, 1f)); + let _e161 = onFrame; + let _e162 = onFrame; + r2_ = dot(_e161, _e162); + let _e164 = (*k); + if (_e164 > 0f) { + let _e166 = aspect; + let _e167 = aspect; + local_2 = (0.25f * ((_e166 * _e167) + 1f)); + } else { + let _e171 = aspect; + let _e172 = aspect; + local_2 = (0.25f * min((_e171 * _e172), 1f)); + } + let _e176 = local_2; + border = _e176; + let _e177 = fromCentre; + let _e178 = (*k); + let _e179 = r2_; + let _e183 = (*k); + let _e184 = border; + return (vec2(0.5f, 0.5f) + ((_e177 * (1f + (_e178 * _e179))) / vec2((1f + (_e183 * _e184))))); +} + +fn main_1() { + var distortion: f32; + var uv: vec2; + var local_3: vec2; + var param_9: vec2; + var param_10: f32; + var dispersion: f32; + var fromCentre_1: vec2; + var step_uv: vec2; + var scene: vec4; + var bloom: vec3; + var half_texel: vec2; + var occlusion: vec4; var ao: f32; var contact: f32; var localStops: f32; var exposed: vec3; var color_10: vec3; - var param_9: vec3; - var param_10: i32; + var param_11: vec3; + var param_12: i32; var contrast: f32; var saturation: f32; var temperature: f32; - var param_11: vec3; + var param_13: vec3; var lift: vec3; var gammaCurve: vec3; var gain: vec3; @@ -25487,243 +28219,281 @@ fn main_1() { var tint: f32; var lutStrength: f32; var encodedIn: vec3; - var param_12: vec3; + var param_14: vec3; var graded: vec3; - var param_13: vec3; - var param_14: f32; var param_15: vec3; + var param_16: f32; + var param_17: vec3; var vignette: f32; - var fromCentre_1: vec2; - var aspect: f32; + var fromCentre_2: vec2; + var aspect_1: f32; var radius: f32; var encoded_1: vec3; - var param_16: vec3; + var param_18: vec3; var grain: f32; - var param_17: vec2; + var param_19: vec2; var dither: f32; - var param_18: vec2; + var param_20: vec2; + var raw: vec4; + var param_21: vec3; + var phi_1262_: bool; - let _e185 = composite_info.look[3u]; - dispersion = _e185; - let _e186 = dispersion; - if (_e186 > 0f) { - let _e188 = v_uv_1; - fromCentre = (_e188 - vec2(0.5f, 0.5f)); - let _e190 = fromCentre; - let _e191 = dispersion; - step_uv = (_e190 * _e191); - let _e193 = v_uv_1; - let _e194 = textureSample(scene_texture_tex, scene_texture_smp, _e193); - scene = _e194; + let _e192 = composite_info.lens[0u]; + distortion = _e192; + let _e193 = distortion; + if (_e193 != 0f) { let _e195 = v_uv_1; - let _e196 = step_uv; - let _e198 = textureSample(scene_texture_tex, scene_texture_smp, (_e195 + _e196)); - scene[0u] = _e198.x; - let _e201 = v_uv_1; - let _e202 = step_uv; - let _e204 = textureSample(scene_texture_tex, scene_texture_smp, (_e201 - _e202)); - scene[2u] = _e204.z; - } else { - let _e207 = v_uv_1; - let _e208 = textureSample(scene_texture_tex, scene_texture_smp, _e207); - scene = _e208; - } - let _e209 = v_uv_1; - let _e210 = textureSample(bloom_texture_tex, bloom_texture_smp, _e209); - bloom = _e210.xyz; - let _e213 = composite_info.ao_texel; - half_texel = (_e213.xy * 0.5f); - let _e216 = v_uv_1; - let _e218 = half_texel[0u]; - let _e220 = half_texel[1u]; - let _e223 = textureSample(ao_texture_tex, ao_texture_smp, (_e216 + vec2(_e218, _e220))); - let _e224 = v_uv_1; - let _e226 = half_texel[0u]; - let _e229 = half_texel[1u]; - let _e232 = textureSample(ao_texture_tex, ao_texture_smp, (_e224 + vec2(-(_e226), _e229))); - let _e234 = v_uv_1; - let _e236 = half_texel[0u]; - let _e238 = half_texel[1u]; - let _e242 = textureSample(ao_texture_tex, ao_texture_smp, (_e234 + vec2(_e236, -(_e238)))); - let _e244 = v_uv_1; - let _e246 = half_texel[0u]; - let _e249 = half_texel[1u]; - let _e253 = textureSample(ao_texture_tex, ao_texture_smp, (_e244 + vec2(-(_e246), -(_e249)))); - occlusion = ((((_e223 + _e232) + _e242) + _e253) * 0.25f); - let _e257 = occlusion[3u]; - ao = _e257; - let _e258 = ao; - let _e261 = composite_info.params[3u]; - ao = mix(1f, _e258, clamp(_e261, 0f, 1f)); - let _e264 = v_uv_1; - let _e265 = textureSample(contact_shadow_texture_tex, contact_shadow_texture_smp, _e264); - contact = _e265.x; - let _e267 = contact; - let _e270 = composite_info.contact[0u]; - let _e273 = ao; - ao = (_e273 * mix(1f, _e267, clamp(_e270, 0f, 1f))); - let _e275 = v_uv_1; - let _e276 = textureSample(local_exposure_texture_tex, local_exposure_texture_smp, _e275); - localStops = _e276.x; - let _e278 = scene; - let _e280 = localStops; - let _e283 = composite_info.contact[3u]; - exposed = (_e278.xyz * exp2((_e280 * _e283))); - let _e287 = exposed; - let _e288 = ao; - let _e290 = bloom; - let _e293 = composite_info.params[1u]; - color_10 = ((_e287 * _e288) + (_e290 * _e293)); - let _e296 = occlusion; - let _e300 = composite_info.contact[2u]; - let _e304 = composite_info.params[3u]; - let _e307 = color_10; - color_10 = (_e307 + ((_e296.xyz * _e300) * clamp(_e304, 0f, 1f))); - let _e311 = composite_info.params[0u]; - let _e313 = color_10; - color_10 = (_e313 * max(_e311, 0f)); - let _e317 = composite_info.params[2u]; - let _e320 = color_10; - param_9 = _e320; - param_10 = i32((_e317 + 0.5f)); - let _e321 = TonemapBy_u0028_vf3_u003b_i1_u003b((¶m_9), (¶m_10)); - color_10 = _e321; - let _e324 = composite_info.look[0u]; - contrast = _e324; - let _e327 = composite_info.look[1u]; - saturation = _e327; - let _e330 = composite_info.look[2u]; - temperature = _e330; - let _e331 = contrast; - if (_e331 != 1f) { - let _e333 = color_10; - let _e337 = contrast; - color_10 = (vec3(0.18f, 0.18f, 0.18f) * pow((max(_e333, vec3(0f, 0f, 0f)) / vec3(0.18f)), vec3(_e337))); - } - let _e341 = color_10; - param_11 = _e341; - let _e342 = Luma_u0028_vf3_u003b((¶m_11)); - let _e344 = color_10; - let _e345 = saturation; - color_10 = mix(vec3(_e342), _e344, vec3(_e345)); - let _e348 = temperature; - let _e351 = temperature; - let _e355 = color_10; - color_10 = (_e355 * vec3((1f + (_e348 * 0.1f)), 1f, (1f - (_e351 * 0.1f)))); - let _e358 = composite_info.lift; - lift = _e358.xyz; - let _e361 = composite_info.gamma; - gammaCurve = _e361.xyz; - let _e364 = composite_info.gain; - gain = _e364.xyz; - let _e366 = color_10; - let _e367 = lift; - let _e370 = lift; - color_10 = ((_e366 * (vec3(1f, 1f, 1f) - _e367)) + _e370); + param_9 = _e195; + let _e196 = distortion; + param_10 = _e196; + let _e197 = Distort_u0028_vf2_u003b_f1_u003b((¶m_9), (¶m_10)); + local_3 = _e197; + } else { + let _e198 = v_uv_1; + local_3 = _e198; + } + let _e199 = local_3; + uv = _e199; + let _e202 = composite_info.look[3u]; + dispersion = _e202; + let _e203 = dispersion; + if (_e203 > 0f) { + let _e205 = uv; + fromCentre_1 = (_e205 - vec2(0.5f, 0.5f)); + let _e207 = fromCentre_1; + let _e208 = dispersion; + step_uv = (_e207 * _e208); + let _e210 = uv; + let _e211 = textureSample(scene_texture_tex, scene_texture_smp, _e210); + scene = _e211; + let _e212 = uv; + let _e213 = step_uv; + let _e215 = textureSample(scene_texture_tex, scene_texture_smp, (_e212 + _e213)); + scene[0u] = _e215.x; + let _e218 = uv; + let _e219 = step_uv; + let _e221 = textureSample(scene_texture_tex, scene_texture_smp, (_e218 - _e219)); + scene[2u] = _e221.z; + } else { + let _e224 = uv; + let _e225 = textureSample(scene_texture_tex, scene_texture_smp, _e224); + scene = _e225; + } + let _e226 = uv; + let _e227 = textureSample(bloom_texture_tex, bloom_texture_smp, _e226); + bloom = _e227.xyz; + let _e230 = composite_info.ao_texel; + half_texel = (_e230.xy * 0.5f); + let _e233 = uv; + let _e235 = half_texel[0u]; + let _e237 = half_texel[1u]; + let _e240 = textureSample(ao_texture_tex, ao_texture_smp, (_e233 + vec2(_e235, _e237))); + let _e241 = uv; + let _e243 = half_texel[0u]; + let _e246 = half_texel[1u]; + let _e249 = textureSample(ao_texture_tex, ao_texture_smp, (_e241 + vec2(-(_e243), _e246))); + let _e251 = uv; + let _e253 = half_texel[0u]; + let _e255 = half_texel[1u]; + let _e259 = textureSample(ao_texture_tex, ao_texture_smp, (_e251 + vec2(_e253, -(_e255)))); + let _e261 = uv; + let _e263 = half_texel[0u]; + let _e266 = half_texel[1u]; + let _e270 = textureSample(ao_texture_tex, ao_texture_smp, (_e261 + vec2(-(_e263), -(_e266)))); + occlusion = ((((_e240 + _e249) + _e259) + _e270) * 0.25f); + let _e274 = occlusion[3u]; + ao = _e274; + let _e275 = ao; + let _e278 = composite_info.params[3u]; + ao = mix(1f, _e275, clamp(_e278, 0f, 1f)); + let _e281 = uv; + let _e282 = textureSample(contact_shadow_texture_tex, contact_shadow_texture_smp, _e281); + contact = _e282.x; + let _e284 = contact; + let _e287 = composite_info.contact[0u]; + let _e290 = ao; + ao = (_e290 * mix(1f, _e284, clamp(_e287, 0f, 1f))); + let _e292 = uv; + let _e293 = textureSample(local_exposure_texture_tex, local_exposure_texture_smp, _e292); + localStops = _e293.x; + let _e295 = scene; + let _e297 = localStops; + let _e300 = composite_info.contact[3u]; + exposed = (_e295.xyz * exp2((_e297 * _e300))); + let _e304 = exposed; + let _e305 = ao; + let _e307 = bloom; + let _e310 = composite_info.params[1u]; + color_10 = ((_e304 * _e305) + (_e307 * _e310)); + let _e313 = occlusion; + let _e317 = composite_info.contact[2u]; + let _e321 = composite_info.params[3u]; + let _e324 = color_10; + color_10 = (_e324 + ((_e313.xyz * _e317) * clamp(_e321, 0f, 1f))); + let _e328 = composite_info.params[0u]; + let _e330 = color_10; + color_10 = (_e330 * max(_e328, 0f)); + let _e334 = composite_info.params[2u]; + let _e337 = color_10; + param_11 = _e337; + param_12 = i32((_e334 + 0.5f)); + let _e338 = TonemapBy_u0028_vf3_u003b_i1_u003b((¶m_11), (¶m_12)); + color_10 = _e338; + let _e341 = composite_info.look[0u]; + contrast = _e341; + let _e344 = composite_info.look[1u]; + saturation = _e344; + let _e347 = composite_info.look[2u]; + temperature = _e347; + let _e348 = contrast; + if (_e348 != 1f) { + let _e350 = color_10; + let _e354 = contrast; + color_10 = (vec3(0.18f, 0.18f, 0.18f) * pow((max(_e350, vec3(0f, 0f, 0f)) / vec3(0.18f)), vec3(_e354))); + } + let _e358 = color_10; + param_13 = _e358; + let _e359 = Luma_u0028_vf3_u003b((¶m_13)); + let _e361 = color_10; + let _e362 = saturation; + color_10 = mix(vec3(_e359), _e361, vec3(_e362)); + let _e365 = temperature; + let _e368 = temperature; let _e372 = color_10; - color_10 = max(_e372, vec3(0f, 0f, 0f)); - let _e374 = gammaCurve; - if any((_e374 != vec3(1f, 1f, 1f))) { - let _e377 = color_10; - let _e378 = gammaCurve; - color_10 = pow(_e377, (vec3(1f, 1f, 1f) / _e378)); - } - let _e381 = gain; - let _e382 = color_10; - color_10 = (_e382 * _e381); - let _e386 = composite_info.output_encode[1u]; - balance = _e386; - let _e389 = composite_info.output_encode[2u]; - tint = _e389; - let _e390 = balance; - let _e392 = tint; - if ((_e390 != 0f) || (_e392 != 0f)) { - let _e395 = balance; - let _e398 = tint; - let _e401 = balance; - let _e405 = color_10; - color_10 = (_e405 * vec3((1f + (_e395 * 0.2f)), (1f + (_e398 * 0.15f)), (1f - (_e401 * 0.2f)))); - let _e407 = tint; - let _e410 = color_10[0u]; - color_10[0u] = (_e410 - (_e407 * 0.075f)); - let _e413 = tint; - let _e416 = color_10[2u]; - color_10[2u] = (_e416 - (_e413 * 0.075f)); - } - let _e421 = composite_info.ao_texel[2u]; - lutStrength = _e421; - let _e422 = lutStrength; - if (_e422 > 0f) { - let _e424 = color_10; - param_12 = clamp(_e424, vec3(0f, 0f, 0f), vec3(1f, 1f, 1f)); - let _e426 = LinearToSrgb_u0028_vf3_u003b((¶m_12)); - encodedIn = _e426; - let _e429 = composite_info.ao_texel[3u]; - let _e431 = encodedIn; - param_13 = _e431; - param_14 = max(_e429, 2f); - let _e432 = SampleLut_u0028_vf3_u003b_f1_u003b((¶m_13), (¶m_14)); - param_15 = _e432; - let _e433 = SrgbToLinear_u0028_vf3_u003b((¶m_15)); - graded = _e433; - let _e434 = color_10; - let _e435 = graded; - let _e436 = lutStrength; - color_10 = mix(_e434, _e435, vec3(clamp(_e436, 0f, 1f))); - } - let _e442 = composite_info.look_more[0u]; - vignette = _e442; - let _e443 = vignette; - if (_e443 > 0f) { - let _e445 = v_uv_1; - fromCentre_1 = (_e445 - vec2(0.5f, 0.5f)); - let _e449 = composite_info.look_more[3u]; - aspect = max(_e449, 0.0001f); - let _e451 = aspect; - let _e454 = composite_info.look_more[1u]; - let _e457 = fromCentre_1[0u]; - fromCentre_1[0u] = (_e457 * mix(1f, _e451, _e454)); - let _e460 = fromCentre_1; - radius = (length(_e460) * 1.4142135f); - let _e463 = vignette; - let _e465 = radius; - let _e468 = color_10; - color_10 = (_e468 * mix(1f, (1f - _e463), clamp(_e465, 0f, 1f))); - } - let _e470 = color_10; - param_16 = max(_e470, vec3(0f, 0f, 0f)); - let _e472 = LinearToSrgb_u0028_vf3_u003b((¶m_16)); - encoded_1 = _e472; - let _e475 = composite_info.look_more[2u]; - grain = _e475; - let _e476 = grain; - if (_e476 > 0f) { - let _e478 = TargetFragCoord_u0028_(); - param_17 = _e478; - let _e479 = Hash_u0028_vf2_u003b((¶m_17)); - let _e481 = grain; - let _e484 = encoded_1; - encoded_1 = (_e484 + vec3(((_e479 - 0.5f) * _e481))); - } - let _e488 = composite_info.output_encode[0u]; - dither = _e488; - let _e489 = dither; - if (_e489 > 0f) { - let _e491 = TargetFragCoord_u0028_(); - param_18 = _e491; - let _e492 = BayerCell_u0028_vf2_u003b((¶m_18)); - let _e494 = dither; - let _e497 = encoded_1; - encoded_1 = (_e497 + vec3(((_e492 + 0.03125f) * _e494))); - } - let _e499 = encoded_1; - let _e501 = scene[3u]; - frag_color = vec4(_e499.x, _e499.y, _e499.z, _e501); + color_10 = (_e372 * vec3((1f + (_e365 * 0.1f)), 1f, (1f - (_e368 * 0.1f)))); + let _e375 = composite_info.lift; + lift = _e375.xyz; + let _e378 = composite_info.gamma; + gammaCurve = _e378.xyz; + let _e381 = composite_info.gain; + gain = _e381.xyz; + let _e383 = color_10; + let _e384 = lift; + let _e387 = lift; + color_10 = ((_e383 * (vec3(1f, 1f, 1f) - _e384)) + _e387); + let _e389 = color_10; + color_10 = max(_e389, vec3(0f, 0f, 0f)); + let _e391 = gammaCurve; + if any((_e391 != vec3(1f, 1f, 1f))) { + let _e394 = color_10; + let _e395 = gammaCurve; + color_10 = pow(_e394, (vec3(1f, 1f, 1f) / _e395)); + } + let _e398 = gain; + let _e399 = color_10; + color_10 = (_e399 * _e398); + let _e403 = composite_info.output_encode[1u]; + balance = _e403; + let _e406 = composite_info.output_encode[2u]; + tint = _e406; + let _e407 = balance; + let _e409 = tint; + if ((_e407 != 0f) || (_e409 != 0f)) { + let _e412 = balance; + let _e415 = tint; + let _e418 = balance; + let _e422 = color_10; + color_10 = (_e422 * vec3((1f + (_e412 * 0.2f)), (1f + (_e415 * 0.15f)), (1f - (_e418 * 0.2f)))); + let _e424 = tint; + let _e427 = color_10[0u]; + color_10[0u] = (_e427 - (_e424 * 0.075f)); + let _e430 = tint; + let _e433 = color_10[2u]; + color_10[2u] = (_e433 - (_e430 * 0.075f)); + } + let _e438 = composite_info.ao_texel[2u]; + lutStrength = _e438; + let _e439 = lutStrength; + if (_e439 > 0f) { + let _e441 = color_10; + param_14 = clamp(_e441, vec3(0f, 0f, 0f), vec3(1f, 1f, 1f)); + let _e443 = LinearToSrgb_u0028_vf3_u003b((¶m_14)); + encodedIn = _e443; + let _e446 = composite_info.ao_texel[3u]; + let _e448 = encodedIn; + param_15 = _e448; + param_16 = max(_e446, 2f); + let _e449 = SampleLut_u0028_vf3_u003b_f1_u003b((¶m_15), (¶m_16)); + param_17 = _e449; + let _e450 = SrgbToLinear_u0028_vf3_u003b((¶m_17)); + graded = _e450; + let _e451 = color_10; + let _e452 = graded; + let _e453 = lutStrength; + color_10 = mix(_e451, _e452, vec3(clamp(_e453, 0f, 1f))); + } + let _e459 = composite_info.look_more[0u]; + vignette = _e459; + let _e460 = vignette; + if (_e460 > 0f) { + let _e462 = v_uv_1; + fromCentre_2 = (_e462 - vec2(0.5f, 0.5f)); + let _e466 = composite_info.look_more[3u]; + aspect_1 = max(_e466, 0.0001f); + let _e468 = aspect_1; + let _e471 = composite_info.look_more[1u]; + let _e474 = fromCentre_2[0u]; + fromCentre_2[0u] = (_e474 * mix(1f, _e468, _e471)); + let _e477 = fromCentre_2; + radius = (length(_e477) * 1.4142135f); + let _e480 = vignette; + let _e482 = radius; + let _e485 = color_10; + color_10 = (_e485 * mix(1f, (1f - _e480), clamp(_e482, 0f, 1f))); + } + let _e487 = color_10; + param_18 = max(_e487, vec3(0f, 0f, 0f)); + let _e489 = LinearToSrgb_u0028_vf3_u003b((¶m_18)); + encoded_1 = _e489; + let _e492 = composite_info.look_more[2u]; + grain = _e492; + let _e493 = grain; + if (_e493 > 0f) { + let _e495 = TargetFragCoord_u0028_(); + param_19 = _e495; + let _e496 = Hash_u0028_vf2_u003b((¶m_19)); + let _e498 = grain; + let _e501 = encoded_1; + encoded_1 = (_e501 + vec3(((_e496 - 0.5f) * _e498))); + } + let _e505 = composite_info.output_encode[0u]; + dither = _e505; + let _e506 = dither; + if (_e506 > 0f) { + let _e508 = TargetFragCoord_u0028_(); + param_20 = _e508; + let _e509 = BayerCell_u0028_vf2_u003b((¶m_20)); + let _e511 = dither; + let _e514 = encoded_1; + encoded_1 = (_e514 + vec3(((_e509 + 0.03125f) * _e511))); + } + let _e516 = encoded_1; + let _e518 = scene[3u]; + frag_color = vec4(_e516.x, _e516.y, _e516.z, _e518); + let _e525 = composite_info.lens[1u]; + let _e526 = (_e525 > 0.5f); + phi_1262_ = _e526; + if _e526 { + let _e528 = v_uv_1[0u]; + let _e531 = composite_info.lens[2u]; + phi_1262_ = (_e528 >= _e531); + } + let _e534 = phi_1262_; + if _e534 { + let _e535 = v_uv_1; + let _e536 = textureSampleLevel(scene_texture_tex, scene_texture_smp, _e535, 0f); + raw = _e536; + let _e537 = raw; + param_21 = clamp(_e537.xyz, vec3(0f, 0f, 0f), vec3(1f, 1f, 1f)); + let _e540 = LinearToSrgb_u0028_vf3_u003b((¶m_21)); + let _e542 = raw[3u]; + frag_color = vec4(_e540.x, _e540.y, _e540.z, _e542); + } return; } @fragment -fn main(@builtin(position) gl_FragCoord: vec4, @location(5) v_uv: vec2) -> @location(0) vec4 { +fn main(@builtin(position) gl_FragCoord: vec4, @location(6) v_uv: vec2) -> @location(0) vec4 { gl_FragCoord_1 = gl_FragCoord; v_uv_1 = v_uv; main_1(); @@ -25737,7 +28507,7 @@ fn main(@builtin(position) gl_FragCoord: vec4, @location(5) v_uv: vec2 name: 'CompositeInfo', group: 1, binding: 0, - sizeInBytes: 144, + sizeInBytes: 160, members: [ WebGpuBlockMember( name: 'params', @@ -25776,6 +28546,11 @@ fn main(@builtin(position) gl_FragCoord: vec4, @location(5) v_uv: vec2 offsetInBytes: 128, sizeInBytes: 16, ), + WebGpuBlockMember( + name: 'lens', + offsetInBytes: 144, + sizeInBytes: 16, + ), ], ), WebGpuBlock( @@ -26644,7 +29419,7 @@ fn main_1() { } @fragment -fn main(@builtin(position) gl_FragCoord: vec4, @location(5) v_uv: vec2) -> @location(0) vec4 { +fn main(@builtin(position) gl_FragCoord: vec4, @location(6) v_uv: vec2) -> @location(0) vec4 { gl_FragCoord_1 = gl_FragCoord; v_uv_1 = v_uv; main_1(); @@ -26794,7 +29569,7 @@ fn main_1() { } @fragment -fn main(@location(5) v_uv: vec2) -> @location(0) vec4 { +fn main(@location(6) v_uv: vec2) -> @location(0) vec4 { v_uv_1 = v_uv; main_1(); let _e3 = frag_color; @@ -26906,7 +29681,7 @@ fn main_1() { } @fragment -fn main(@location(5) v_uv: vec2) -> @location(0) vec4 { +fn main(@location(6) v_uv: vec2) -> @location(0) vec4 { v_uv_1 = v_uv; main_1(); let _e3 = frag_color; @@ -27407,177 +30182,1034 @@ fn main_1() { let _e380 = lengthSign; local_5 = vec2(0f, (_e380 * 0.5f)); } else { - let _e383 = lengthSign; - local_5 = vec2((_e383 * 0.5f), 0f); - } - let _e386 = local_5; - start = (_e378 + _e386); - let _e388 = gradient; - gradientScaled = (_e388 * 0.25f); - let _e390 = start; - let _e391 = along; - posN = (_e390 - _e391); - let _e393 = start; - let _e394 = along; - posP = (_e393 + _e394); - let _e396 = posN; - let _e397 = textureSampleLevel(source_texture_tex, source_texture_smp, _e396, 0f); - param_22 = _e397.xyz; - let _e399 = Weight_u0028_vf3_u003b((¶m_22)); - let _e400 = pairAverage; - endN = (_e399 - _e400); - let _e402 = posP; - let _e403 = textureSampleLevel(source_texture_tex, source_texture_smp, _e402, 0f); - param_23 = _e403.xyz; - let _e405 = Weight_u0028_vf3_u003b((¶m_23)); - let _e406 = pairAverage; - endP = (_e405 - _e406); - let _e408 = endN; - let _e410 = gradientScaled; - doneN = (abs(_e408) >= _e410); - let _e412 = endP; - let _e414 = gradientScaled; - doneP = (abs(_e412) >= _e414); - i_1 = 1i; + let _e383 = lengthSign; + local_5 = vec2((_e383 * 0.5f), 0f); + } + let _e386 = local_5; + start = (_e378 + _e386); + let _e388 = gradient; + gradientScaled = (_e388 * 0.25f); + let _e390 = start; + let _e391 = along; + posN = (_e390 - _e391); + let _e393 = start; + let _e394 = along; + posP = (_e393 + _e394); + let _e396 = posN; + let _e397 = textureSampleLevel(source_texture_tex, source_texture_smp, _e396, 0f); + param_22 = _e397.xyz; + let _e399 = Weight_u0028_vf3_u003b((¶m_22)); + let _e400 = pairAverage; + endN = (_e399 - _e400); + let _e402 = posP; + let _e403 = textureSampleLevel(source_texture_tex, source_texture_smp, _e402, 0f); + param_23 = _e403.xyz; + let _e405 = Weight_u0028_vf3_u003b((¶m_23)); + let _e406 = pairAverage; + endP = (_e405 - _e406); + let _e408 = endN; + let _e410 = gradientScaled; + doneN = (abs(_e408) >= _e410); + let _e412 = endP; + let _e414 = gradientScaled; + doneP = (abs(_e412) >= _e414); + i_1 = 1i; + loop { + let _e416 = i_1; + if (_e416 < 5i) { + let _e418 = doneN; + let _e419 = doneP; + if (_e418 && _e419) { + break; + } + let _e421 = i_1; + param_24 = _e421; + let _e422 = SearchStep_u0028_i1_u003b((¶m_24)); + stride = _e422; + let _e423 = doneN; + if !(_e423) { + let _e425 = along; + let _e426 = stride; + let _e428 = posN; + posN = (_e428 - (_e425 * _e426)); + let _e430 = posN; + let _e431 = textureSampleLevel(source_texture_tex, source_texture_smp, _e430, 0f); + param_25 = _e431.xyz; + let _e433 = Weight_u0028_vf3_u003b((¶m_25)); + let _e434 = pairAverage; + endN = (_e433 - _e434); + let _e436 = endN; + let _e438 = gradientScaled; + doneN = (abs(_e436) >= _e438); + } + let _e440 = doneP; + if !(_e440) { + let _e442 = along; + let _e443 = stride; + let _e445 = posP; + posP = (_e445 + (_e442 * _e443)); + let _e447 = posP; + let _e448 = textureSampleLevel(source_texture_tex, source_texture_smp, _e447, 0f); + param_26 = _e448.xyz; + let _e450 = Weight_u0028_vf3_u003b((¶m_26)); + let _e451 = pairAverage; + endP = (_e450 - _e451); + let _e453 = endP; + let _e455 = gradientScaled; + doneP = (abs(_e453) >= _e455); + } + continue; + } else { + break; + } + continuing { + let _e457 = i_1; + i_1 = (_e457 + 1i); + } + } + let _e459 = horizontalSpan; + if _e459 { + let _e461 = v_uv_1[0u]; + let _e463 = posN[0u]; + local_6 = (_e461 - _e463); + } else { + let _e466 = v_uv_1[1u]; + let _e468 = posN[1u]; + local_6 = (_e466 - _e468); + } + let _e470 = local_6; + distanceN = _e470; + let _e471 = horizontalSpan; + if _e471 { + let _e473 = posP[0u]; + let _e475 = v_uv_1[0u]; + local_7 = (_e473 - _e475); + } else { + let _e478 = posP[1u]; + let _e480 = v_uv_1[1u]; + local_7 = (_e478 - _e480); + } + let _e482 = local_7; + distanceP = _e482; + let _e483 = mid; + let _e484 = pairAverage; + middleBelow = ((_e483 - _e484) < 0f); + let _e487 = distanceN; + let _e488 = distanceP; + nearerN = (_e487 < _e488); + let _e490 = nearerN; + if _e490 { + let _e491 = endN; + let _e493 = middleBelow; + local_8 = ((_e491 < 0f) != _e493); + } else { + let _e495 = endP; + let _e497 = middleBelow; + local_8 = ((_e495 < 0f) != _e497); + } + let _e499 = local_8; + goodSpan = _e499; + let _e500 = distanceN; + let _e501 = distanceP; + nearest = min(_e500, _e501); + let _e503 = nearest; + let _e504 = distanceN; + let _e505 = distanceP; + pixelOffset = (0.5f - (_e503 / (_e504 + _e505))); + let _e509 = goodSpan; + let _e510 = pixelOffset; + let _e512 = subpixH; + offset = max(select(0f, _e510, _e509), _e512); + let _e514 = v_uv_1; + let _e515 = horizontalSpan; + if _e515 { + let _e516 = offset; + let _e517 = lengthSign; + local_9 = vec2(0f, (_e516 * _e517)); + } else { + let _e520 = offset; + let _e521 = lengthSign; + local_9 = vec2((_e520 * _e521), 0f); + } + let _e524 = local_9; + at = (_e514 + _e524); + let _e526 = at; + let _e527 = textureSampleLevel(source_texture_tex, source_texture_smp, _e526, 0f); + smoothed = _e527.xyz; + let _e529 = smoothed; + param_27 = _e529; + let _e530 = northRgb; + param_28 = _e530; + let _e531 = southRgb; + param_29 = _e531; + let _e532 = westRgb; + param_30 = _e532; + let _e533 = eastRgb; + param_31 = _e533; + let _e534 = Sharpen_u0028_vf3_u003b_vf3_u003b_vf3_u003b_vf3_u003b_vf3_u003b((¶m_27), (¶m_28), (¶m_29), (¶m_30), (¶m_31)); + frag_color = vec4(_e534.x, _e534.y, _e534.z, 1f); + return; +} + +@fragment +fn main(@location(6) v_uv: vec2) -> @location(0) vec4 { + v_uv_1 = v_uv; + main_1(); + let _e3 = frag_color; + return _e3; +} +''', + attributes: [], + blocks: [ + WebGpuBlock( + name: 'FxaaInfo', + group: 1, + binding: 0, + sizeInBytes: 32, + members: [ + WebGpuBlockMember( + name: 'params', + offsetInBytes: 0, + sizeInBytes: 16, + ), + WebGpuBlockMember( + name: 'sharpen', + offsetInBytes: 16, + sizeInBytes: 16, + ), + ], + ), + ], + samplers: [ + WebGpuSampler( + name: 'source_texture', + group: 1, + textureBinding: 1, + samplerBinding: 2, + dimension: WebGpuTextureDimension.twoDimensional, + ), + ], + ), + 'SmaaEdges': WebGpuStage( + wgsl: r''' +struct SmaaInfo { + params: vec4, +} + +@group(1) @binding(1) +var source_texture_tex: texture_2d; +@group(1) @binding(2) +var source_texture_smp: sampler; +@group(1) @binding(0) +var smaa_info: SmaaInfo; +var v_uv_1: vec2; +var frag_color: vec4; + +fn Luma_u0028_vf2_u003b(at: ptr>) -> f32 { + var c: vec3; + + let _e19 = (*at); + let _e20 = textureSampleLevel(source_texture_tex, source_texture_smp, _e19, 0f); + c = _e20.xyz; + let _e22 = c; + return dot(_e22, vec3(0.2126f, 0.7152f, 0.0722f)); +} + +fn main_1() { + var texel: vec2; + var middle: f32; + var param: vec2; + var left: f32; + var param_1: vec2; + var top: f32; + var param_2: vec2; + var delta: vec2; + var edges: vec2; + var right: f32; + var param_3: vec2; + var bottom: f32; + var param_4: vec2; + var leftLeft: f32; + var param_5: vec2; + var topTop: f32; + var param_6: vec2; + var largest: f32; + + let _e36 = smaa_info.params; + texel = _e36.xy; + let _e38 = v_uv_1; + param = _e38; + let _e39 = Luma_u0028_vf2_u003b((¶m)); + middle = _e39; + let _e40 = v_uv_1; + let _e42 = texel[0u]; + param_1 = (_e40 + vec2(-(_e42), 0f)); + let _e46 = Luma_u0028_vf2_u003b((¶m_1)); + left = _e46; + let _e47 = v_uv_1; + let _e49 = texel[1u]; + param_2 = (_e47 + vec2(0f, -(_e49))); + let _e53 = Luma_u0028_vf2_u003b((¶m_2)); + top = _e53; + let _e54 = middle; + let _e55 = left; + let _e57 = middle; + let _e58 = top; + delta = abs(vec2((_e54 - _e55), (_e57 - _e58))); + let _e64 = smaa_info.params[2u]; + let _e66 = delta; + edges = step(vec2(_e64), _e66); + let _e69 = edges[0u]; + let _e71 = edges[1u]; + if ((_e69 + _e71) == 0f) { + frag_color = vec4(0f, 0f, 0f, 0f); + return; + } + let _e74 = middle; + let _e75 = v_uv_1; + let _e77 = texel[0u]; + param_3 = (_e75 + vec2(_e77, 0f)); + let _e80 = Luma_u0028_vf2_u003b((¶m_3)); + right = abs((_e74 - _e80)); + let _e83 = middle; + let _e84 = v_uv_1; + let _e86 = texel[1u]; + param_4 = (_e84 + vec2(0f, _e86)); + let _e89 = Luma_u0028_vf2_u003b((¶m_4)); + bottom = abs((_e83 - _e89)); + let _e92 = left; + let _e93 = v_uv_1; + let _e95 = texel[0u]; + param_5 = (_e93 + vec2((-2f * _e95), 0f)); + let _e99 = Luma_u0028_vf2_u003b((¶m_5)); + leftLeft = abs((_e92 - _e99)); + let _e102 = top; + let _e103 = v_uv_1; + let _e105 = texel[1u]; + param_6 = (_e103 + vec2(0f, (-2f * _e105))); + let _e109 = Luma_u0028_vf2_u003b((¶m_6)); + topTop = abs((_e102 - _e109)); + let _e113 = delta[0u]; + let _e115 = delta[1u]; + let _e117 = right; + let _e118 = bottom; + let _e121 = leftLeft; + let _e122 = topTop; + largest = max(max(max(_e113, _e115), max(_e117, _e118)), max(_e121, _e122)); + let _e125 = largest; + let _e129 = smaa_info.params[3u]; + let _e130 = delta; + let _e133 = edges; + edges = (_e133 * step(vec2(_e125), (_e130 * _e129))); + let _e135 = edges; + frag_color = vec4(_e135.x, _e135.y, 0f, 0f); + return; +} + +@fragment +fn main(@location(6) v_uv: vec2) -> @location(0) vec4 { + v_uv_1 = v_uv; + main_1(); + let _e3 = frag_color; + return _e3; +} +''', + attributes: [], + blocks: [ + WebGpuBlock( + name: 'SmaaInfo', + group: 1, + binding: 0, + sizeInBytes: 16, + members: [ + WebGpuBlockMember( + name: 'params', + offsetInBytes: 0, + sizeInBytes: 16, + ), + ], + ), + ], + samplers: [ + WebGpuSampler( + name: 'source_texture', + group: 1, + textureBinding: 1, + samplerBinding: 2, + dimension: WebGpuTextureDimension.twoDimensional, + ), + ], + ), + 'SmaaWeights': WebGpuStage( + wgsl: r''' +struct SmaaInfo { + params: vec4, +} + +@group(1) @binding(3) +var edges_texture_tex: texture_2d; +@group(1) @binding(4) +var edges_texture_smp: sampler; +var v_uv_1: vec2; +@group(1) @binding(0) +var smaa_info: SmaaInfo; +@group(1) @binding(1) +var area_texture_tex: texture_2d; +@group(1) @binding(2) +var area_texture_smp: sampler; +var frag_color: vec4; + +fn Area_u0028_f1_u003b_f1_u003b_f1_u003b_f1_u003b(first: ptr, last: ptr, codeFirst: ptr, codeLast: ptr) -> vec2 { + var at: vec2; + + let _e28 = (*codeFirst); + let _e29 = (*codeLast); + let _e32 = (*first); + let _e33 = (*last); + at = ((((vec2(_e28, _e29) * 16f) + sqrt(vec2(_e32, _e33))) + vec2(0.5f)) / vec2(80f)); + let _e41 = at; + let _e42 = textureSampleLevel(area_texture_tex, area_texture_smp, _e41, 0f); + return _e42.xy; +} + +fn Edges_u0028_vf2_u003b(offset: ptr>) -> vec4 { + let _e24 = v_uv_1; + let _e25 = (*offset); + let _e27 = smaa_info.params; + let _e31 = textureSampleLevel(edges_texture_tex, edges_texture_smp, (_e24 + (_e25 * _e27.xy)), 0f); + return _e31; +} + +fn main_1() { + var here: vec4; + var param: vec2; + var weights: vec4; + var first_1: i32; + var i: i32; + var param_1: vec2; + var param_2: vec2; + var last_1: i32; + var i_1: i32; + var next: vec4; + var param_3: vec2; + var f: f32; + var l: f32; + var codeFirst_1: f32; + var local: f32; + var param_4: vec2; + var param_5: vec2; + var codeLast_1: f32; + var local_1: f32; + var param_6: vec2; + var param_7: vec2; + var param_8: f32; + var param_9: f32; + var param_10: f32; + var param_11: f32; + var first_2: i32; + var i_2: i32; + var param_12: vec2; + var param_13: vec2; + var last_2: i32; + var i_3: i32; + var next_1: vec4; + var param_14: vec2; + var f_1: f32; + var l_1: f32; + var codeFirst_2: f32; + var local_2: f32; + var param_15: vec2; + var param_16: vec2; + var codeLast_2: f32; + var local_3: f32; + var param_17: vec2; + var param_18: vec2; + var param_19: f32; + var param_20: f32; + var param_21: f32; + var param_22: f32; + var phi_126_: bool; + var phi_159_: bool; + var phi_265_: bool; + var phi_297_: bool; + + param = vec2(0f, 0f); + let _e70 = Edges_u0028_vf2_u003b((¶m)); + here = _e70; + weights = vec4(0f, 0f, 0f, 0f); + let _e72 = here[1u]; + if (_e72 > 0.5f) { + first_1 = 32i; + i = 0i; + loop { + let _e74 = i; + if (_e74 < 32i) { + let _e76 = i; + param_1 = vec2(-(f32(_e76)), 0f); + let _e80 = Edges_u0028_vf2_u003b((¶m_1)); + let _e82 = (_e80.x > 0.5f); + phi_126_ = _e82; + if !(_e82) { + let _e84 = i; + param_2 = vec2((-(f32(_e84)) - 1f), 0f); + let _e89 = Edges_u0028_vf2_u003b((¶m_2)); + phi_126_ = (_e89.y < 0.5f); + } + let _e93 = phi_126_; + if _e93 { + let _e94 = i; + first_1 = _e94; + break; + } + continue; + } else { + break; + } + continuing { + let _e95 = i; + i = (_e95 + 1i); + } + } + last_1 = 32i; + i_1 = 0i; + loop { + let _e97 = i_1; + if (_e97 < 32i) { + let _e99 = i_1; + param_3 = vec2((f32(_e99) + 1f), 0f); + let _e103 = Edges_u0028_vf2_u003b((¶m_3)); + next = _e103; + let _e105 = next[0u]; + let _e106 = (_e105 > 0.5f); + phi_159_ = _e106; + if !(_e106) { + let _e109 = next[1u]; + phi_159_ = (_e109 < 0.5f); + } + let _e112 = phi_159_; + if _e112 { + let _e113 = i_1; + last_1 = _e113; + break; + } + continue; + } else { + break; + } + continuing { + let _e114 = i_1; + i_1 = (_e114 + 1i); + } + } + let _e116 = first_1; + f = f32(_e116); + let _e118 = last_1; + l = f32(_e118); + let _e120 = first_1; + if (_e120 == 32i) { + local = 0f; + } else { + let _e122 = f; + param_4 = vec2(-(_e122), 0f); + let _e125 = Edges_u0028_vf2_u003b((¶m_4)); + let _e128 = f; + param_5 = vec2(-(_e128), -1f); + let _e131 = Edges_u0028_vf2_u003b((¶m_5)); + local = ((3f * _e125.x) + _e131.x); + } + let _e134 = local; + codeFirst_1 = _e134; + let _e135 = last_1; + if (_e135 == 32i) { + local_1 = 0f; + } else { + let _e137 = l; + param_6 = vec2((_e137 + 1f), 0f); + let _e140 = Edges_u0028_vf2_u003b((¶m_6)); + let _e143 = l; + param_7 = vec2((_e143 + 1f), -1f); + let _e146 = Edges_u0028_vf2_u003b((¶m_7)); + local_1 = ((3f * _e140.x) + _e146.x); + } + let _e149 = local_1; + codeLast_1 = _e149; + let _e150 = f; + param_8 = _e150; + let _e151 = l; + param_9 = _e151; + let _e152 = codeFirst_1; + param_10 = _e152; + let _e153 = codeLast_1; + param_11 = _e153; + let _e154 = Area_u0028_f1_u003b_f1_u003b_f1_u003b_f1_u003b((¶m_8), (¶m_9), (¶m_10), (¶m_11)); + weights[0u] = _e154.x; + weights[1u] = _e154.y; + } + let _e160 = here[0u]; + if (_e160 > 0.5f) { + first_2 = 32i; + i_2 = 0i; + loop { + let _e162 = i_2; + if (_e162 < 32i) { + let _e164 = i_2; + param_12 = vec2(0f, -(f32(_e164))); + let _e168 = Edges_u0028_vf2_u003b((¶m_12)); + let _e170 = (_e168.y > 0.5f); + phi_265_ = _e170; + if !(_e170) { + let _e172 = i_2; + param_13 = vec2(0f, (-(f32(_e172)) - 1f)); + let _e177 = Edges_u0028_vf2_u003b((¶m_13)); + phi_265_ = (_e177.x < 0.5f); + } + let _e181 = phi_265_; + if _e181 { + let _e182 = i_2; + first_2 = _e182; + break; + } + continue; + } else { + break; + } + continuing { + let _e183 = i_2; + i_2 = (_e183 + 1i); + } + } + last_2 = 32i; + i_3 = 0i; + loop { + let _e185 = i_3; + if (_e185 < 32i) { + let _e187 = i_3; + param_14 = vec2(0f, (f32(_e187) + 1f)); + let _e191 = Edges_u0028_vf2_u003b((¶m_14)); + next_1 = _e191; + let _e193 = next_1[1u]; + let _e194 = (_e193 > 0.5f); + phi_297_ = _e194; + if !(_e194) { + let _e197 = next_1[0u]; + phi_297_ = (_e197 < 0.5f); + } + let _e200 = phi_297_; + if _e200 { + let _e201 = i_3; + last_2 = _e201; + break; + } + continue; + } else { + break; + } + continuing { + let _e202 = i_3; + i_3 = (_e202 + 1i); + } + } + let _e204 = first_2; + f_1 = f32(_e204); + let _e206 = last_2; + l_1 = f32(_e206); + let _e208 = first_2; + if (_e208 == 32i) { + local_2 = 0f; + } else { + let _e210 = f_1; + param_15 = vec2(0f, -(_e210)); + let _e213 = Edges_u0028_vf2_u003b((¶m_15)); + let _e216 = f_1; + param_16 = vec2(-1f, -(_e216)); + let _e219 = Edges_u0028_vf2_u003b((¶m_16)); + local_2 = ((3f * _e213.y) + _e219.y); + } + let _e222 = local_2; + codeFirst_2 = _e222; + let _e223 = last_2; + if (_e223 == 32i) { + local_3 = 0f; + } else { + let _e225 = l_1; + param_17 = vec2(0f, (_e225 + 1f)); + let _e228 = Edges_u0028_vf2_u003b((¶m_17)); + let _e231 = l_1; + param_18 = vec2(-1f, (_e231 + 1f)); + let _e234 = Edges_u0028_vf2_u003b((¶m_18)); + local_3 = ((3f * _e228.y) + _e234.y); + } + let _e237 = local_3; + codeLast_2 = _e237; + let _e238 = f_1; + param_19 = _e238; + let _e239 = l_1; + param_20 = _e239; + let _e240 = codeFirst_2; + param_21 = _e240; + let _e241 = codeLast_2; + param_22 = _e241; + let _e242 = Area_u0028_f1_u003b_f1_u003b_f1_u003b_f1_u003b((¶m_19), (¶m_20), (¶m_21), (¶m_22)); + weights[2u] = _e242.x; + weights[3u] = _e242.y; + } + let _e247 = weights; + frag_color = _e247; + return; +} + +@fragment +fn main(@location(6) v_uv: vec2) -> @location(0) vec4 { + v_uv_1 = v_uv; + main_1(); + let _e3 = frag_color; + return _e3; +} +''', + attributes: [], + blocks: [ + WebGpuBlock( + name: 'SmaaInfo', + group: 1, + binding: 0, + sizeInBytes: 16, + members: [ + WebGpuBlockMember( + name: 'params', + offsetInBytes: 0, + sizeInBytes: 16, + ), + ], + ), + ], + samplers: [ + WebGpuSampler( + name: 'area_texture', + group: 1, + textureBinding: 1, + samplerBinding: 2, + dimension: WebGpuTextureDimension.twoDimensional, + ), + WebGpuSampler( + name: 'edges_texture', + group: 1, + textureBinding: 3, + samplerBinding: 4, + dimension: WebGpuTextureDimension.twoDimensional, + ), + ], + ), + 'SmaaBlend': WebGpuStage( + wgsl: r''' +struct SmaaInfo { + params: vec4, +} + +@group(1) @binding(0) +var smaa_info: SmaaInfo; +@group(1) @binding(1) +var blend_texture_tex: texture_2d; +@group(1) @binding(2) +var blend_texture_smp: sampler; +var v_uv_1: vec2; +var frag_color: vec4; +@group(1) @binding(3) +var source_texture_tex: texture_2d; +@group(1) @binding(4) +var source_texture_smp: sampler; + +fn main_1() { + var texel: vec2; + var right: f32; + var bottom: f32; + var mine: vec4; + var top: f32; + var left: f32; + var across: bool; + var toward: f32; + var away: f32; + var axis: vec2; + var local: vec2; + var first: vec4; + var second: vec4; + var total: f32; + + let _e29 = smaa_info.params; + texel = _e29.xy; + let _e31 = v_uv_1; + let _e33 = texel[0u]; + let _e36 = textureSampleLevel(blend_texture_tex, blend_texture_smp, (_e31 + vec2(_e33, 0f)), 0f); + right = _e36.w; + let _e38 = v_uv_1; + let _e40 = texel[1u]; + let _e43 = textureSampleLevel(blend_texture_tex, blend_texture_smp, (_e38 + vec2(0f, _e40)), 0f); + bottom = _e43.y; + let _e45 = v_uv_1; + let _e46 = textureSampleLevel(blend_texture_tex, blend_texture_smp, _e45, 0f); + mine = _e46; + let _e48 = mine[0u]; + top = _e48; + let _e50 = mine[2u]; + left = _e50; + let _e51 = right; + let _e52 = bottom; + let _e54 = top; + let _e56 = left; + if ((((_e51 + _e52) + _e54) + _e56) < 0.00001f) { + let _e59 = v_uv_1; + let _e60 = textureSampleLevel(source_texture_tex, source_texture_smp, _e59, 0f); + frag_color = _e60; + return; + } + let _e61 = right; + let _e62 = left; + let _e64 = bottom; + let _e65 = top; + across = (max(_e61, _e62) > max(_e64, _e65)); + let _e68 = across; + let _e69 = right; + let _e70 = bottom; + toward = select(_e70, _e69, _e68); + let _e72 = across; + let _e73 = left; + let _e74 = top; + away = select(_e74, _e73, _e72); + let _e76 = across; + if _e76 { + let _e78 = texel[0u]; + local = vec2(_e78, 0f); + } else { + let _e81 = texel[1u]; + local = vec2(0f, _e81); + } + let _e83 = local; + axis = _e83; + let _e84 = v_uv_1; + let _e85 = toward; + let _e86 = axis; + let _e89 = textureSampleLevel(source_texture_tex, source_texture_smp, (_e84 + (_e86 * _e85)), 0f); + first = _e89; + let _e90 = v_uv_1; + let _e91 = away; + let _e92 = axis; + let _e95 = textureSampleLevel(source_texture_tex, source_texture_smp, (_e90 - (_e92 * _e91)), 0f); + second = _e95; + let _e96 = toward; + let _e97 = away; + total = (_e96 + _e97); + let _e99 = first; + let _e100 = toward; + let _e101 = total; + let _e104 = second; + let _e105 = away; + let _e106 = total; + frag_color = ((_e99 * (_e100 / _e101)) + (_e104 * (_e105 / _e106))); + return; +} + +@fragment +fn main(@location(6) v_uv: vec2) -> @location(0) vec4 { + v_uv_1 = v_uv; + main_1(); + let _e3 = frag_color; + return _e3; +} +''', + attributes: [], + blocks: [ + WebGpuBlock( + name: 'SmaaInfo', + group: 1, + binding: 0, + sizeInBytes: 16, + members: [ + WebGpuBlockMember( + name: 'params', + offsetInBytes: 0, + sizeInBytes: 16, + ), + ], + ), + ], + samplers: [ + WebGpuSampler( + name: 'blend_texture', + group: 1, + textureBinding: 1, + samplerBinding: 2, + dimension: WebGpuTextureDimension.twoDimensional, + ), + WebGpuSampler( + name: 'source_texture', + group: 1, + textureBinding: 3, + samplerBinding: 4, + dimension: WebGpuTextureDimension.twoDimensional, + ), + ], + ), + 'LensFlare': WebGpuStage( + wgsl: r''' +struct LensFlareInfo { + params: vec4, + more: vec4, +} + +@group(1) @binding(1) +var bloom_texture_tex: texture_2d; +@group(1) @binding(2) +var bloom_texture_smp: sampler; +var v_uv_1: vec2; +@group(1) @binding(0) +var lens_flare_info: LensFlareInfo; +var frag_color: vec4; + +fn Falloff_u0028_vf2_u003b_f1_u003b(at: ptr>, power: ptr) -> f32 { + var d: f32; + + let _e27 = (*at); + d = (length((vec2(0.5f, 0.5f) - _e27)) / 0.70710677f); + let _e31 = d; + let _e34 = (*power); + return pow(max((1f - _e31), 0f), _e34); +} + +fn Fringed_u0028_vf2_u003b_vf2_u003b_f1_u003b(at_1: ptr>, along: ptr>, spread: ptr) -> vec3 { + let _e27 = (*at_1); + let _e28 = (*along); + let _e29 = (*spread); + let _e32 = textureSampleLevel(bloom_texture_tex, bloom_texture_smp, (_e27 + (_e28 * _e29)), 0f); + let _e34 = (*at_1); + let _e35 = textureSampleLevel(bloom_texture_tex, bloom_texture_smp, _e34, 0f); + let _e37 = (*at_1); + let _e38 = (*along); + let _e39 = (*spread); + let _e42 = textureSampleLevel(bloom_texture_tex, bloom_texture_smp, (_e37 - (_e38 * _e39)), 0f); + return vec3(_e32.x, _e35.y, _e42.z); +} + +fn main_1() { + var glow: vec3; + var intensity: f32; + var flipped: vec2; + var towardMiddle: vec2; + var reach: f32; + var along_1: vec2; + var local: vec2; + var spread_1: f32; + var flare: vec3; + var ghosts: i32; + var i: i32; + var at_2: vec2; + var param: vec2; + var param_1: vec2; + var param_2: f32; + var param_3: vec2; + var param_4: f32; + var aspect: f32; + var halo: vec2; + var haloAt: vec2; + var param_5: vec2; + var param_6: vec2; + var param_7: f32; + var param_8: vec2; + var param_9: f32; + + let _e49 = v_uv_1; + let _e50 = textureSampleLevel(bloom_texture_tex, bloom_texture_smp, _e49, 0f); + glow = _e50.xyz; + let _e54 = lens_flare_info.params[0u]; + intensity = _e54; + let _e55 = v_uv_1; + flipped = (vec2(1f, 1f) - _e55); + let _e57 = flipped; + let _e61 = lens_flare_info.params[2u]; + towardMiddle = ((vec2(0.5f, 0.5f) - _e57) * _e61); + let _e63 = towardMiddle; + reach = length(_e63); + let _e65 = reach; + if (_e65 > 0.000001f) { + let _e67 = towardMiddle; + let _e68 = reach; + local = (_e67 / vec2(_e68)); + } else { + local = vec2(0f, 0f); + } + let _e71 = local; + along_1 = _e71; + let _e74 = lens_flare_info.more[0u]; + spread_1 = _e74; + flare = vec3(0f, 0f, 0f); + let _e77 = lens_flare_info.params[1u]; + ghosts = i32((_e77 + 0.5f)); + i = 0i; loop { - let _e416 = i_1; - if (_e416 < 5i) { - let _e418 = doneN; - let _e419 = doneP; - if (_e418 && _e419) { + let _e80 = i; + if (_e80 < 8i) { + let _e82 = i; + let _e83 = ghosts; + if (_e82 >= _e83) { break; } - let _e421 = i_1; - param_24 = _e421; - let _e422 = SearchStep_u0028_i1_u003b((¶m_24)); - stride = _e422; - let _e423 = doneN; - if !(_e423) { - let _e425 = along; - let _e426 = stride; - let _e428 = posN; - posN = (_e428 - (_e425 * _e426)); - let _e430 = posN; - let _e431 = textureSampleLevel(source_texture_tex, source_texture_smp, _e430, 0f); - param_25 = _e431.xyz; - let _e433 = Weight_u0028_vf3_u003b((¶m_25)); - let _e434 = pairAverage; - endN = (_e433 - _e434); - let _e436 = endN; - let _e438 = gradientScaled; - doneN = (abs(_e436) >= _e438); - } - let _e440 = doneP; - if !(_e440) { - let _e442 = along; - let _e443 = stride; - let _e445 = posP; - posP = (_e445 + (_e442 * _e443)); - let _e447 = posP; - let _e448 = textureSampleLevel(source_texture_tex, source_texture_smp, _e447, 0f); - param_26 = _e448.xyz; - let _e450 = Weight_u0028_vf3_u003b((¶m_26)); - let _e451 = pairAverage; - endP = (_e450 - _e451); - let _e453 = endP; - let _e455 = gradientScaled; - doneP = (abs(_e453) >= _e455); - } + let _e85 = flipped; + let _e86 = towardMiddle; + let _e87 = i; + at_2 = (_e85 + (_e86 * f32(_e87))); + let _e91 = at_2; + param = _e91; + let _e92 = along_1; + param_1 = _e92; + let _e93 = spread_1; + param_2 = _e93; + let _e94 = Fringed_u0028_vf2_u003b_vf2_u003b_f1_u003b((¶m), (¶m_1), (¶m_2)); + let _e95 = at_2; + param_3 = _e95; + param_4 = 10f; + let _e96 = Falloff_u0028_vf2_u003b_f1_u003b((¶m_3), (¶m_4)); + let _e98 = flare; + flare = (_e98 + (_e94 * _e96)); continue; } else { break; } continuing { - let _e457 = i_1; - i_1 = (_e457 + 1i); - } - } - let _e459 = horizontalSpan; - if _e459 { - let _e461 = v_uv_1[0u]; - let _e463 = posN[0u]; - local_6 = (_e461 - _e463); - } else { - let _e466 = v_uv_1[1u]; - let _e468 = posN[1u]; - local_6 = (_e466 - _e468); - } - let _e470 = local_6; - distanceN = _e470; - let _e471 = horizontalSpan; - if _e471 { - let _e473 = posP[0u]; - let _e475 = v_uv_1[0u]; - local_7 = (_e473 - _e475); - } else { - let _e478 = posP[1u]; - let _e480 = v_uv_1[1u]; - local_7 = (_e478 - _e480); - } - let _e482 = local_7; - distanceP = _e482; - let _e483 = mid; - let _e484 = pairAverage; - middleBelow = ((_e483 - _e484) < 0f); - let _e487 = distanceN; - let _e488 = distanceP; - nearerN = (_e487 < _e488); - let _e490 = nearerN; - if _e490 { - let _e491 = endN; - let _e493 = middleBelow; - local_8 = ((_e491 < 0f) != _e493); - } else { - let _e495 = endP; - let _e497 = middleBelow; - local_8 = ((_e495 < 0f) != _e497); - } - let _e499 = local_8; - goodSpan = _e499; - let _e500 = distanceN; - let _e501 = distanceP; - nearest = min(_e500, _e501); - let _e503 = nearest; - let _e504 = distanceN; - let _e505 = distanceP; - pixelOffset = (0.5f - (_e503 / (_e504 + _e505))); - let _e509 = goodSpan; - let _e510 = pixelOffset; - let _e512 = subpixH; - offset = max(select(0f, _e510, _e509), _e512); - let _e514 = v_uv_1; - let _e515 = horizontalSpan; - if _e515 { - let _e516 = offset; - let _e517 = lengthSign; - local_9 = vec2(0f, (_e516 * _e517)); - } else { - let _e520 = offset; - let _e521 = lengthSign; - local_9 = vec2((_e520 * _e521), 0f); - } - let _e524 = local_9; - at = (_e514 + _e524); - let _e526 = at; - let _e527 = textureSampleLevel(source_texture_tex, source_texture_smp, _e526, 0f); - smoothed = _e527.xyz; - let _e529 = smoothed; - param_27 = _e529; - let _e530 = northRgb; - param_28 = _e530; - let _e531 = southRgb; - param_29 = _e531; - let _e532 = westRgb; - param_30 = _e532; - let _e533 = eastRgb; - param_31 = _e533; - let _e534 = Sharpen_u0028_vf3_u003b_vf3_u003b_vf3_u003b_vf3_u003b_vf3_u003b((¶m_27), (¶m_28), (¶m_29), (¶m_30), (¶m_31)); - frag_color = vec4(_e534.x, _e534.y, _e534.z, 1f); + let _e100 = i; + i = (_e100 + 1i); + } + } + let _e104 = lens_flare_info.more[2u]; + aspect = max(_e104, 0.0001f); + let _e106 = along_1; + let _e107 = aspect; + let _e113 = lens_flare_info.params[3u]; + halo = ((_e106 * vec2((1f / _e107), 1f)) * _e113); + let _e115 = flipped; + let _e116 = halo; + haloAt = (_e115 + _e116); + let _e118 = haloAt; + param_5 = _e118; + let _e119 = along_1; + param_6 = _e119; + let _e120 = spread_1; + param_7 = _e120; + let _e121 = Fringed_u0028_vf2_u003b_vf2_u003b_f1_u003b((¶m_5), (¶m_6), (¶m_7)); + let _e122 = haloAt; + param_8 = _e122; + param_9 = 5f; + let _e123 = Falloff_u0028_vf2_u003b_f1_u003b((¶m_8), (¶m_9)); + let _e127 = lens_flare_info.more[1u]; + let _e129 = flare; + flare = (_e129 + ((_e121 * _e123) * _e127)); + let _e131 = glow; + let _e132 = flare; + let _e133 = intensity; + let _e135 = (_e131 + (_e132 * _e133)); + frag_color = vec4(_e135.x, _e135.y, _e135.z, 1f); return; } @fragment -fn main(@location(5) v_uv: vec2) -> @location(0) vec4 { +fn main(@location(6) v_uv: vec2) -> @location(0) vec4 { v_uv_1 = v_uv; main_1(); let _e3 = frag_color; @@ -27587,7 +31219,7 @@ fn main(@location(5) v_uv: vec2) -> @location(0) vec4 { attributes: [], blocks: [ WebGpuBlock( - name: 'FxaaInfo', + name: 'LensFlareInfo', group: 1, binding: 0, sizeInBytes: 32, @@ -27597,17 +31229,13 @@ fn main(@location(5) v_uv: vec2) -> @location(0) vec4 { offsetInBytes: 0, sizeInBytes: 16, ), - WebGpuBlockMember( - name: 'sharpen', - offsetInBytes: 16, - sizeInBytes: 16, - ), + WebGpuBlockMember(name: 'more', offsetInBytes: 16, sizeInBytes: 16), ], ), ], samplers: [ WebGpuSampler( - name: 'source_texture', + name: 'bloom_texture', group: 1, textureBinding: 1, samplerBinding: 2, @@ -27633,7 +31261,7 @@ fn main_1() { } @fragment -fn main(@location(5) v_uv: vec2) -> FragmentOutput { +fn main(@location(6) v_uv: vec2) -> FragmentOutput { v_uv_1 = v_uv; main_1(); let _e4 = out_first; @@ -27651,6 +31279,7 @@ var g_albedo: vec3; var g_debug_surface: vec3; var g_debug_surface_on: bool; var g_premultiply: bool; +var g_pass_through: f32; var frag_color: vec4; var gl_FragCoord_1: vec4; var v_world_position_1: vec3; @@ -27665,13 +31294,14 @@ fn main_1() { g_debug_surface = vec3(0f, 0f, 0f); g_debug_surface_on = false; g_premultiply = false; - let _e19 = gl_FragCoord_1[2u]; - frag_color = vec4(_e19, 0f, 0f, 1f); + g_pass_through = 0f; + let _e20 = gl_FragCoord_1[2u]; + frag_color = vec4(_e20, 0f, 0f, 1f); return; } @fragment -fn main(@builtin(position) gl_FragCoord: vec4, @location(6) v_world_position: vec3, @location(2) v_normal: vec3, @location(4) v_texcoord: vec2, @location(3) v_tangent: vec4, @location(0) v_color: vec4, @location(1) v_lightmap_uv: vec2) -> @location(0) vec4 { +fn main(@builtin(position) gl_FragCoord: vec4, @location(7) v_world_position: vec3, @location(3) v_normal: vec3, @location(5) v_texcoord: vec2, @location(4) v_tangent: vec4, @location(0) v_color: vec4, @location(2) v_lightmap_uv: vec2) -> @location(0) vec4 { gl_FragCoord_1 = gl_FragCoord; v_world_position_1 = v_world_position; v_normal_1 = v_normal; @@ -27693,50 +31323,72 @@ fn main(@builtin(position) gl_FragCoord: vec4, @location(6) v_world_positio struct FogInfo { fog: vec4, eye: vec4, + forward: vec4, + projection: vec4, } -var v_uv_1: vec2; @group(1) @binding(0) var fog_info: FogInfo; +var v_uv_1: vec2; var v_world_position_1: vec3; var frag_color: vec4; var v_color_1: vec4; +fn FogDistance_u0028_vf3_u003b(world: ptr>) -> f32 { + var local: f32; + + let _e19 = fog_info.projection[0u]; + if (_e19 > 0.5f) { + let _e21 = (*world); + let _e23 = fog_info.eye; + let _e27 = fog_info.forward; + local = max(dot((_e21 - _e23.xyz), _e27.xyz), 0f); + } else { + let _e31 = (*world); + let _e33 = fog_info.eye; + local = distance(_e31, _e33.xyz); + } + let _e36 = local; + return _e36; +} + fn main_1() { var centred: vec2; var radius: f32; var falloff: f32; var intensity: f32; var fogged: f32; + var param: vec3; - let _e16 = v_uv_1; - centred = ((_e16 * 2f) - vec2(1f)); - let _e20 = centred; - radius = length(_e20); - let _e22 = radius; - falloff = (1f - smoothstep(0f, 1f, _e22)); - let _e25 = falloff; - let _e26 = falloff; - intensity = (_e25 * _e26); + let _e21 = v_uv_1; + centred = ((_e21 * 2f) - vec2(1f)); + let _e25 = centred; + radius = length(_e25); + let _e27 = radius; + falloff = (1f - smoothstep(0f, 1f, _e27)); + let _e30 = falloff; + let _e31 = falloff; + intensity = (_e30 * _e31); fogged = 1f; - let _e30 = fog_info.fog[3u]; - if (_e30 > 0f) { - let _e34 = fog_info.fog[3u]; - let _e36 = v_world_position_1; - let _e38 = fog_info.eye; - fogged = clamp(exp((-(_e34) * distance(_e36, _e38.xyz))), 0f, 1f); - } - let _e44 = v_color_1; - let _e47 = v_color_1[3u]; - let _e49 = intensity; - let _e51 = fogged; - let _e52 = (((_e44.xyz * _e47) * _e49) * _e51); - frag_color = vec4(_e52.x, _e52.y, _e52.z, 1f); + let _e35 = fog_info.fog[3u]; + if (_e35 > 0f) { + let _e39 = fog_info.fog[3u]; + let _e41 = v_world_position_1; + param = _e41; + let _e42 = FogDistance_u0028_vf3_u003b((¶m)); + fogged = clamp(exp((-(_e39) * _e42)), 0f, 1f); + } + let _e46 = v_color_1; + let _e49 = v_color_1[3u]; + let _e51 = intensity; + let _e53 = fogged; + let _e54 = (((_e46.xyz * _e49) * _e51) * _e53); + frag_color = vec4(_e54.x, _e54.y, _e54.z, 1f); return; } @fragment -fn main(@location(5) v_uv: vec2, @location(6) v_world_position: vec3, @location(0) v_color: vec4) -> @location(0) vec4 { +fn main(@location(6) v_uv: vec2, @location(7) v_world_position: vec3, @location(0) v_color: vec4) -> @location(0) vec4 { v_uv_1 = v_uv; v_world_position_1 = v_world_position; v_color_1 = v_color; @@ -27751,10 +31403,20 @@ fn main(@location(5) v_uv: vec2, @location(6) v_world_position: vec3, name: 'FogInfo', group: 1, binding: 0, - sizeInBytes: 32, + sizeInBytes: 64, members: [ WebGpuBlockMember(name: 'fog', offsetInBytes: 0, sizeInBytes: 16), WebGpuBlockMember(name: 'eye', offsetInBytes: 16, sizeInBytes: 16), + WebGpuBlockMember( + name: 'forward', + offsetInBytes: 32, + sizeInBytes: 16, + ), + WebGpuBlockMember( + name: 'projection', + offsetInBytes: 48, + sizeInBytes: 16, + ), ], ), ], @@ -27765,49 +31427,71 @@ fn main(@location(5) v_uv: vec2, @location(6) v_world_position: vec3, struct FogInfo { fog: vec4, eye: vec4, + forward: vec4, + projection: vec4, } +@group(1) @binding(0) +var fog_info: FogInfo; @group(1) @binding(1) var particle_texture_tex: texture_2d; @group(1) @binding(2) var particle_texture_smp: sampler; var v_uv_1: vec2; -@group(1) @binding(0) -var fog_info: FogInfo; var v_world_position_1: vec3; var v_color_1: vec4; var frag_color: vec4; +fn FogDistance_u0028_vf3_u003b(world: ptr>) -> f32 { + var local: f32; + + let _e20 = fog_info.projection[0u]; + if (_e20 > 0.5f) { + let _e22 = (*world); + let _e24 = fog_info.eye; + let _e28 = fog_info.forward; + local = max(dot((_e22 - _e24.xyz), _e28.xyz), 0f); + } else { + let _e32 = (*world); + let _e34 = fog_info.eye; + local = distance(_e32, _e34.xyz); + } + let _e37 = local; + return _e37; +} + fn main_1() { var texel: vec4; var fogged: f32; + var param: vec3; var scale: f32; - let _e15 = v_uv_1; - let _e16 = textureSample(particle_texture_tex, particle_texture_smp, _e15); - texel = _e16; + let _e20 = v_uv_1; + let _e21 = textureSample(particle_texture_tex, particle_texture_smp, _e20); + texel = _e21; fogged = 1f; - let _e19 = fog_info.fog[3u]; - if (_e19 > 0f) { - let _e23 = fog_info.fog[3u]; - let _e25 = v_world_position_1; - let _e27 = fog_info.eye; - fogged = clamp(exp((-(_e23) * distance(_e25, _e27.xyz))), 0f, 1f); - } - let _e34 = v_color_1[3u]; - let _e36 = texel[3u]; - let _e38 = fogged; - scale = ((_e34 * _e36) * _e38); - let _e40 = v_color_1; - let _e42 = texel; - let _e45 = scale; - let _e46 = ((_e40.xyz * _e42.xyz) * _e45); - frag_color = vec4(_e46.x, _e46.y, _e46.z, 1f); + let _e24 = fog_info.fog[3u]; + if (_e24 > 0f) { + let _e28 = fog_info.fog[3u]; + let _e30 = v_world_position_1; + param = _e30; + let _e31 = FogDistance_u0028_vf3_u003b((¶m)); + fogged = clamp(exp((-(_e28) * _e31)), 0f, 1f); + } + let _e36 = v_color_1[3u]; + let _e38 = texel[3u]; + let _e40 = fogged; + scale = ((_e36 * _e38) * _e40); + let _e42 = v_color_1; + let _e44 = texel; + let _e47 = scale; + let _e48 = ((_e42.xyz * _e44.xyz) * _e47); + frag_color = vec4(_e48.x, _e48.y, _e48.z, 1f); return; } @fragment -fn main(@location(5) v_uv: vec2, @location(6) v_world_position: vec3, @location(0) v_color: vec4) -> @location(0) vec4 { +fn main(@location(6) v_uv: vec2, @location(7) v_world_position: vec3, @location(0) v_color: vec4) -> @location(0) vec4 { v_uv_1 = v_uv; v_world_position_1 = v_world_position; v_color_1 = v_color; @@ -27822,10 +31506,20 @@ fn main(@location(5) v_uv: vec2, @location(6) v_world_position: vec3, name: 'FogInfo', group: 1, binding: 0, - sizeInBytes: 32, + sizeInBytes: 64, members: [ WebGpuBlockMember(name: 'fog', offsetInBytes: 0, sizeInBytes: 16), WebGpuBlockMember(name: 'eye', offsetInBytes: 16, sizeInBytes: 16), + WebGpuBlockMember( + name: 'forward', + offsetInBytes: 32, + sizeInBytes: 16, + ), + WebGpuBlockMember( + name: 'projection', + offsetInBytes: 48, + sizeInBytes: 16, + ), ], ), ], @@ -27859,6 +31553,13 @@ struct ContributorLightInfo { slots: vec4, } +struct FogInfo { + fog: vec4, + eye: vec4, + forward: vec4, + projection: vec4, +} + struct SixWayInfo { right: vec4, up: vec4, @@ -27867,11 +31568,6 @@ struct SixWayInfo { ambient: vec4, } -struct FogInfo { - fog: vec4, - eye: vec4, -} - var g_cluster_offset: f32; var g_cluster_count: f32; @group(1) @binding(2) @@ -27882,6 +31578,8 @@ var light_list_texture_tex: texture_2d; var light_list_texture_smp: sampler; @group(1) @binding(0) var contributor_light_info: ContributorLightInfo; +@group(1) @binding(1) +var fog_info: FogInfo; @group(1) @binding(3) var six_way_info: SixWayInfo; @group(1) @binding(8) @@ -27895,17 +31593,33 @@ var six_way_negative_tex: texture_2d; var six_way_negative_smp: sampler; var v_world_position_1: vec3; var v_color_1: vec4; -@group(1) @binding(1) -var fog_info: FogInfo; var frag_color: vec4; +fn FogDistance_u0028_vf3_u003b(world: ptr>) -> f32 { + var local: f32; + + let _e48 = fog_info.projection[0u]; + if (_e48 > 0.5f) { + let _e50 = (*world); + let _e52 = fog_info.eye; + let _e56 = fog_info.forward; + local = max(dot((_e50 - _e52.xyz), _e56.xyz), 0f); + } else { + let _e60 = (*world); + let _e62 = fog_info.eye; + local = distance(_e60, _e62.xyz); + } + let _e65 = local; + return _e65; +} + fn SixWayResponse_u0028_vf3_u003b_vf3_u003b_vf3_u003b(l: ptr>, positive: ptr>, negative: ptr>) -> f32 { var x: f32; var y: f32; var z: f32; - var local: f32; var local_1: f32; var local_2: f32; + var local_3: f32; let _e53 = (*l); let _e55 = six_way_info.right; @@ -27921,42 +31635,42 @@ fn SixWayResponse_u0028_vf3_u003b_vf3_u003b_vf3_u003b(l: ptr let _e71 = x; if (_e71 > 0f) { let _e74 = (*positive)[0u]; - local = _e74; + local_1 = _e74; } else { let _e76 = (*negative)[0u]; - local = _e76; + local_1 = _e76; } - let _e77 = local; + let _e77 = local_1; let _e79 = y; let _e80 = y; let _e82 = y; if (_e82 > 0f) { let _e85 = (*positive)[1u]; - local_1 = _e85; + local_2 = _e85; } else { let _e87 = (*negative)[1u]; - local_1 = _e87; + local_2 = _e87; } - let _e88 = local_1; + let _e88 = local_2; let _e91 = z; let _e92 = z; let _e94 = z; if (_e94 > 0f) { let _e97 = (*positive)[2u]; - local_2 = _e97; + local_3 = _e97; } else { let _e99 = (*negative)[2u]; - local_2 = _e99; + local_3 = _e99; } - let _e100 = local_2; + let _e100 = local_3; return ((((_e68 * _e69) * _e77) + ((_e79 * _e80) * _e88)) + ((_e91 * _e92) * _e100)); } fn LightListLane_u0028_vf4_u003b_i1_u003b(four: ptr>, slot: ptr) -> f32 { var lane: i32; - var local_3: f32; var local_4: f32; var local_5: f32; + var local_6: f32; let _e50 = (*slot); let _e51 = (*slot); @@ -27964,28 +31678,28 @@ fn LightListLane_u0028_vf4_u003b_i1_u003b(four: ptr>, slot: let _e55 = lane; if (_e55 == 0i) { let _e58 = (*four)[0u]; - local_3 = _e58; + local_4 = _e58; } else { let _e59 = lane; if (_e59 == 1i) { let _e62 = (*four)[1u]; - local_4 = _e62; + local_5 = _e62; } else { let _e63 = lane; if (_e63 == 2i) { let _e66 = (*four)[2u]; - local_5 = _e66; + local_6 = _e66; } else { let _e68 = (*four)[3u]; - local_5 = _e68; + local_6 = _e68; } - let _e69 = local_5; - local_4 = _e69; + let _e69 = local_6; + local_5 = _e69; } - let _e70 = local_4; - local_3 = _e70; + let _e70 = local_5; + local_4 = _e70; } - let _e71 = local_3; + let _e71 = local_4; return _e71; } @@ -28087,7 +31801,7 @@ fn Clustered_u0028_() -> bool { return (_e46 > 0.5f); } -fn ContributorLight_u0028_i1_u003b_vf3_u003b_vf3_u003b_vf3_u003b(index: ptr, world: ptr>, toLight: ptr>, radiance: ptr>) { +fn ContributorLight_u0028_i1_u003b_vf3_u003b_vf3_u003b_vf3_u003b(index: ptr, world_1: ptr>, toLight: ptr>, radiance: ptr>) { var position: vec4; var color: vec4; var direction: vec4; @@ -28095,12 +31809,12 @@ fn ContributorLight_u0028_i1_u003b_vf3_u003b_vf3_u003b_vf3_u003b(index: ptr; var degenerate: bool; - var local_8: vec3; + var local_9: vec3; var ratio: f32; - var local_9: f32; + var local_10: f32; var window: f32; var falloff: f32; var spot: bool; var ramp: f32; - var local_10: f32; - var attenuation: f32; var local_11: f32; + var attenuation: f32; var local_12: f32; + var local_13: f32; let _e80 = (*index); if (_e80 < 8i) { @@ -28145,14 +31859,14 @@ fn ContributorLight_u0028_i1_u003b_vf3_u003b_vf3_u003b_vf3_u003b(index: ptr 0f) { let _e177 = distance_; let _e179 = direction[3u]; - local_9 = (_e177 / _e179); + local_10 = (_e177 / _e179); } else { - local_9 = 0f; + local_10 = 0f; } - let _e181 = local_9; + let _e181 = local_10; ratio = _e181; let _e182 = ratio; let _e183 = ratio; @@ -28246,28 +31960,28 @@ fn ContributorLight_u0028_i1_u003b_vf3_u003b_vf3_u003b_vf3_u003b(index: ptr>) { +fn FindCluster_u0028_vf3_u003b(world_2: ptr>) { var clip: vec4; var ndc: vec2; var grid: vec3; @@ -28284,7 +31998,7 @@ fn FindCluster_u0028_vf3_u003b(world_1: ptr>) { var tx: f32; var ty: f32; var tz: f32; - var local_13: f32; + var local_14: f32; var cell: f32; var row_3: f32; var header: vec4; @@ -28292,7 +32006,7 @@ fn FindCluster_u0028_vf3_u003b(world_1: ptr>) { var param_13: f32; let _e59 = light_list_info.cluster_view_projection; - let _e60 = (*world_1); + let _e60 = (*world_2); clip = (_e59 * vec4(_e60.x, _e60.y, _e60.z, 1f)); let _e66 = clip; let _e69 = clip[3u]; @@ -28312,15 +32026,15 @@ fn FindCluster_u0028_vf3_u003b(world_1: ptr>) { let _e104 = clip[3u]; let _e105 = near; if (_e104 <= _e105) { - local_13 = 0f; + local_14 = 0f; } else { let _e108 = clip[3u]; let _e109 = near; let _e114 = light_list_info.cluster_depth[1u]; let _e118 = grid[2u]; - local_13 = clamp(floor((log((_e108 / _e109)) * _e114)), 0f, (_e118 - 1f)); + local_14 = clamp(floor((log((_e108 / _e109)) * _e114)), 0f, (_e118 - 1f)); } - let _e121 = local_13; + let _e121 = local_14; tz = _e121; let _e122 = tx; let _e123 = ty; @@ -28346,7 +32060,7 @@ fn FindCluster_u0028_vf3_u003b(world_1: ptr>) { return; } -fn ContributorLightCount_u0028_vf3_u003b(world_2: ptr>) -> i32 { +fn ContributorLightCount_u0028_vf3_u003b(world_3: ptr>) -> i32 { var tail: f32; var param_14: vec3; @@ -28354,7 +32068,7 @@ fn ContributorLightCount_u0028_vf3_u003b(world_2: ptr>) -> i tail = _e49; let _e50 = Clustered_u0028_(); if _e50 { - let _e51 = (*world_2); + let _e51 = (*world_3); param_14 = _e51; FindCluster_u0028_vf3_u003b((¶m_14)); let _e52 = g_cluster_count; @@ -28384,97 +32098,99 @@ fn main_1() { var mean: f32; var color_1: vec3; var fogged: f32; + var param_23: vec3; var coverage: f32; g_cluster_offset = 0f; g_cluster_count = 0f; - let _e63 = v_uv_1; - let _e64 = textureSample(six_way_positive_tex, six_way_positive_smp, _e63); - positive_1 = _e64; - let _e65 = v_uv_1; - let _e66 = textureSample(six_way_negative_tex, six_way_negative_smp, _e65); - negative_1 = _e66; + let _e64 = v_uv_1; + let _e65 = textureSample(six_way_positive_tex, six_way_positive_smp, _e64); + positive_1 = _e65; + let _e66 = v_uv_1; + let _e67 = textureSample(six_way_negative_tex, six_way_negative_smp, _e66); + negative_1 = _e67; lit = vec3(0f, 0f, 0f); - let _e67 = v_world_position_1; - param_15 = _e67; - let _e68 = ContributorLightCount_u0028_vf3_u003b((¶m_15)); - count = _e68; + let _e68 = v_world_position_1; + param_15 = _e68; + let _e69 = ContributorLightCount_u0028_vf3_u003b((¶m_15)); + count = _e69; i = 0i; loop { - let _e69 = i; - if (_e69 < 32i) { - let _e71 = i; - let _e72 = count; - if (_e71 >= _e72) { + let _e70 = i; + if (_e70 < 32i) { + let _e72 = i; + let _e73 = count; + if (_e72 >= _e73) { break; } - let _e74 = i; - param_16 = _e74; - let _e75 = v_world_position_1; - param_17 = _e75; + let _e75 = i; + param_16 = _e75; + let _e76 = v_world_position_1; + param_17 = _e76; ContributorLight_u0028_i1_u003b_vf3_u003b_vf3_u003b_vf3_u003b((¶m_16), (¶m_17), (¶m_18), (¶m_19)); - let _e76 = param_18; - l_1 = _e76; - let _e77 = param_19; - radiance_1 = _e77; - let _e78 = radiance_1; - let _e79 = l_1; - param_20 = _e79; - let _e80 = positive_1; - param_21 = _e80.xyz; - let _e82 = negative_1; - param_22 = _e82.xyz; - let _e84 = SixWayResponse_u0028_vf3_u003b_vf3_u003b_vf3_u003b((¶m_20), (¶m_21), (¶m_22)); - let _e86 = lit; - lit = (_e86 + (_e78 * _e84)); + let _e77 = param_18; + l_1 = _e77; + let _e78 = param_19; + radiance_1 = _e78; + let _e79 = radiance_1; + let _e80 = l_1; + param_20 = _e80; + let _e81 = positive_1; + param_21 = _e81.xyz; + let _e83 = negative_1; + param_22 = _e83.xyz; + let _e85 = SixWayResponse_u0028_vf3_u003b_vf3_u003b_vf3_u003b((¶m_20), (¶m_21), (¶m_22)); + let _e87 = lit; + lit = (_e87 + (_e79 * _e85)); continue; } else { break; } continuing { - let _e88 = i; - i = (_e88 + 1i); - } - } - let _e91 = positive_1[0u]; - let _e93 = positive_1[1u]; - let _e96 = positive_1[2u]; - let _e99 = negative_1[0u]; - let _e102 = negative_1[1u]; - let _e105 = negative_1[2u]; - mean = ((((((_e91 + _e93) + _e96) + _e99) + _e102) + _e105) / 6f); - let _e108 = v_color_1; - let _e110 = lit; - let _e112 = six_way_info.ambient; - let _e114 = mean; - let _e119 = six_way_info.emission; - let _e122 = negative_1[3u]; - color_1 = ((_e108.xyz * (_e110 + (_e112.xyz * _e114))) + (_e119.xyz * _e122)); + let _e89 = i; + i = (_e89 + 1i); + } + } + let _e92 = positive_1[0u]; + let _e94 = positive_1[1u]; + let _e97 = positive_1[2u]; + let _e100 = negative_1[0u]; + let _e103 = negative_1[1u]; + let _e106 = negative_1[2u]; + mean = ((((((_e92 + _e94) + _e97) + _e100) + _e103) + _e106) / 6f); + let _e109 = v_color_1; + let _e111 = lit; + let _e113 = six_way_info.ambient; + let _e115 = mean; + let _e120 = six_way_info.emission; + let _e123 = negative_1[3u]; + color_1 = ((_e109.xyz * (_e111 + (_e113.xyz * _e115))) + (_e120.xyz * _e123)); fogged = 1f; - let _e127 = fog_info.fog[3u]; - if (_e127 > 0f) { - let _e131 = fog_info.fog[3u]; - let _e133 = v_world_position_1; - let _e135 = fog_info.eye; - fogged = clamp(exp((-(_e131) * distance(_e133, _e135.xyz))), 0f, 1f); - } - let _e142 = fog_info.fog; - let _e144 = color_1; - let _e145 = fogged; - color_1 = mix(_e142.xyz, _e144, vec3(_e145)); - let _e149 = v_color_1[3u]; - let _e151 = positive_1[3u]; - coverage = clamp((_e149 * _e151), 0f, 1f); - let _e154 = color_1; + let _e128 = fog_info.fog[3u]; + if (_e128 > 0f) { + let _e132 = fog_info.fog[3u]; + let _e134 = v_world_position_1; + param_23 = _e134; + let _e135 = FogDistance_u0028_vf3_u003b((¶m_23)); + fogged = clamp(exp((-(_e132) * _e135)), 0f, 1f); + } + let _e140 = fog_info.fog; + let _e142 = color_1; + let _e143 = fogged; + color_1 = mix(_e140.xyz, _e142, vec3(_e143)); + let _e147 = v_color_1[3u]; + let _e149 = positive_1[3u]; + coverage = clamp((_e147 * _e149), 0f, 1f); + let _e152 = color_1; + let _e153 = coverage; + let _e154 = (_e152 * _e153); let _e155 = coverage; - let _e156 = (_e154 * _e155); - let _e157 = coverage; - frag_color = vec4(_e156.x, _e156.y, _e156.z, _e157); + frag_color = vec4(_e154.x, _e154.y, _e154.z, _e155); return; } @fragment -fn main(@location(5) v_uv: vec2, @location(6) v_world_position: vec3, @location(0) v_color: vec4) -> @location(0) vec4 { +fn main(@location(6) v_uv: vec2, @location(7) v_world_position: vec3, @location(0) v_color: vec4) -> @location(0) vec4 { v_uv_1 = v_uv; v_world_position_1 = v_world_position; v_color_1 = v_color; @@ -28522,10 +32238,20 @@ fn main(@location(5) v_uv: vec2, @location(6) v_world_position: vec3, name: 'FogInfo', group: 1, binding: 1, - sizeInBytes: 32, + sizeInBytes: 64, members: [ WebGpuBlockMember(name: 'fog', offsetInBytes: 0, sizeInBytes: 16), WebGpuBlockMember(name: 'eye', offsetInBytes: 16, sizeInBytes: 16), + WebGpuBlockMember( + name: 'forward', + offsetInBytes: 32, + sizeInBytes: 16, + ), + WebGpuBlockMember( + name: 'projection', + offsetInBytes: 48, + sizeInBytes: 16, + ), ], ), WebGpuBlock( @@ -28628,6 +32354,8 @@ struct SoftParticleInfo { struct FogInfo { fog: vec4, eye: vec4, + forward: vec4, + projection: vec4, } var gl_FragCoord_1: vec4; @@ -28637,9 +32365,9 @@ var soft_particle_info: SoftParticleInfo; var scene_depth_texture_tex: texture_2d; @group(1) @binding(3) var scene_depth_texture_smp: sampler; -var v_uv_1: vec2; @group(1) @binding(0) var fog_info: FogInfo; +var v_uv_1: vec2; var v_world_position_1: vec3; var frag_color: vec4; var v_color_1: vec4; @@ -28647,18 +32375,18 @@ var v_color_1: vec4; fn FragCoordFromTop_u0028_f1_u003b(rows: ptr) -> vec2 { var local: vec2; - let _e21 = (*rows); - if (_e21 > 0f) { - let _e24 = gl_FragCoord_1[0u]; - let _e25 = (*rows); - let _e27 = gl_FragCoord_1[1u]; - local = vec2(_e24, (_e25 - _e27)); + let _e23 = (*rows); + if (_e23 > 0f) { + let _e26 = gl_FragCoord_1[0u]; + let _e27 = (*rows); + let _e29 = gl_FragCoord_1[1u]; + local = vec2(_e26, (_e27 - _e29)); } else { - let _e30 = gl_FragCoord_1; - local = _e30.xy; + let _e32 = gl_FragCoord_1; + local = _e32.xy; } - let _e32 = local; - return _e32; + let _e34 = local; + return _e34; } fn SoftParticleFade_u0028_vf3_u003b(world: ptr>) -> f32 { @@ -28668,25 +32396,43 @@ fn SoftParticleFade_u0028_vf3_u003b(world: ptr>) -> f32 { var depth: f32; var fade: f32; - let _e27 = soft_particle_info.target_[2u]; - param = _e27; - let _e28 = FragCoordFromTop_u0028_f1_u003b((¶m)); - let _e30 = soft_particle_info.target_; - uv = (_e28 * _e30.xy); - let _e33 = uv; - let _e34 = textureSampleLevel(scene_depth_texture_tex, scene_depth_texture_smp, _e33, 0f); - stored = _e34.w; - let _e36 = (*world); - let _e38 = soft_particle_info.eye; - let _e42 = soft_particle_info.forward; - depth = dot((_e36 - _e38.xyz), _e42.xyz); - let _e45 = stored; - let _e46 = depth; - let _e50 = soft_particle_info.target_[3u]; - fade = clamp(((_e45 - _e46) * _e50), 0f, 1f); - let _e53 = stored; - let _e55 = fade; - return select(1f, _e55, (_e53 > 0f)); + let _e29 = soft_particle_info.target_[2u]; + param = _e29; + let _e30 = FragCoordFromTop_u0028_f1_u003b((¶m)); + let _e32 = soft_particle_info.target_; + uv = (_e30 * _e32.xy); + let _e35 = uv; + let _e36 = textureSampleLevel(scene_depth_texture_tex, scene_depth_texture_smp, _e35, 0f); + stored = _e36.w; + let _e38 = (*world); + let _e40 = soft_particle_info.eye; + let _e44 = soft_particle_info.forward; + depth = dot((_e38 - _e40.xyz), _e44.xyz); + let _e47 = stored; + let _e48 = depth; + let _e52 = soft_particle_info.target_[3u]; + fade = clamp(((_e47 - _e48) * _e52), 0f, 1f); + let _e55 = stored; + let _e57 = fade; + return select(1f, _e57, (_e55 > 0f)); +} + +fn FogDistance_u0028_vf3_u003b(world_1: ptr>) -> f32 { + var local_1: f32; + + let _e25 = fog_info.projection[0u]; + if (_e25 > 0.5f) { + let _e27 = (*world_1); + let _e29 = fog_info.eye; + let _e33 = fog_info.forward; + local_1 = max(dot((_e27 - _e29.xyz), _e33.xyz), 0f); + } else { + let _e37 = (*world_1); + let _e39 = fog_info.eye; + local_1 = distance(_e37, _e39.xyz); + } + let _e42 = local_1; + return _e42; } fn main_1() { @@ -28696,27 +32442,29 @@ fn main_1() { var intensity: f32; var fogged: f32; var param_1: vec3; + var param_2: vec3; - let _e25 = v_uv_1; - centred = ((_e25 * 2f) - vec2(1f)); - let _e29 = centred; - radius = length(_e29); - let _e31 = radius; - falloff = (1f - smoothstep(0f, 1f, _e31)); - let _e34 = falloff; - let _e35 = falloff; - intensity = (_e34 * _e35); + let _e28 = v_uv_1; + centred = ((_e28 * 2f) - vec2(1f)); + let _e32 = centred; + radius = length(_e32); + let _e34 = radius; + falloff = (1f - smoothstep(0f, 1f, _e34)); + let _e37 = falloff; + let _e38 = falloff; + intensity = (_e37 * _e38); fogged = 1f; - let _e39 = fog_info.fog[3u]; - if (_e39 > 0f) { - let _e43 = fog_info.fog[3u]; - let _e45 = v_world_position_1; - let _e47 = fog_info.eye; - fogged = clamp(exp((-(_e43) * distance(_e45, _e47.xyz))), 0f, 1f); + let _e42 = fog_info.fog[3u]; + if (_e42 > 0f) { + let _e46 = fog_info.fog[3u]; + let _e48 = v_world_position_1; + param_1 = _e48; + let _e49 = FogDistance_u0028_vf3_u003b((¶m_1)); + fogged = clamp(exp((-(_e46) * _e49)), 0f, 1f); } let _e53 = v_world_position_1; - param_1 = _e53; - let _e54 = SoftParticleFade_u0028_vf3_u003b((¶m_1)); + param_2 = _e53; + let _e54 = SoftParticleFade_u0028_vf3_u003b((¶m_2)); let _e55 = intensity; intensity = (_e55 * _e54); let _e57 = v_color_1; @@ -28729,7 +32477,7 @@ fn main_1() { } @fragment -fn main(@builtin(position) gl_FragCoord: vec4, @location(5) v_uv: vec2, @location(6) v_world_position: vec3, @location(0) v_color: vec4) -> @location(0) vec4 { +fn main(@builtin(position) gl_FragCoord: vec4, @location(6) v_uv: vec2, @location(7) v_world_position: vec3, @location(0) v_color: vec4) -> @location(0) vec4 { gl_FragCoord_1 = gl_FragCoord; v_uv_1 = v_uv; v_world_position_1 = v_world_position; @@ -28745,10 +32493,20 @@ fn main(@builtin(position) gl_FragCoord: vec4, @location(5) v_uv: vec2 name: 'FogInfo', group: 1, binding: 0, - sizeInBytes: 32, + sizeInBytes: 64, members: [ WebGpuBlockMember(name: 'fog', offsetInBytes: 0, sizeInBytes: 16), WebGpuBlockMember(name: 'eye', offsetInBytes: 16, sizeInBytes: 16), + WebGpuBlockMember( + name: 'forward', + offsetInBytes: 32, + sizeInBytes: 16, + ), + WebGpuBlockMember( + name: 'projection', + offsetInBytes: 48, + sizeInBytes: 16, + ), ], ), WebGpuBlock( @@ -28792,6 +32550,8 @@ struct SoftParticleInfo { struct FogInfo { fog: vec4, eye: vec4, + forward: vec4, + projection: vec4, } var gl_FragCoord_1: vec4; @@ -28801,13 +32561,13 @@ var soft_particle_info: SoftParticleInfo; var scene_depth_texture_tex: texture_2d; @group(1) @binding(5) var scene_depth_texture_smp: sampler; +@group(1) @binding(0) +var fog_info: FogInfo; @group(1) @binding(2) var particle_texture_tex: texture_2d; @group(1) @binding(3) var particle_texture_smp: sampler; var v_uv_1: vec2; -@group(1) @binding(0) -var fog_info: FogInfo; var v_world_position_1: vec3; var v_color_1: vec4; var frag_color: vec4; @@ -28815,18 +32575,18 @@ var frag_color: vec4; fn FragCoordFromTop_u0028_f1_u003b(rows: ptr) -> vec2 { var local: vec2; - let _e22 = (*rows); - if (_e22 > 0f) { - let _e25 = gl_FragCoord_1[0u]; - let _e26 = (*rows); - let _e28 = gl_FragCoord_1[1u]; - local = vec2(_e25, (_e26 - _e28)); + let _e24 = (*rows); + if (_e24 > 0f) { + let _e27 = gl_FragCoord_1[0u]; + let _e28 = (*rows); + let _e30 = gl_FragCoord_1[1u]; + local = vec2(_e27, (_e28 - _e30)); } else { - let _e31 = gl_FragCoord_1; - local = _e31.xy; + let _e33 = gl_FragCoord_1; + local = _e33.xy; } - let _e33 = local; - return _e33; + let _e35 = local; + return _e35; } fn SoftParticleFade_u0028_vf3_u003b(world: ptr>) -> f32 { @@ -28836,51 +32596,71 @@ fn SoftParticleFade_u0028_vf3_u003b(world: ptr>) -> f32 { var depth: f32; var fade: f32; - let _e28 = soft_particle_info.target_[2u]; - param = _e28; - let _e29 = FragCoordFromTop_u0028_f1_u003b((¶m)); - let _e31 = soft_particle_info.target_; - uv = (_e29 * _e31.xy); - let _e34 = uv; - let _e35 = textureSampleLevel(scene_depth_texture_tex, scene_depth_texture_smp, _e34, 0f); - stored = _e35.w; - let _e37 = (*world); - let _e39 = soft_particle_info.eye; - let _e43 = soft_particle_info.forward; - depth = dot((_e37 - _e39.xyz), _e43.xyz); - let _e46 = stored; - let _e47 = depth; - let _e51 = soft_particle_info.target_[3u]; - fade = clamp(((_e46 - _e47) * _e51), 0f, 1f); - let _e54 = stored; - let _e56 = fade; - return select(1f, _e56, (_e54 > 0f)); + let _e30 = soft_particle_info.target_[2u]; + param = _e30; + let _e31 = FragCoordFromTop_u0028_f1_u003b((¶m)); + let _e33 = soft_particle_info.target_; + uv = (_e31 * _e33.xy); + let _e36 = uv; + let _e37 = textureSampleLevel(scene_depth_texture_tex, scene_depth_texture_smp, _e36, 0f); + stored = _e37.w; + let _e39 = (*world); + let _e41 = soft_particle_info.eye; + let _e45 = soft_particle_info.forward; + depth = dot((_e39 - _e41.xyz), _e45.xyz); + let _e48 = stored; + let _e49 = depth; + let _e53 = soft_particle_info.target_[3u]; + fade = clamp(((_e48 - _e49) * _e53), 0f, 1f); + let _e56 = stored; + let _e58 = fade; + return select(1f, _e58, (_e56 > 0f)); +} + +fn FogDistance_u0028_vf3_u003b(world_1: ptr>) -> f32 { + var local_1: f32; + + let _e26 = fog_info.projection[0u]; + if (_e26 > 0.5f) { + let _e28 = (*world_1); + let _e30 = fog_info.eye; + let _e34 = fog_info.forward; + local_1 = max(dot((_e28 - _e30.xyz), _e34.xyz), 0f); + } else { + let _e38 = (*world_1); + let _e40 = fog_info.eye; + local_1 = distance(_e38, _e40.xyz); + } + let _e43 = local_1; + return _e43; } fn main_1() { var texel: vec4; var fogged: f32; - var scale: f32; var param_1: vec3; + var scale: f32; + var param_2: vec3; - let _e24 = v_uv_1; - let _e25 = textureSample(particle_texture_tex, particle_texture_smp, _e24); - texel = _e25; + let _e27 = v_uv_1; + let _e28 = textureSample(particle_texture_tex, particle_texture_smp, _e27); + texel = _e28; fogged = 1f; - let _e28 = fog_info.fog[3u]; - if (_e28 > 0f) { - let _e32 = fog_info.fog[3u]; - let _e34 = v_world_position_1; - let _e36 = fog_info.eye; - fogged = clamp(exp((-(_e32) * distance(_e34, _e36.xyz))), 0f, 1f); + let _e31 = fog_info.fog[3u]; + if (_e31 > 0f) { + let _e35 = fog_info.fog[3u]; + let _e37 = v_world_position_1; + param_1 = _e37; + let _e38 = FogDistance_u0028_vf3_u003b((¶m_1)); + fogged = clamp(exp((-(_e35) * _e38)), 0f, 1f); } let _e43 = v_color_1[3u]; let _e45 = texel[3u]; let _e47 = fogged; scale = ((_e43 * _e45) * _e47); let _e49 = v_world_position_1; - param_1 = _e49; - let _e50 = SoftParticleFade_u0028_vf3_u003b((¶m_1)); + param_2 = _e49; + let _e50 = SoftParticleFade_u0028_vf3_u003b((¶m_2)); let _e51 = scale; scale = (_e51 * _e50); let _e53 = v_color_1; @@ -28892,7 +32672,7 @@ fn main_1() { } @fragment -fn main(@builtin(position) gl_FragCoord: vec4, @location(5) v_uv: vec2, @location(6) v_world_position: vec3, @location(0) v_color: vec4) -> @location(0) vec4 { +fn main(@builtin(position) gl_FragCoord: vec4, @location(6) v_uv: vec2, @location(7) v_world_position: vec3, @location(0) v_color: vec4) -> @location(0) vec4 { gl_FragCoord_1 = gl_FragCoord; v_uv_1 = v_uv; v_world_position_1 = v_world_position; @@ -28908,10 +32688,20 @@ fn main(@builtin(position) gl_FragCoord: vec4, @location(5) v_uv: vec2 name: 'FogInfo', group: 1, binding: 0, - sizeInBytes: 32, + sizeInBytes: 64, members: [ WebGpuBlockMember(name: 'fog', offsetInBytes: 0, sizeInBytes: 16), WebGpuBlockMember(name: 'eye', offsetInBytes: 16, sizeInBytes: 16), + WebGpuBlockMember( + name: 'forward', + offsetInBytes: 32, + sizeInBytes: 16, + ), + WebGpuBlockMember( + name: 'projection', + offsetInBytes: 48, + sizeInBytes: 16, + ), ], ), WebGpuBlock( @@ -28977,6 +32767,13 @@ struct SoftParticleInfo { forward: vec4, } +struct FogInfo { + fog: vec4, + eye: vec4, + forward: vec4, + projection: vec4, +} + struct SixWayInfo { right: vec4, up: vec4, @@ -28985,11 +32782,6 @@ struct SixWayInfo { ambient: vec4, } -struct FogInfo { - fog: vec4, - eye: vec4, -} - var g_cluster_offset: f32; var g_cluster_count: f32; @group(1) @binding(2) @@ -29007,6 +32799,8 @@ var soft_particle_info: SoftParticleInfo; var scene_depth_texture_tex: texture_2d; @group(1) @binding(8) var scene_depth_texture_smp: sampler; +@group(1) @binding(1) +var fog_info: FogInfo; @group(1) @binding(3) var six_way_info: SixWayInfo; @group(1) @binding(11) @@ -29020,8 +32814,6 @@ var six_way_negative_tex: texture_2d; var six_way_negative_smp: sampler; var v_world_position_1: vec3; var v_color_1: vec4; -@group(1) @binding(1) -var fog_info: FogInfo; var frag_color: vec4; fn FragCoordFromTop_u0028_f1_u003b(rows: ptr) -> vec2 { @@ -29069,13 +32861,31 @@ fn SoftParticleFade_u0028_vf3_u003b(world: ptr>) -> f32 { return select(1f, _e84, (_e82 > 0f)); } +fn FogDistance_u0028_vf3_u003b(world_1: ptr>) -> f32 { + var local_1: f32; + + let _e52 = fog_info.projection[0u]; + if (_e52 > 0.5f) { + let _e54 = (*world_1); + let _e56 = fog_info.eye; + let _e60 = fog_info.forward; + local_1 = max(dot((_e54 - _e56.xyz), _e60.xyz), 0f); + } else { + let _e64 = (*world_1); + let _e66 = fog_info.eye; + local_1 = distance(_e64, _e66.xyz); + } + let _e69 = local_1; + return _e69; +} + fn SixWayResponse_u0028_vf3_u003b_vf3_u003b_vf3_u003b(l: ptr>, positive: ptr>, negative: ptr>) -> f32 { var x: f32; var y: f32; var z: f32; - var local_1: f32; var local_2: f32; var local_3: f32; + var local_4: f32; let _e57 = (*l); let _e59 = six_way_info.right; @@ -29091,42 +32901,42 @@ fn SixWayResponse_u0028_vf3_u003b_vf3_u003b_vf3_u003b(l: ptr let _e75 = x; if (_e75 > 0f) { let _e78 = (*positive)[0u]; - local_1 = _e78; + local_2 = _e78; } else { let _e80 = (*negative)[0u]; - local_1 = _e80; + local_2 = _e80; } - let _e81 = local_1; + let _e81 = local_2; let _e83 = y; let _e84 = y; let _e86 = y; if (_e86 > 0f) { let _e89 = (*positive)[1u]; - local_2 = _e89; + local_3 = _e89; } else { let _e91 = (*negative)[1u]; - local_2 = _e91; + local_3 = _e91; } - let _e92 = local_2; + let _e92 = local_3; let _e95 = z; let _e96 = z; let _e98 = z; if (_e98 > 0f) { let _e101 = (*positive)[2u]; - local_3 = _e101; + local_4 = _e101; } else { let _e103 = (*negative)[2u]; - local_3 = _e103; + local_4 = _e103; } - let _e104 = local_3; + let _e104 = local_4; return ((((_e72 * _e73) * _e81) + ((_e83 * _e84) * _e92)) + ((_e95 * _e96) * _e104)); } fn LightListLane_u0028_vf4_u003b_i1_u003b(four: ptr>, slot: ptr) -> f32 { var lane: i32; - var local_4: f32; var local_5: f32; var local_6: f32; + var local_7: f32; let _e54 = (*slot); let _e55 = (*slot); @@ -29134,28 +32944,28 @@ fn LightListLane_u0028_vf4_u003b_i1_u003b(four: ptr>, slot: let _e59 = lane; if (_e59 == 0i) { let _e62 = (*four)[0u]; - local_4 = _e62; + local_5 = _e62; } else { let _e63 = lane; if (_e63 == 1i) { let _e66 = (*four)[1u]; - local_5 = _e66; + local_6 = _e66; } else { let _e67 = lane; if (_e67 == 2i) { let _e70 = (*four)[2u]; - local_6 = _e70; + local_7 = _e70; } else { let _e72 = (*four)[3u]; - local_6 = _e72; + local_7 = _e72; } - let _e73 = local_6; - local_5 = _e73; + let _e73 = local_7; + local_6 = _e73; } - let _e74 = local_5; - local_4 = _e74; + let _e74 = local_6; + local_5 = _e74; } - let _e75 = local_4; + let _e75 = local_5; return _e75; } @@ -29257,7 +33067,7 @@ fn Clustered_u0028_() -> bool { return (_e50 > 0.5f); } -fn ContributorLight_u0028_i1_u003b_vf3_u003b_vf3_u003b_vf3_u003b(index: ptr, world_1: ptr>, toLight: ptr>, radiance: ptr>) { +fn ContributorLight_u0028_i1_u003b_vf3_u003b_vf3_u003b_vf3_u003b(index: ptr, world_2: ptr>, toLight: ptr>, radiance: ptr>) { var position: vec4; var color: vec4; var direction: vec4; @@ -29265,12 +33075,12 @@ fn ContributorLight_u0028_i1_u003b_vf3_u003b_vf3_u003b_vf3_u003b(index: ptr; var degenerate: bool; - var local_9: vec3; + var local_10: vec3; var ratio: f32; - var local_10: f32; + var local_11: f32; var window: f32; var falloff: f32; var spot: bool; var ramp: f32; - var local_11: f32; - var attenuation: f32; var local_12: f32; + var attenuation: f32; var local_13: f32; + var local_14: f32; let _e84 = (*index); if (_e84 < 8i) { @@ -29315,14 +33125,14 @@ fn ContributorLight_u0028_i1_u003b_vf3_u003b_vf3_u003b_vf3_u003b(index: ptr 0f) { let _e181 = distance_; let _e183 = direction[3u]; - local_10 = (_e181 / _e183); + local_11 = (_e181 / _e183); } else { - local_10 = 0f; + local_11 = 0f; } - let _e185 = local_10; + let _e185 = local_11; ratio = _e185; let _e186 = ratio; let _e187 = ratio; @@ -29416,28 +33226,28 @@ fn ContributorLight_u0028_i1_u003b_vf3_u003b_vf3_u003b_vf3_u003b(index: ptr>) { +fn FindCluster_u0028_vf3_u003b(world_3: ptr>) { var clip: vec4; var ndc: vec2; var grid: vec3; @@ -29454,7 +33264,7 @@ fn FindCluster_u0028_vf3_u003b(world_2: ptr>) { var tx: f32; var ty: f32; var tz: f32; - var local_14: f32; + var local_15: f32; var cell: f32; var row_3: f32; var header: vec4; @@ -29462,7 +33272,7 @@ fn FindCluster_u0028_vf3_u003b(world_2: ptr>) { var param_14: f32; let _e63 = light_list_info.cluster_view_projection; - let _e64 = (*world_2); + let _e64 = (*world_3); clip = (_e63 * vec4(_e64.x, _e64.y, _e64.z, 1f)); let _e70 = clip; let _e73 = clip[3u]; @@ -29482,15 +33292,15 @@ fn FindCluster_u0028_vf3_u003b(world_2: ptr>) { let _e108 = clip[3u]; let _e109 = near; if (_e108 <= _e109) { - local_14 = 0f; + local_15 = 0f; } else { let _e112 = clip[3u]; let _e113 = near; let _e118 = light_list_info.cluster_depth[1u]; let _e122 = grid[2u]; - local_14 = clamp(floor((log((_e112 / _e113)) * _e118)), 0f, (_e122 - 1f)); + local_15 = clamp(floor((log((_e112 / _e113)) * _e118)), 0f, (_e122 - 1f)); } - let _e125 = local_14; + let _e125 = local_15; tz = _e125; let _e126 = tx; let _e127 = ty; @@ -29516,7 +33326,7 @@ fn FindCluster_u0028_vf3_u003b(world_2: ptr>) { return; } -fn ContributorLightCount_u0028_vf3_u003b(world_3: ptr>) -> i32 { +fn ContributorLightCount_u0028_vf3_u003b(world_4: ptr>) -> i32 { var tail: f32; var param_15: vec3; @@ -29524,7 +33334,7 @@ fn ContributorLightCount_u0028_vf3_u003b(world_3: ptr>) -> i tail = _e53; let _e54 = Clustered_u0028_(); if _e54 { - let _e55 = (*world_3); + let _e55 = (*world_4); param_15 = _e55; FindCluster_u0028_vf3_u003b((¶m_15)); let _e56 = g_cluster_count; @@ -29554,103 +33364,105 @@ fn main_1() { var mean: f32; var color_1: vec3; var fogged: f32; - var coverage: f32; var param_24: vec3; + var coverage: f32; + var param_25: vec3; g_cluster_offset = 0f; g_cluster_count = 0f; - let _e68 = v_uv_1; - let _e69 = textureSample(six_way_positive_tex, six_way_positive_smp, _e68); - positive_1 = _e69; - let _e70 = v_uv_1; - let _e71 = textureSample(six_way_negative_tex, six_way_negative_smp, _e70); - negative_1 = _e71; + let _e69 = v_uv_1; + let _e70 = textureSample(six_way_positive_tex, six_way_positive_smp, _e69); + positive_1 = _e70; + let _e71 = v_uv_1; + let _e72 = textureSample(six_way_negative_tex, six_way_negative_smp, _e71); + negative_1 = _e72; lit = vec3(0f, 0f, 0f); - let _e72 = v_world_position_1; - param_16 = _e72; - let _e73 = ContributorLightCount_u0028_vf3_u003b((¶m_16)); - count = _e73; + let _e73 = v_world_position_1; + param_16 = _e73; + let _e74 = ContributorLightCount_u0028_vf3_u003b((¶m_16)); + count = _e74; i = 0i; loop { - let _e74 = i; - if (_e74 < 32i) { - let _e76 = i; - let _e77 = count; - if (_e76 >= _e77) { + let _e75 = i; + if (_e75 < 32i) { + let _e77 = i; + let _e78 = count; + if (_e77 >= _e78) { break; } - let _e79 = i; - param_17 = _e79; - let _e80 = v_world_position_1; - param_18 = _e80; + let _e80 = i; + param_17 = _e80; + let _e81 = v_world_position_1; + param_18 = _e81; ContributorLight_u0028_i1_u003b_vf3_u003b_vf3_u003b_vf3_u003b((¶m_17), (¶m_18), (¶m_19), (¶m_20)); - let _e81 = param_19; - l_1 = _e81; - let _e82 = param_20; - radiance_1 = _e82; - let _e83 = radiance_1; - let _e84 = l_1; - param_21 = _e84; - let _e85 = positive_1; - param_22 = _e85.xyz; - let _e87 = negative_1; - param_23 = _e87.xyz; - let _e89 = SixWayResponse_u0028_vf3_u003b_vf3_u003b_vf3_u003b((¶m_21), (¶m_22), (¶m_23)); - let _e91 = lit; - lit = (_e91 + (_e83 * _e89)); + let _e82 = param_19; + l_1 = _e82; + let _e83 = param_20; + radiance_1 = _e83; + let _e84 = radiance_1; + let _e85 = l_1; + param_21 = _e85; + let _e86 = positive_1; + param_22 = _e86.xyz; + let _e88 = negative_1; + param_23 = _e88.xyz; + let _e90 = SixWayResponse_u0028_vf3_u003b_vf3_u003b_vf3_u003b((¶m_21), (¶m_22), (¶m_23)); + let _e92 = lit; + lit = (_e92 + (_e84 * _e90)); continue; } else { break; } continuing { - let _e93 = i; - i = (_e93 + 1i); - } - } - let _e96 = positive_1[0u]; - let _e98 = positive_1[1u]; - let _e101 = positive_1[2u]; - let _e104 = negative_1[0u]; - let _e107 = negative_1[1u]; - let _e110 = negative_1[2u]; - mean = ((((((_e96 + _e98) + _e101) + _e104) + _e107) + _e110) / 6f); - let _e113 = v_color_1; - let _e115 = lit; - let _e117 = six_way_info.ambient; - let _e119 = mean; - let _e124 = six_way_info.emission; - let _e127 = negative_1[3u]; - color_1 = ((_e113.xyz * (_e115 + (_e117.xyz * _e119))) + (_e124.xyz * _e127)); + let _e94 = i; + i = (_e94 + 1i); + } + } + let _e97 = positive_1[0u]; + let _e99 = positive_1[1u]; + let _e102 = positive_1[2u]; + let _e105 = negative_1[0u]; + let _e108 = negative_1[1u]; + let _e111 = negative_1[2u]; + mean = ((((((_e97 + _e99) + _e102) + _e105) + _e108) + _e111) / 6f); + let _e114 = v_color_1; + let _e116 = lit; + let _e118 = six_way_info.ambient; + let _e120 = mean; + let _e125 = six_way_info.emission; + let _e128 = negative_1[3u]; + color_1 = ((_e114.xyz * (_e116 + (_e118.xyz * _e120))) + (_e125.xyz * _e128)); fogged = 1f; - let _e132 = fog_info.fog[3u]; - if (_e132 > 0f) { - let _e136 = fog_info.fog[3u]; - let _e138 = v_world_position_1; - let _e140 = fog_info.eye; - fogged = clamp(exp((-(_e136) * distance(_e138, _e140.xyz))), 0f, 1f); - } - let _e147 = fog_info.fog; - let _e149 = color_1; - let _e150 = fogged; - color_1 = mix(_e147.xyz, _e149, vec3(_e150)); - let _e154 = v_color_1[3u]; - let _e156 = positive_1[3u]; - coverage = clamp((_e154 * _e156), 0f, 1f); - let _e159 = v_world_position_1; - param_24 = _e159; - let _e160 = SoftParticleFade_u0028_vf3_u003b((¶m_24)); - let _e161 = coverage; - coverage = (_e161 * _e160); - let _e163 = color_1; + let _e133 = fog_info.fog[3u]; + if (_e133 > 0f) { + let _e137 = fog_info.fog[3u]; + let _e139 = v_world_position_1; + param_24 = _e139; + let _e140 = FogDistance_u0028_vf3_u003b((¶m_24)); + fogged = clamp(exp((-(_e137) * _e140)), 0f, 1f); + } + let _e145 = fog_info.fog; + let _e147 = color_1; + let _e148 = fogged; + color_1 = mix(_e145.xyz, _e147, vec3(_e148)); + let _e152 = v_color_1[3u]; + let _e154 = positive_1[3u]; + coverage = clamp((_e152 * _e154), 0f, 1f); + let _e157 = v_world_position_1; + param_25 = _e157; + let _e158 = SoftParticleFade_u0028_vf3_u003b((¶m_25)); + let _e159 = coverage; + coverage = (_e159 * _e158); + let _e161 = color_1; + let _e162 = coverage; + let _e163 = (_e161 * _e162); let _e164 = coverage; - let _e165 = (_e163 * _e164); - let _e166 = coverage; - frag_color = vec4(_e165.x, _e165.y, _e165.z, _e166); + frag_color = vec4(_e163.x, _e163.y, _e163.z, _e164); return; } @fragment -fn main(@builtin(position) gl_FragCoord: vec4, @location(5) v_uv: vec2, @location(6) v_world_position: vec3, @location(0) v_color: vec4) -> @location(0) vec4 { +fn main(@builtin(position) gl_FragCoord: vec4, @location(6) v_uv: vec2, @location(7) v_world_position: vec3, @location(0) v_color: vec4) -> @location(0) vec4 { gl_FragCoord_1 = gl_FragCoord; v_uv_1 = v_uv; v_world_position_1 = v_world_position; @@ -29699,10 +33511,20 @@ fn main(@builtin(position) gl_FragCoord: vec4, @location(5) v_uv: vec2 name: 'FogInfo', group: 1, binding: 1, - sizeInBytes: 32, + sizeInBytes: 64, members: [ WebGpuBlockMember(name: 'fog', offsetInBytes: 0, sizeInBytes: 16), WebGpuBlockMember(name: 'eye', offsetInBytes: 16, sizeInBytes: 16), + WebGpuBlockMember( + name: 'forward', + offsetInBytes: 32, + sizeInBytes: 16, + ), + WebGpuBlockMember( + name: 'projection', + offsetInBytes: 48, + sizeInBytes: 16, + ), ], ), WebGpuBlock( @@ -29825,58 +33647,80 @@ fn main(@builtin(position) gl_FragCoord: vec4, @location(5) v_uv: vec2 struct FogInfo { fog: vec4, eye: vec4, + forward: vec4, + projection: vec4, } -var v_uv_1: vec2; -var v_color_1: vec4; @group(1) @binding(0) var fog_info: FogInfo; +var v_uv_1: vec2; +var v_color_1: vec4; var v_world_position_1: vec3; var frag_color: vec4; +fn FogDistance_u0028_vf3_u003b(world: ptr>) -> f32 { + var local: f32; + + let _e21 = fog_info.projection[0u]; + if (_e21 > 0.5f) { + let _e23 = (*world); + let _e25 = fog_info.eye; + let _e29 = fog_info.forward; + local = max(dot((_e23 - _e25.xyz), _e29.xyz), 0f); + } else { + let _e33 = (*world); + let _e35 = fog_info.eye; + local = distance(_e33, _e35.xyz); + } + let _e38 = local; + return _e38; +} + fn main_1() { var power: f32; var alpha: f32; var colour: vec3; var visibility: f32; + var param: vec3; - let _e17 = v_uv_1; - let _e18 = v_uv_1; - power = (-0.5f * dot(_e17, _e18)); - let _e21 = power; - if (_e21 < -4.5f) { + let _e22 = v_uv_1; + let _e23 = v_uv_1; + power = (-0.5f * dot(_e22, _e23)); + let _e26 = power; + if (_e26 < -4.5f) { discard; } - let _e24 = v_color_1[3u]; - let _e25 = power; - alpha = (_e24 * exp(_e25)); - let _e28 = alpha; - if (_e28 < 0.003921569f) { + let _e29 = v_color_1[3u]; + let _e30 = power; + alpha = (_e29 * exp(_e30)); + let _e33 = alpha; + if (_e33 < 0.003921569f) { discard; } - let _e30 = v_color_1; - colour = _e30.xyz; - let _e34 = fog_info.fog[3u]; - if (_e34 > 0f) { - let _e38 = fog_info.fog[3u]; - let _e40 = v_world_position_1; - let _e42 = fog_info.eye; - visibility = clamp(exp((-(_e38) * distance(_e40, _e42.xyz))), 0f, 1f); - let _e49 = fog_info.fog; - let _e51 = colour; - let _e52 = visibility; - colour = mix(_e49.xyz, _e51, vec3(_e52)); - } - let _e55 = colour; - let _e56 = alpha; - let _e57 = (_e55 * _e56); + let _e35 = v_color_1; + colour = _e35.xyz; + let _e39 = fog_info.fog[3u]; + if (_e39 > 0f) { + let _e43 = fog_info.fog[3u]; + let _e45 = v_world_position_1; + param = _e45; + let _e46 = FogDistance_u0028_vf3_u003b((¶m)); + visibility = clamp(exp((-(_e43) * _e46)), 0f, 1f); + let _e51 = fog_info.fog; + let _e53 = colour; + let _e54 = visibility; + colour = mix(_e51.xyz, _e53, vec3(_e54)); + } + let _e57 = colour; let _e58 = alpha; - frag_color = vec4(_e57.x, _e57.y, _e57.z, _e58); + let _e59 = (_e57 * _e58); + let _e60 = alpha; + frag_color = vec4(_e59.x, _e59.y, _e59.z, _e60); return; } @fragment -fn main(@location(5) v_uv: vec2, @location(0) v_color: vec4, @location(6) v_world_position: vec3) -> @location(0) vec4 { +fn main(@location(6) v_uv: vec2, @location(0) v_color: vec4, @location(7) v_world_position: vec3) -> @location(0) vec4 { v_uv_1 = v_uv; v_color_1 = v_color; v_world_position_1 = v_world_position; @@ -29891,10 +33735,20 @@ fn main(@location(5) v_uv: vec2, @location(0) v_color: vec4, @location name: 'FogInfo', group: 1, binding: 0, - sizeInBytes: 32, + sizeInBytes: 64, members: [ WebGpuBlockMember(name: 'fog', offsetInBytes: 0, sizeInBytes: 16), WebGpuBlockMember(name: 'eye', offsetInBytes: 16, sizeInBytes: 16), + WebGpuBlockMember( + name: 'forward', + offsetInBytes: 32, + sizeInBytes: 16, + ), + WebGpuBlockMember( + name: 'projection', + offsetInBytes: 48, + sizeInBytes: 16, + ), ], ), ], @@ -29902,18 +33756,22 @@ fn main(@location(5) v_uv: vec2, @location(0) v_color: vec4, @location ), 'SplatHashed': WebGpuStage( wgsl: r''' -struct SplatHashInfo { - frame: vec4, +struct FogInfo { + fog: vec4, eye: vec4, forward: vec4, + projection: vec4, } -struct FogInfo { - fog: vec4, +struct SplatHashInfo { + frame: vec4, eye: vec4, + forward: vec4, } var gl_FragCoord_1: vec4; +@group(1) @binding(0) +var fog_info: FogInfo; var v_uv_1: vec2; var v_color_1: vec4; var v_world_position_1: vec3; @@ -29923,25 +33781,41 @@ var splat_hash_info: SplatHashInfo; var blue_noise_texture_tex: texture_2d; @group(1) @binding(3) var blue_noise_texture_smp: sampler; -@group(1) @binding(0) -var fog_info: FogInfo; var frag_color: vec4; +fn FogDistance_u0028_vf3_u003b(world: ptr>) -> f32 { + var local: f32; + + let _e43 = fog_info.projection[0u]; + if (_e43 > 0.5f) { + let _e45 = (*world); + let _e47 = fog_info.eye; + let _e51 = fog_info.forward; + local = max(dot((_e45 - _e47.xyz), _e51.xyz), 0f); + } else { + let _e55 = (*world); + let _e57 = fog_info.eye; + local = distance(_e55, _e57.xyz); + } + let _e60 = local; + return _e60; +} + fn FragCoordFromTop_u0028_f1_u003b(rows: ptr) -> vec2 { - var local: vec2; + var local_1: vec2; - let _e40 = (*rows); - if (_e40 > 0f) { - let _e43 = gl_FragCoord_1[0u]; - let _e44 = (*rows); - let _e46 = gl_FragCoord_1[1u]; - local = vec2(_e43, (_e44 - _e46)); + let _e41 = (*rows); + if (_e41 > 0f) { + let _e44 = gl_FragCoord_1[0u]; + let _e45 = (*rows); + let _e47 = gl_FragCoord_1[1u]; + local_1 = vec2(_e44, (_e45 - _e47)); } else { - let _e49 = gl_FragCoord_1; - local = _e49.xy; + let _e50 = gl_FragCoord_1; + local_1 = _e50.xy; } - let _e51 = local; - return _e51; + let _e52 = local_1; + return _e52; } fn main_1() { @@ -29959,86 +33833,88 @@ fn main_1() { var threshold: f32; var colour: vec3; var visibility: f32; + var param_1: vec3; - let _e52 = v_uv_1; - let _e53 = v_uv_1; - power = (-0.5f * dot(_e52, _e53)); - let _e56 = power; - if (_e56 < -4.5f) { + let _e54 = v_uv_1; + let _e55 = v_uv_1; + power = (-0.5f * dot(_e54, _e55)); + let _e58 = power; + if (_e58 < -4.5f) { discard; } - let _e59 = v_color_1[3u]; - let _e60 = power; - alpha = (_e59 * exp(_e60)); - let _e63 = alpha; - if (_e63 < 0.003921569f) { + let _e61 = v_color_1[3u]; + let _e62 = power; + alpha = (_e61 * exp(_e62)); + let _e65 = alpha; + if (_e65 < 0.003921569f) { discard; } - let _e65 = v_world_position_1; - let _e67 = splat_hash_info.eye; - let _e71 = splat_hash_info.forward; - along = dot((_e65 - _e67.xyz), _e71.xyz); - let _e74 = along; - let _e77 = v_color_1; - identity = (floor((_e74 * 1000f)) + floor(dot(_e77, vec4(255f, 1785f, 7905f, 32385f)))); - let _e81 = identity; - identity = (_e81 - (floor((_e81 / 4096f)) * 4096f)); - let _e86 = identity; - let _e88 = ((_e86 * 1597f) + 2531f); - mixed = (_e88 - (floor((_e88 / 4096f)) * 4096f)); - let _e93 = mixed; - let _e94 = mixed; - let _e96 = ((2f * _e94) + 1f); - let _e101 = (_e93 * (_e96 - (floor((_e96 / 4096f)) * 4096f))); - mixed = (_e101 - (floor((_e101 / 4096f)) * 4096f)); - let _e106 = mixed; - row = floor((_e106 / 64f)); - let _e109 = mixed; - let _e110 = row; - let _e112 = (_e109 + (_e110 * 37f)); - let _e117 = row; - offset = vec2((_e112 - (floor((_e112 / 64f)) * 64f)), _e117); - let _e121 = splat_hash_info.frame[0u]; - slice = _e121; - let _e124 = splat_hash_info.frame[1u]; - param = _e124; - let _e125 = FragCoordFromTop_u0028_f1_u003b((¶m)); - let _e127 = offset; - let _e128 = (floor(_e125) + _e127); - let _e129 = vec2(64f); - cell = (_e128 - (floor((_e128 / _e129)) * _e129)); - let _e134 = slice; - let _e139 = slice; - corner = (vec2((_e134 - (floor((_e134 / 8f)) * 8f)), floor((_e139 / 8f))) * 64f); - let _e144 = corner; - let _e145 = cell; - let _e150 = textureSampleLevel(blue_noise_texture_tex, blue_noise_texture_smp, (((_e144 + _e145) + vec2(0.5f)) / vec2(512f, 256f)), 0f); - threshold = (_e150.x * 0.99609375f); - let _e153 = alpha; - let _e154 = threshold; - if (_e153 <= _e154) { + let _e67 = v_world_position_1; + let _e69 = splat_hash_info.eye; + let _e73 = splat_hash_info.forward; + along = dot((_e67 - _e69.xyz), _e73.xyz); + let _e76 = along; + let _e79 = v_color_1; + identity = (floor((_e76 * 1000f)) + floor(dot(_e79, vec4(255f, 1785f, 7905f, 32385f)))); + let _e83 = identity; + identity = (_e83 - (floor((_e83 / 4096f)) * 4096f)); + let _e88 = identity; + let _e90 = ((_e88 * 1597f) + 2531f); + mixed = (_e90 - (floor((_e90 / 4096f)) * 4096f)); + let _e95 = mixed; + let _e96 = mixed; + let _e98 = ((2f * _e96) + 1f); + let _e103 = (_e95 * (_e98 - (floor((_e98 / 4096f)) * 4096f))); + mixed = (_e103 - (floor((_e103 / 4096f)) * 4096f)); + let _e108 = mixed; + row = floor((_e108 / 64f)); + let _e111 = mixed; + let _e112 = row; + let _e114 = (_e111 + (_e112 * 37f)); + let _e119 = row; + offset = vec2((_e114 - (floor((_e114 / 64f)) * 64f)), _e119); + let _e123 = splat_hash_info.frame[0u]; + slice = _e123; + let _e126 = splat_hash_info.frame[1u]; + param = _e126; + let _e127 = FragCoordFromTop_u0028_f1_u003b((¶m)); + let _e129 = offset; + let _e130 = (floor(_e127) + _e129); + let _e131 = vec2(64f); + cell = (_e130 - (floor((_e130 / _e131)) * _e131)); + let _e136 = slice; + let _e141 = slice; + corner = (vec2((_e136 - (floor((_e136 / 8f)) * 8f)), floor((_e141 / 8f))) * 64f); + let _e146 = corner; + let _e147 = cell; + let _e152 = textureSampleLevel(blue_noise_texture_tex, blue_noise_texture_smp, (((_e146 + _e147) + vec2(0.5f)) / vec2(512f, 256f)), 0f); + threshold = (_e152.x * 0.99609375f); + let _e155 = alpha; + let _e156 = threshold; + if (_e155 <= _e156) { discard; } - let _e156 = v_color_1; - colour = _e156.xyz; - let _e160 = fog_info.fog[3u]; - if (_e160 > 0f) { - let _e164 = fog_info.fog[3u]; - let _e166 = v_world_position_1; - let _e168 = fog_info.eye; - visibility = clamp(exp((-(_e164) * distance(_e166, _e168.xyz))), 0f, 1f); - let _e175 = fog_info.fog; - let _e177 = colour; - let _e178 = visibility; - colour = mix(_e175.xyz, _e177, vec3(_e178)); - } - let _e181 = colour; - frag_color = vec4(_e181.x, _e181.y, _e181.z, 1f); + let _e158 = v_color_1; + colour = _e158.xyz; + let _e162 = fog_info.fog[3u]; + if (_e162 > 0f) { + let _e166 = fog_info.fog[3u]; + let _e168 = v_world_position_1; + param_1 = _e168; + let _e169 = FogDistance_u0028_vf3_u003b((¶m_1)); + visibility = clamp(exp((-(_e166) * _e169)), 0f, 1f); + let _e174 = fog_info.fog; + let _e176 = colour; + let _e177 = visibility; + colour = mix(_e174.xyz, _e176, vec3(_e177)); + } + let _e180 = colour; + frag_color = vec4(_e180.x, _e180.y, _e180.z, 1f); return; } @fragment -fn main(@builtin(position) gl_FragCoord: vec4, @location(5) v_uv: vec2, @location(0) v_color: vec4, @location(6) v_world_position: vec3) -> @location(0) vec4 { +fn main(@builtin(position) gl_FragCoord: vec4, @location(6) v_uv: vec2, @location(0) v_color: vec4, @location(7) v_world_position: vec3) -> @location(0) vec4 { gl_FragCoord_1 = gl_FragCoord; v_uv_1 = v_uv; v_color_1 = v_color; @@ -30054,10 +33930,20 @@ fn main(@builtin(position) gl_FragCoord: vec4, @location(5) v_uv: vec2 name: 'FogInfo', group: 1, binding: 0, - sizeInBytes: 32, + sizeInBytes: 64, members: [ WebGpuBlockMember(name: 'fog', offsetInBytes: 0, sizeInBytes: 16), WebGpuBlockMember(name: 'eye', offsetInBytes: 16, sizeInBytes: 16), + WebGpuBlockMember( + name: 'forward', + offsetInBytes: 32, + sizeInBytes: 16, + ), + WebGpuBlockMember( + name: 'projection', + offsetInBytes: 48, + sizeInBytes: 16, + ), ], ), WebGpuBlock( @@ -30091,6 +33977,8 @@ fn main(@builtin(position) gl_FragCoord: vec4, @location(5) v_uv: vec2 struct FogInfo { fog: vec4, eye: vec4, + forward: vec4, + projection: vec4, } @group(1) @binding(0) @@ -30100,32 +33988,77 @@ var v_normal_1: vec3; var frag_color: vec4; var v_color_1: vec4; -fn main_1() { +fn FogDistance_u0028_vf3_u003b(world: ptr>) -> f32 { + var local: f32; + + let _e20 = fog_info.projection[0u]; + if (_e20 > 0.5f) { + let _e22 = (*world); + let _e24 = fog_info.eye; + let _e28 = fog_info.forward; + local = max(dot((_e22 - _e24.xyz), _e28.xyz), 0f); + } else { + let _e32 = (*world); + let _e34 = fog_info.eye; + local = distance(_e32, _e34.xyz); + } + let _e37 = local; + return _e37; +} + +fn TowardsEyeFrom_u0028_vf3_u003b(world_1: ptr>) -> vec3 { var to_eye: vec3; - var distance_to_eye: f32; + var length_to_eye: f32; + var local_1: vec3; + + let _e22 = fog_info.projection[0u]; + if (_e22 > 0.5f) { + let _e25 = fog_info.forward; + return -(_e25.xyz); + } + let _e29 = fog_info.eye; + let _e31 = (*world_1); + to_eye = (_e29.xyz - _e31); + let _e33 = to_eye; + length_to_eye = length(_e33); + let _e35 = length_to_eye; + if (_e35 > 0f) { + let _e37 = to_eye; + let _e38 = length_to_eye; + local_1 = (_e37 / vec3(_e38)); + } else { + local_1 = vec3(0f, 0f, 0f); + } + let _e41 = local_1; + return _e41; +} + +fn main_1() { + var towards_eye: vec3; + var param: vec3; var n: vec3; var facing: f32; - var local: f32; + var local_2: f32; var intensity: f32; var fogged: f32; + var param_1: vec3; - let _e19 = fog_info.eye; - let _e21 = v_world_position_1; - to_eye = (_e19.xyz - _e21); - let _e23 = to_eye; - distance_to_eye = length(_e23); - let _e25 = v_normal_1; - n = normalize(_e25); - let _e27 = distance_to_eye; - if (_e27 > 0f) { - let _e29 = n; - let _e30 = to_eye; - let _e31 = distance_to_eye; - local = abs(dot(_e29, (_e30 / vec3(_e31)))); + let _e24 = v_world_position_1; + param = _e24; + let _e25 = TowardsEyeFrom_u0028_vf3_u003b((¶m)); + towards_eye = _e25; + let _e26 = v_normal_1; + n = normalize(_e26); + let _e28 = towards_eye; + let _e29 = towards_eye; + if (dot(_e28, _e29) > 0f) { + let _e32 = n; + let _e33 = towards_eye; + local_2 = abs(dot(_e32, _e33)); } else { - local = 1f; + local_2 = 1f; } - let _e36 = local; + let _e36 = local_2; facing = _e36; let _e37 = facing; intensity = mix(0.35f, 1f, _e37); @@ -30133,20 +34066,22 @@ fn main_1() { let _e41 = fog_info.fog[3u]; if (_e41 > 0f) { let _e45 = fog_info.fog[3u]; - let _e47 = distance_to_eye; - fogged = clamp(exp((-(_e45) * _e47)), 0f, 1f); - } - let _e51 = v_color_1; - let _e54 = v_color_1[3u]; - let _e56 = intensity; - let _e58 = fogged; - let _e59 = (((_e51.xyz * _e54) * _e56) * _e58); - frag_color = vec4(_e59.x, _e59.y, _e59.z, 1f); + let _e47 = v_world_position_1; + param_1 = _e47; + let _e48 = FogDistance_u0028_vf3_u003b((¶m_1)); + fogged = clamp(exp((-(_e45) * _e48)), 0f, 1f); + } + let _e52 = v_color_1; + let _e55 = v_color_1[3u]; + let _e57 = intensity; + let _e59 = fogged; + let _e60 = (((_e52.xyz * _e55) * _e57) * _e59); + frag_color = vec4(_e60.x, _e60.y, _e60.z, 1f); return; } @fragment -fn main(@location(6) v_world_position: vec3, @location(2) v_normal: vec3, @location(0) v_color: vec4) -> @location(0) vec4 { +fn main(@location(7) v_world_position: vec3, @location(3) v_normal: vec3, @location(0) v_color: vec4) -> @location(0) vec4 { v_world_position_1 = v_world_position; v_normal_1 = v_normal; v_color_1 = v_color; @@ -30161,10 +34096,20 @@ fn main(@location(6) v_world_position: vec3, @location(2) v_normal: vec3[ WebGpuBlockMember(name: 'fog', offsetInBytes: 0, sizeInBytes: 16), WebGpuBlockMember(name: 'eye', offsetInBytes: 16, sizeInBytes: 16), + WebGpuBlockMember( + name: 'forward', + offsetInBytes: 32, + sizeInBytes: 16, + ), + WebGpuBlockMember( + name: 'projection', + offsetInBytes: 48, + sizeInBytes: 16, + ), ], ), ], @@ -30885,7 +34830,7 @@ fn main_1() { } @fragment -fn main(@builtin(position) gl_FragCoord: vec4, @location(5) v_uv: vec2) -> @location(0) vec4 { +fn main(@builtin(position) gl_FragCoord: vec4, @location(6) v_uv: vec2) -> @location(0) vec4 { gl_FragCoord_1 = gl_FragCoord; v_uv_1 = v_uv; main_1(); @@ -32349,7 +36294,7 @@ fn main_1() { } @fragment -fn main(@location(5) v_uv: vec2) -> @location(0) vec4 { +fn main(@location(6) v_uv: vec2) -> @location(0) vec4 { v_uv_1 = v_uv; main_1(); let _e3 = frag_color; @@ -32566,7 +36511,7 @@ fn main_1() { } @fragment -fn main(@location(5) v_uv: vec2) -> @location(0) vec4 { +fn main(@location(6) v_uv: vec2) -> @location(0) vec4 { v_uv_1 = v_uv; main_1(); let _e3 = frag_color; @@ -33071,7 +37016,7 @@ fn main_1() { } @fragment -fn main(@builtin(position) gl_FragCoord: vec4, @location(5) v_uv: vec2) -> @location(0) vec4 { +fn main(@builtin(position) gl_FragCoord: vec4, @location(6) v_uv: vec2) -> @location(0) vec4 { gl_FragCoord_1 = gl_FragCoord; v_uv_1 = v_uv; main_1(); @@ -33264,7 +37209,7 @@ fn main_1() { } @fragment -fn main(@location(5) v_uv: vec2) -> @location(0) vec4 { +fn main(@location(6) v_uv: vec2) -> @location(0) vec4 { v_uv_1 = v_uv; main_1(); let _e3 = frag_color; @@ -33396,7 +37341,7 @@ fn main_1() { } @fragment -fn main(@location(5) v_uv: vec2) -> @location(0) vec4 { +fn main(@location(6) v_uv: vec2) -> @location(0) vec4 { v_uv_1 = v_uv; main_1(); let _e3 = frag_color; @@ -33833,7 +37778,7 @@ fn main_1() { } @fragment -fn main(@builtin(position) gl_FragCoord: vec4, @location(5) v_uv: vec2, @location(0) v_color: vec4, @location(6) v_world_position: vec3) -> @location(0) vec4 { +fn main(@builtin(position) gl_FragCoord: vec4, @location(6) v_uv: vec2, @location(0) v_color: vec4, @location(7) v_world_position: vec3) -> @location(0) vec4 { gl_FragCoord_1 = gl_FragCoord; v_uv_1 = v_uv; v_color_1 = v_color; @@ -34650,7 +38595,7 @@ fn main_1() { } @fragment -fn main(@location(5) v_uv: vec2) -> @location(0) vec4 { +fn main(@location(6) v_uv: vec2) -> @location(0) vec4 { v_uv_1 = v_uv; main_1(); let _e3 = frag_color; @@ -34853,7 +38798,7 @@ fn main_1() { } @fragment -fn main(@location(5) v_uv: vec2) -> @location(0) vec4 { +fn main(@location(6) v_uv: vec2) -> @location(0) vec4 { v_uv_1 = v_uv; main_1(); let _e3 = frag_color; @@ -35301,10 +39246,11 @@ fn main_1() { var xy: vec2; var nearH: vec4; var farH: vec4; - var origin: vec3; + var nearPoint: vec3; var along: vec3; - var surfaceDepth: f32; var cosine_1: f32; + var origin: vec3; + var surfaceDepth: f32; var toSurface: f32; var local_6: f32; var distance_: f32; @@ -35327,139 +39273,146 @@ fn main_1() { var param_7: f32; var shaft: vec3; - let _e90 = v_uv_1; - let _e91 = textureSample(scene_texture_tex, scene_texture_smp, _e90); - scene = _e91; - let _e94 = shaft_info.forward[3u]; - steps = i32((_e94 + 0.5f)); - let _e97 = steps; - if (_e97 < 1i) { - let _e99 = scene; - frag_color = _e99; + let _e91 = v_uv_1; + let _e92 = textureSample(scene_texture_tex, scene_texture_smp, _e91); + scene = _e92; + let _e95 = shaft_info.forward[3u]; + steps = i32((_e95 + 0.5f)); + let _e98 = steps; + if (_e98 < 1i) { + let _e100 = scene; + frag_color = _e100; return; } - let _e101 = v_uv_1[0u]; - let _e105 = v_uv_1[1u]; - xy = vec2(((_e101 * 2f) - 1f), (1f - (_e105 * 2f))); - let _e110 = shaft_info.inverse_view_projection; - let _e111 = xy; - nearH = (_e110 * vec4(_e111.x, _e111.y, 0f, 1f)); - let _e117 = shaft_info.inverse_view_projection; - let _e118 = xy; - farH = (_e117 * vec4(_e118.x, _e118.y, 1f, 1f)); - let _e123 = nearH; - let _e126 = nearH[3u]; - origin = (_e123.xyz / vec3(_e126)); - let _e129 = farH; - let _e132 = farH[3u]; - let _e135 = origin; - along = normalize(((_e129.xyz / vec3(_e132)) - _e135)); - let _e138 = v_uv_1; - let _e139 = textureSample(surface_texture_tex, surface_texture_smp, _e138); - surfaceDepth = _e139.w; - let _e141 = along; - let _e143 = shaft_info.forward; - cosine_1 = max(dot(_e141, _e143.xyz), 0.0001f); - let _e147 = surfaceDepth; - if (_e147 > 0f) { - let _e149 = surfaceDepth; - let _e150 = cosine_1; - local_6 = (_e149 / _e150); + let _e102 = v_uv_1[0u]; + let _e106 = v_uv_1[1u]; + xy = vec2(((_e102 * 2f) - 1f), (1f - (_e106 * 2f))); + let _e111 = shaft_info.inverse_view_projection; + let _e112 = xy; + nearH = (_e111 * vec4(_e112.x, _e112.y, 0f, 1f)); + let _e118 = shaft_info.inverse_view_projection; + let _e119 = xy; + farH = (_e118 * vec4(_e119.x, _e119.y, 1f, 1f)); + let _e124 = nearH; + let _e127 = nearH[3u]; + nearPoint = (_e124.xyz / vec3(_e127)); + let _e130 = farH; + let _e133 = farH[3u]; + let _e136 = nearPoint; + along = normalize(((_e130.xyz / vec3(_e133)) - _e136)); + let _e139 = along; + let _e141 = shaft_info.forward; + cosine_1 = max(dot(_e139, _e141.xyz), 0.0001f); + let _e145 = nearPoint; + let _e146 = along; + let _e147 = nearPoint; + let _e149 = shaft_info.camera; + let _e153 = shaft_info.forward; + let _e156 = cosine_1; + origin = (_e145 - (_e146 * (dot((_e147 - _e149.xyz), _e153.xyz) / _e156))); + let _e160 = v_uv_1; + let _e161 = textureSample(surface_texture_tex, surface_texture_smp, _e160); + surfaceDepth = _e161.w; + let _e163 = surfaceDepth; + if (_e163 > 0f) { + let _e165 = surfaceDepth; + let _e166 = cosine_1; + local_6 = (_e165 / _e166); } else { local_6 = 1000000000f; } - let _e152 = local_6; - toSurface = _e152; - let _e155 = shaft_info.camera[3u]; - let _e156 = toSurface; - distance_ = min(_e155, _e156); - let _e158 = distance_; - if (_e158 <= 0f) { - let _e160 = scene; - frag_color = _e160; + let _e168 = local_6; + toSurface = _e168; + let _e171 = shaft_info.camera[3u]; + let _e172 = toSurface; + distance_ = min(_e171, _e172); + let _e174 = distance_; + if (_e174 <= 0f) { + let _e176 = scene; + frag_color = _e176; return; } - let _e161 = distance_; - let _e162 = steps; - stride = (_e161 / f32(_e162)); - let _e165 = TargetFragCoord_u0028_(); - param_3 = _e165; - let _e166 = PixelNoise_u0028_vf2_u003b((¶m_3)); - let _e167 = stride; - offset = (_e166 * _e167); - let _e171 = shaft_info.scatter[3u]; - sigma = max(_e171, 0f); - let _e173 = sigma; - let _e175 = stride; - stepTransmittance = exp((-(_e173) * _e175)); - let _e178 = sigma; - let _e180 = offset; - transmittance = exp((-(_e178) * _e180)); - let _e184 = shaft_info.camera; - eye = _e184.xyz; + let _e177 = distance_; + let _e178 = steps; + stride = (_e177 / f32(_e178)); + let _e181 = TargetFragCoord_u0028_(); + param_3 = _e181; + let _e182 = PixelNoise_u0028_vf2_u003b((¶m_3)); + let _e183 = stride; + offset = (_e182 * _e183); + let _e187 = shaft_info.scatter[3u]; + sigma = max(_e187, 0f); + let _e189 = sigma; + let _e191 = stride; + stepTransmittance = exp((-(_e189) * _e191)); + let _e194 = sigma; + let _e196 = offset; + transmittance = exp((-(_e194) * _e196)); + let _e200 = shaft_info.camera; + eye = _e200.xyz; inscatter = 0f; i = 0i; loop { - let _e186 = i; - if (_e186 < 64i) { - let _e188 = i; - let _e189 = steps; - if (_e188 >= _e189) { + let _e202 = i; + if (_e202 < 64i) { + let _e204 = i; + let _e205 = steps; + if (_e204 >= _e205) { break; } - let _e191 = offset; - let _e192 = i; - let _e194 = stride; - travelled = (_e191 + (f32(_e192) * _e194)); - let _e197 = origin; - let _e198 = along; - let _e199 = travelled; - at_3 = (_e197 + (_e198 * _e199)); - let _e202 = at_3; - let _e203 = eye; - let _e206 = at_3; - param_4 = _e206; - param_5 = length((_e202 - _e203)); - let _e207 = LitAt_u0028_vf3_u003b_f1_u003b((¶m_4), (¶m_5)); - lit = _e207; - let _e208 = transmittance; - let _e209 = stepTransmittance; - let _e212 = lit; - let _e214 = inscatter; - inscatter = (_e214 + ((_e208 * (1f - _e209)) * _e212)); - let _e216 = stepTransmittance; - let _e217 = transmittance; - transmittance = (_e217 * _e216); + let _e207 = offset; + let _e208 = i; + let _e210 = stride; + travelled = (_e207 + (f32(_e208) * _e210)); + let _e213 = origin; + let _e214 = along; + let _e215 = travelled; + at_3 = (_e213 + (_e214 * _e215)); + let _e218 = at_3; + let _e219 = eye; + let _e222 = at_3; + param_4 = _e222; + param_5 = length((_e218 - _e219)); + let _e223 = LitAt_u0028_vf3_u003b_f1_u003b((¶m_4), (¶m_5)); + lit = _e223; + let _e224 = transmittance; + let _e225 = stepTransmittance; + let _e228 = lit; + let _e230 = inscatter; + inscatter = (_e230 + ((_e224 * (1f - _e225)) * _e228)); + let _e232 = stepTransmittance; + let _e233 = transmittance; + transmittance = (_e233 * _e232); continue; } else { break; } continuing { - let _e219 = i; - i = (_e219 + 1i); - } - } - let _e221 = along; - let _e223 = shaft_info.sun; - param_6 = dot(_e221, _e223.xyz); - let _e228 = shaft_info.sun[3u]; - param_7 = _e228; - let _e229 = HenyeyGreenstein_u0028_f1_u003b_f1_u003b((¶m_6), (¶m_7)); - phase = _e229; - let _e231 = shaft_info.scatter; - let _e233 = phase; - let _e234 = inscatter; - shaft = (_e231.xyz * (_e233 * _e234)); - let _e237 = scene; - let _e239 = shaft; - let _e240 = (_e237.xyz + _e239); - let _e242 = scene[3u]; - frag_color = vec4(_e240.x, _e240.y, _e240.z, _e242); + let _e235 = i; + i = (_e235 + 1i); + } + } + let _e237 = along; + let _e239 = shaft_info.sun; + param_6 = dot(_e237, _e239.xyz); + let _e244 = shaft_info.sun[3u]; + param_7 = _e244; + let _e245 = HenyeyGreenstein_u0028_f1_u003b_f1_u003b((¶m_6), (¶m_7)); + phase = _e245; + let _e247 = shaft_info.scatter; + let _e249 = phase; + let _e250 = inscatter; + shaft = (_e247.xyz * (_e249 * _e250)); + let _e253 = scene; + let _e255 = shaft; + let _e256 = (_e253.xyz + _e255); + let _e258 = scene[3u]; + frag_color = vec4(_e256.x, _e256.y, _e256.z, _e258); return; } @fragment -fn main(@builtin(position) gl_FragCoord: vec4, @location(5) v_uv: vec2) -> @location(0) vec4 { +fn main(@builtin(position) gl_FragCoord: vec4, @location(6) v_uv: vec2) -> @location(0) vec4 { gl_FragCoord_1 = gl_FragCoord; v_uv_1 = v_uv; main_1(); @@ -36607,7 +40560,7 @@ fn main_1() { } @fragment -fn main(@builtin(position) gl_FragCoord: vec4, @location(5) v_uv: vec2) -> @location(0) vec4 { +fn main(@builtin(position) gl_FragCoord: vec4, @location(6) v_uv: vec2) -> @location(0) vec4 { gl_FragCoord_1 = gl_FragCoord; v_uv_1 = v_uv; main_1(); @@ -37067,7 +41020,7 @@ fn main_1() { } @fragment -fn main(@location(5) v_uv: vec2) -> @location(0) vec4 { +fn main(@location(6) v_uv: vec2) -> @location(0) vec4 { v_uv_1 = v_uv; main_1(); let _e3 = frag_color; @@ -37519,7 +41472,7 @@ fn main_1() { } @fragment -fn main(@builtin(position) gl_FragCoord: vec4, @location(5) v_uv: vec2) -> @location(0) vec4 { +fn main(@builtin(position) gl_FragCoord: vec4, @location(6) v_uv: vec2) -> @location(0) vec4 { gl_FragCoord_1 = gl_FragCoord; v_uv_1 = v_uv; main_1(); @@ -37706,7 +41659,7 @@ fn main_1() { } @fragment -fn main(@location(5) v_uv: vec2) -> @location(0) vec4 { +fn main(@location(6) v_uv: vec2) -> @location(0) vec4 { v_uv_1 = v_uv; main_1(); let _e3 = frag_color; @@ -37866,7 +41819,7 @@ fn main_1() { } @fragment -fn main(@location(5) v_uv: vec2) -> @location(0) vec4 { +fn main(@location(6) v_uv: vec2) -> @location(0) vec4 { v_uv_1 = v_uv; main_1(); let _e3 = frag_color; @@ -37986,7 +41939,7 @@ fn main_1() { } @fragment -fn main(@location(5) v_uv: vec2) -> @location(0) vec4 { +fn main(@location(6) v_uv: vec2) -> @location(0) vec4 { v_uv_1 = v_uv; main_1(); let _e3 = frag_color; @@ -38505,7 +42458,7 @@ fn main_1() { } @fragment -fn main(@builtin(position) gl_FragCoord: vec4, @location(5) v_uv: vec2) -> @location(0) vec4 { +fn main(@builtin(position) gl_FragCoord: vec4, @location(6) v_uv: vec2) -> @location(0) vec4 { gl_FragCoord_1 = gl_FragCoord; v_uv_1 = v_uv; main_1(); @@ -38852,7 +42805,7 @@ fn main_1() { } @fragment -fn main(@location(5) v_uv: vec2) -> @location(0) vec4 { +fn main(@location(6) v_uv: vec2) -> @location(0) vec4 { v_uv_1 = v_uv; main_1(); let _e3 = frag_color; @@ -38868,38 +42821,690 @@ fn main(@location(5) v_uv: vec2) -> @location(0) vec4 { sizeInBytes: 48, members: [ WebGpuBlockMember( - name: 'params', - offsetInBytes: 0, + name: 'params', + offsetInBytes: 0, + sizeInBytes: 16, + ), + WebGpuBlockMember( + name: 'screen', + offsetInBytes: 16, + sizeInBytes: 16, + ), + WebGpuBlockMember( + name: 'light', + offsetInBytes: 32, + sizeInBytes: 16, + ), + ], + ), + ], + samplers: [ + WebGpuSampler( + name: 'scene_texture', + group: 1, + textureBinding: 1, + samplerBinding: 2, + dimension: WebGpuTextureDimension.twoDimensional, + ), + WebGpuSampler( + name: 'surface_texture', + group: 1, + textureBinding: 3, + samplerBinding: 4, + dimension: WebGpuTextureDimension.twoDimensional, + ), + ], + ), + 'Decal': WebGpuStage( + wgsl: r''' +struct DecalInfo { + inverse_view_projection: mat4x4, + camera: vec4, + forward: vec4, + params: vec4, + view: vec4, + slots: array, 4>, + axis_x: array, 16>, + axis_y: array, 16>, + axis_z: array, 16>, + region: array, 16>, + color: array, 16>, + fade: array, 16>, + emissive: array, 16>, +} + +@group(1) @binding(0) +var decal_info: DecalInfo; +@group(1) @binding(11) +var surface_texture_tex: texture_2d; +@group(1) @binding(12) +var surface_texture_smp: sampler; +var v_uv_1: vec2; +@group(1) @binding(9) +var decal_texture_3_tex: texture_2d; +@group(1) @binding(10) +var decal_texture_3_smp: sampler; +@group(1) @binding(7) +var decal_texture_2_tex: texture_2d; +@group(1) @binding(8) +var decal_texture_2_smp: sampler; +@group(1) @binding(5) +var decal_texture_1_tex: texture_2d; +@group(1) @binding(6) +var decal_texture_1_smp: sampler; +@group(1) @binding(3) +var decal_texture_0_tex: texture_2d; +@group(1) @binding(4) +var decal_texture_0_smp: sampler; +var frag_color: vec4; +@group(1) @binding(1) +var albedo_texture_tex: texture_2d; +@group(1) @binding(2) +var albedo_texture_smp: sampler; + +fn SampleSlot_u0028_f1_u003b_vf2_u003b_f1_u003b(slot: ptr, uv: ptr>, lod: ptr) -> vec4 { + var picture: vec4; + + picture = vec4(1f, 1f, 1f, 1f); + let _e64 = (*slot); + if (_e64 > 2.5f) { + let _e66 = (*uv); + let _e67 = (*lod); + let _e68 = textureSampleLevel(decal_texture_3_tex, decal_texture_3_smp, _e66, _e67); + picture = _e68; + } else { + let _e69 = (*slot); + if (_e69 > 1.5f) { + let _e71 = (*uv); + let _e72 = (*lod); + let _e73 = textureSampleLevel(decal_texture_2_tex, decal_texture_2_smp, _e71, _e72); + picture = _e73; + } else { + let _e74 = (*slot); + if (_e74 > 0.5f) { + let _e76 = (*uv); + let _e77 = (*lod); + let _e78 = textureSampleLevel(decal_texture_1_tex, decal_texture_1_smp, _e76, _e77); + picture = _e78; + } else { + let _e79 = (*slot); + if (_e79 > -0.5f) { + let _e81 = (*uv); + let _e82 = (*lod); + let _e83 = textureSampleLevel(decal_texture_0_tex, decal_texture_0_smp, _e81, _e82); + picture = _e83; + } + } + } + } + let _e84 = picture; + return _e84; +} + +fn SrgbToLinear_u0028_vf3_u003b(srgb: ptr>) -> vec3 { + let _e61 = (*srgb); + let _e64 = (*srgb); + let _e69 = (*srgb); + return mix((_e61 / vec3(12.92f)), pow(((_e64 + vec3(0.055f, 0.055f, 0.055f)) / vec3(1.055f)), vec3(2.4f, 2.4f, 2.4f)), step(vec3(0.04045f, 0.04045f, 0.04045f), _e69)); +} + +fn WorldAt_u0028_vf2_u003b_f1_u003b(uv_1: ptr>, depth: ptr) -> vec3 { + var inView: vec2; + var xy: vec2; + var nearH: vec4; + var farH: vec4; + var origin: vec3; + var along: vec3; + var axis: vec3; + + let _e69 = (*uv_1); + let _e71 = decal_info.view; + let _e75 = decal_info.view; + inView = ((_e69 - _e71.xy) / _e75.zw); + let _e79 = inView[0u]; + let _e83 = inView[1u]; + xy = vec2(((_e79 * 2f) - 1f), (1f - (_e83 * 2f))); + let _e88 = decal_info.inverse_view_projection; + let _e89 = xy; + nearH = (_e88 * vec4(_e89.x, _e89.y, 0f, 1f)); + let _e95 = decal_info.inverse_view_projection; + let _e96 = xy; + farH = (_e95 * vec4(_e96.x, _e96.y, 1f, 1f)); + let _e101 = nearH; + let _e104 = nearH[3u]; + origin = (_e101.xyz / vec3(_e104)); + let _e107 = farH; + let _e110 = farH[3u]; + let _e113 = origin; + along = normalize(((_e107.xyz / vec3(_e110)) - _e113)); + let _e117 = decal_info.forward; + axis = _e117.xyz; + let _e119 = origin; + let _e120 = along; + let _e121 = (*depth); + let _e122 = origin; + let _e124 = decal_info.camera; + let _e127 = axis; + let _e130 = along; + let _e131 = axis; + return (_e119 + (_e120 * ((_e121 - dot((_e122 - _e124.xyz), _e127)) / dot(_e130, _e131)))); +} + +fn WorldStep_u0028_vf2_u003b_f1_u003b_vf3_u003b(step_: ptr>, depth_1: ptr, at: ptr>) -> vec3 { + var ahead: vec4; + var behind: vec4; + var aheadGap: f32; + var local: f32; + var behindGap: f32; + var local_1: f32; + var forward: vec3; + var param: vec2; + var param_1: f32; + var backward: vec3; + var param_2: vec2; + var param_3: f32; + var chosen: vec3; + + let _e76 = v_uv_1; + let _e77 = (*step_); + let _e79 = textureSampleLevel(surface_texture_tex, surface_texture_smp, (_e76 + _e77), 0f); + ahead = _e79; + let _e80 = v_uv_1; + let _e81 = (*step_); + let _e83 = textureSampleLevel(surface_texture_tex, surface_texture_smp, (_e80 - _e81), 0f); + behind = _e83; + let _e85 = ahead[3u]; + if (_e85 > 0f) { + let _e88 = ahead[3u]; + let _e89 = (*depth_1); + local = abs((_e88 - _e89)); + } else { + local = 1000000000000000000000000000000f; + } + let _e92 = local; + aheadGap = _e92; + let _e94 = behind[3u]; + if (_e94 > 0f) { + let _e97 = behind[3u]; + let _e98 = (*depth_1); + local_1 = abs((_e97 - _e98)); + } else { + local_1 = 1000000000000000000000000000000f; + } + let _e101 = local_1; + behindGap = _e101; + let _e102 = v_uv_1; + let _e103 = (*step_); + param = (_e102 + _e103); + let _e106 = ahead[3u]; + param_1 = _e106; + let _e107 = WorldAt_u0028_vf2_u003b_f1_u003b((¶m), (¶m_1)); + let _e108 = (*at); + forward = (_e107 - _e108); + let _e110 = (*at); + let _e111 = v_uv_1; + let _e112 = (*step_); + param_2 = (_e111 - _e112); + let _e115 = behind[3u]; + param_3 = _e115; + let _e116 = WorldAt_u0028_vf2_u003b_f1_u003b((¶m_2), (¶m_3)); + backward = (_e110 - _e116); + let _e118 = aheadGap; + let _e119 = behindGap; + let _e121 = forward; + let _e122 = backward; + chosen = select(_e122, _e121, vec3((_e118 <= _e119))); + let _e125 = aheadGap; + let _e126 = behindGap; + let _e129 = chosen; + return select(vec3(0f, 0f, 0f), _e129, vec3((min(_e125, _e126) < 100000000000000000000000000000f))); +} + +fn DecodeOctahedral_u0028_vf2_u003b(e: ptr>) -> vec3 { + var n: vec3; + var t: f32; + var local_2: f32; + var local_3: f32; + + let _e65 = (*e); + (*e) = ((_e65 * 2f) - vec2(1f)); + let _e69 = (*e); + let _e71 = (*e)[0u]; + let _e75 = (*e)[1u]; + n = vec3(_e69.x, _e69.y, ((1f - abs(_e71)) - abs(_e75))); + let _e82 = n[2u]; + t = max(-(_e82), 0f); + let _e86 = n[0u]; + if (_e86 >= 0f) { + let _e88 = t; + local_2 = -(_e88); + } else { + let _e90 = t; + local_2 = _e90; + } + let _e91 = local_2; + let _e93 = n[0u]; + n[0u] = (_e93 + _e91); + let _e97 = n[1u]; + if (_e97 >= 0f) { + let _e99 = t; + local_3 = -(_e99); + } else { + let _e101 = t; + local_3 = _e101; + } + let _e102 = local_3; + let _e104 = n[1u]; + n[1u] = (_e104 + _e102); + let _e107 = n; + return normalize(_e107); +} + +fn main_1() { + var factor: bool; + var surface: vec4; + var keepFactor: vec3; + var keepTerm: vec3; + var depth_2: f32; + var at_1: vec3; + var param_4: vec2; + var param_5: f32; + var normal: vec3; + var param_6: vec2; + var dx: vec3; + var param_7: vec2; + var param_8: f32; + var param_9: vec3; + var dy: vec3; + var param_10: vec2; + var param_11: f32; + var param_12: vec3; + var albedo: vec3; + var param_13: vec3; + var painted: vec3; + var kept: f32; + var laid: vec3; + var emitted: vec3; + var count: i32; + var i: i32; + var rowX: vec4; + var rowY: vec4; + var rowZ: vec4; + var local_4: vec3; + var fade: vec4; + var facing: f32; + var byAngle: f32; + var byDepth: f32; + var local_5: f32; + var region: vec4; + var uv_2: vec2; + var slot_1: f32; + var size: vec2; + var alongX: vec2; + var alongY: vec2; + var footprint: f32; + var lod_1: f32; + var local_6: f32; + var picture_1: vec4; + var param_14: f32; + var param_15: vec2; + var param_16: f32; + var tint: vec4; + var colour: vec3; + var param_17: vec3; + var alpha: f32; + var named: f32; + var swap: vec3; + var multiply: vec3; + var add: vec3; + + let _e118 = decal_info.params[3u]; + factor = (_e118 < 0.5f); + let _e120 = v_uv_1; + let _e121 = textureSampleLevel(surface_texture_tex, surface_texture_smp, _e120, 0f); + surface = _e121; + keepFactor = vec3(1f, 1f, 1f); + keepTerm = vec3(0f, 0f, 0f); + let _e123 = surface[3u]; + if (_e123 <= 0f) { + let _e125 = factor; + let _e126 = keepFactor; + let _e127 = keepTerm; + let _e129 = select(_e127, _e126, vec3(_e125)); + frag_color = vec4(_e129.x, _e129.y, _e129.z, 1f); + return; + } + let _e135 = surface[3u]; + depth_2 = _e135; + let _e136 = v_uv_1; + param_4 = _e136; + let _e137 = depth_2; + param_5 = _e137; + let _e138 = WorldAt_u0028_vf2_u003b_f1_u003b((¶m_4), (¶m_5)); + at_1 = _e138; + let _e139 = surface; + param_6 = _e139.xy; + let _e141 = DecodeOctahedral_u0028_vf2_u003b((¶m_6)); + normal = _e141; + let _e144 = decal_info.params[1u]; + param_7 = vec2(_e144, 0f); + let _e146 = depth_2; + param_8 = _e146; + let _e147 = at_1; + param_9 = _e147; + let _e148 = WorldStep_u0028_vf2_u003b_f1_u003b_vf3_u003b((¶m_7), (¶m_8), (¶m_9)); + dx = _e148; + let _e151 = decal_info.params[2u]; + param_10 = vec2(0f, _e151); + let _e153 = depth_2; + param_11 = _e153; + let _e154 = at_1; + param_12 = _e154; + let _e155 = WorldStep_u0028_vf2_u003b_f1_u003b_vf3_u003b((¶m_10), (¶m_11), (¶m_12)); + dy = _e155; + let _e156 = v_uv_1; + let _e157 = textureSampleLevel(albedo_texture_tex, albedo_texture_smp, _e156, 0f); + param_13 = _e157.xyz; + let _e159 = SrgbToLinear_u0028_vf3_u003b((¶m_13)); + albedo = _e159; + let _e160 = albedo; + painted = _e160; + kept = 1f; + laid = vec3(0f, 0f, 0f); + emitted = vec3(0f, 0f, 0f); + let _e163 = decal_info.params[0u]; + count = i32((_e163 + 0.5f)); + i = 0i; + loop { + let _e166 = i; + if (_e166 < 16i) { + let _e168 = i; + let _e169 = count; + if (_e168 >= _e169) { + break; + } + let _e171 = i; + let _e174 = decal_info.axis_x[_e171]; + rowX = _e174; + let _e175 = i; + let _e178 = decal_info.axis_y[_e175]; + rowY = _e178; + let _e179 = i; + let _e182 = decal_info.axis_z[_e179]; + rowZ = _e182; + let _e183 = rowX; + let _e185 = at_1; + let _e188 = rowX[3u]; + let _e190 = rowY; + let _e192 = at_1; + let _e195 = rowY[3u]; + let _e197 = rowZ; + let _e199 = at_1; + let _e202 = rowZ[3u]; + local_4 = vec3((dot(_e183.xyz, _e185) + _e188), (dot(_e190.xyz, _e192) + _e195), (dot(_e197.xyz, _e199) + _e202)); + let _e205 = local_4; + if any((abs(_e205) > vec3(0.5f, 0.5f, 0.5f))) { + continue; + } + let _e209 = i; + let _e212 = decal_info.fade[_e209]; + fade = _e212; + let _e213 = normal; + let _e214 = rowY; + facing = dot(_e213, normalize(_e214.xyz)); + let _e218 = facing; + let _e220 = fade[1u]; + let _e223 = fade[2u]; + byAngle = clamp(((_e218 - _e220) / max(_e223, 0.0001f)), 0f, 1f); + let _e228 = fade[3u]; + if (_e228 > 0f) { + let _e231 = local_4[1u]; + let _e235 = fade[3u]; + local_5 = clamp(((0.5f - abs(_e231)) / (_e235 * 0.5f)), 0f, 1f); + } else { + local_5 = 1f; + } + let _e239 = local_5; + byDepth = _e239; + let _e240 = i; + let _e243 = decal_info.region[_e240]; + region = _e243; + let _e244 = region; + let _e246 = local_4; + let _e250 = region; + uv_2 = (_e244.xy + ((_e246.xz + vec2(0.5f)) * _e250.zw)); + let _e255 = fade[0u]; + slot_1 = _e255; + let _e256 = slot_1; + let _e261 = decal_info.slots[i32(clamp(_e256, 0f, 3f))]; + size = _e261.xy; + let _e263 = rowX; + let _e265 = dx; + let _e267 = rowZ; + let _e269 = dx; + let _e272 = region; + alongX = (vec2(dot(_e263.xyz, _e265), dot(_e267.xyz, _e269)) * _e272.zw); + let _e275 = rowX; + let _e277 = dy; + let _e279 = rowZ; + let _e281 = dy; + let _e284 = region; + alongY = (vec2(dot(_e275.xyz, _e277), dot(_e279.xyz, _e281)) * _e284.zw); + let _e288 = alongX[0u]; + let _e291 = alongY[0u]; + let _e295 = size[0u]; + let _e298 = alongX[1u]; + let _e301 = alongY[1u]; + let _e305 = size[1u]; + footprint = max((max(abs(_e288), abs(_e291)) * _e295), (max(abs(_e298), abs(_e301)) * _e305)); + let _e308 = footprint; + if (_e308 > 1f) { + let _e310 = footprint; + local_6 = log2(_e310); + } else { + local_6 = 0f; + } + let _e312 = local_6; + lod_1 = _e312; + let _e313 = slot_1; + param_14 = _e313; + let _e314 = uv_2; + param_15 = _e314; + let _e315 = lod_1; + param_16 = _e315; + let _e316 = SampleSlot_u0028_f1_u003b_vf2_u003b_f1_u003b((¶m_14), (¶m_15), (¶m_16)); + picture_1 = _e316; + let _e317 = i; + let _e320 = decal_info.color[_e317]; + tint = _e320; + let _e321 = picture_1; + param_17 = _e321.xyz; + let _e323 = SrgbToLinear_u0028_vf3_u003b((¶m_17)); + let _e324 = tint; + colour = (_e323 * _e324.xyz); + let _e328 = picture_1[3u]; + let _e330 = tint[3u]; + let _e332 = byAngle; + let _e334 = byDepth; + alpha = clamp((((_e328 * _e330) * _e332) * _e334), 0f, 1f); + let _e337 = painted; + let _e338 = colour; + let _e339 = alpha; + painted = mix(_e337, _e338, vec3(_e339)); + let _e342 = alpha; + let _e344 = kept; + kept = (_e344 * (1f - _e342)); + let _e346 = laid; + let _e347 = colour; + let _e348 = alpha; + laid = mix(_e346, _e347, vec3(_e348)); + let _e351 = emitted; + let _e352 = colour; + let _e353 = i; + let _e356 = decal_info.emissive[_e353]; + let _e359 = alpha; + emitted = mix(_e351, (_e352 * _e356.xyz), vec3(_e359)); + continue; + } else { + break; + } + continuing { + let _e362 = i; + i = (_e362 + 1i); + } + } + let _e365 = albedo[0u]; + let _e367 = albedo[1u]; + let _e369 = albedo[2u]; + named = clamp((max(_e365, max(_e367, _e369)) / 0.08f), 0f, 1f); + let _e374 = painted; + let _e375 = albedo; + let _e377 = albedo; + swap = (vec3(1f, 1f, 1f) + ((_e374 - _e375) / max(_e377, vec3(0.08f, 0.08f, 0.08f)))); + let _e381 = swap; + let _e382 = named; + let _e384 = kept; + let _e385 = named; + multiply = ((_e381 * _e382) + vec3((_e384 * (1f - _e385)))); + let _e390 = laid; + let _e391 = named; + let _e394 = emitted; + add = ((_e390 * (1f - _e391)) + _e394); + let _e396 = factor; + let _e397 = multiply; + let _e398 = add; + let _e400 = select(_e398, _e397, vec3(_e396)); + frag_color = vec4(_e400.x, _e400.y, _e400.z, 1f); + return; +} + +@fragment +fn main(@location(6) v_uv: vec2) -> @location(0) vec4 { + v_uv_1 = v_uv; + main_1(); + let _e3 = frag_color; + return _e3; +} +''', + attributes: [], + blocks: [ + WebGpuBlock( + name: 'DecalInfo', + group: 1, + binding: 0, + sizeInBytes: 1984, + members: [ + WebGpuBlockMember( + name: 'inverse_view_projection', + offsetInBytes: 0, + sizeInBytes: 64, + ), + WebGpuBlockMember( + name: 'camera', + offsetInBytes: 64, + sizeInBytes: 16, + ), + WebGpuBlockMember( + name: 'forward', + offsetInBytes: 80, sizeInBytes: 16, ), WebGpuBlockMember( - name: 'screen', - offsetInBytes: 16, + name: 'params', + offsetInBytes: 96, sizeInBytes: 16, ), WebGpuBlockMember( - name: 'light', - offsetInBytes: 32, + name: 'view', + offsetInBytes: 112, sizeInBytes: 16, ), + WebGpuBlockMember( + name: 'slots', + offsetInBytes: 128, + sizeInBytes: 64, + ), + WebGpuBlockMember( + name: 'axis_x', + offsetInBytes: 192, + sizeInBytes: 256, + ), + WebGpuBlockMember( + name: 'axis_y', + offsetInBytes: 448, + sizeInBytes: 256, + ), + WebGpuBlockMember( + name: 'axis_z', + offsetInBytes: 704, + sizeInBytes: 256, + ), + WebGpuBlockMember( + name: 'region', + offsetInBytes: 960, + sizeInBytes: 256, + ), + WebGpuBlockMember( + name: 'color', + offsetInBytes: 1216, + sizeInBytes: 256, + ), + WebGpuBlockMember( + name: 'fade', + offsetInBytes: 1472, + sizeInBytes: 256, + ), + WebGpuBlockMember( + name: 'emissive', + offsetInBytes: 1728, + sizeInBytes: 256, + ), ], ), ], samplers: [ WebGpuSampler( - name: 'scene_texture', + name: 'albedo_texture', group: 1, textureBinding: 1, samplerBinding: 2, dimension: WebGpuTextureDimension.twoDimensional, ), WebGpuSampler( - name: 'surface_texture', + name: 'decal_texture_0', group: 1, textureBinding: 3, samplerBinding: 4, dimension: WebGpuTextureDimension.twoDimensional, ), + WebGpuSampler( + name: 'decal_texture_1', + group: 1, + textureBinding: 5, + samplerBinding: 6, + dimension: WebGpuTextureDimension.twoDimensional, + ), + WebGpuSampler( + name: 'decal_texture_2', + group: 1, + textureBinding: 7, + samplerBinding: 8, + dimension: WebGpuTextureDimension.twoDimensional, + ), + WebGpuSampler( + name: 'decal_texture_3', + group: 1, + textureBinding: 9, + samplerBinding: 10, + dimension: WebGpuTextureDimension.twoDimensional, + ), + WebGpuSampler( + name: 'surface_texture', + group: 1, + textureBinding: 11, + samplerBinding: 12, + dimension: WebGpuTextureDimension.twoDimensional, + ), ], ), 'ProbePrefilter': WebGpuStage( @@ -39100,7 +43705,7 @@ fn main_1() { } @fragment -fn main(@location(5) v_uv: vec2) -> @location(0) vec4 { +fn main(@location(6) v_uv: vec2) -> @location(0) vec4 { v_uv_1 = v_uv; main_1(); let _e3 = frag_color; @@ -39133,6 +43738,85 @@ fn main(@location(5) v_uv: vec2) -> @location(0) vec4 { ), ], ), + 'RenderTextureEncode': WebGpuStage( + wgsl: r''' +struct RenderTextureInfo { + params: vec4, +} + +var v_uv_1: vec2; +@group(1) @binding(0) +var encode_info: RenderTextureInfo; +@group(1) @binding(1) +var source_texture_tex: texture_2d; +@group(1) @binding(2) +var source_texture_smp: sampler; +var frag_color: vec4; + +fn LinearToSrgb_u0028_vf3_u003b(linear: ptr>) -> vec3 { + let _e22 = (*linear); + let _e24 = (*linear); + let _e29 = (*linear); + return mix((_e22 * 12.92f), ((pow(max(_e24, vec3(0f, 0f, 0f)), vec3(0.41666666f, 0.41666666f, 0.41666666f)) * 1.055f) - vec3(0.055f, 0.055f, 0.055f)), step(vec3(0.0031308f, 0.0031308f, 0.0031308f), _e29)); +} + +fn main_1() { + var uv: vec2; + var light: vec3; + var param: vec3; + + let _e24 = v_uv_1; + uv = _e24; + let _e27 = encode_info.params[1u]; + if (_e27 > 0.5f) { + let _e30 = uv[1u]; + uv[1u] = (1f - _e30); + } + let _e33 = uv; + let _e34 = textureSampleLevel(source_texture_tex, source_texture_smp, _e33, 0f); + let _e38 = encode_info.params[0u]; + light = (_e34.xyz * _e38); + let _e40 = light; + param = clamp(_e40, vec3(0f, 0f, 0f), vec3(1f, 1f, 1f)); + let _e42 = LinearToSrgb_u0028_vf3_u003b((¶m)); + frag_color = vec4(_e42.x, _e42.y, _e42.z, 1f); + return; +} + +@fragment +fn main(@location(6) v_uv: vec2) -> @location(0) vec4 { + v_uv_1 = v_uv; + main_1(); + let _e3 = frag_color; + return _e3; +} +''', + attributes: [], + blocks: [ + WebGpuBlock( + name: 'RenderTextureInfo', + group: 1, + binding: 0, + sizeInBytes: 16, + members: [ + WebGpuBlockMember( + name: 'params', + offsetInBytes: 0, + sizeInBytes: 16, + ), + ], + ), + ], + samplers: [ + WebGpuSampler( + name: 'source_texture', + group: 1, + textureBinding: 1, + samplerBinding: 2, + dimension: WebGpuTextureDimension.twoDimensional, + ), + ], + ), 'ShadowDistance': WebGpuStage( wgsl: r''' struct ShadowLight { @@ -39143,6 +43827,7 @@ var g_albedo: vec3; var g_debug_surface: vec3; var g_debug_surface_on: bool; var g_premultiply: bool; +var g_pass_through: f32; @group(1) @binding(0) var shadow_light: ShadowLight; var v_world_position_1: vec3; @@ -39161,19 +43846,20 @@ fn main_1() { g_debug_surface = vec3(0f, 0f, 0f); g_debug_surface_on = false; g_premultiply = false; - let _e24 = shadow_light.light[3u]; - range = max(_e24, 0.0001f); - let _e26 = v_world_position_1; - let _e28 = shadow_light.light; - distance_ = length((_e26 - _e28.xyz)); - let _e32 = distance_; - let _e33 = range; - frag_color = vec4(clamp((_e32 / _e33), 0f, 1f), 0f, 0f, 1f); + g_pass_through = 0f; + let _e25 = shadow_light.light[3u]; + range = max(_e25, 0.0001f); + let _e27 = v_world_position_1; + let _e29 = shadow_light.light; + distance_ = length((_e27 - _e29.xyz)); + let _e33 = distance_; + let _e34 = range; + frag_color = vec4(clamp((_e33 / _e34), 0f, 1f), 0f, 0f, 1f); return; } @fragment -fn main(@location(6) v_world_position: vec3, @location(2) v_normal: vec3, @location(4) v_texcoord: vec2, @location(3) v_tangent: vec4, @location(0) v_color: vec4, @location(1) v_lightmap_uv: vec2) -> @location(0) vec4 { +fn main(@location(7) v_world_position: vec3, @location(3) v_normal: vec3, @location(5) v_texcoord: vec2, @location(4) v_tangent: vec4, @location(0) v_color: vec4, @location(2) v_lightmap_uv: vec2) -> @location(0) vec4 { v_world_position_1 = v_world_position; v_normal_1 = v_normal; v_texcoord_1 = v_texcoord; @@ -39209,6 +43895,7 @@ var g_albedo: vec3; var g_debug_surface: vec3; var g_debug_surface_on: bool; var g_premultiply: bool; +var g_pass_through: f32; @group(1) @binding(1) var base_color_texture_tex: texture_2d; @group(1) @binding(2) @@ -39231,22 +43918,23 @@ fn main_1() { g_debug_surface = vec3(0f, 0f, 0f); g_debug_surface_on = false; g_premultiply = false; - let _e26 = v_texcoord_1; - let _e27 = textureSample(base_color_texture_tex, base_color_texture_smp, _e26); - let _e31 = mask_info.mask[1u]; - alpha = (_e27.w * _e31); - let _e33 = alpha; - let _e36 = mask_info.mask[0u]; - if (_e33 < _e36) { + g_pass_through = 0f; + let _e27 = v_texcoord_1; + let _e28 = textureSample(base_color_texture_tex, base_color_texture_smp, _e27); + let _e32 = mask_info.mask[1u]; + alpha = (_e28.w * _e32); + let _e34 = alpha; + let _e37 = mask_info.mask[0u]; + if (_e34 < _e37) { discard; } - let _e39 = gl_FragCoord_1[2u]; - frag_color = vec4(_e39, 0f, 0f, 1f); + let _e40 = gl_FragCoord_1[2u]; + frag_color = vec4(_e40, 0f, 0f, 1f); return; } @fragment -fn main(@location(4) v_texcoord: vec2, @builtin(position) gl_FragCoord: vec4, @location(6) v_world_position: vec3, @location(2) v_normal: vec3, @location(3) v_tangent: vec4, @location(0) v_color: vec4, @location(1) v_lightmap_uv: vec2) -> @location(0) vec4 { +fn main(@location(5) v_texcoord: vec2, @builtin(position) gl_FragCoord: vec4, @location(7) v_world_position: vec3, @location(3) v_normal: vec3, @location(4) v_tangent: vec4, @location(0) v_color: vec4, @location(2) v_lightmap_uv: vec2) -> @location(0) vec4 { v_texcoord_1 = v_texcoord; gl_FragCoord_1 = gl_FragCoord; v_world_position_1 = v_world_position; @@ -39287,64 +43975,284 @@ struct MaskInfo { mask: vec4, } -struct ShadowLight { +struct ShadowLight { + light: vec4, +} + +var g_albedo: vec3; +var g_debug_surface: vec3; +var g_debug_surface_on: bool; +var g_premultiply: bool; +var g_pass_through: f32; +@group(1) @binding(2) +var base_color_texture_tex: texture_2d; +@group(1) @binding(3) +var base_color_texture_smp: sampler; +var v_texcoord_1: vec2; +@group(1) @binding(0) +var mask_info: MaskInfo; +@group(1) @binding(1) +var shadow_light: ShadowLight; +var v_world_position_1: vec3; +var frag_color: vec4; +var v_normal_1: vec3; +var v_tangent_1: vec4; +var v_color_1: vec4; +var v_lightmap_uv_1: vec2; + +fn main_1() { + var alpha: f32; + var range: f32; + var distance_: f32; + + g_albedo = vec3(0f, 0f, 0f); + g_debug_surface = vec3(0f, 0f, 0f); + g_debug_surface_on = false; + g_premultiply = false; + g_pass_through = 0f; + let _e29 = v_texcoord_1; + let _e30 = textureSample(base_color_texture_tex, base_color_texture_smp, _e29); + let _e34 = mask_info.mask[1u]; + alpha = (_e30.w * _e34); + let _e36 = alpha; + let _e39 = mask_info.mask[0u]; + if (_e36 < _e39) { + discard; + } + let _e43 = shadow_light.light[3u]; + range = max(_e43, 0.0001f); + let _e45 = v_world_position_1; + let _e47 = shadow_light.light; + distance_ = length((_e45 - _e47.xyz)); + let _e51 = distance_; + let _e52 = range; + frag_color = vec4(clamp((_e51 / _e52), 0f, 1f), 0f, 0f, 1f); + return; +} + +@fragment +fn main(@location(5) v_texcoord: vec2, @location(7) v_world_position: vec3, @location(3) v_normal: vec3, @location(4) v_tangent: vec4, @location(0) v_color: vec4, @location(2) v_lightmap_uv: vec2) -> @location(0) vec4 { + v_texcoord_1 = v_texcoord; + v_world_position_1 = v_world_position; + v_normal_1 = v_normal; + v_tangent_1 = v_tangent; + v_color_1 = v_color; + v_lightmap_uv_1 = v_lightmap_uv; + main_1(); + let _e13 = frag_color; + return _e13; +} +''', + attributes: [], + blocks: [ + WebGpuBlock( + name: 'MaskInfo', + group: 1, + binding: 0, + sizeInBytes: 16, + members: [ + WebGpuBlockMember(name: 'mask', offsetInBytes: 0, sizeInBytes: 16), + ], + ), + WebGpuBlock( + name: 'ShadowLight', + group: 1, + binding: 1, + sizeInBytes: 16, + members: [ + WebGpuBlockMember(name: 'light', offsetInBytes: 0, sizeInBytes: 16), + ], + ), + ], + samplers: [ + WebGpuSampler( + name: 'base_color_texture', + group: 1, + textureBinding: 2, + samplerBinding: 3, + dimension: WebGpuTextureDimension.twoDimensional, + ), + ], + ), + 'CausticSurface': WebGpuStage( + wgsl: r''' +var g_albedo: vec3; +var g_debug_surface: vec3; +var g_debug_surface_on: bool; +var g_premultiply: bool; +var g_pass_through: f32; +var frag_color: vec4; +var v_normal_1: vec3; +var gl_FragCoord_1: vec4; +var v_world_position_1: vec3; +var v_texcoord_1: vec2; +var v_tangent_1: vec4; +var v_color_1: vec4; +var v_lightmap_uv_1: vec2; + +fn main_1() { + g_albedo = vec3(0f, 0f, 0f); + g_debug_surface = vec3(0f, 0f, 0f); + g_debug_surface_on = false; + g_premultiply = false; + g_pass_through = 0f; + let _e18 = v_normal_1; + let _e19 = normalize(_e18); + let _e21 = gl_FragCoord_1[2u]; + frag_color = vec4(_e19.x, _e19.y, _e19.z, _e21); + return; +} + +@fragment +fn main(@location(3) v_normal: vec3, @builtin(position) gl_FragCoord: vec4, @location(7) v_world_position: vec3, @location(5) v_texcoord: vec2, @location(4) v_tangent: vec4, @location(0) v_color: vec4, @location(2) v_lightmap_uv: vec2) -> @location(0) vec4 { + v_normal_1 = v_normal; + gl_FragCoord_1 = gl_FragCoord; + v_world_position_1 = v_world_position; + v_texcoord_1 = v_texcoord; + v_tangent_1 = v_tangent; + v_color_1 = v_color; + v_lightmap_uv_1 = v_lightmap_uv; + main_1(); + let _e15 = frag_color; + return _e15; +} +''', + attributes: [], + blocks: [], + samplers: [], + ), + 'CausticPhoton': WebGpuStage( + wgsl: r''' +var v_uv_1: vec2; +var v_color_1: vec4; +var frag_color: vec4; + +fn main_1() { + var r2_: f32; + var k: f32; + var e: vec3; + + let _e11 = v_uv_1; + let _e12 = v_uv_1; + r2_ = dot(_e11, _e12); + let _e14 = r2_; + if (_e14 >= 1f) { + discard; + } + let _e16 = r2_; + let _e18 = r2_; + k = ((1f - _e16) * (1f - _e18)); + let _e21 = v_color_1; + let _e23 = k; + e = (_e21.xyz * _e23); + let _e26 = e[0u]; + let _e28 = e[1u]; + let _e30 = e[2u]; + frag_color = vec4(0f, _e26, _e28, _e30); + return; +} + +@fragment +fn main(@location(6) v_uv: vec2, @location(0) v_color: vec4) -> @location(0) vec4 { + v_uv_1 = v_uv; + v_color_1 = v_color; + main_1(); + let _e5 = frag_color; + return _e5; +} +''', + attributes: [], + blocks: [], + samplers: [], + ), + 'ShadowTransmittance': WebGpuStage( + wgsl: r''' +struct TransmittanceInfo { + color: vec4, light: vec4, + params: vec4, } var g_albedo: vec3; var g_debug_surface: vec3; var g_debug_surface_on: bool; var g_premultiply: bool; -@group(1) @binding(2) +var g_pass_through: f32; +@group(1) @binding(0) +var transmittance_info: TransmittanceInfo; +@group(1) @binding(1) var base_color_texture_tex: texture_2d; -@group(1) @binding(3) +@group(1) @binding(2) var base_color_texture_smp: sampler; var v_texcoord_1: vec2; -@group(1) @binding(0) -var mask_info: MaskInfo; -@group(1) @binding(1) -var shadow_light: ShadowLight; -var v_world_position_1: vec3; -var frag_color: vec4; var v_normal_1: vec3; +var frag_color: vec4; +var v_world_position_1: vec3; var v_tangent_1: vec4; var v_color_1: vec4; var v_lightmap_uv_1: vec2; fn main_1() { - var alpha: f32; - var range: f32; - var distance_: f32; + var opacity: f32; + var transmission: f32; + var tint: vec3; + var body: vec3; + var facing: f32; + var f0_: f32; + var fresnel: f32; + var lost: f32; + var through: vec3; g_albedo = vec3(0f, 0f, 0f); g_debug_surface = vec3(0f, 0f, 0f); g_debug_surface_on = false; g_premultiply = false; - let _e28 = v_texcoord_1; - let _e29 = textureSample(base_color_texture_tex, base_color_texture_smp, _e28); - let _e33 = mask_info.mask[1u]; - alpha = (_e29.w * _e33); - let _e35 = alpha; - let _e38 = mask_info.mask[0u]; - if (_e35 < _e38) { - discard; - } - let _e42 = shadow_light.light[3u]; - range = max(_e42, 0.0001f); - let _e44 = v_world_position_1; - let _e46 = shadow_light.light; - distance_ = length((_e44 - _e46.xyz)); - let _e50 = distance_; - let _e51 = range; - frag_color = vec4(clamp((_e50 / _e51), 0f, 1f), 0f, 0f, 1f); + g_pass_through = 0f; + let _e41 = transmittance_info.color[3u]; + opacity = clamp(_e41, 0f, 1f); + let _e45 = transmittance_info.params[0u]; + transmission = clamp(_e45, 0f, 1f); + let _e48 = transmittance_info.color; + let _e51 = v_texcoord_1; + let _e52 = textureSample(base_color_texture_tex, base_color_texture_smp, _e51); + tint = (max(_e48.xyz, vec3(0f, 0f, 0f)) * _e52.xyz); + let _e55 = opacity; + let _e58 = opacity; + let _e59 = transmission; + let _e61 = tint; + body = (vec3((1f - _e55)) + (_e61 * (_e58 * _e59))); + let _e64 = v_normal_1; + let _e67 = transmittance_info.light; + facing = clamp(abs(dot(normalize(_e64), _e67.xyz)), 0f, 1f); + let _e74 = transmittance_info.params[1u]; + f0_ = clamp(_e74, 0f, 1f); + let _e76 = f0_; + let _e77 = f0_; + let _e79 = facing; + fresnel = (_e76 + ((1f - _e77) * pow((1f - _e79), 5f))); + let _e84 = fresnel; + let _e85 = facing; + let _e87 = facing; + lost = (_e84 * min(((2f * _e85) * _e87), 1f)); + let _e91 = body; + let _e94 = lost; + through = (sqrt(max(_e91, vec3(0f, 0f, 0f))) * (1f - _e94)); + let _e99 = transmittance_info.params[2u]; + if (_e99 > 0.5f) { + through = vec3(0f, 0f, 0f); + } + let _e102 = through[0u]; + let _e105 = through[1u]; + let _e108 = through[2u]; + frag_color = vec4(0f, (1f - _e102), (1f - _e105), _e108); return; } @fragment -fn main(@location(4) v_texcoord: vec2, @location(6) v_world_position: vec3, @location(2) v_normal: vec3, @location(3) v_tangent: vec4, @location(0) v_color: vec4, @location(1) v_lightmap_uv: vec2) -> @location(0) vec4 { +fn main(@location(5) v_texcoord: vec2, @location(3) v_normal: vec3, @location(7) v_world_position: vec3, @location(4) v_tangent: vec4, @location(0) v_color: vec4, @location(2) v_lightmap_uv: vec2) -> @location(0) vec4 { v_texcoord_1 = v_texcoord; - v_world_position_1 = v_world_position; v_normal_1 = v_normal; + v_world_position_1 = v_world_position; v_tangent_1 = v_tangent; v_color_1 = v_color; v_lightmap_uv_1 = v_lightmap_uv; @@ -39356,21 +44264,22 @@ fn main(@location(4) v_texcoord: vec2, @location(6) v_world_position: vec3< attributes: [], blocks: [ WebGpuBlock( - name: 'MaskInfo', + name: 'TransmittanceInfo', group: 1, binding: 0, - sizeInBytes: 16, - members: [ - WebGpuBlockMember(name: 'mask', offsetInBytes: 0, sizeInBytes: 16), - ], - ), - WebGpuBlock( - name: 'ShadowLight', - group: 1, - binding: 1, - sizeInBytes: 16, + sizeInBytes: 48, members: [ - WebGpuBlockMember(name: 'light', offsetInBytes: 0, sizeInBytes: 16), + WebGpuBlockMember(name: 'color', offsetInBytes: 0, sizeInBytes: 16), + WebGpuBlockMember( + name: 'light', + offsetInBytes: 16, + sizeInBytes: 16, + ), + WebGpuBlockMember( + name: 'params', + offsetInBytes: 32, + sizeInBytes: 16, + ), ], ), ], @@ -39378,8 +44287,8 @@ fn main(@location(4) v_texcoord: vec2, @location(6) v_world_position: vec3< WebGpuSampler( name: 'base_color_texture', group: 1, - textureBinding: 2, - samplerBinding: 3, + textureBinding: 1, + samplerBinding: 2, dimension: WebGpuTextureDimension.twoDimensional, ), ], @@ -39450,7 +44359,7 @@ fn main_1() { } @fragment -fn main(@location(5) v_uv: vec2) -> FragmentOutput { +fn main(@location(6) v_uv: vec2) -> FragmentOutput { v_uv_1 = v_uv; main_1(); let _e4 = frag_color; @@ -39631,7 +44540,7 @@ fn main_1() { } @fragment -fn main(@location(5) v_uv: vec2) -> @location(0) vec4 { +fn main(@location(6) v_uv: vec2) -> @location(0) vec4 { v_uv_1 = v_uv; main_1(); let _e3 = frag_color; @@ -39672,7 +44581,7 @@ fn main_1() { } @fragment -fn main(@location(5) v_uv: vec2) -> @location(0) vec4 { +fn main(@location(6) v_uv: vec2) -> @location(0) vec4 { v_uv_1 = v_uv; main_1(); let _e3 = frag_color; @@ -39741,38 +44650,592 @@ fn main_1() { let _e69 = colour; colour = (_e69 + (_e59.xyz * (_e62 * pow(_e63, _e65)))); } - let _e72 = v_disc_1[1u]; - if (_e72 > 0f) { - let _e75 = v_disc_1[0u]; - let _e77 = v_disc_1[1u]; - let _e80 = v_disc_1[0u]; - let _e81 = towards; - local_1 = smoothstep((_e75 - _e77), _e80, _e81); + let _e72 = v_disc_1[1u]; + if (_e72 > 0f) { + let _e75 = v_disc_1[0u]; + let _e77 = v_disc_1[1u]; + let _e80 = v_disc_1[0u]; + let _e81 = towards; + local_1 = smoothstep((_e75 - _e77), _e80, _e81); + } else { + let _e84 = v_disc_1[0u]; + let _e85 = towards; + local_1 = step(_e84, _e85); + } + let _e87 = local_1; + disc = _e87; + let _e88 = v_glow_1; + let _e90 = disc; + let _e92 = v_disc_1[2u]; + let _e95 = colour; + colour = (_e95 + (_e88.xyz * (_e90 * _e92))); + let _e97 = colour; + frag_color = vec4(_e97.x, _e97.y, _e97.z, 1f); + return; +} + +@fragment +fn main(@location(6) v_ray: vec3, @location(11) v_zenith: vec4, @location(4) v_nadir: vec4, @location(2) v_horizon: vec4, @location(9) v_sun: vec4, @location(1) v_glow: vec4, @location(0) v_disc: vec4) -> @location(0) vec4 { + v_ray_1 = v_ray; + v_zenith_1 = v_zenith; + v_nadir_1 = v_nadir; + v_horizon_1 = v_horizon; + v_sun_1 = v_sun; + v_glow_1 = v_glow; + v_disc_1 = v_disc; + main_1(); + let _e15 = frag_color; + return _e15; +} +''', + attributes: [], + blocks: [], + samplers: [], + ), + 'SkyCube': WebGpuStage( + wgsl: r''' +@group(1) @binding(0) +var sky_texture_tex: texture_cube; +@group(1) @binding(1) +var sky_texture_smp: sampler; +var v_ray_1: vec3; +var frag_color: vec4; +var v_tint_1: vec4; + +fn main_1() { + var texel: vec3; + var linear: vec3; + + let _e15 = v_ray_1; + let _e17 = textureSample(sky_texture_tex, sky_texture_smp, normalize(_e15)); + texel = _e17.xyz; + let _e19 = texel; + let _e22 = texel; + let _e28 = texel; + linear = mix((_e19 / vec3(12.92f)), pow(((_e22 + vec3(0.055f)) / vec3(1.055f)), vec3(2.4f, 2.4f, 2.4f)), step(vec3(0.04045f, 0.04045f, 0.04045f), _e28)); + let _e31 = linear; + let _e32 = v_tint_1; + let _e34 = (_e31 * _e32.xyz); + frag_color = vec4(_e34.x, _e34.y, _e34.z, 1f); + return; +} + +@fragment +fn main(@location(6) v_ray: vec3, @location(10) v_tint: vec4) -> @location(0) vec4 { + v_ray_1 = v_ray; + v_tint_1 = v_tint; + main_1(); + let _e5 = frag_color; + return _e5; +} +''', + attributes: [], + blocks: [], + samplers: [ + WebGpuSampler( + name: 'sky_texture', + group: 1, + textureBinding: 0, + samplerBinding: 1, + dimension: WebGpuTextureDimension.cube, + ), + ], + ), + 'SkyPhysical': WebGpuStage( + wgsl: r''' +var v_planet_1: vec4; +var v_rayleigh_1: vec4; +var v_mie_1: vec4; +var v_stars_1: vec4; +var v_ray_1: vec3; +var v_sun_1: vec4; +var v_disc_1: vec4; +var frag_color: vec4; + +fn Hash_u0028_vf3_u003b(p: ptr>) -> f32 { + let _e48 = (*p); + (*p) = fract((_e48 * 0.1031f)); + let _e51 = (*p); + let _e52 = (*p); + let _e57 = (*p); + (*p) = (_e57 + vec3(dot(_e51, (_e52.zyx + vec3(31.32f))))); + let _e61 = (*p)[0u]; + let _e63 = (*p)[1u]; + let _e66 = (*p)[2u]; + return fract(((_e61 + _e63) * _e66)); +} + +fn StarField_u0028_vf3_u003b(d: ptr>) -> f32 { + var a: vec3; + var face: f32; + var uv: vec2; + var grid: vec2; + var cell: vec2; + var key: vec3; + var param: vec3; + var centre: vec2; + var param_1: vec3; + var param_2: vec3; + var off: f32; + var magnitude: f32; + var param_3: vec3; + var phi_240_: bool; + + let _e61 = (*d); + a = abs(_e61); + let _e64 = a[0u]; + let _e66 = a[1u]; + let _e67 = (_e64 >= _e66); + phi_240_ = _e67; + if _e67 { + let _e69 = a[0u]; + let _e71 = a[2u]; + phi_240_ = (_e69 >= _e71); + } + let _e74 = phi_240_; + if _e74 { + let _e76 = (*d)[0u]; + face = select(1f, 0f, (_e76 > 0f)); + let _e79 = (*d); + let _e82 = a[0u]; + uv = (_e79.zy / vec2(_e82)); + } else { + let _e86 = a[1u]; + let _e88 = a[2u]; + if (_e86 >= _e88) { + let _e91 = (*d)[1u]; + face = select(3f, 2f, (_e91 > 0f)); + let _e94 = (*d); + let _e97 = a[1u]; + uv = (_e94.xz / vec2(_e97)); + } else { + let _e101 = (*d)[2u]; + face = select(5f, 4f, (_e101 > 0f)); + let _e104 = (*d); + let _e107 = a[2u]; + uv = (_e104.xy / vec2(_e107)); + } + } + let _e110 = uv; + let _e115 = v_stars_1[2u]; + grid = (((_e110 * 0.5f) + vec2(0.5f)) * _e115); + let _e117 = grid; + cell = floor(_e117); + let _e119 = cell; + let _e120 = face; + key = vec3(_e119.x, _e119.y, _e120); + let _e124 = key; + param = _e124; + let _e125 = Hash_u0028_vf3_u003b((¶m)); + let _e127 = v_stars_1[1u]; + if (_e125 >= _e127) { + return 0f; + } + let _e129 = key; + param_1 = (_e129 + vec3(17f, 0f, 0f)); + let _e131 = Hash_u0028_vf3_u003b((¶m_1)); + let _e132 = key; + param_2 = (_e132 + vec3(0f, 29f, 0f)); + let _e134 = Hash_u0028_vf3_u003b((¶m_2)); + centre = ((vec2(_e131, _e134) * 0.6f) + vec2(0.2f)); + let _e139 = grid; + let _e140 = cell; + let _e142 = centre; + off = length(((_e139 - _e140) - _e142)); + let _e145 = key; + param_3 = (_e145 + vec3(0f, 0f, 13f)); + let _e147 = Hash_u0028_vf3_u003b((¶m_3)); + magnitude = _e147; + let _e148 = off; + let _e151 = magnitude; + let _e153 = magnitude; + let _e155 = magnitude; + return ((1f - smoothstep(0f, 0.35f, _e148)) * (0.15f + (((0.85f * _e151) * _e153) * _e155))); +} + +fn Through_u0028_vf2_u003b(air: ptr>) -> vec3 { + let _e48 = v_rayleigh_1; + let _e51 = (*air)[0u]; + let _e54 = v_mie_1[1u]; + let _e56 = (*air)[1u]; + return exp(-(((_e48.xyz * _e51) + vec3((_e54 * _e56))))); +} + +fn Leave_u0028_f1_u003b_f1_u003b_f1_u003b(r: ptr, mu: ptr, radius: ptr) -> f32 { + var b: f32; + var d_1: f32; + var local: f32; + + let _e53 = (*r); + let _e54 = (*mu); + b = (_e53 * _e54); + let _e56 = b; + let _e57 = b; + let _e59 = (*r); + let _e60 = (*radius); + let _e62 = (*r); + let _e63 = (*radius); + d_1 = ((_e56 * _e57) - ((_e59 - _e60) * (_e62 + _e63))); + let _e67 = d_1; + if (_e67 < 0f) { + local = -1f; + } else { + let _e69 = b; + let _e71 = d_1; + local = (-(_e69) + sqrt(_e71)); + } + let _e74 = local; + return _e74; +} + +fn SunwardAir_u0028_vf3_u003b_vf3_u003b(p_1: ptr>, s: ptr>) -> vec2 { + var r_1: f32; + var span: f32; + var param_4: f32; + var param_5: f32; + var param_6: f32; + var stride: f32; + var air_1: vec2; + var j: i32; + var q: vec3; + var h: f32; + + let _e59 = (*p_1); + r_1 = length(_e59); + let _e61 = (*p_1); + let _e62 = (*s); + let _e64 = r_1; + let _e66 = r_1; + param_4 = _e66; + param_5 = (dot(_e61, _e62) / _e64); + let _e68 = v_planet_1[1u]; + param_6 = _e68; + let _e69 = Leave_u0028_f1_u003b_f1_u003b_f1_u003b((¶m_4), (¶m_5), (¶m_6)); + span = _e69; + let _e70 = span; + stride = (_e70 / 8f); + air_1 = vec2(0f, 0f); + j = 0i; + loop { + let _e72 = j; + if (_e72 < 8i) { + let _e74 = (*p_1); + let _e75 = (*s); + let _e76 = j; + let _e79 = stride; + q = (_e74 + (_e75 * ((f32(_e76) + 0.5f) * _e79))); + let _e83 = q; + let _e86 = v_planet_1[0u]; + h = (length(_e83) - _e86); + let _e88 = h; + let _e91 = v_rayleigh_1[3u]; + let _e93 = v_mie_1[2u]; + let _e98 = stride; + let _e100 = air_1; + air_1 = (_e100 + (exp((vec2(-(_e88)) / vec2(_e91, _e93))) * _e98)); + continue; + } else { + break; + } + continuing { + let _e102 = j; + j = (_e102 + 1i); + } + } + let _e104 = air_1; + return _e104; +} + +fn Meet_u0028_f1_u003b_f1_u003b_f1_u003b(r_2: ptr, mu_1: ptr, radius_1: ptr) -> f32 { + var b_1: f32; + var d_2: f32; + var local_1: f32; + + let _e53 = (*r_2); + let _e54 = (*mu_1); + b_1 = (_e53 * _e54); + let _e56 = b_1; + let _e57 = b_1; + let _e59 = (*r_2); + let _e60 = (*radius_1); + let _e62 = (*r_2); + let _e63 = (*radius_1); + d_2 = ((_e56 * _e57) - ((_e59 - _e60) * (_e62 + _e63))); + let _e67 = d_2; + if (_e67 < 0f) { + local_1 = -1f; + } else { + let _e69 = b_1; + let _e71 = d_2; + local_1 = (-(_e69) - sqrt(_e71)); + } + let _e74 = local_1; + return _e74; +} + +fn main_1() { + var d_3: vec3; + var s_1: vec3; + var eye: vec3; + var ground: f32; + var param_7: f32; + var param_8: f32; + var param_9: f32; + var grounded: bool; + var span_1: f32; + var local_2: f32; + var param_10: f32; + var param_11: f32; + var param_12: f32; + var seenAir: vec2; + var molecules: vec3; + var haze: vec3; + var i: i32; + var a0_: f32; + var a1_: f32; + var t: f32; + var stride_1: f32; + var p_2: vec3; + var r_3: f32; + var air_2: vec2; + var param_13: f32; + var param_14: f32; + var param_15: f32; + var through: vec3; + var param_16: vec3; + var param_17: vec3; + var param_18: vec2; + var mu_2: f32; + var g: f32; + var gg: f32; + var rayleighPhase: f32; + var miePhase: f32; + var colour: vec3; + var seen: vec3; + var param_19: vec2; + var p_3: vec3; + var lit: f32; + var param_20: vec3; + var param_21: vec3; + var param_22: vec2; + var disc: f32; + var local_3: f32; + var night: f32; + var param_23: vec3; + + let _e95 = v_ray_1; + d_3 = normalize(_e95); + let _e97 = v_sun_1; + s_1 = _e97.xyz; + let _e100 = v_planet_1[2u]; + eye = vec3(0f, _e100, 0f); + let _e103 = v_planet_1[2u]; + param_7 = _e103; + let _e105 = d_3[1u]; + param_8 = _e105; + let _e107 = v_planet_1[0u]; + param_9 = _e107; + let _e108 = Meet_u0028_f1_u003b_f1_u003b_f1_u003b((¶m_7), (¶m_8), (¶m_9)); + ground = _e108; + let _e109 = ground; + grounded = (_e109 > 0f); + let _e111 = grounded; + if _e111 { + let _e112 = ground; + local_2 = _e112; + } else { + let _e114 = v_planet_1[2u]; + param_10 = _e114; + let _e116 = d_3[1u]; + param_11 = _e116; + let _e118 = v_planet_1[1u]; + param_12 = _e118; + let _e119 = Leave_u0028_f1_u003b_f1_u003b_f1_u003b((¶m_10), (¶m_11), (¶m_12)); + local_2 = _e119; + } + let _e120 = local_2; + span_1 = _e120; + seenAir = vec2(0f, 0f); + molecules = vec3(0f, 0f, 0f); + haze = vec3(0f, 0f, 0f); + i = 0i; + loop { + let _e121 = i; + if (_e121 < 16i) { + let _e123 = i; + a0_ = (f32(_e123) / 16f); + let _e126 = i; + a1_ = (f32((_e126 + 1i)) / 16f); + let _e130 = span_1; + let _e132 = a0_; + let _e133 = a0_; + let _e135 = a1_; + let _e136 = a1_; + t = ((_e130 * 0.5f) * ((_e132 * _e133) + (_e135 * _e136))); + let _e140 = span_1; + let _e141 = a1_; + let _e142 = a1_; + let _e144 = a0_; + let _e145 = a0_; + stride_1 = (_e140 * ((_e141 * _e142) - (_e144 * _e145))); + let _e149 = eye; + let _e150 = d_3; + let _e151 = t; + p_2 = (_e149 + (_e150 * _e151)); + let _e154 = p_2; + r_3 = length(_e154); + let _e156 = r_3; + let _e158 = v_planet_1[0u]; + let _e162 = v_rayleigh_1[3u]; + let _e164 = v_mie_1[2u]; + let _e169 = stride_1; + air_2 = (exp((vec2(-((_e156 - _e158))) / vec2(_e162, _e164))) * _e169); + let _e171 = air_2; + let _e172 = seenAir; + seenAir = (_e172 + _e171); + let _e174 = p_2; + let _e175 = s_1; + let _e177 = r_3; + let _e179 = r_3; + param_13 = _e179; + param_14 = (dot(_e174, _e175) / _e177); + let _e181 = v_planet_1[0u]; + param_15 = _e181; + let _e182 = Meet_u0028_f1_u003b_f1_u003b_f1_u003b((¶m_13), (¶m_14), (¶m_15)); + if (_e182 > 0f) { + continue; + } + let _e184 = seenAir; + let _e185 = air_2; + let _e188 = p_2; + param_16 = _e188; + let _e189 = s_1; + param_17 = _e189; + let _e190 = SunwardAir_u0028_vf3_u003b_vf3_u003b((¶m_16), (¶m_17)); + param_18 = ((_e184 - (_e185 * 0.5f)) + _e190); + let _e192 = Through_u0028_vf2_u003b((¶m_18)); + through = _e192; + let _e194 = air_2[0u]; + let _e195 = through; + let _e197 = molecules; + molecules = (_e197 + (_e195 * _e194)); + let _e200 = air_2[1u]; + let _e201 = through; + let _e203 = haze; + haze = (_e203 + (_e201 * _e200)); + continue; + } else { + break; + } + continuing { + let _e205 = i; + i = (_e205 + 1i); + } + } + let _e207 = d_3; + let _e208 = s_1; + mu_2 = dot(_e207, _e208); + let _e211 = v_mie_1[3u]; + g = _e211; + let _e212 = g; + let _e213 = g; + gg = (_e212 * _e213); + let _e215 = mu_2; + let _e216 = mu_2; + rayleighPhase = (0.059683103f * (1f + (_e215 * _e216))); + let _e220 = gg; + let _e222 = mu_2; + let _e223 = mu_2; + let _e228 = gg; + let _e230 = gg; + let _e232 = g; + let _e234 = mu_2; + miePhase = ((0.119366206f * ((1f - _e220) * (1f + (_e222 * _e223)))) / ((2f + _e228) * pow(((1f + _e230) - ((2f * _e232) * _e234)), 1.5f))); + let _e241 = v_sun_1[3u]; + let _e242 = molecules; + let _e243 = v_rayleigh_1; + let _e246 = rayleighPhase; + let _e248 = haze; + let _e250 = v_mie_1[0u]; + let _e251 = miePhase; + colour = ((((_e242 * _e243.xyz) * _e246) + (_e248 * (_e250 * _e251))) * _e241); + let _e256 = seenAir; + param_19 = _e256; + let _e257 = Through_u0028_vf2_u003b((¶m_19)); + seen = _e257; + let _e258 = grounded; + if _e258 { + let _e259 = eye; + let _e260 = d_3; + let _e261 = span_1; + p_3 = (_e259 + (_e260 * _e261)); + let _e264 = p_3; + let _e266 = s_1; + lit = dot(normalize(_e264), _e266); + let _e268 = lit; + if (_e268 > 0f) { + let _e270 = seen; + let _e271 = p_3; + param_20 = _e271; + let _e272 = s_1; + param_21 = _e272; + let _e273 = SunwardAir_u0028_vf3_u003b_vf3_u003b((¶m_20), (¶m_21)); + param_22 = _e273; + let _e274 = Through_u0028_vf2_u003b((¶m_22)); + let _e277 = v_planet_1[3u]; + let _e279 = lit; + let _e282 = v_sun_1[3u]; + let _e285 = colour; + colour = (_e285 + ((_e270 * _e274) * (((_e277 / 3.1415927f) * _e279) * _e282))); + } } else { - let _e84 = v_disc_1[0u]; - let _e85 = towards; - local_1 = step(_e84, _e85); - } - let _e87 = local_1; - disc = _e87; - let _e88 = v_glow_1; - let _e90 = disc; - let _e92 = v_disc_1[2u]; - let _e95 = colour; - colour = (_e95 + (_e88.xyz * (_e90 * _e92))); - let _e97 = colour; - frag_color = vec4(_e97.x, _e97.y, _e97.z, 1f); + let _e288 = v_disc_1[1u]; + if (_e288 > 0f) { + let _e291 = v_disc_1[0u]; + let _e293 = v_disc_1[1u]; + let _e296 = v_disc_1[0u]; + let _e297 = mu_2; + local_3 = smoothstep((_e291 - _e293), _e296, _e297); + } else { + let _e300 = v_disc_1[0u]; + let _e301 = mu_2; + local_3 = step(_e300, _e301); + } + let _e303 = local_3; + disc = _e303; + let _e304 = seen; + let _e305 = disc; + let _e307 = v_disc_1[2u]; + let _e310 = colour; + colour = (_e310 + (_e304 * (_e305 * _e307))); + let _e313 = s_1[1u]; + night = (1f - smoothstep(-0.2f, 0.1f, _e313)); + let _e317 = v_stars_1[0u]; + let _e319 = night; + if ((_e317 > 0f) && (_e319 > 0f)) { + let _e322 = seen; + let _e324 = v_stars_1[0u]; + let _e325 = night; + let _e327 = d_3; + param_23 = _e327; + let _e328 = StarField_u0028_vf3_u003b((¶m_23)); + let _e331 = colour; + colour = (_e331 + (_e322 * ((_e324 * _e325) * _e328))); + } + } + let _e333 = colour; + frag_color = vec4(_e333.x, _e333.y, _e333.z, 1f); return; } @fragment -fn main(@location(4) v_ray: vec3, @location(7) v_zenith: vec4, @location(3) v_nadir: vec4, @location(2) v_horizon: vec4, @location(5) v_sun: vec4, @location(1) v_glow: vec4, @location(0) v_disc: vec4) -> @location(0) vec4 { +fn main(@location(5) v_planet: vec4, @location(7) v_rayleigh: vec4, @location(3) v_mie: vec4, @location(8) v_stars: vec4, @location(6) v_ray: vec3, @location(9) v_sun: vec4, @location(0) v_disc: vec4) -> @location(0) vec4 { + v_planet_1 = v_planet; + v_rayleigh_1 = v_rayleigh; + v_mie_1 = v_mie; + v_stars_1 = v_stars; v_ray_1 = v_ray; - v_zenith_1 = v_zenith; - v_nadir_1 = v_nadir; - v_horizon_1 = v_horizon; v_sun_1 = v_sun; - v_glow_1 = v_glow; v_disc_1 = v_disc; main_1(); let _e15 = frag_color; @@ -39783,55 +45246,6 @@ fn main(@location(4) v_ray: vec3, @location(7) v_zenith: vec4, @locati blocks: [], samplers: [], ), - 'SkyCube': WebGpuStage( - wgsl: r''' -@group(1) @binding(0) -var sky_texture_tex: texture_cube; -@group(1) @binding(1) -var sky_texture_smp: sampler; -var v_ray_1: vec3; -var frag_color: vec4; -var v_tint_1: vec4; - -fn main_1() { - var texel: vec3; - var linear: vec3; - - let _e15 = v_ray_1; - let _e17 = textureSample(sky_texture_tex, sky_texture_smp, normalize(_e15)); - texel = _e17.xyz; - let _e19 = texel; - let _e22 = texel; - let _e28 = texel; - linear = mix((_e19 / vec3(12.92f)), pow(((_e22 + vec3(0.055f)) / vec3(1.055f)), vec3(2.4f, 2.4f, 2.4f)), step(vec3(0.04045f, 0.04045f, 0.04045f), _e28)); - let _e31 = linear; - let _e32 = v_tint_1; - let _e34 = (_e31 * _e32.xyz); - frag_color = vec4(_e34.x, _e34.y, _e34.z, 1f); - return; -} - -@fragment -fn main(@location(4) v_ray: vec3, @location(6) v_tint: vec4) -> @location(0) vec4 { - v_ray_1 = v_ray; - v_tint_1 = v_tint; - main_1(); - let _e5 = frag_color; - return _e5; -} -''', - attributes: [], - blocks: [], - samplers: [ - WebGpuSampler( - name: 'sky_texture', - group: 1, - textureBinding: 0, - samplerBinding: 1, - dimension: WebGpuTextureDimension.cube, - ), - ], - ), 'Luminance': WebGpuStage( wgsl: r''' struct LuminanceInfo { @@ -39917,7 +45331,7 @@ fn main_1() { } @fragment -fn main(@location(5) v_uv: vec2) -> @location(0) vec4 { +fn main(@location(6) v_uv: vec2) -> @location(0) vec4 { v_uv_1 = v_uv; main_1(); let _e3 = frag_color; @@ -40102,7 +45516,7 @@ fn main_1() { } @fragment -fn main(@location(5) v_uv: vec2) -> @location(0) vec4 { +fn main(@location(6) v_uv: vec2) -> @location(0) vec4 { v_uv_1 = v_uv; main_1(); let _e3 = frag_color; @@ -40147,6 +45561,7 @@ var g_albedo: vec3; var g_debug_surface: vec3; var g_debug_surface_on: bool; var g_premultiply: bool; +var g_pass_through: f32; @group(1) @binding(0) var id_info: IdInfo; @group(1) @binding(1) @@ -40169,29 +45584,30 @@ fn main_1() { g_debug_surface = vec3(0f, 0f, 0f); g_debug_surface_on = false; g_premultiply = false; - let _e28 = id_info.mask[0u]; - cutoff = _e28; - let _e29 = cutoff; - if (_e29 >= 0f) { - let _e31 = v_texcoord_1; - let _e32 = textureSample(base_color_texture_tex, base_color_texture_smp, _e31); - let _e36 = id_info.mask[1u]; - let _e39 = v_color_1[3u]; - alpha = ((_e32.w * _e36) * _e39); - let _e41 = alpha; - let _e42 = cutoff; - if (_e41 < _e42) { + g_pass_through = 0f; + let _e29 = id_info.mask[0u]; + cutoff = _e29; + let _e30 = cutoff; + if (_e30 >= 0f) { + let _e32 = v_texcoord_1; + let _e33 = textureSample(base_color_texture_tex, base_color_texture_smp, _e32); + let _e37 = id_info.mask[1u]; + let _e40 = v_color_1[3u]; + alpha = ((_e33.w * _e37) * _e40); + let _e42 = alpha; + let _e43 = cutoff; + if (_e42 < _e43) { discard; } } - let _e45 = id_info.id; - let _e46 = _e45.xyz; - frag_color = vec4(_e46.x, _e46.y, _e46.z, 1f); + let _e46 = id_info.id; + let _e47 = _e46.xyz; + frag_color = vec4(_e47.x, _e47.y, _e47.z, 1f); return; } @fragment -fn main(@location(4) v_texcoord: vec2, @location(0) v_color: vec4, @location(6) v_world_position: vec3, @location(2) v_normal: vec3, @location(3) v_tangent: vec4, @location(1) v_lightmap_uv: vec2) -> @location(0) vec4 { +fn main(@location(5) v_texcoord: vec2, @location(0) v_color: vec4, @location(7) v_world_position: vec3, @location(3) v_normal: vec3, @location(4) v_tangent: vec4, @location(2) v_lightmap_uv: vec2) -> @location(0) vec4 { v_texcoord_1 = v_texcoord; v_color_1 = v_color; v_world_position_1 = v_world_position; @@ -40274,7 +45690,7 @@ fn main_1() { } @fragment -fn main(@location(5) v_uv: vec2) -> @location(0) vec4 { +fn main(@location(6) v_uv: vec2) -> @location(0) vec4 { v_uv_1 = v_uv; main_1(); let _e3 = frag_color; @@ -40792,7 +46208,7 @@ fn main_1() { } @fragment -fn main(@location(5) v_uv: vec2) -> @location(0) vec4 { +fn main(@location(6) v_uv: vec2) -> @location(0) vec4 { v_uv_1 = v_uv; main_1(); let _e3 = frag_color; @@ -40888,7 +46304,7 @@ fn main_1() { } @fragment -fn main(@location(5) v_uv: vec2) -> @location(0) vec4 { +fn main(@location(6) v_uv: vec2) -> @location(0) vec4 { v_uv_1 = v_uv; main_1(); let _e3 = frag_color; @@ -41001,7 +46417,7 @@ fn main_1() { } @fragment -fn main(@location(5) v_uv: vec2) -> @location(0) vec4 { +fn main(@location(6) v_uv: vec2) -> @location(0) vec4 { v_uv_1 = v_uv; main_1(); let _e3 = frag_color; @@ -41065,6 +46481,7 @@ struct FogInfo { fog: vec4, eye: vec4, forward: vec4, + projection: vec4, } struct LightListInfo { @@ -41097,6 +46514,7 @@ struct FragInfo { ambient_ground: vec4, shadow_bias: vec4, target_origin: vec4, + debug_view: vec4, } struct PointShadow { @@ -41118,11 +46536,13 @@ var g_albedo: vec3; var g_debug_surface: vec3; var g_debug_surface_on: bool; var g_premultiply: bool; +var g_pass_through: f32; var g_cluster_offset: f32; var g_cluster_count: f32; -var v_world_position_1: vec3; +var light_transmittance: vec3; @group(1) @binding(0) var fog_info: FogInfo; +var v_world_position_1: vec3; var frag_albedo: vec4; var frag_surface: vec4; var v_normal_1: vec3; @@ -41165,10 +46585,10 @@ var v_tangent_1: vec4; var v_lightmap_uv_1: vec2; fn ViewDepth_u0028_() -> f32 { - let _e166 = v_world_position_1; - let _e168 = fog_info.eye; - let _e172 = fog_info.forward; - return dot((_e166 - _e168.xyz), _e172.xyz); + let _e178 = v_world_position_1; + let _e180 = fog_info.eye; + let _e184 = fog_info.forward; + return dot((_e178 - _e180.xyz), _e184.xyz); } fn WeightedBlendedWeight_u0028_f1_u003b(alpha: ptr) -> f32 { @@ -41177,22 +46597,22 @@ fn WeightedBlendedWeight_u0028_f1_u003b(alpha: ptr) -> f32 { var far: f32; var far3_: f32; - let _e171 = ViewDepth_u0028_(); - z = abs(_e171); - let _e173 = z; - near = (_e173 / 5f); - let _e175 = z; - far = (_e175 / 200f); - let _e177 = far; - let _e178 = far; - let _e180 = far; - far3_ = ((_e177 * _e178) * _e180); - let _e182 = (*alpha); - let _e183 = near; - let _e184 = near; - let _e187 = far3_; - let _e188 = far3_; - return (_e182 * clamp((10f / ((0.00001f + (_e183 * _e184)) + (_e187 * _e188))), 0.01f, 3000f)); + let _e183 = ViewDepth_u0028_(); + z = abs(_e183); + let _e185 = z; + near = (_e185 / 5f); + let _e187 = z; + far = (_e187 / 200f); + let _e189 = far; + let _e190 = far; + let _e192 = far; + far3_ = ((_e189 * _e190) * _e192); + let _e194 = (*alpha); + let _e195 = near; + let _e196 = near; + let _e199 = far3_; + let _e200 = far3_; + return (_e194 * clamp((10f / ((0.00001f + (_e195 * _e196)) + (_e199 * _e200))), 0.01f, 3000f)); } fn WriteWeightedBlended_u0028_() { @@ -41204,39 +46624,39 @@ fn WriteWeightedBlended_u0028_() { var revealage: bool; var local: vec4; - let _e175 = fog_info.forward[3u]; - mode = _e175; - let _e176 = mode; - if (_e176 > 0.5f) { - let _e179 = frag_color[3u]; - alpha_1 = _e179; - let _e180 = alpha_1; - param = _e180; - let _e181 = WeightedBlendedWeight_u0028_f1_u003b((¶m)); - weight = _e181; - let _e182 = frag_color; - let _e184 = weight; - let _e185 = (_e182.xyz * _e184); - let _e186 = alpha_1; - let _e187 = weight; - accumulate = vec4(_e185.x, _e185.y, _e185.z, (_e186 * _e187)); - let _e193 = mode; - let _e195 = mode; - revealage = ((_e193 > 1.5f) && (_e195 < 2.5f)); - let _e198 = revealage; - if _e198 { - let _e199 = alpha_1; - local = vec4(_e199); + let _e187 = fog_info.forward[3u]; + mode = _e187; + let _e188 = mode; + if (_e188 > 0.5f) { + let _e191 = frag_color[3u]; + alpha_1 = _e191; + let _e192 = alpha_1; + param = _e192; + let _e193 = WeightedBlendedWeight_u0028_f1_u003b((¶m)); + weight = _e193; + let _e194 = frag_color; + let _e196 = weight; + let _e197 = (_e194.xyz * _e196); + let _e198 = alpha_1; + let _e199 = weight; + accumulate = vec4(_e197.x, _e197.y, _e197.z, (_e198 * _e199)); + let _e205 = mode; + let _e207 = mode; + revealage = ((_e205 > 1.5f) && (_e207 < 2.5f)); + let _e210 = revealage; + if _e210 { + let _e211 = alpha_1; + local = vec4(_e211); } else { - let _e201 = accumulate; - local = _e201; + let _e213 = accumulate; + local = _e213; } - let _e202 = local; - frag_color = _e202; - let _e203 = mode; - if (_e203 > 2.5f) { - let _e205 = alpha_1; - frag_surface = vec4(_e205); + let _e214 = local; + frag_color = _e214; + let _e215 = mode; + if (_e215 > 2.5f) { + let _e217 = alpha_1; + frag_surface = vec4(_e217); } } return; @@ -41245,29 +46665,29 @@ fn WriteWeightedBlended_u0028_() { fn EncodeOctahedral_u0028_vf3_u003b(n: ptr>) -> vec2 { var e: vec2; - let _e169 = (*n)[0u]; - let _e172 = (*n)[1u]; - let _e176 = (*n)[2u]; - let _e179 = (*n); - (*n) = (_e179 / vec3(((abs(_e169) + abs(_e172)) + abs(_e176)))); - let _e182 = (*n); - e = _e182.xy; - let _e185 = (*n)[2u]; - if (_e185 < 0f) { - let _e187 = (*n); - let _e193 = (*n)[0u]; - let _e197 = (*n)[1u]; - e = ((vec2(1f) - abs(_e187.yx)) * vec2(select(-1f, 1f, (_e193 >= 0f)), select(-1f, 1f, (_e197 >= 0f)))); + let _e181 = (*n)[0u]; + let _e184 = (*n)[1u]; + let _e188 = (*n)[2u]; + let _e191 = (*n); + (*n) = (_e191 / vec3(((abs(_e181) + abs(_e184)) + abs(_e188)))); + let _e194 = (*n); + e = _e194.xy; + let _e197 = (*n)[2u]; + if (_e197 < 0f) { + let _e199 = (*n); + let _e205 = (*n)[0u]; + let _e209 = (*n)[1u]; + e = ((vec2(1f) - abs(_e199.yx)) * vec2(select(-1f, 1f, (_e205 >= 0f)), select(-1f, 1f, (_e209 >= 0f)))); } - let _e202 = e; - return ((_e202 * 0.5f) + vec2(0.5f)); + let _e214 = e; + return ((_e214 * 0.5f) + vec2(0.5f)); } fn LinearToSrgb_u0028_vf3_u003b(linear: ptr>) -> vec3 { - let _e167 = (*linear); - let _e169 = (*linear); - let _e173 = (*linear); - return mix((_e167 * 12.92f), ((pow(_e169, vec3(0.41666666f, 0.41666666f, 0.41666666f)) * 1.055f) - vec3(0.055f, 0.055f, 0.055f)), step(vec3(0.0031308f, 0.0031308f, 0.0031308f), _e173)); + let _e179 = (*linear); + let _e181 = (*linear); + let _e185 = (*linear); + return mix((_e179 * 12.92f), ((pow(_e181, vec3(0.41666666f, 0.41666666f, 0.41666666f)) * 1.055f) - vec3(0.055f, 0.055f, 0.055f)), step(vec3(0.0031308f, 0.0031308f, 0.0031308f), _e185)); } fn WriteSurfaceGeometry_u0028_f1_u003b(roughness: ptr) { @@ -41275,57 +46695,96 @@ fn WriteSurfaceGeometry_u0028_f1_u003b(roughness: ptr) { var geometric: vec3; var param_2: vec3; - let _e170 = g_albedo; - param_1 = clamp(_e170, vec3(0f, 0f, 0f), vec3(1f, 1f, 1f)); - let _e172 = LinearToSrgb_u0028_vf3_u003b((¶m_1)); - frag_albedo = vec4(_e172.x, _e172.y, _e172.z, 1f); - let _e177 = g_debug_surface_on; - if _e177 { - let _e178 = g_debug_surface; - let _e179 = ViewDepth_u0028_(); - frag_surface = vec4(_e178.x, _e178.y, _e178.z, _e179); + let _e182 = g_albedo; + param_1 = clamp(_e182, vec3(0f, 0f, 0f), vec3(1f, 1f, 1f)); + let _e184 = LinearToSrgb_u0028_vf3_u003b((¶m_1)); + frag_albedo = vec4(_e184.x, _e184.y, _e184.z, 1f); + let _e189 = g_debug_surface_on; + if _e189 { + let _e190 = g_debug_surface; + let _e191 = ViewDepth_u0028_(); + frag_surface = vec4(_e190.x, _e190.y, _e190.z, _e191); return; } - let _e184 = v_normal_1; - geometric = normalize(_e184); - let _e186 = gl_FrontFacing_1; - if !(_e186) { - let _e188 = geometric; - geometric = -(_e188); - } - let _e190 = geometric; - param_2 = _e190; - let _e191 = EncodeOctahedral_u0028_vf3_u003b((¶m_2)); - let _e192 = (*roughness); - let _e194 = ViewDepth_u0028_(); - frag_surface = vec4(_e191.x, _e191.y, clamp(_e192, 0f, 1f), _e194); + let _e196 = v_normal_1; + geometric = normalize(_e196); + let _e198 = gl_FrontFacing_1; + if !(_e198) { + let _e200 = geometric; + geometric = -(_e200); + } + let _e202 = geometric; + param_2 = _e202; + let _e203 = EncodeOctahedral_u0028_vf3_u003b((¶m_2)); + let _e204 = (*roughness); + let _e206 = ViewDepth_u0028_(); + frag_surface = vec4(_e203.x, _e203.y, clamp(_e204, 0f, 1f), _e206); return; } +fn Orthographic_u0028_() -> bool { + let _e180 = fog_info.projection[0u]; + return (_e180 > 0.5f); +} + fn EyeDistance_u0028_() -> f32 { - let _e166 = v_world_position_1; - let _e168 = fog_info.eye; - return distance(_e166, _e168.xyz); + var local_1: f32; + + let _e179 = Orthographic_u0028_(); + if _e179 { + let _e180 = ViewDepth_u0028_(); + local_1 = max(_e180, 0f); + } else { + let _e182 = v_world_position_1; + let _e184 = fog_info.eye; + local_1 = distance(_e182, _e184.xyz); + } + let _e187 = local_1; + return _e187; } fn ApplyFog_u0028_vf3_u003b(color: ptr>) -> vec3 { var density: f32; var d: f32; + var falloff: f32; + var k: f32; + var local_2: f32; - let _e171 = fog_info.fog[3u]; - density = _e171; - let _e172 = density; - if (_e172 <= 0f) { - let _e174 = (*color); - return _e174; + let _e186 = fog_info.fog[3u]; + density = _e186; + let _e187 = density; + if (_e187 <= 0f) { + let _e189 = (*color); + return _e189; + } + let _e190 = EyeDistance_u0028_(); + d = _e190; + let _e193 = fog_info.eye[3u]; + falloff = _e193; + let _e194 = falloff; + if (_e194 > 0f) { + let _e196 = falloff; + let _e198 = v_world_position_1[1u]; + let _e201 = fog_info.eye[1u]; + k = (_e196 * (_e198 - _e201)); + let _e204 = k; + if (abs(_e204) > 0.001f) { + let _e207 = k; + let _e211 = k; + local_2 = ((1f - exp(-(_e207))) / _e211); + } else { + let _e213 = k; + local_2 = (1f - (0.5f * _e213)); + } + let _e216 = local_2; + let _e217 = density; + density = (_e217 * _e216); } - let _e175 = EyeDistance_u0028_(); - d = _e175; - let _e177 = fog_info.fog; - let _e179 = (*color); - let _e180 = density; - let _e182 = d; - return mix(_e177.xyz, _e179, vec3(clamp(exp((-(_e180) * _e182)), 0f, 1f))); + let _e220 = fog_info.fog; + let _e222 = (*color); + let _e223 = density; + let _e225 = d; + return mix(_e220.xyz, _e222, vec3(clamp(exp((-(_e223) * _e225)), 0f, 1f))); } fn WriteSurface_u0028_vf3_u003b_f1_u003b_f1_u003b(linearColor: ptr>, alpha_2: ptr, roughness_1: ptr) { @@ -41333,55 +46792,196 @@ fn WriteSurface_u0028_vf3_u003b_f1_u003b_f1_u003b(linearColor: ptr; var param_4: f32; - let _e172 = g_premultiply; - let _e173 = (*alpha_2); - weight_1 = select(1f, _e173, _e172); - let _e175 = (*linearColor); - param_3 = _e175; - let _e176 = ApplyFog_u0028_vf3_u003b((¶m_3)); - let _e177 = weight_1; - let _e178 = (_e176 * _e177); - let _e179 = (*alpha_2); - frag_color = vec4(_e178.x, _e178.y, _e178.z, _e179); - let _e184 = (*roughness_1); - param_4 = _e184; + let _e184 = g_premultiply; + let _e185 = (*alpha_2); + weight_1 = select(1f, _e185, _e184); + let _e187 = (*linearColor); + param_3 = _e187; + let _e188 = ApplyFog_u0028_vf3_u003b((¶m_3)); + let _e189 = weight_1; + let _e190 = (_e188 * _e189); + let _e191 = (*alpha_2); + let _e192 = g_pass_through; + frag_color = vec4(_e190.x, _e190.y, _e190.z, (_e191 * (1f - _e192))); + let _e199 = (*roughness_1); + param_4 = _e199; WriteSurfaceGeometry_u0028_f1_u003b((¶m_4)); WriteWeightedBlended_u0028_(); return; } -fn ShadeLight_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b_struct_u002d_LightSample_u002d_vf3_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf31_u003b(s: ptr, light: ptr) -> vec3 { - let _e169 = (*s).albedo; - return _e169; +fn SrgbToLinear_u0028_vf3_u003b(srgb: ptr>) -> vec3 { + let _e179 = (*srgb); + let _e182 = (*srgb); + let _e187 = (*srgb); + return mix((_e179 / vec3(12.92f)), pow(((_e182 + vec3(0.055f, 0.055f, 0.055f)) / vec3(1.055f)), vec3(2.4f, 2.4f, 2.4f)), step(vec3(0.04045f, 0.04045f, 0.04045f), _e187)); +} + +fn NonFinite_u0028_vf3_u003b(c: ptr>) -> bool { + var phi_2753_: bool; + + let _e179 = (*c); + let _e180 = (*c); + let _e182 = any((_e179 != _e180)); + phi_2753_ = _e182; + if !(_e182) { + let _e184 = (*c); + phi_2753_ = any((abs(_e184) > vec3(300000000000000000000000000000000000000f, 300000000000000000000000000000000000000f, 300000000000000000000000000000000000000f))); + } + let _e189 = phi_2753_; + return _e189; +} + +fn WriteDebugView_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b_vf3_u003b(s: ptr, lit: ptr>) -> bool { + var view: f32; + var code: i32; + var shown: vec3; + var param_5: vec3; + var param_6: vec3; + var grey: f32; + var param_7: vec3; + var param_8: vec3; + var local_3: vec3; + var weight_2: f32; + var local_4: f32; + var param_9: vec3; + var param_10: f32; + + let _e195 = frag_info.debug_view[0u]; + view = _e195; + let _e196 = view; + if (_e196 < 0.5f) { + return false; + } + let _e199 = gl_FragCoord_1[0u]; + let _e202 = frag_info.debug_view[1u]; + let _e205 = frag_info.debug_view[2u]; + if (_e199 < (_e202 * _e205)) { + return false; + } + let _e208 = view; + code = i32((_e208 + 0.5f)); + shown = vec3(0f, 0f, 0f); + let _e211 = code; + if (_e211 == 1i) { + let _e214 = (*s).albedo; + param_5 = clamp(_e214, vec3(0f, 0f, 0f), vec3(1f, 1f, 1f)); + let _e216 = LinearToSrgb_u0028_vf3_u003b((¶m_5)); + shown = _e216; + } else { + let _e217 = code; + if (_e217 == 2i) { + let _e220 = (*s).n; + shown = ((_e220 * 0.5f) + vec3(0.5f, 0.5f, 0.5f)); + } else { + let _e223 = code; + if (_e223 == 3i) { + let _e226 = (*s).roughness; + shown = vec3(clamp(_e226, 0f, 1f)); + } else { + let _e229 = code; + if (_e229 == 4i) { + let _e232 = (*s).metallic; + shown = vec3(clamp(_e232, 0f, 1f)); + } else { + let _e235 = code; + if (_e235 == 5i) { + let _e238 = (*s).occlusion; + shown = vec3(clamp(_e238, 0f, 1f)); + } else { + let _e241 = code; + if (_e241 == 6i) { + let _e244 = (*s).emissive; + param_6 = clamp(_e244, vec3(0f, 0f, 0f), vec3(1f, 1f, 1f)); + let _e246 = LinearToSrgb_u0028_vf3_u003b((¶m_6)); + shown = _e246; + } else { + let _e247 = code; + if (_e247 == 7i) { + let _e249 = v_texcoord_1; + let _e250 = fract(_e249); + shown = vec3(_e250.x, _e250.y, 0f); + } else { + let _e254 = code; + if (_e254 == 8i) { + let _e256 = (*lit); + param_7 = clamp(_e256, vec3(0f, 0f, 0f), vec3(1f, 1f, 1f)); + let _e258 = LinearToSrgb_u0028_vf3_u003b((¶m_7)); + grey = dot(_e258, vec3(0.2126f, 0.7152f, 0.0722f)); + let _e260 = (*lit); + param_8 = _e260; + let _e261 = NonFinite_u0028_vf3_u003b((¶m_8)); + if _e261 { + local_3 = vec3(1f, 0f, 1f); + } else { + let _e262 = grey; + local_3 = vec3((_e262 * 0.5f)); + } + let _e265 = local_3; + shown = _e265; + } + } + } + } + } + } + } + } + let _e266 = g_premultiply; + if _e266 { + let _e268 = (*s).alpha; + local_4 = _e268; + } else { + local_4 = 1f; + } + let _e269 = local_4; + weight_2 = _e269; + let _e270 = shown; + param_9 = _e270; + let _e271 = SrgbToLinear_u0028_vf3_u003b((¶m_9)); + let _e272 = weight_2; + let _e273 = (_e271 * _e272); + let _e275 = (*s).alpha; + frag_color = vec4(_e273.x, _e273.y, _e273.z, _e275); + let _e281 = (*s).roughness; + param_10 = _e281; + WriteSurfaceGeometry_u0028_f1_u003b((¶m_10)); + WriteWeightedBlended_u0028_(); + return true; +} + +fn ShadeLight_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b_struct_u002d_LightSample_u002d_vf3_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf31_u003b(s_1: ptr, light: ptr) -> vec3 { + let _e181 = (*s_1).albedo; + return _e181; } fn PointShadowDiskTap_u0028_i1_u003b(i: ptr) -> vec2 { - let _e167 = (*i); - if (_e167 == 0i) { + let _e179 = (*i); + if (_e179 == 0i) { return vec2(-0.94201624f, -0.39906216f); } - let _e169 = (*i); - if (_e169 == 1i) { + let _e181 = (*i); + if (_e181 == 1i) { return vec2(0.9455861f, -0.76890725f); } - let _e171 = (*i); - if (_e171 == 2i) { + let _e183 = (*i); + if (_e183 == 2i) { return vec2(-0.0941841f, -0.9293887f); } - let _e173 = (*i); - if (_e173 == 3i) { + let _e185 = (*i); + if (_e185 == 3i) { return vec2(0.34495938f, 0.2938776f); } - let _e175 = (*i); - if (_e175 == 4i) { + let _e187 = (*i); + if (_e187 == 4i) { return vec2(-0.9158858f, 0.45771432f); } - let _e177 = (*i); - if (_e177 == 5i) { + let _e189 = (*i); + if (_e189 == 5i) { return vec2(-0.8154423f, -0.87912464f); } - let _e179 = (*i); - if (_e179 == 6i) { + let _e191 = (*i); + if (_e191 == 6i) { return vec2(-0.38277543f, 0.27676845f); } return vec2(0.974844f, 0.7564838f); @@ -41389,208 +46989,208 @@ fn PointShadowDiskTap_u0028_i1_u003b(i: ptr) -> vec2 { fn PointShadowOffset_u0028_i1_u003b_f1_u003b_f1_u003b_f1_u003b(i_1: ptr, ca: ptr, sa: ptr, radius: ptr) -> vec2 { var p: vec2; - var param_5: i32; - - let _e172 = (*i_1); - param_5 = _e172; - let _e173 = PointShadowDiskTap_u0028_i1_u003b((¶m_5)); - p = _e173; - let _e175 = p[0u]; - let _e176 = (*ca); - let _e179 = p[1u]; - let _e180 = (*sa); - let _e184 = p[0u]; - let _e185 = (*sa); - let _e188 = p[1u]; - let _e189 = (*ca); - let _e193 = (*radius); - return (vec2(((_e175 * _e176) - (_e179 * _e180)), ((_e184 * _e185) + (_e188 * _e189))) * _e193); + var param_11: i32; + + let _e184 = (*i_1); + param_11 = _e184; + let _e185 = PointShadowDiskTap_u0028_i1_u003b((¶m_11)); + p = _e185; + let _e187 = p[0u]; + let _e188 = (*ca); + let _e191 = p[1u]; + let _e192 = (*sa); + let _e196 = p[0u]; + let _e197 = (*sa); + let _e200 = p[1u]; + let _e201 = (*ca); + let _e205 = (*radius); + return (vec2(((_e187 * _e188) - (_e191 * _e192)), ((_e196 * _e197) + (_e200 * _e201))) * _e205); } fn PointShadowDistance_u0028_vf2_u003b_vf2_u003b_vf2_u003b_f1_u003b(uv: ptr>, offset: ptr>, tile: ptr>, range: ptr) -> f32 { var inset: f32; - var local_1: vec2; + var local_5: vec2; var atlas: vec2; - let _e175 = point_shadow.params[0u]; - inset = _e175; - let _e176 = (*uv); - let _e177 = (*offset); - let _e179 = inset; - let _e180 = inset; - local_1 = clamp((_e176 + _e177), vec2(_e179), vec2((1f - _e180))); - let _e185 = local_1; - let _e186 = (*tile); - atlas = ((_e185 + _e186) * vec2(0.16666667f, 0.16666667f)); - let _e191 = point_shadow.params3_[0u]; - if (_e191 > 0.5f) { - let _e194 = atlas[1u]; - atlas[1u] = (1f - _e194); + let _e187 = point_shadow.params[0u]; + inset = _e187; + let _e188 = (*uv); + let _e189 = (*offset); + let _e191 = inset; + let _e192 = inset; + local_5 = clamp((_e188 + _e189), vec2(_e191), vec2((1f - _e192))); + let _e197 = local_5; + let _e198 = (*tile); + atlas = ((_e197 + _e198) * vec2(0.16666667f, 0.16666667f)); + let _e203 = point_shadow.params3_[0u]; + if (_e203 > 0.5f) { + let _e206 = atlas[1u]; + atlas[1u] = (1f - _e206); } - let _e197 = atlas; - let _e198 = textureSampleLevel(point_shadow_texture_tex, point_shadow_texture_smp, _e197, 0f); - let _e200 = atlas; - let _e201 = textureSampleLevel(point_shadow_static_texture_tex, point_shadow_static_texture_smp, _e200, 0f); - let _e204 = (*range); - return (min(_e198.x, _e201.x) * _e204); + let _e209 = atlas; + let _e210 = textureSampleLevel(point_shadow_texture_tex, point_shadow_texture_smp, _e209, 0f); + let _e212 = atlas; + let _e213 = textureSampleLevel(point_shadow_static_texture_tex, point_shadow_static_texture_smp, _e212, 0f); + let _e216 = (*range); + return (min(_e210.x, _e213.x) * _e216); } fn PointShadowTap_u0028_vf2_u003b_vf2_u003b_vf2_u003b_f1_u003b_f1_u003b(uv_1: ptr>, offset_1: ptr>, tile_1: ptr>, range_1: ptr, receiver: ptr) -> f32 { var stored: f32; - var param_6: vec2; - var param_7: vec2; - var param_8: vec2; - var param_9: f32; + var param_12: vec2; + var param_13: vec2; + var param_14: vec2; + var param_15: f32; - let _e176 = (*uv_1); - param_6 = _e176; - let _e177 = (*offset_1); - param_7 = _e177; - let _e178 = (*tile_1); - param_8 = _e178; - let _e179 = (*range_1); - param_9 = _e179; - let _e180 = PointShadowDistance_u0028_vf2_u003b_vf2_u003b_vf2_u003b_f1_u003b((¶m_6), (¶m_7), (¶m_8), (¶m_9)); - stored = _e180; - let _e181 = stored; - let _e182 = (*range_1); - if (_e181 >= (_e182 * 0.999f)) { + let _e188 = (*uv_1); + param_12 = _e188; + let _e189 = (*offset_1); + param_13 = _e189; + let _e190 = (*tile_1); + param_14 = _e190; + let _e191 = (*range_1); + param_15 = _e191; + let _e192 = PointShadowDistance_u0028_vf2_u003b_vf2_u003b_vf2_u003b_f1_u003b((¶m_12), (¶m_13), (¶m_14), (¶m_15)); + stored = _e192; + let _e193 = stored; + let _e194 = (*range_1); + if (_e193 >= (_e194 * 0.999f)) { return 1f; } - let _e185 = (*receiver); - let _e186 = stored; - return select(1f, 0f, (_e185 > _e186)); + let _e197 = (*receiver); + let _e198 = stored; + return select(1f, 0f, (_e197 > _e198)); } fn PointShadowPenumbra_u0028_vf2_u003b_vf2_u003b_f1_u003b_f1_u003b_f1_u003b_f1_u003b_f1_u003b_f1_u003b(uv_2: ptr>, tile_2: ptr>, range_2: ptr, receiver_1: ptr, ca_1: ptr, sa_1: ptr, tanHalf: ptr, blockerOut: ptr) -> f32 { var lightRadius: f32; var minRadius: f32; var maxRadius: f32; - var param_10: vec2; - var param_11: vec2; - var param_12: vec2; - var param_13: f32; + var param_16: vec2; + var param_17: vec2; + var param_18: vec2; + var param_19: f32; var sum: f32; var count: f32; var i_2: i32; var stored_1: f32; - var param_14: i32; - var param_15: f32; - var param_16: f32; - var param_17: f32; - var param_18: vec2; - var param_19: vec2; - var param_20: vec2; + var param_20: i32; var param_21: f32; + var param_22: f32; + var param_23: f32; + var param_24: vec2; + var param_25: vec2; + var param_26: vec2; + var param_27: f32; var blocker: f32; var world: f32; (*blockerOut) = -1f; - let _e197 = point_shadow.params2_[1u]; - lightRadius = _e197; - let _e200 = point_shadow.params2_[0u]; - minRadius = _e200; - let _e203 = point_shadow.params2_[2u]; - maxRadius = _e203; - let _e204 = lightRadius; - if (_e204 <= 0f) { - let _e206 = (*uv_2); - param_10 = _e206; - param_11 = vec2(0f, 0f); - let _e207 = (*tile_2); - param_12 = _e207; - let _e208 = (*range_2); - param_13 = _e208; - let _e209 = PointShadowDistance_u0028_vf2_u003b_vf2_u003b_vf2_u003b_f1_u003b((¶m_10), (¶m_11), (¶m_12), (¶m_13)); - (*blockerOut) = _e209; - let _e210 = minRadius; - return _e210; + let _e209 = point_shadow.params2_[1u]; + lightRadius = _e209; + let _e212 = point_shadow.params2_[0u]; + minRadius = _e212; + let _e215 = point_shadow.params2_[2u]; + maxRadius = _e215; + let _e216 = lightRadius; + if (_e216 <= 0f) { + let _e218 = (*uv_2); + param_16 = _e218; + param_17 = vec2(0f, 0f); + let _e219 = (*tile_2); + param_18 = _e219; + let _e220 = (*range_2); + param_19 = _e220; + let _e221 = PointShadowDistance_u0028_vf2_u003b_vf2_u003b_vf2_u003b_f1_u003b((¶m_16), (¶m_17), (¶m_18), (¶m_19)); + (*blockerOut) = _e221; + let _e222 = minRadius; + return _e222; } sum = 0f; count = 0f; i_2 = 0i; loop { - let _e211 = i_2; - if (_e211 < 8i) { - let _e213 = i_2; - param_14 = _e213; - let _e214 = (*ca_1); - param_15 = _e214; - let _e215 = (*sa_1); - param_16 = _e215; - let _e216 = maxRadius; - param_17 = _e216; - let _e217 = PointShadowOffset_u0028_i1_u003b_f1_u003b_f1_u003b_f1_u003b((¶m_14), (¶m_15), (¶m_16), (¶m_17)); - let _e218 = (*uv_2); - param_18 = _e218; - param_19 = _e217; - let _e219 = (*tile_2); - param_20 = _e219; - let _e220 = (*range_2); - param_21 = _e220; - let _e221 = PointShadowDistance_u0028_vf2_u003b_vf2_u003b_vf2_u003b_f1_u003b((¶m_18), (¶m_19), (¶m_20), (¶m_21)); - stored_1 = _e221; - let _e222 = stored_1; - let _e223 = (*range_2); - if (_e222 >= (_e223 * 0.999f)) { + let _e223 = i_2; + if (_e223 < 8i) { + let _e225 = i_2; + param_20 = _e225; + let _e226 = (*ca_1); + param_21 = _e226; + let _e227 = (*sa_1); + param_22 = _e227; + let _e228 = maxRadius; + param_23 = _e228; + let _e229 = PointShadowOffset_u0028_i1_u003b_f1_u003b_f1_u003b_f1_u003b((¶m_20), (¶m_21), (¶m_22), (¶m_23)); + let _e230 = (*uv_2); + param_24 = _e230; + param_25 = _e229; + let _e231 = (*tile_2); + param_26 = _e231; + let _e232 = (*range_2); + param_27 = _e232; + let _e233 = PointShadowDistance_u0028_vf2_u003b_vf2_u003b_vf2_u003b_f1_u003b((¶m_24), (¶m_25), (¶m_26), (¶m_27)); + stored_1 = _e233; + let _e234 = stored_1; + let _e235 = (*range_2); + if (_e234 >= (_e235 * 0.999f)) { continue; } - let _e226 = stored_1; - let _e227 = (*receiver_1); - if (_e226 >= _e227) { + let _e238 = stored_1; + let _e239 = (*receiver_1); + if (_e238 >= _e239) { continue; } - let _e229 = stored_1; - let _e230 = sum; - sum = (_e230 + _e229); - let _e232 = count; - count = (_e232 + 1f); + let _e241 = stored_1; + let _e242 = sum; + sum = (_e242 + _e241); + let _e244 = count; + count = (_e244 + 1f); continue; } else { break; } continuing { - let _e234 = i_2; - i_2 = (_e234 + 1i); + let _e246 = i_2; + i_2 = (_e246 + 1i); } } - let _e236 = count; - if (_e236 < 0.5f) { + let _e248 = count; + if (_e248 < 0.5f) { return -1f; } - let _e238 = sum; - let _e239 = count; - blocker = max((_e238 / _e239), 0.0001f); - let _e242 = blocker; - (*blockerOut) = _e242; - let _e243 = lightRadius; - let _e244 = (*receiver_1); - let _e245 = blocker; - let _e249 = blocker; - world = ((_e243 * max((_e244 - _e245), 0f)) / _e249); - let _e251 = world; - let _e252 = (*receiver_1); - let _e254 = (*tanHalf); - let _e257 = minRadius; - let _e258 = maxRadius; - return clamp((_e251 / ((2f * _e252) * _e254)), _e257, _e258); + let _e250 = sum; + let _e251 = count; + blocker = max((_e250 / _e251), 0.0001f); + let _e254 = blocker; + (*blockerOut) = _e254; + let _e255 = lightRadius; + let _e256 = (*receiver_1); + let _e257 = blocker; + let _e261 = blocker; + world = ((_e255 * max((_e256 - _e257), 0f)) / _e261); + let _e263 = world; + let _e264 = (*receiver_1); + let _e266 = (*tanHalf); + let _e269 = minRadius; + let _e270 = maxRadius; + return clamp((_e263 / ((2f * _e264) * _e266)), _e269, _e270); } fn FragCoordFromTop_u0028_f1_u003b(rows: ptr) -> vec2 { - var local_2: vec2; + var local_6: vec2; - let _e168 = (*rows); - if (_e168 > 0f) { - let _e171 = gl_FragCoord_1[0u]; - let _e172 = (*rows); - let _e174 = gl_FragCoord_1[1u]; - local_2 = vec2(_e171, (_e172 - _e174)); + let _e180 = (*rows); + if (_e180 > 0f) { + let _e183 = gl_FragCoord_1[0u]; + let _e184 = (*rows); + let _e186 = gl_FragCoord_1[1u]; + local_6 = vec2(_e183, (_e184 - _e186)); } else { - let _e177 = gl_FragCoord_1; - local_2 = _e177.xy; + let _e189 = gl_FragCoord_1; + local_6 = _e189.xy; } - let _e179 = local_2; - return _e179; + let _e191 = local_6; + return _e191; } fn PointShadowFactor_u0028_vf3_u003b_vf3_u003b_i1_u003b(world_1: ptr>, normal: ptr>, lightIndex: ptr) -> f32 { @@ -41613,292 +47213,292 @@ fn PointShadowFactor_u0028_vf3_u003b_vf3_u003b_i1_u003b(world_1: ptr; var receiver_2: f32; var noise: f32; - var param_22: f32; + var param_28: f32; var angle: f32; var ca_2: f32; var sa_2: f32; var tanHalf_1: f32; var blocker_1: f32; var radius_1: f32; - var param_23: vec2; - var param_24: vec2; - var param_25: f32; - var param_26: f32; - var param_27: f32; - var param_28: f32; - var param_29: f32; - var param_30: f32; - var local_3: vec3; - var lit: f32; - var param_31: vec2; - var param_32: vec2; - var param_33: vec2; + var param_29: vec2; + var param_30: vec2; + var param_31: f32; + var param_32: f32; + var param_33: f32; var param_34: f32; var param_35: f32; + var param_36: f32; + var local_7: vec3; + var lit_1: f32; + var param_37: vec2; + var param_38: vec2; + var param_39: vec2; + var param_40: f32; + var param_41: f32; var i_3: i32; - var param_36: i32; - var param_37: f32; - var param_38: f32; - var param_39: f32; - var param_40: vec2; - var param_41: vec2; - var param_42: vec2; + var param_42: i32; var param_43: f32; var param_44: f32; - var phi_2339_: bool; - var phi_2400_: bool; - - let _e220 = (*lightIndex); - let _e224 = point_shadow.slots[_e220][0u]; - slot = i32((_e224 + 0.5f)); - let _e227 = (*lightIndex); - let _e231 = point_shadow.slots[_e227][0u]; - if (_e231 < 0f) { + var param_45: f32; + var param_46: vec2; + var param_47: vec2; + var param_48: vec2; + var param_49: f32; + var param_50: f32; + var phi_2440_: bool; + var phi_2501_: bool; + + let _e232 = (*lightIndex); + let _e236 = point_shadow.slots[_e232][0u]; + slot = i32((_e236 + 0.5f)); + let _e239 = (*lightIndex); + let _e243 = point_shadow.slots[_e239][0u]; + if (_e243 < 0f) { return 1f; } - let _e235 = point_shadow.params[2u]; - strength = _e235; - let _e236 = strength; - if (_e236 <= 0f) { + let _e247 = point_shadow.params[2u]; + strength = _e247; + let _e248 = strength; + if (_e248 <= 0f) { return 1f; } - let _e238 = slot; - let _e241 = point_shadow.lights[_e238]; - let _e243 = (*world_1); - toLight = (_e241.xyz - _e243); - let _e245 = toLight; - toLightLength = max(length(_e245), 0.000001f); - let _e248 = (*normal); - let _e249 = toLight; - let _e250 = toLightLength; - nDotL = max(dot(_e248, (_e249 / vec3(_e250))), 0.15f); - let _e255 = nDotL; - let _e256 = nDotL; - let _e261 = nDotL; - let _e262 = nDotL; - slope = min((sqrt(max((1f - (_e255 * _e256)), 0f)) / (_e261 * _e262)), 8f); - let _e266 = toLightLength; - let _e268 = (*lightIndex); - let _e272 = point_shadow.slots[_e268][2u]; - let _e277 = point_shadow.params3_[1u]; - texel = (((2f * _e266) * max(_e272, 0.0001f)) * _e277); - let _e279 = (*world_1); - let _e280 = (*normal); - let _e281 = texel; - let _e285 = point_shadow.params[3u]; - let _e287 = slope; - origin = (_e279 + (((_e280 * _e281) * _e285) * (1f + _e287))); - let _e291 = origin; - let _e292 = slot; - let _e295 = point_shadow.lights[_e292]; - toFragment = (_e291 - _e295.xyz); - let _e298 = toFragment; - distance_ = length(_e298); - let _e300 = slot; - let _e304 = point_shadow.lights[_e300][3u]; - range_3 = max(_e304, 0.0001f); - let _e306 = distance_; - let _e307 = range_3; - if (_e306 >= _e307) { + let _e250 = slot; + let _e253 = point_shadow.lights[_e250]; + let _e255 = (*world_1); + toLight = (_e253.xyz - _e255); + let _e257 = toLight; + toLightLength = max(length(_e257), 0.000001f); + let _e260 = (*normal); + let _e261 = toLight; + let _e262 = toLightLength; + nDotL = max(dot(_e260, (_e261 / vec3(_e262))), 0.15f); + let _e267 = nDotL; + let _e268 = nDotL; + let _e273 = nDotL; + let _e274 = nDotL; + slope = min((sqrt(max((1f - (_e267 * _e268)), 0f)) / (_e273 * _e274)), 8f); + let _e278 = toLightLength; + let _e280 = (*lightIndex); + let _e284 = point_shadow.slots[_e280][2u]; + let _e289 = point_shadow.params3_[1u]; + texel = (((2f * _e278) * max(_e284, 0.0001f)) * _e289); + let _e291 = (*world_1); + let _e292 = (*normal); + let _e293 = texel; + let _e297 = point_shadow.params[3u]; + let _e299 = slope; + origin = (_e291 + (((_e292 * _e293) * _e297) * (1f + _e299))); + let _e303 = origin; + let _e304 = slot; + let _e307 = point_shadow.lights[_e304]; + toFragment = (_e303 - _e307.xyz); + let _e310 = toFragment; + distance_ = length(_e310); + let _e312 = slot; + let _e316 = point_shadow.lights[_e312][3u]; + range_3 = max(_e316, 0.0001f); + let _e318 = distance_; + let _e319 = range_3; + if (_e318 >= _e319) { return 1f; } face = 0i; - let _e309 = (*lightIndex); - let _e313 = point_shadow.slots[_e309][1u]; - if (_e313 < 0.5f) { - let _e315 = toFragment; - a = abs(_e315); - let _e318 = a[0u]; - let _e320 = a[1u]; - let _e321 = (_e318 >= _e320); - phi_2339_ = _e321; - if _e321 { - let _e323 = a[0u]; - let _e325 = a[2u]; - phi_2339_ = (_e323 >= _e325); - } - let _e328 = phi_2339_; - if _e328 { - let _e330 = toFragment[0u]; - face = select(1i, 0i, (_e330 > 0f)); + let _e321 = (*lightIndex); + let _e325 = point_shadow.slots[_e321][1u]; + if (_e325 < 0.5f) { + let _e327 = toFragment; + a = abs(_e327); + let _e330 = a[0u]; + let _e332 = a[1u]; + let _e333 = (_e330 >= _e332); + phi_2440_ = _e333; + if _e333 { + let _e335 = a[0u]; + let _e337 = a[2u]; + phi_2440_ = (_e335 >= _e337); + } + let _e340 = phi_2440_; + if _e340 { + let _e342 = toFragment[0u]; + face = select(1i, 0i, (_e342 > 0f)); } else { - let _e334 = a[1u]; - let _e336 = a[2u]; - if (_e334 >= _e336) { - let _e339 = toFragment[1u]; - face = select(3i, 2i, (_e339 > 0f)); + let _e346 = a[1u]; + let _e348 = a[2u]; + if (_e346 >= _e348) { + let _e351 = toFragment[1u]; + face = select(3i, 2i, (_e351 > 0f)); } else { - let _e343 = toFragment[2u]; - face = select(5i, 4i, (_e343 > 0f)); + let _e355 = toFragment[2u]; + face = select(5i, 4i, (_e355 > 0f)); } } } - let _e346 = slot; - let _e348 = face; - let _e352 = point_shadow.faces[((_e346 * 6i) + _e348)]; - let _e353 = origin; - clip = (_e352 * vec4(_e353.x, _e353.y, _e353.z, 1f)); - let _e360 = clip[3u]; - if (_e360 <= 0f) { + let _e358 = slot; + let _e360 = face; + let _e364 = point_shadow.faces[((_e358 * 6i) + _e360)]; + let _e365 = origin; + clip = (_e364 * vec4(_e365.x, _e365.y, _e365.z, 1f)); + let _e372 = clip[3u]; + if (_e372 <= 0f) { return 1f; } - let _e362 = clip; - let _e365 = clip[3u]; - ndc = (_e362.xy / vec2(_e365)); - let _e369 = ndc[0u]; - let _e371 = (abs(_e369) > 1f); - phi_2400_ = _e371; - if !(_e371) { - let _e374 = ndc[1u]; - phi_2400_ = (abs(_e374) > 1f); - } - let _e378 = phi_2400_; - if _e378 { + let _e374 = clip; + let _e377 = clip[3u]; + ndc = (_e374.xy / vec2(_e377)); + let _e381 = ndc[0u]; + let _e383 = (abs(_e381) > 1f); + phi_2501_ = _e383; + if !(_e383) { + let _e386 = ndc[1u]; + phi_2501_ = (abs(_e386) > 1f); + } + let _e390 = phi_2501_; + if _e390 { return 1f; } - let _e380 = ndc[0u]; - let _e384 = ndc[1u]; - uv_3 = vec2(((_e380 * 0.5f) + 0.5f), (0.5f - (_e384 * 0.5f))); - let _e388 = face; - let _e390 = slot; - tile_3 = vec2(f32(_e388), f32(_e390)); - let _e393 = distance_; - let _e396 = point_shadow.params[1u]; - receiver_2 = (_e393 - _e396); - let _e400 = frag_info.target_origin[0u]; - param_22 = _e400; - let _e401 = FragCoordFromTop_u0028_f1_u003b((¶m_22)); - noise = fract((52.982918f * fract(dot(_e401, vec2(0.06711056f, 0.00583715f))))); - let _e406 = noise; - angle = (_e406 * 6.2831855f); - let _e408 = angle; - ca_2 = cos(_e408); - let _e410 = angle; - sa_2 = sin(_e410); - let _e412 = (*lightIndex); - let _e416 = point_shadow.slots[_e412][2u]; - tanHalf_1 = max(_e416, 0.0001f); + let _e392 = ndc[0u]; + let _e396 = ndc[1u]; + uv_3 = vec2(((_e392 * 0.5f) + 0.5f), (0.5f - (_e396 * 0.5f))); + let _e400 = face; + let _e402 = slot; + tile_3 = vec2(f32(_e400), f32(_e402)); + let _e405 = distance_; + let _e408 = point_shadow.params[1u]; + receiver_2 = (_e405 - _e408); + let _e412 = frag_info.target_origin[0u]; + param_28 = _e412; + let _e413 = FragCoordFromTop_u0028_f1_u003b((¶m_28)); + noise = fract((52.982918f * fract(dot(_e413, vec2(0.06711056f, 0.00583715f))))); + let _e418 = noise; + angle = (_e418 * 6.2831855f); + let _e420 = angle; + ca_2 = cos(_e420); + let _e422 = angle; + sa_2 = sin(_e422); + let _e424 = (*lightIndex); + let _e428 = point_shadow.slots[_e424][2u]; + tanHalf_1 = max(_e428, 0.0001f); blocker_1 = -1f; - let _e418 = uv_3; - param_23 = _e418; - let _e419 = tile_3; - param_24 = _e419; - let _e420 = range_3; - param_25 = _e420; - let _e421 = receiver_2; - param_26 = _e421; - let _e422 = ca_2; - param_27 = _e422; - let _e423 = sa_2; - param_28 = _e423; - let _e424 = tanHalf_1; - param_29 = _e424; - let _e425 = PointShadowPenumbra_u0028_vf2_u003b_vf2_u003b_f1_u003b_f1_u003b_f1_u003b_f1_u003b_f1_u003b_f1_u003b((¶m_23), (¶m_24), (¶m_25), (¶m_26), (¶m_27), (¶m_28), (¶m_29), (¶m_30)); - let _e426 = param_30; - blocker_1 = _e426; - radius_1 = _e425; - let _e429 = point_shadow.params2_[3u]; - if (_e429 > 0.5f) { + let _e430 = uv_3; + param_29 = _e430; + let _e431 = tile_3; + param_30 = _e431; + let _e432 = range_3; + param_31 = _e432; + let _e433 = receiver_2; + param_32 = _e433; + let _e434 = ca_2; + param_33 = _e434; + let _e435 = sa_2; + param_34 = _e435; + let _e436 = tanHalf_1; + param_35 = _e436; + let _e437 = PointShadowPenumbra_u0028_vf2_u003b_vf2_u003b_f1_u003b_f1_u003b_f1_u003b_f1_u003b_f1_u003b_f1_u003b((¶m_29), (¶m_30), (¶m_31), (¶m_32), (¶m_33), (¶m_34), (¶m_35), (¶m_36)); + let _e438 = param_36; + blocker_1 = _e438; + radius_1 = _e437; + let _e441 = point_shadow.params2_[3u]; + if (_e441 > 0.5f) { g_debug_surface_on = true; - let _e431 = radius_1; - if (_e431 < 0f) { - local_3 = vec3(0f, 0f, 1f); + let _e443 = radius_1; + if (_e443 < 0f) { + local_7 = vec3(0f, 0f, 1f); } else { - let _e433 = radius_1; - let _e436 = point_shadow.params2_[2u]; - let _e440 = blocker_1; - let _e441 = range_3; - local_3 = vec3(clamp((_e433 / max(_e436, 0.000001f)), 0f, 1f), clamp((_e440 / _e441), 0f, 1f), 0f); + let _e445 = radius_1; + let _e448 = point_shadow.params2_[2u]; + let _e452 = blocker_1; + let _e453 = range_3; + local_7 = vec3(clamp((_e445 / max(_e448, 0.000001f)), 0f, 1f), clamp((_e452 / _e453), 0f, 1f), 0f); } - let _e445 = local_3; - g_debug_surface = _e445; + let _e457 = local_7; + g_debug_surface = _e457; } - let _e446 = radius_1; - if (_e446 < 0f) { + let _e458 = radius_1; + if (_e458 < 0f) { return 1f; } - let _e448 = uv_3; - param_31 = _e448; - param_32 = vec2(0f, 0f); - let _e449 = tile_3; - param_33 = _e449; - let _e450 = range_3; - param_34 = _e450; - let _e451 = receiver_2; - param_35 = _e451; - let _e452 = PointShadowTap_u0028_vf2_u003b_vf2_u003b_vf2_u003b_f1_u003b_f1_u003b((¶m_31), (¶m_32), (¶m_33), (¶m_34), (¶m_35)); - lit = _e452; - let _e453 = radius_1; - if (_e453 > 0f) { + let _e460 = uv_3; + param_37 = _e460; + param_38 = vec2(0f, 0f); + let _e461 = tile_3; + param_39 = _e461; + let _e462 = range_3; + param_40 = _e462; + let _e463 = receiver_2; + param_41 = _e463; + let _e464 = PointShadowTap_u0028_vf2_u003b_vf2_u003b_vf2_u003b_f1_u003b_f1_u003b((¶m_37), (¶m_38), (¶m_39), (¶m_40), (¶m_41)); + lit_1 = _e464; + let _e465 = radius_1; + if (_e465 > 0f) { i_3 = 0i; loop { - let _e455 = i_3; - if (_e455 < 8i) { - let _e457 = i_3; - param_36 = _e457; - let _e458 = ca_2; - param_37 = _e458; - let _e459 = sa_2; - param_38 = _e459; - let _e460 = radius_1; - param_39 = _e460; - let _e461 = PointShadowOffset_u0028_i1_u003b_f1_u003b_f1_u003b_f1_u003b((¶m_36), (¶m_37), (¶m_38), (¶m_39)); - let _e462 = uv_3; - param_40 = _e462; - param_41 = _e461; - let _e463 = tile_3; - param_42 = _e463; - let _e464 = range_3; - param_43 = _e464; - let _e465 = receiver_2; - param_44 = _e465; - let _e466 = PointShadowTap_u0028_vf2_u003b_vf2_u003b_vf2_u003b_f1_u003b_f1_u003b((¶m_40), (¶m_41), (¶m_42), (¶m_43), (¶m_44)); - let _e467 = lit; - lit = (_e467 + _e466); + let _e467 = i_3; + if (_e467 < 8i) { + let _e469 = i_3; + param_42 = _e469; + let _e470 = ca_2; + param_43 = _e470; + let _e471 = sa_2; + param_44 = _e471; + let _e472 = radius_1; + param_45 = _e472; + let _e473 = PointShadowOffset_u0028_i1_u003b_f1_u003b_f1_u003b_f1_u003b((¶m_42), (¶m_43), (¶m_44), (¶m_45)); + let _e474 = uv_3; + param_46 = _e474; + param_47 = _e473; + let _e475 = tile_3; + param_48 = _e475; + let _e476 = range_3; + param_49 = _e476; + let _e477 = receiver_2; + param_50 = _e477; + let _e478 = PointShadowTap_u0028_vf2_u003b_vf2_u003b_vf2_u003b_f1_u003b_f1_u003b((¶m_46), (¶m_47), (¶m_48), (¶m_49), (¶m_50)); + let _e479 = lit_1; + lit_1 = (_e479 + _e478); continue; } else { break; } continuing { - let _e469 = i_3; - i_3 = (_e469 + 1i); + let _e481 = i_3; + i_3 = (_e481 + 1i); } } - let _e471 = lit; - lit = (_e471 * 0.11111111f); + let _e483 = lit_1; + lit_1 = (_e483 * 0.11111111f); } - let _e473 = lit; - let _e474 = strength; - return mix(1f, _e473, clamp(_e474, 0f, 1f)); + let _e485 = lit_1; + let _e486 = strength; + return mix(1f, _e485, clamp(_e486, 0f, 1f)); } fn VogelDisc_u0028_i1_u003b_i1_u003b_f1_u003b(i_4: ptr, n_1: ptr, turn: ptr) -> vec2 { var r: f32; var theta: f32; - let _e171 = (*i_4); - let _e174 = (*n_1); - r = sqrt(((f32(_e171) + 0.5f) / f32(_e174))); - let _e178 = (*i_4); - let _e181 = (*turn); - theta = ((f32(_e178) * 2.3999631f) + _e181); - let _e183 = r; - let _e184 = theta; - let _e186 = theta; - return (vec2(cos(_e184), sin(_e186)) * _e183); + let _e183 = (*i_4); + let _e186 = (*n_1); + r = sqrt(((f32(_e183) + 0.5f) / f32(_e186))); + let _e190 = (*i_4); + let _e193 = (*turn); + theta = ((f32(_e190) * 2.3999631f) + _e193); + let _e195 = r; + let _e196 = theta; + let _e198 = theta; + return (vec2(cos(_e196), sin(_e198)) * _e195); } fn ShadowNoise_u0028_() -> f32 { var at: vec2; - var param_45: f32; + var param_51: f32; - let _e170 = frag_info.target_origin[0u]; - param_45 = _e170; - let _e171 = FragCoordFromTop_u0028_f1_u003b((¶m_45)); - let _e174 = frag_info.target_origin[3u]; - at = (_e171 + vec2((5.588238f * max(_e174, 0f)))); - let _e179 = at; - return fract((52.982918f * fract(dot(_e179, vec2(0.06711056f, 0.00583715f))))); + let _e182 = frag_info.target_origin[0u]; + param_51 = _e182; + let _e183 = FragCoordFromTop_u0028_f1_u003b((¶m_51)); + let _e186 = frag_info.target_origin[3u]; + at = (_e183 + vec2((5.588238f * max(_e186, 0f)))); + let _e191 = at; + return fract((52.982918f * fract(dot(_e191, vec2(0.06711056f, 0.00583715f))))); } fn EvsmChebyshev_u0028_vf2_u003b_f1_u003b_f1_u003b_f1_u003b(moments: ptr>, t: ptr, minVariance: ptr, bleed: ptr) -> f32 { @@ -41907,91 +47507,92 @@ fn EvsmChebyshev_u0028_vf2_u003b_f1_u003b_f1_u003b_f1_u003b(moments: ptr) -> vec2 { var d_2: f32; - let _e168 = (*depth); - d_2 = ((2f * clamp(_e168, 0f, 1f)) - 1f); - let _e172 = d_2; - let _e175 = d_2; - return vec2(exp((40f * _e172)), -(exp((-5f * _e175)))); + let _e180 = (*depth); + d_2 = ((2f * clamp(_e180, 0f, 1f)) - 1f); + let _e184 = d_2; + let _e187 = d_2; + return vec2(exp((40f * _e184)), -(exp((-5f * _e187)))); } fn EvsmVisibility_u0028_vf4_u003b_f1_u003b_f1_u003b(moments_1: ptr>, depth_1: ptr, bleed_1: ptr) -> f32 { var warped: vec2; - var param_46: f32; + var param_52: f32; var scale: vec2; var positive: f32; - var param_47: vec2; - var param_48: f32; - var param_49: f32; - var param_50: f32; - var negative: f32; - var param_51: vec2; - var param_52: f32; - var param_53: f32; + var param_53: vec2; var param_54: f32; + var param_55: f32; + var param_56: f32; + var negative: f32; + var param_57: vec2; + var param_58: f32; + var param_59: f32; + var param_60: f32; - let _e182 = (*depth_1); - param_46 = _e182; - let _e183 = EvsmWarp_u0028_f1_u003b((¶m_46)); - warped = _e183; - let _e184 = warped; - scale = (vec2(0.004f, 0.0005f) * _e184); - let _e187 = scale[0u]; - let _e189 = scale[0u]; - let _e191 = (*moments_1); - param_47 = _e191.xy; - let _e194 = warped[0u]; - param_48 = _e194; - param_49 = (_e187 * _e189); - let _e195 = (*bleed_1); - param_50 = _e195; - let _e196 = EvsmChebyshev_u0028_vf2_u003b_f1_u003b_f1_u003b_f1_u003b((¶m_47), (¶m_48), (¶m_49), (¶m_50)); - positive = _e196; - let _e198 = scale[1u]; - let _e200 = scale[1u]; - let _e202 = (*moments_1); - param_51 = _e202.zw; - let _e205 = warped[1u]; - param_52 = _e205; - param_53 = (_e198 * _e200); - let _e206 = (*bleed_1); + let _e194 = (*depth_1); + param_52 = _e194; + let _e195 = EvsmWarp_u0028_f1_u003b((¶m_52)); + warped = _e195; + let _e196 = warped; + scale = (vec2(0.004f, 0.0005f) * _e196); + let _e199 = scale[0u]; + let _e201 = scale[0u]; + let _e203 = (*moments_1); + param_53 = _e203.xy; + let _e206 = warped[0u]; param_54 = _e206; - let _e207 = EvsmChebyshev_u0028_vf2_u003b_f1_u003b_f1_u003b_f1_u003b((¶m_51), (¶m_52), (¶m_53), (¶m_54)); - negative = _e207; - let _e208 = positive; - let _e209 = negative; - return min(_e208, _e209); -} - -fn ShadowFactor_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b_struct_u002d_LightSample_u002d_vf3_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf31_u003b_i1_u003b(s_1: ptr, light_1: ptr, lightIndex_1: ptr) -> f32 { + param_55 = (_e199 * _e201); + let _e207 = (*bleed_1); + param_56 = _e207; + let _e208 = EvsmChebyshev_u0028_vf2_u003b_f1_u003b_f1_u003b_f1_u003b((¶m_53), (¶m_54), (¶m_55), (¶m_56)); + positive = _e208; + let _e210 = scale[1u]; + let _e212 = scale[1u]; + let _e214 = (*moments_1); + param_57 = _e214.zw; + let _e217 = warped[1u]; + param_58 = _e217; + param_59 = (_e210 * _e212); + let _e218 = (*bleed_1); + param_60 = _e218; + let _e219 = EvsmChebyshev_u0028_vf2_u003b_f1_u003b_f1_u003b_f1_u003b((¶m_57), (¶m_58), (¶m_59), (¶m_60)); + negative = _e219; + let _e220 = positive; + let _e221 = negative; + return min(_e220, _e221); +} + +fn ShadowFactor_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b_struct_u002d_LightSample_u002d_vf3_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf31_u003b_i1_u003b(s_2: ptr, light_1: ptr, lightIndex_1: ptr) -> f32 { var strength_1: f32; var cascadeCount: i32; var viewDistance: f32; + var local_8: f32; var cascade: i32; var uv_4: vec2; var projected: vec3; @@ -42002,8 +47603,8 @@ fn ShadowFactor_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf var attempt: i32; var which: i32; var matrix: mat4x4; - var local_4: mat4x4; - var local_5: mat4x4; + var local_9: mat4x4; + var local_10: mat4x4; var axisX: vec3; var axisY: vec3; var rowX: f32; @@ -42017,17 +47618,19 @@ fn ShadowFactor_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf var inTile: vec2; var cosSlope: f32; var bias: f32; - var local_6: f32; - var local_7: f32; + var local_11: f32; + var local_12: f32; var texel_1: vec2; var tileLo: vec2; var tileHi: vec2; + var stored_2: vec4; + var through: vec3; var softness: f32; - var lit_1: f32; + var lit_2: f32; var moments_2: vec4; - var param_55: vec4; - var param_56: f32; - var param_57: f32; + var param_61: vec4; + var param_62: f32; + var param_63: f32; var y: i32; var x: i32; var occluder: f32; @@ -42038,65 +47641,73 @@ fn ShadowFactor_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf var blockerCount: f32; var i_5: i32; var disc: vec2; - var param_58: i32; - var param_59: i32; - var param_60: f32; + var param_64: i32; + var param_65: i32; + var param_66: f32; var tapBias: f32; var occluder_1: f32; var gap: f32; var radius_2: f32; var i_6: i32; var disc_1: vec2; - var param_61: i32; - var param_62: i32; - var param_63: f32; + var param_67: i32; + var param_68: i32; + var param_69: f32; var tapBias_1: f32; var occluder_2: f32; - var phi_2719_: bool; - var phi_2730_: bool; - var phi_2891_: bool; - var phi_2898_: bool; - var phi_2905_: bool; - - let _e234 = frag_info.shadow_params[3u]; - strength_1 = _e234; - let _e235 = strength_1; - if (_e235 <= 0f) { + var phi_2990_: bool; + var phi_3001_: bool; + var phi_3162_: bool; + var phi_3169_: bool; + var phi_3176_: bool; + + let _e249 = frag_info.shadow_params[3u]; + strength_1 = _e249; + let _e250 = strength_1; + if (_e250 <= 0f) { return 1f; } - let _e237 = (*lightIndex_1); - let _e240 = frag_info.frame_params[2u]; - if (_e237 != i32((_e240 + 0.5f))) { + let _e252 = (*lightIndex_1); + let _e255 = frag_info.frame_params[2u]; + if (_e252 != i32((_e255 + 0.5f))) { return 1f; } - let _e246 = frag_info.shadow_cascades[2u]; - cascadeCount = i32((_e246 + 0.5f)); - let _e249 = v_world_position_1; - let _e251 = frag_info.camera_position; - viewDistance = length((_e249 - _e251.xyz)); + let _e261 = frag_info.shadow_cascades[2u]; + cascadeCount = i32((_e261 + 0.5f)); + let _e264 = Orthographic_u0028_(); + if _e264 { + let _e265 = ViewDepth_u0028_(); + local_8 = _e265; + } else { + let _e266 = v_world_position_1; + let _e268 = frag_info.camera_position; + local_8 = length((_e266 - _e268.xyz)); + } + let _e272 = local_8; + viewDistance = _e272; cascade = 0i; - let _e255 = cascadeCount; - let _e256 = (_e255 > 1i); - phi_2719_ = _e256; - if _e256 { - let _e257 = viewDistance; - let _e260 = frag_info.shadow_cascades[0u]; - phi_2719_ = (_e257 > _e260); - } - let _e263 = phi_2719_; - if _e263 { + let _e273 = cascadeCount; + let _e274 = (_e273 > 1i); + phi_2990_ = _e274; + if _e274 { + let _e275 = viewDistance; + let _e278 = frag_info.shadow_cascades[0u]; + phi_2990_ = (_e275 > _e278); + } + let _e281 = phi_2990_; + if _e281 { cascade = 1i; } - let _e264 = cascadeCount; - let _e265 = (_e264 > 2i); - phi_2730_ = _e265; - if _e265 { - let _e266 = viewDistance; - let _e269 = frag_info.shadow_cascades[1u]; - phi_2730_ = (_e266 > _e269); + let _e282 = cascadeCount; + let _e283 = (_e282 > 2i); + phi_3001_ = _e283; + if _e283 { + let _e284 = viewDistance; + let _e287 = frag_info.shadow_cascades[1u]; + phi_3001_ = (_e284 > _e287); } - let _e272 = phi_2730_; - if _e272 { + let _e290 = phi_3001_; + if _e290 { cascade = 2i; } uv_4 = vec2(0f, 0f); @@ -42107,232 +47718,247 @@ fn ShadowFactor_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf receiverSlope = 0f; attempt = 0i; loop { - let _e273 = attempt; - if (_e273 < 3i) { - let _e275 = cascade; - let _e276 = attempt; - which = (_e275 + _e276); - let _e278 = which; - let _e279 = cascadeCount; - if (_e278 >= _e279) { + let _e291 = attempt; + if (_e291 < 3i) { + let _e293 = cascade; + let _e294 = attempt; + which = (_e293 + _e294); + let _e296 = which; + let _e297 = cascadeCount; + if (_e296 >= _e297) { break; } - let _e281 = which; - if (_e281 == 0i) { - let _e284 = frag_info.shadow_matrix; - local_4 = _e284; + let _e299 = which; + if (_e299 == 0i) { + let _e302 = frag_info.shadow_matrix; + local_9 = _e302; } else { - let _e285 = which; - if (_e285 == 1i) { - let _e288 = frag_info.shadow_matrix_far; - local_5 = _e288; + let _e303 = which; + if (_e303 == 1i) { + let _e306 = frag_info.shadow_matrix_far; + local_10 = _e306; } else { - let _e290 = frag_info.shadow_matrix_farthest; - local_5 = _e290; + let _e308 = frag_info.shadow_matrix_farthest; + local_10 = _e308; } - let _e291 = local_5; - local_4 = _e291; - } - let _e292 = local_4; - matrix = _e292; - let _e295 = matrix[0][0u]; - let _e298 = matrix[1][0u]; - let _e301 = matrix[2][0u]; - axisX = vec3(_e295, _e298, _e301); - let _e305 = matrix[0][1u]; - let _e308 = matrix[1][1u]; - let _e311 = matrix[2][1u]; - axisY = vec3(_e305, _e308, _e311); - let _e313 = axisX; - rowX = max(length(_e313), 0.000001f); - let _e316 = axisY; - rowY = max(length(_e316), 0.000001f); - let _e321 = frag_info.shadow_cascades[3u]; - let _e323 = rowX; - texelMetres = ((2f * _e321) / _e323); - let _e327 = frag_info.shadow_cascades[3u]; - let _e330 = (*s_1).n; + let _e309 = local_10; + local_9 = _e309; + } + let _e310 = local_9; + matrix = _e310; + let _e313 = matrix[0][0u]; + let _e316 = matrix[1][0u]; + let _e319 = matrix[2][0u]; + axisX = vec3(_e313, _e316, _e319); + let _e323 = matrix[0][1u]; + let _e326 = matrix[1][1u]; + let _e329 = matrix[2][1u]; + axisY = vec3(_e323, _e326, _e329); let _e331 = axisX; - let _e334 = rowX; - let _e335 = rowX; - let _e339 = (*s_1).n; - let _e340 = axisY; - let _e343 = rowY; - let _e344 = rowY; - reach = ((3f * _e327) * ((abs(dot(_e330, _e331)) / (_e334 * _e335)) + (abs(dot(_e339, _e340)) / (_e343 * _e344)))); - let _e351 = frag_info.shadow_params[2u]; - let _e352 = texelMetres; - flatOffset = min(_e351, _e352); - let _e354 = v_world_position_1; - let _e356 = (*s_1).n; - let _e357 = flatOffset; - let _e358 = reach; - origin_1 = (_e354 + (_e356 * (_e357 + _e358))); - let _e362 = matrix; - let _e363 = origin_1; - lightSpace = (_e362 * vec4(_e363.x, _e363.y, _e363.z, 1f)); - let _e370 = lightSpace[3u]; - if (_e370 <= 0f) { + rowX = max(length(_e331), 0.000001f); + let _e334 = axisY; + rowY = max(length(_e334), 0.000001f); + let _e339 = frag_info.shadow_cascades[3u]; + let _e341 = rowX; + texelMetres = ((2f * _e339) / _e341); + let _e345 = frag_info.shadow_cascades[3u]; + let _e348 = (*s_2).n; + let _e349 = axisX; + let _e352 = rowX; + let _e353 = rowX; + let _e357 = (*s_2).n; + let _e358 = axisY; + let _e361 = rowY; + let _e362 = rowY; + reach = ((3f * _e345) * ((abs(dot(_e348, _e349)) / (_e352 * _e353)) + (abs(dot(_e357, _e358)) / (_e361 * _e362)))); + let _e369 = frag_info.shadow_params[2u]; + let _e370 = texelMetres; + flatOffset = min(_e369, _e370); + let _e372 = v_world_position_1; + let _e374 = (*s_2).n; + let _e375 = flatOffset; + let _e376 = reach; + origin_1 = (_e372 + (_e374 * (_e375 + _e376))); + let _e380 = matrix; + let _e381 = origin_1; + lightSpace = (_e380 * vec4(_e381.x, _e381.y, _e381.z, 1f)); + let _e388 = lightSpace[3u]; + if (_e388 <= 0f) { continue; } - let _e372 = lightSpace; - let _e375 = lightSpace[3u]; - candidate = (_e372.xyz / vec3(_e375)); - let _e379 = candidate[0u]; - let _e383 = candidate[1u]; - inTile = vec2(((_e379 * 0.5f) + 0.5f), (0.5f - (_e383 * 0.5f))); - let _e388 = inTile[0u]; - let _e389 = (_e388 < 0f); - phi_2891_ = _e389; - if !(_e389) { - let _e392 = inTile[0u]; - phi_2891_ = (_e392 > 1f); - } - let _e395 = phi_2891_; - phi_2898_ = _e395; - if !(_e395) { - let _e398 = inTile[1u]; - phi_2898_ = (_e398 < 0f); - } - let _e401 = phi_2898_; - phi_2905_ = _e401; - if !(_e401) { - let _e404 = inTile[1u]; - phi_2905_ = (_e404 > 1f); - } - let _e407 = phi_2905_; - if _e407 { + let _e390 = lightSpace; + let _e393 = lightSpace[3u]; + candidate = (_e390.xyz / vec3(_e393)); + let _e397 = candidate[0u]; + let _e401 = candidate[1u]; + inTile = vec2(((_e397 * 0.5f) + 0.5f), (0.5f - (_e401 * 0.5f))); + let _e406 = inTile[0u]; + let _e407 = (_e406 < 0f); + phi_3162_ = _e407; + if !(_e407) { + let _e410 = inTile[0u]; + phi_3162_ = (_e410 > 1f); + } + let _e413 = phi_3162_; + phi_3169_ = _e413; + if !(_e413) { + let _e416 = inTile[1u]; + phi_3169_ = (_e416 < 0f); + } + let _e419 = phi_3169_; + phi_3176_ = _e419; + if !(_e419) { + let _e422 = inTile[1u]; + phi_3176_ = (_e422 > 1f); + } + let _e425 = phi_3176_; + if _e425 { continue; } - let _e409 = candidate[2u]; - if (_e409 > 1f) { - let _e411 = which; - let _e412 = cascadeCount; - if (_e411 < (_e412 - 1i)) { + let _e427 = candidate[2u]; + if (_e427 > 1f) { + let _e429 = which; + let _e430 = cascadeCount; + if (_e429 < (_e430 - 1i)) { continue; } candidate[2u] = 1f; } - let _e417 = inTile[0u]; - let _e418 = which; - let _e421 = cascadeCount; - let _e425 = inTile[1u]; - uv_4 = vec2(((_e417 + f32(_e418)) / f32(_e421)), _e425); - let _e427 = candidate; - projected = _e427; - let _e428 = which; - cascade = _e428; - let _e429 = texelMetres; - cascadeTexel = _e429; - let _e432 = matrix[0][2u]; - let _e435 = matrix[1][2u]; - let _e438 = matrix[2][2u]; - cascadeDepth = (1f / max(length(vec3(_e432, _e435, _e438)), 0.000001f)); - let _e444 = (*s_1).n; - let _e447 = matrix[0][2u]; - let _e450 = matrix[1][2u]; - let _e453 = matrix[2][2u]; - let _e457 = cascadeDepth; - cosSlope = clamp((abs(dot(_e444, vec3(_e447, _e450, _e453))) * _e457), 0.1f, 1f); - let _e460 = texelMetres; - let _e461 = cosSlope; - let _e462 = cosSlope; - let _e468 = cosSlope; - let _e470 = cascadeDepth; - receiverSlope = (((_e460 * sqrt(max((1f - (_e461 * _e462)), 0f))) / _e468) / _e470); + let _e435 = inTile[0u]; + let _e436 = which; + let _e439 = cascadeCount; + let _e443 = inTile[1u]; + uv_4 = vec2(((_e435 + f32(_e436)) / f32(_e439)), _e443); + let _e445 = candidate; + projected = _e445; + let _e446 = which; + cascade = _e446; + let _e447 = texelMetres; + cascadeTexel = _e447; + let _e450 = matrix[0][2u]; + let _e453 = matrix[1][2u]; + let _e456 = matrix[2][2u]; + cascadeDepth = (1f / max(length(vec3(_e450, _e453, _e456)), 0.000001f)); + let _e462 = (*s_2).n; + let _e465 = matrix[0][2u]; + let _e468 = matrix[1][2u]; + let _e471 = matrix[2][2u]; + let _e475 = cascadeDepth; + cosSlope = clamp((abs(dot(_e462, vec3(_e465, _e468, _e471))) * _e475), 0.1f, 1f); + let _e478 = texelMetres; + let _e479 = cosSlope; + let _e480 = cosSlope; + let _e486 = cosSlope; + let _e488 = cascadeDepth; + receiverSlope = (((_e478 * sqrt(max((1f - (_e479 * _e480)), 0f))) / _e486) / _e488); found = true; break; } else { break; } continuing { - let _e472 = attempt; - attempt = (_e472 + 1i); + let _e490 = attempt; + attempt = (_e490 + 1i); } } - let _e474 = found; - if !(_e474) { + let _e492 = found; + if !(_e492) { return 1f; } - let _e476 = cascade; - if (_e476 == 0i) { - let _e480 = frag_info.shadow_bias[0u]; - local_6 = _e480; + let _e494 = cascade; + if (_e494 == 0i) { + let _e498 = frag_info.shadow_bias[0u]; + local_11 = _e498; } else { - let _e481 = cascade; - if (_e481 == 1i) { - let _e485 = frag_info.shadow_bias[1u]; - local_7 = _e485; + let _e499 = cascade; + if (_e499 == 1i) { + let _e503 = frag_info.shadow_bias[1u]; + local_12 = _e503; } else { - let _e488 = frag_info.shadow_bias[2u]; - local_7 = _e488; - } - let _e489 = local_7; - local_6 = _e489; - } - let _e490 = local_6; - bias = _e490; - let _e493 = frag_info.shadow_params[0u]; - let _e496 = frag_info.shadow_cascades[3u]; - texel_1 = vec2(_e493, _e496); - let _e498 = cascade; - let _e500 = cascadeCount; - let _e504 = texel_1; - tileLo = (vec2((f32(_e498) / f32(_e500)), 0f) + (_e504 * 0.5f)); - let _e507 = cascade; - let _e510 = cascadeCount; - let _e514 = texel_1; - tileHi = (vec2((f32((_e507 + 1i)) / f32(_e510)), 1f) - (_e514 * 0.5f)); - let _e519 = frag_info.ambient_ground[3u]; - softness = _e519; - lit_1 = 0f; - let _e520 = softness; - if (_e520 < 0f) { - let _e522 = uv_4; - let _e523 = tileLo; - let _e524 = tileHi; - let _e526 = textureSampleLevel(shadow_texture_tex, shadow_texture_smp, clamp(_e522, _e523, _e524), 0f); - moments_2 = _e526; - let _e528 = projected[2u]; - let _e529 = bias; - let _e531 = softness; - let _e535 = moments_2; - param_55 = _e535; - param_56 = (_e528 - _e529); - param_57 = clamp((-(_e531) - 1f), 0f, 0.95f); - let _e536 = EvsmVisibility_u0028_vf4_u003b_f1_u003b_f1_u003b((¶m_55), (¶m_56), (¶m_57)); - lit_1 = _e536; - } else { - let _e537 = softness; - if (_e537 <= 0f) { + let _e506 = frag_info.shadow_bias[2u]; + local_12 = _e506; + } + let _e507 = local_12; + local_11 = _e507; + } + let _e508 = local_11; + bias = _e508; + let _e511 = frag_info.shadow_params[0u]; + let _e514 = frag_info.shadow_cascades[3u]; + texel_1 = vec2(_e511, _e514); + let _e516 = cascade; + let _e518 = cascadeCount; + let _e522 = texel_1; + tileLo = (vec2((f32(_e516) / f32(_e518)), 0f) + (_e522 * 0.5f)); + let _e525 = cascade; + let _e528 = cascadeCount; + let _e532 = texel_1; + tileHi = (vec2((f32((_e525 + 1i)) / f32(_e528)), 1f) - (_e532 * 0.5f)); + let _e537 = frag_info.shadow_bias[3u]; + if (_e537 > 0.5f) { + let _e539 = uv_4; + let _e540 = tileLo; + let _e541 = tileHi; + let _e543 = textureSampleLevel(shadow_texture_tex, shadow_texture_smp, clamp(_e539, _e540, _e541), 0f); + stored_2 = _e543; + let _e545 = stored_2[1u]; + let _e548 = stored_2[2u]; + let _e551 = stored_2[3u]; + through = clamp(vec3((1f - _e545), (1f - _e548), _e551), vec3(0f), vec3(4f)); + let _e556 = through; + let _e557 = strength_1; + light_transmittance = mix(vec3(1f, 1f, 1f), _e556, vec3(clamp(_e557, 0f, 1f))); + } + let _e563 = frag_info.ambient_ground[3u]; + softness = _e563; + lit_2 = 0f; + let _e564 = softness; + if (_e564 < 0f) { + let _e566 = uv_4; + let _e567 = tileLo; + let _e568 = tileHi; + let _e570 = textureSampleLevel(shadow_texture_tex, shadow_texture_smp, clamp(_e566, _e567, _e568), 0f); + moments_2 = _e570; + let _e572 = projected[2u]; + let _e573 = bias; + let _e575 = softness; + let _e579 = moments_2; + param_61 = _e579; + param_62 = (_e572 - _e573); + param_63 = clamp((-(_e575) - 1f), 0f, 0.95f); + let _e580 = EvsmVisibility_u0028_vf4_u003b_f1_u003b_f1_u003b((¶m_61), (¶m_62), (¶m_63)); + lit_2 = _e580; + } else { + let _e581 = softness; + if (_e581 <= 0f) { y = -1i; loop { - let _e539 = y; - if (_e539 <= 1i) { + let _e583 = y; + if (_e583 <= 1i) { x = -1i; loop { - let _e541 = x; - if (_e541 <= 1i) { - let _e543 = uv_4; - let _e544 = x; - let _e546 = y; - let _e549 = texel_1; - let _e552 = tileLo; - let _e553 = tileHi; - let _e555 = textureSampleLevel(shadow_texture_tex, shadow_texture_smp, clamp((_e543 + (vec2(f32(_e544), f32(_e546)) * _e549)), _e552, _e553), 0f); - occluder = _e555.x; - let _e558 = projected[2u]; - let _e559 = bias; - let _e561 = occluder; - let _e564 = lit_1; - lit_1 = (_e564 + select(1f, 0f, ((_e558 - _e559) > _e561))); + let _e585 = x; + if (_e585 <= 1i) { + let _e587 = uv_4; + let _e588 = x; + let _e590 = y; + let _e593 = texel_1; + let _e596 = tileLo; + let _e597 = tileHi; + let _e599 = textureSampleLevel(shadow_texture_tex, shadow_texture_smp, clamp((_e587 + (vec2(f32(_e588), f32(_e590)) * _e593)), _e596, _e597), 0f); + occluder = _e599.x; + let _e602 = projected[2u]; + let _e603 = bias; + let _e605 = occluder; + let _e608 = lit_2; + lit_2 = (_e608 + select(1f, 0f, ((_e602 - _e603) > _e605))); continue; } else { break; } continuing { - let _e566 = x; - x = (_e566 + 1i); + let _e610 = x; + x = (_e610 + 1i); } } continue; @@ -42340,145 +47966,145 @@ fn ShadowFactor_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf break; } continuing { - let _e568 = y; - y = (_e568 + 1i); + let _e612 = y; + y = (_e612 + 1i); } } - let _e570 = lit_1; - lit_1 = (_e570 * 0.11111111f); + let _e614 = lit_2; + lit_2 = (_e614 * 0.11111111f); } else { - let _e572 = softness; - spread = tan(min(_e572, 0.5f)); - let _e575 = ShadowNoise_u0028_(); - turn_1 = (_e575 * 6.2831855f); - let _e577 = spread; - let _e579 = projected[2u]; - let _e581 = cascadeDepth; - let _e583 = cascadeTexel; - searchRadius = clamp((((_e577 * _e579) * _e581) / _e583), 1f, 16f); + let _e616 = softness; + spread = tan(min(_e616, 0.5f)); + let _e619 = ShadowNoise_u0028_(); + turn_1 = (_e619 * 6.2831855f); + let _e621 = spread; + let _e623 = projected[2u]; + let _e625 = cascadeDepth; + let _e627 = cascadeTexel; + searchRadius = clamp((((_e621 * _e623) * _e625) / _e627), 1f, 16f); blockerSum = 0f; blockerCount = 0f; i_5 = 0i; loop { - let _e586 = i_5; - if (_e586 < 16i) { - let _e588 = i_5; - param_58 = _e588; - param_59 = 16i; - let _e589 = turn_1; - param_60 = _e589; - let _e590 = VogelDisc_u0028_i1_u003b_i1_u003b_f1_u003b((¶m_58), (¶m_59), (¶m_60)); - disc = _e590; - let _e591 = bias; - let _e592 = disc; - let _e594 = searchRadius; - let _e598 = receiverSlope; - tapBias = (_e591 + (max(((length(_e592) * _e594) - 1.5f), 0f) * _e598)); - let _e601 = uv_4; - let _e602 = disc; - let _e603 = texel_1; - let _e605 = searchRadius; - let _e608 = tileLo; - let _e609 = tileHi; - let _e611 = textureSampleLevel(shadow_texture_tex, shadow_texture_smp, clamp((_e601 + ((_e602 * _e603) * _e605)), _e608, _e609), 0f); - occluder_1 = _e611.x; - let _e614 = projected[2u]; - let _e615 = tapBias; - let _e617 = occluder_1; - if ((_e614 - _e615) > _e617) { - let _e619 = occluder_1; - let _e620 = blockerSum; - blockerSum = (_e620 + _e619); - let _e622 = blockerCount; - blockerCount = (_e622 + 1f); + let _e630 = i_5; + if (_e630 < 16i) { + let _e632 = i_5; + param_64 = _e632; + param_65 = 16i; + let _e633 = turn_1; + param_66 = _e633; + let _e634 = VogelDisc_u0028_i1_u003b_i1_u003b_f1_u003b((¶m_64), (¶m_65), (¶m_66)); + disc = _e634; + let _e635 = bias; + let _e636 = disc; + let _e638 = searchRadius; + let _e642 = receiverSlope; + tapBias = (_e635 + (max(((length(_e636) * _e638) - 1.5f), 0f) * _e642)); + let _e645 = uv_4; + let _e646 = disc; + let _e647 = texel_1; + let _e649 = searchRadius; + let _e652 = tileLo; + let _e653 = tileHi; + let _e655 = textureSampleLevel(shadow_texture_tex, shadow_texture_smp, clamp((_e645 + ((_e646 * _e647) * _e649)), _e652, _e653), 0f); + occluder_1 = _e655.x; + let _e658 = projected[2u]; + let _e659 = tapBias; + let _e661 = occluder_1; + if ((_e658 - _e659) > _e661) { + let _e663 = occluder_1; + let _e664 = blockerSum; + blockerSum = (_e664 + _e663); + let _e666 = blockerCount; + blockerCount = (_e666 + 1f); } continue; } else { break; } continuing { - let _e624 = i_5; - i_5 = (_e624 + 1i); + let _e668 = i_5; + i_5 = (_e668 + 1i); } } - let _e626 = blockerCount; - if (_e626 <= 0f) { + let _e670 = blockerCount; + if (_e670 <= 0f) { return 1f; } - let _e629 = projected[2u]; - let _e630 = blockerSum; - let _e631 = blockerCount; - let _e635 = cascadeDepth; - gap = (max((_e629 - (_e630 / _e631)), 0f) * _e635); - let _e637 = spread; - let _e638 = gap; - let _e640 = cascadeTexel; - radius_2 = clamp(((_e637 * _e638) / _e640), 1f, 16f); + let _e673 = projected[2u]; + let _e674 = blockerSum; + let _e675 = blockerCount; + let _e679 = cascadeDepth; + gap = (max((_e673 - (_e674 / _e675)), 0f) * _e679); + let _e681 = spread; + let _e682 = gap; + let _e684 = cascadeTexel; + radius_2 = clamp(((_e681 * _e682) / _e684), 1f, 16f); i_6 = 0i; loop { - let _e643 = i_6; - if (_e643 < 16i) { - let _e645 = turn_1; - let _e647 = i_6; - param_61 = _e647; - param_62 = 16i; - param_63 = (_e645 + 1f); - let _e648 = VogelDisc_u0028_i1_u003b_i1_u003b_f1_u003b((¶m_61), (¶m_62), (¶m_63)); - disc_1 = _e648; - let _e649 = bias; - let _e650 = disc_1; - let _e652 = radius_2; - let _e656 = receiverSlope; - tapBias_1 = (_e649 + (max(((length(_e650) * _e652) - 1.5f), 0f) * _e656)); - let _e659 = uv_4; - let _e660 = disc_1; - let _e661 = texel_1; - let _e663 = radius_2; - let _e666 = tileLo; - let _e667 = tileHi; - let _e669 = textureSampleLevel(shadow_texture_tex, shadow_texture_smp, clamp((_e659 + ((_e660 * _e661) * _e663)), _e666, _e667), 0f); - occluder_2 = _e669.x; - let _e672 = projected[2u]; - let _e673 = tapBias_1; - let _e675 = occluder_2; - let _e678 = lit_1; - lit_1 = (_e678 + select(1f, 0f, ((_e672 - _e673) > _e675))); + let _e687 = i_6; + if (_e687 < 16i) { + let _e689 = turn_1; + let _e691 = i_6; + param_67 = _e691; + param_68 = 16i; + param_69 = (_e689 + 1f); + let _e692 = VogelDisc_u0028_i1_u003b_i1_u003b_f1_u003b((¶m_67), (¶m_68), (¶m_69)); + disc_1 = _e692; + let _e693 = bias; + let _e694 = disc_1; + let _e696 = radius_2; + let _e700 = receiverSlope; + tapBias_1 = (_e693 + (max(((length(_e694) * _e696) - 1.5f), 0f) * _e700)); + let _e703 = uv_4; + let _e704 = disc_1; + let _e705 = texel_1; + let _e707 = radius_2; + let _e710 = tileLo; + let _e711 = tileHi; + let _e713 = textureSampleLevel(shadow_texture_tex, shadow_texture_smp, clamp((_e703 + ((_e704 * _e705) * _e707)), _e710, _e711), 0f); + occluder_2 = _e713.x; + let _e716 = projected[2u]; + let _e717 = tapBias_1; + let _e719 = occluder_2; + let _e722 = lit_2; + lit_2 = (_e722 + select(1f, 0f, ((_e716 - _e717) > _e719))); continue; } else { break; } continuing { - let _e680 = i_6; - i_6 = (_e680 + 1i); + let _e724 = i_6; + i_6 = (_e724 + 1i); } } - let _e682 = lit_1; - lit_1 = (_e682 * 0.0625f); + let _e726 = lit_2; + lit_2 = (_e726 * 0.0625f); } } - let _e684 = lit_1; - let _e685 = strength_1; - return mix(1f, _e684, clamp(_e685, 0f, 1f)); + let _e728 = lit_2; + let _e729 = strength_1; + return mix(1f, _e728, clamp(_e729, 0f, 1f)); } -fn LightVisibility_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b_struct_u002d_LightSample_u002d_vf3_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf31_u003b_i1_u003b(s_2: ptr, light_2: ptr, index: ptr) -> f32 { - var param_64: Surface; - var param_65: LightSample; - var param_66: i32; +fn LightVisibility_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b_struct_u002d_LightSample_u002d_vf3_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf31_u003b_i1_u003b(s_3: ptr, light_2: ptr, index: ptr) -> f32 { + var param_70: Surface; + var param_71: LightSample; + var param_72: i32; - let _e172 = (*s_2); - param_64 = _e172; - let _e173 = (*light_2); - param_65 = _e173; - let _e174 = (*index); - param_66 = _e174; - let _e175 = ShadowFactor_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b_struct_u002d_LightSample_u002d_vf3_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf31_u003b_i1_u003b((¶m_64), (¶m_65), (¶m_66)); - return _e175; + let _e184 = (*s_3); + param_70 = _e184; + let _e185 = (*light_2); + param_71 = _e185; + let _e186 = (*index); + param_72 = _e186; + let _e187 = ShadowFactor_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b_struct_u002d_LightSample_u002d_vf3_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf31_u003b_i1_u003b((¶m_70), (¶m_71), (¶m_72)); + return _e187; } fn LightHasShadow_u0028_i1_u003b(index_1: ptr) -> bool { - let _e167 = (*index_1); - return (_e167 < 8i); + let _e179 = (*index_1); + return (_e179 < 8i); } fn PunctualAttenuation_u0028_f1_u003b_f1_u003b(distance_1: ptr, range_4: ptr) -> f32 { @@ -42486,26 +48112,26 @@ fn PunctualAttenuation_u0028_f1_u003b_f1_u003b(distance_1: ptr, r var ratio: f32; var window: f32; - let _e171 = (*distance_1); - let _e172 = (*distance_1); - attenuation = (1f / max((_e171 * _e172), 0.0001f)); - let _e176 = (*range_4); - if (_e176 > 0f) { - let _e178 = (*distance_1); - let _e179 = (*range_4); - ratio = (_e178 / _e179); - let _e181 = ratio; - let _e182 = ratio; - let _e184 = ratio; - let _e186 = ratio; - window = clamp((1f - (((_e181 * _e182) * _e184) * _e186)), 0f, 1f); - let _e190 = window; - let _e191 = window; - let _e193 = attenuation; - attenuation = (_e193 * (_e190 * _e191)); - } - let _e195 = attenuation; - return _e195; + let _e183 = (*distance_1); + let _e184 = (*distance_1); + attenuation = (1f / max((_e183 * _e184), 0.0001f)); + let _e188 = (*range_4); + if (_e188 > 0f) { + let _e190 = (*distance_1); + let _e191 = (*range_4); + ratio = (_e190 / _e191); + let _e193 = ratio; + let _e194 = ratio; + let _e196 = ratio; + let _e198 = ratio; + window = clamp((1f - (((_e193 * _e194) * _e196) * _e198)), 0f, 1f); + let _e202 = window; + let _e203 = window; + let _e205 = attenuation; + attenuation = (_e205 * (_e202 * _e203)); + } + let _e207 = attenuation; + return _e207; } fn RectangleClosestPoint_u0028_vf3_u003b_vf3_u003b_vf3_u003b_vf3_u003b_vf3_u003b(centre: ptr>, halfWidth: ptr>, halfHeight: ptr>, world_2: ptr>, mirror: ptr>) -> vec3 { @@ -42515,82 +48141,82 @@ fn RectangleClosestPoint_u0028_vf3_u003b_vf3_u003b_vf3_u003b_vf3_u003b_vf3_u003b var denom: f32; var onPlane: vec3; var t_1: f32; - var local_8: vec3; + var local_13: vec3; var offset_2: vec3; var wLen2_: f32; var hLen2_: f32; var u: f32; var v: f32; - let _e183 = (*halfWidth); - let _e184 = (*halfHeight); - n_2 = cross(_e183, _e184); - let _e186 = n_2; - nLen = length(_e186); - let _e188 = nLen; - if (_e188 < 0.000000000001f) { - let _e190 = (*centre); - return _e190; - } - let _e191 = nLen; - let _e192 = n_2; - n_2 = (_e192 / vec3(_e191)); - let _e195 = (*centre); - let _e196 = (*world_2); - toPlane = (_e195 - _e196); - let _e198 = (*mirror); - let _e199 = n_2; - denom = dot(_e198, _e199); - let _e201 = denom; - if (abs(_e201) < 0.00001f) { - let _e204 = toPlane; - let _e205 = n_2; - let _e206 = toPlane; - let _e207 = n_2; - onPlane = (_e204 - (_e205 * dot(_e206, _e207))); - } else { - let _e211 = toPlane; - let _e212 = n_2; - let _e214 = denom; - t_1 = (dot(_e211, _e212) / _e214); - let _e216 = t_1; - if (_e216 > 0f) { - let _e218 = (*mirror); - let _e219 = t_1; - local_8 = (_e218 * _e219); + let _e195 = (*halfWidth); + let _e196 = (*halfHeight); + n_2 = cross(_e195, _e196); + let _e198 = n_2; + nLen = length(_e198); + let _e200 = nLen; + if (_e200 < 0.000000000001f) { + let _e202 = (*centre); + return _e202; + } + let _e203 = nLen; + let _e204 = n_2; + n_2 = (_e204 / vec3(_e203)); + let _e207 = (*centre); + let _e208 = (*world_2); + toPlane = (_e207 - _e208); + let _e210 = (*mirror); + let _e211 = n_2; + denom = dot(_e210, _e211); + let _e213 = denom; + if (abs(_e213) < 0.00001f) { + let _e216 = toPlane; + let _e217 = n_2; + let _e218 = toPlane; + let _e219 = n_2; + onPlane = (_e216 - (_e217 * dot(_e218, _e219))); + } else { + let _e223 = toPlane; + let _e224 = n_2; + let _e226 = denom; + t_1 = (dot(_e223, _e224) / _e226); + let _e228 = t_1; + if (_e228 > 0f) { + let _e230 = (*mirror); + let _e231 = t_1; + local_13 = (_e230 * _e231); } else { - let _e221 = toPlane; - let _e222 = n_2; - let _e223 = toPlane; - let _e224 = n_2; - local_8 = (_e221 - (_e222 * dot(_e223, _e224))); - } - let _e228 = local_8; - onPlane = _e228; - } - let _e229 = onPlane; - let _e230 = toPlane; - offset_2 = (_e229 - _e230); - let _e232 = (*halfWidth); - let _e233 = (*halfWidth); - wLen2_ = max(dot(_e232, _e233), 0.000000000001f); - let _e236 = (*halfHeight); - let _e237 = (*halfHeight); - hLen2_ = max(dot(_e236, _e237), 0.000000000001f); - let _e240 = offset_2; - let _e241 = (*halfWidth); - let _e243 = wLen2_; - u = clamp((dot(_e240, _e241) / _e243), -1f, 1f); - let _e246 = offset_2; - let _e247 = (*halfHeight); - let _e249 = hLen2_; - v = clamp((dot(_e246, _e247) / _e249), -1f, 1f); - let _e252 = (*centre); + let _e233 = toPlane; + let _e234 = n_2; + let _e235 = toPlane; + let _e236 = n_2; + local_13 = (_e233 - (_e234 * dot(_e235, _e236))); + } + let _e240 = local_13; + onPlane = _e240; + } + let _e241 = onPlane; + let _e242 = toPlane; + offset_2 = (_e241 - _e242); + let _e244 = (*halfWidth); + let _e245 = (*halfWidth); + wLen2_ = max(dot(_e244, _e245), 0.000000000001f); + let _e248 = (*halfHeight); + let _e249 = (*halfHeight); + hLen2_ = max(dot(_e248, _e249), 0.000000000001f); + let _e252 = offset_2; let _e253 = (*halfWidth); - let _e254 = u; - let _e257 = (*halfHeight); - let _e258 = v; - return ((_e252 + (_e253 * _e254)) + (_e257 * _e258)); + let _e255 = wLen2_; + u = clamp((dot(_e252, _e253) / _e255), -1f, 1f); + let _e258 = offset_2; + let _e259 = (*halfHeight); + let _e261 = hLen2_; + v = clamp((dot(_e258, _e259) / _e261), -1f, 1f); + let _e264 = (*centre); + let _e265 = (*halfWidth); + let _e266 = u; + let _e269 = (*halfHeight); + let _e270 = v; + return ((_e264 + (_e265 * _e266)) + (_e269 * _e270)); } fn LambertEdge_u0028_vf3_u003b_vf3_u003b_vf3_u003b(a_1: ptr>, b: ptr>, n_3: ptr>) -> f32 { @@ -42599,34 +48225,34 @@ fn LambertEdge_u0028_vf3_u003b_vf3_u003b_vf3_u003b(a_1: ptr> var angle_1: f32; var axis: vec3; var len: f32; - var local_9: f32; + var local_14: f32; - let _e175 = (*a_1); - let _e176 = (*a_1); - ua = (_e175 / vec3(max(length(_e176), 0.000000000001f))); - let _e181 = (*b); - let _e182 = (*b); - ub = (_e181 / vec3(max(length(_e182), 0.000000000001f))); - let _e187 = ua; - let _e188 = ub; - angle_1 = acos(clamp(dot(_e187, _e188), -1f, 1f)); - let _e192 = ua; - let _e193 = ub; - axis = cross(_e192, _e193); - let _e195 = axis; - len = length(_e195); - let _e197 = len; - if (_e197 > 0.000001f) { - let _e199 = angle_1; - let _e200 = axis; - let _e201 = (*n_3); - let _e204 = len; - local_9 = ((_e199 * dot(_e200, _e201)) / _e204); - } else { - local_9 = 0f; - } - let _e206 = local_9; - return _e206; + let _e187 = (*a_1); + let _e188 = (*a_1); + ua = (_e187 / vec3(max(length(_e188), 0.000000000001f))); + let _e193 = (*b); + let _e194 = (*b); + ub = (_e193 / vec3(max(length(_e194), 0.000000000001f))); + let _e199 = ua; + let _e200 = ub; + angle_1 = acos(clamp(dot(_e199, _e200), -1f, 1f)); + let _e204 = ua; + let _e205 = ub; + axis = cross(_e204, _e205); + let _e207 = axis; + len = length(_e207); + let _e209 = len; + if (_e209 > 0.000001f) { + let _e211 = angle_1; + let _e212 = axis; + let _e213 = (*n_3); + let _e216 = len; + local_14 = ((_e211 * dot(_e212, _e213)) / _e216); + } else { + local_14 = 0f; + } + let _e218 = local_14; + return _e218; } fn RectangleFormFactor_u0028_vf3_u005b_4_u005d_u003b_vf3_u003b(corners: ptr, 4>>, n_4: ptr>) -> f32 { @@ -42636,212 +48262,212 @@ fn RectangleFormFactor_u0028_vf3_u005b_4_u005d_u003b_vf3_u003b(corners: ptr; var b_1: vec3; - var local_10: i32; + var local_15: i32; var ha: f32; var hb: f32; var d_3: f32; var q: vec3; - var local_11: f32; + var local_16: f32; var aAbove: bool; var bAbove: bool; - var local_12: f32; - var param_67: vec3; - var param_68: vec3; - var param_69: vec3; - var param_70: vec3; - var param_71: vec3; - var param_72: vec3; + var local_17: f32; + var param_73: vec3; + var param_74: vec3; + var param_75: vec3; + var param_76: vec3; + var param_77: vec3; + var param_78: vec3; total = 0f; exit = vec3(0f, 0f, 0f); entry = vec3(0f, 0f, 0f); i_7 = 0i; loop { - let _e189 = i_7; - if (_e189 < 4i) { - let _e191 = i_7; - let _e193 = (*corners)[_e191]; - a_2 = _e193; - let _e194 = i_7; - if (_e194 == 3i) { - local_10 = 0i; + let _e201 = i_7; + if (_e201 < 4i) { + let _e203 = i_7; + let _e205 = (*corners)[_e203]; + a_2 = _e205; + let _e206 = i_7; + if (_e206 == 3i) { + local_15 = 0i; } else { - let _e196 = i_7; - local_10 = (_e196 + 1i); - } - let _e198 = local_10; - let _e200 = (*corners)[_e198]; - b_1 = _e200; - let _e201 = a_2; - let _e202 = (*n_4); - ha = dot(_e201, _e202); - let _e204 = b_1; - let _e205 = (*n_4); - hb = dot(_e204, _e205); - let _e207 = ha; - let _e208 = hb; - d_3 = (_e207 - _e208); - let _e210 = a_2; - let _e211 = b_1; - let _e212 = a_2; - let _e214 = d_3; - if (abs(_e214) > 0.000000000001f) { - let _e217 = ha; - let _e218 = d_3; - local_11 = (_e217 / _e218); + let _e208 = i_7; + local_15 = (_e208 + 1i); + } + let _e210 = local_15; + let _e212 = (*corners)[_e210]; + b_1 = _e212; + let _e213 = a_2; + let _e214 = (*n_4); + ha = dot(_e213, _e214); + let _e216 = b_1; + let _e217 = (*n_4); + hb = dot(_e216, _e217); + let _e219 = ha; + let _e220 = hb; + d_3 = (_e219 - _e220); + let _e222 = a_2; + let _e223 = b_1; + let _e224 = a_2; + let _e226 = d_3; + if (abs(_e226) > 0.000000000001f) { + let _e229 = ha; + let _e230 = d_3; + local_16 = (_e229 / _e230); } else { - local_11 = 0f; - } - let _e220 = local_11; - q = (_e210 + ((_e211 - _e212) * _e220)); - let _e223 = ha; - aAbove = (_e223 > 0f); - let _e225 = hb; - bAbove = (_e225 > 0f); - let _e227 = aAbove; - let _e228 = bAbove; - if (_e227 || _e228) { - let _e230 = aAbove; - let _e231 = a_2; - let _e232 = q; - let _e235 = bAbove; - let _e236 = b_1; - let _e237 = q; - param_67 = select(_e232, _e231, vec3(_e230)); - param_68 = select(_e237, _e236, vec3(_e235)); - let _e240 = (*n_4); - param_69 = _e240; - let _e241 = LambertEdge_u0028_vf3_u003b_vf3_u003b_vf3_u003b((¶m_67), (¶m_68), (¶m_69)); - local_12 = _e241; + local_16 = 0f; + } + let _e232 = local_16; + q = (_e222 + ((_e223 - _e224) * _e232)); + let _e235 = ha; + aAbove = (_e235 > 0f); + let _e237 = hb; + bAbove = (_e237 > 0f); + let _e239 = aAbove; + let _e240 = bAbove; + if (_e239 || _e240) { + let _e242 = aAbove; + let _e243 = a_2; + let _e244 = q; + let _e247 = bAbove; + let _e248 = b_1; + let _e249 = q; + param_73 = select(_e244, _e243, vec3(_e242)); + param_74 = select(_e249, _e248, vec3(_e247)); + let _e252 = (*n_4); + param_75 = _e252; + let _e253 = LambertEdge_u0028_vf3_u003b_vf3_u003b_vf3_u003b((¶m_73), (¶m_74), (¶m_75)); + local_17 = _e253; } else { - local_12 = 0f; - } - let _e242 = local_12; - let _e243 = total; - total = (_e243 + _e242); - let _e245 = aAbove; - let _e246 = bAbove; - let _e249 = q; - let _e250 = exit; - exit = select(_e250, _e249, vec3((_e245 && !(_e246)))); - let _e253 = aAbove; - let _e255 = bAbove; - let _e257 = q; - let _e258 = entry; - entry = select(_e258, _e257, vec3((!(_e253) && _e255))); + local_17 = 0f; + } + let _e254 = local_17; + let _e255 = total; + total = (_e255 + _e254); + let _e257 = aAbove; + let _e258 = bAbove; + let _e261 = q; + let _e262 = exit; + exit = select(_e262, _e261, vec3((_e257 && !(_e258)))); + let _e265 = aAbove; + let _e267 = bAbove; + let _e269 = q; + let _e270 = entry; + entry = select(_e270, _e269, vec3((!(_e265) && _e267))); continue; } else { break; } continuing { - let _e261 = i_7; - i_7 = (_e261 + 1i); + let _e273 = i_7; + i_7 = (_e273 + 1i); } } - let _e263 = exit; - param_70 = _e263; - let _e264 = entry; - param_71 = _e264; - let _e265 = (*n_4); - param_72 = _e265; - let _e266 = LambertEdge_u0028_vf3_u003b_vf3_u003b_vf3_u003b((¶m_70), (¶m_71), (¶m_72)); - let _e267 = total; - total = (_e267 + _e266); - let _e269 = total; - return max((-(_e269) * 0.5f), 0f); + let _e275 = exit; + param_76 = _e275; + let _e276 = entry; + param_77 = _e276; + let _e277 = (*n_4); + param_78 = _e277; + let _e278 = LambertEdge_u0028_vf3_u003b_vf3_u003b_vf3_u003b((¶m_76), (¶m_77), (¶m_78)); + let _e279 = total; + total = (_e279 + _e278); + let _e281 = total; + return max((-(_e281) * 0.5f), 0f); } fn LightListLane_u0028_vf4_u003b_i1_u003b(four: ptr>, slot_1: ptr) -> f32 { var lane: i32; - var local_13: f32; - var local_14: f32; - var local_15: f32; + var local_18: f32; + var local_19: f32; + var local_20: f32; - let _e172 = (*slot_1); - let _e173 = (*slot_1); - lane = (_e172 - ((_e173 / 4i) * 4i)); - let _e177 = lane; - if (_e177 == 0i) { - let _e180 = (*four)[0u]; - local_13 = _e180; - } else { - let _e181 = lane; - if (_e181 == 1i) { - let _e184 = (*four)[1u]; - local_14 = _e184; + let _e184 = (*slot_1); + let _e185 = (*slot_1); + lane = (_e184 - ((_e185 / 4i) * 4i)); + let _e189 = lane; + if (_e189 == 0i) { + let _e192 = (*four)[0u]; + local_18 = _e192; + } else { + let _e193 = lane; + if (_e193 == 1i) { + let _e196 = (*four)[1u]; + local_19 = _e196; } else { - let _e185 = lane; - if (_e185 == 2i) { - let _e188 = (*four)[2u]; - local_15 = _e188; + let _e197 = lane; + if (_e197 == 2i) { + let _e200 = (*four)[2u]; + local_20 = _e200; } else { - let _e190 = (*four)[3u]; - local_15 = _e190; + let _e202 = (*four)[3u]; + local_20 = _e202; } - let _e191 = local_15; - local_14 = _e191; + let _e203 = local_20; + local_19 = _e203; } - let _e192 = local_14; - local_13 = _e192; + let _e204 = local_19; + local_18 = _e204; } - let _e193 = local_13; - return _e193; + let _e205 = local_18; + return _e205; } fn LightListScale_u0028_i1_u003b(slot_2: ptr) -> f32 { - var param_73: vec4; - var param_74: i32; - - let _e169 = (*slot_2); - let _e173 = light_list_info.scales[(_e169 / 4i)]; - param_73 = _e173; - let _e174 = (*slot_2); - param_74 = _e174; - let _e175 = LightListLane_u0028_vf4_u003b_i1_u003b((¶m_73), (¶m_74)); - return _e175; + var param_79: vec4; + var param_80: i32; + + let _e181 = (*slot_2); + let _e185 = light_list_info.scales[(_e181 / 4i)]; + param_79 = _e185; + let _e186 = (*slot_2); + param_80 = _e186; + let _e187 = LightListLane_u0028_vf4_u003b_i1_u003b((¶m_79), (¶m_80)); + return _e187; } fn InSlots_u0028_f1_u003b(row: ptr) -> bool { var a_3: vec4; var b_2: vec4; - let _e171 = light_list_info.slot_rows[0i]; - let _e172 = (*row); - a_3 = abs((_e171 - vec4(_e172))); - let _e178 = light_list_info.slot_rows[1i]; - let _e179 = (*row); - b_2 = abs((_e178 - vec4(_e179))); - let _e184 = a_3[0u]; - let _e186 = a_3[1u]; - let _e189 = a_3[2u]; - let _e191 = a_3[3u]; - let _e195 = b_2[0u]; - let _e197 = b_2[1u]; - let _e200 = b_2[2u]; - let _e202 = b_2[3u]; - return (min(min(min(_e184, _e186), min(_e189, _e191)), min(min(_e195, _e197), min(_e200, _e202))) < 0.5f); + let _e183 = light_list_info.slot_rows[0i]; + let _e184 = (*row); + a_3 = abs((_e183 - vec4(_e184))); + let _e190 = light_list_info.slot_rows[1i]; + let _e191 = (*row); + b_2 = abs((_e190 - vec4(_e191))); + let _e196 = a_3[0u]; + let _e198 = a_3[1u]; + let _e201 = a_3[2u]; + let _e203 = a_3[3u]; + let _e207 = b_2[0u]; + let _e209 = b_2[1u]; + let _e212 = b_2[2u]; + let _e214 = b_2[3u]; + return (min(min(min(_e196, _e198), min(_e201, _e203)), min(min(_e207, _e209), min(_e212, _e214))) < 0.5f); } fn LightListRow_u0028_i1_u003b(slot_3: ptr) -> f32 { - var param_75: vec4; - var param_76: i32; - - let _e169 = (*slot_3); - let _e173 = light_list_info.indices[(_e169 / 4i)]; - param_75 = _e173; - let _e174 = (*slot_3); - param_76 = _e174; - let _e175 = LightListLane_u0028_vf4_u003b_i1_u003b((¶m_75), (¶m_76)); - return _e175; + var param_81: vec4; + var param_82: i32; + + let _e181 = (*slot_3); + let _e185 = light_list_info.indices[(_e181 / 4i)]; + param_81 = _e185; + let _e186 = (*slot_3); + param_82 = _e186; + let _e187 = LightListLane_u0028_vf4_u003b_i1_u003b((¶m_81), (¶m_82)); + return _e187; } fn LightListTexel_u0028_f1_u003b_f1_u003b(texel_2: ptr, row_1: ptr) -> vec4 { - let _e168 = (*texel_2); - let _e172 = light_list_info.list[1u]; - let _e174 = (*row_1); - let _e178 = light_list_info.list[2u]; - let _e181 = textureSampleLevel(light_list_texture_tex, light_list_texture_smp, vec2(((_e168 + 0.5f) * _e172), ((_e174 + 0.5f) * _e178)), 0f); - return _e181; + let _e180 = (*texel_2); + let _e184 = light_list_info.list[1u]; + let _e186 = (*row_1); + let _e190 = light_list_info.list[2u]; + let _e193 = textureSampleLevel(light_list_texture_tex, light_list_texture_smp, vec2(((_e180 + 0.5f) * _e184), ((_e186 + 0.5f) * _e190)), 0f); + return _e193; } fn ClusterRow_u0028_i1_u003b(slot_4: ptr) -> f32 { @@ -42850,43 +48476,43 @@ fn ClusterRow_u0028_i1_u003b(slot_4: ptr) -> f32 { var within: f32; var texel_3: f32; var four_1: vec4; - var param_77: f32; - var param_78: f32; - var param_79: vec4; - var param_80: i32; + var param_83: f32; + var param_84: f32; + var param_85: vec4; + var param_86: i32; - let _e176 = g_cluster_offset; - let _e177 = (*slot_4); - entry_1 = (_e176 + f32(_e177)); - let _e180 = entry_1; - row_2 = floor((_e180 / 16f)); - let _e183 = entry_1; - let _e184 = row_2; - within = (_e183 - (_e184 * 16f)); - let _e187 = within; - texel_3 = floor((_e187 / 4f)); - let _e192 = light_list_info.cluster_depth[3u]; - let _e193 = row_2; - let _e195 = texel_3; - param_77 = _e195; - param_78 = (_e192 + _e193); - let _e196 = LightListTexel_u0028_f1_u003b_f1_u003b((¶m_77), (¶m_78)); - four_1 = _e196; - let _e197 = within; - let _e198 = texel_3; - let _e203 = four_1; - param_79 = _e203; - param_80 = i32(((_e197 - (_e198 * 4f)) + 0.5f)); - let _e204 = LightListLane_u0028_vf4_u003b_i1_u003b((¶m_79), (¶m_80)); - return _e204; + let _e188 = g_cluster_offset; + let _e189 = (*slot_4); + entry_1 = (_e188 + f32(_e189)); + let _e192 = entry_1; + row_2 = floor((_e192 / 16f)); + let _e195 = entry_1; + let _e196 = row_2; + within = (_e195 - (_e196 * 16f)); + let _e199 = within; + texel_3 = floor((_e199 / 4f)); + let _e204 = light_list_info.cluster_depth[3u]; + let _e205 = row_2; + let _e207 = texel_3; + param_83 = _e207; + param_84 = (_e204 + _e205); + let _e208 = LightListTexel_u0028_f1_u003b_f1_u003b((¶m_83), (¶m_84)); + four_1 = _e208; + let _e209 = within; + let _e210 = texel_3; + let _e215 = four_1; + param_85 = _e215; + param_86 = i32(((_e209 - (_e210 * 4f)) + 0.5f)); + let _e216 = LightListLane_u0028_vf4_u003b_i1_u003b((¶m_85), (¶m_86)); + return _e216; } fn Clustered_u0028_() -> bool { - let _e168 = light_list_info.cluster_grid[3u]; - return (_e168 > 0.5f); + let _e180 = light_list_info.cluster_grid[3u]; + return (_e180 > 0.5f); } -fn SampleLight_u0028_i1_u003b_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b(index_2: ptr, s_3: ptr) -> LightSample { +fn SampleLight_u0028_i1_u003b_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b(index_2: ptr, s_4: ptr) -> LightSample { var light_3: LightSample; var position: vec4; var color_1: vec4; @@ -42895,14 +48521,14 @@ fn SampleLight_u0028_i1_u003b_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_ var slot_5: i32; var clustered: bool; var listRow: f32; - var local_16: f32; - var param_81: i32; - var param_82: i32; + var local_21: f32; + var param_87: i32; + var param_88: i32; var v_1: f32; var u_1: f32; - var local_17: f32; - var param_83: f32; - var param_84: i32; + var local_22: f32; + var param_89: f32; + var param_90: i32; var type_39: f32; var halfWidth_1: vec3; var halfHeight_1: vec3; @@ -42910,293 +48536,293 @@ fn SampleLight_u0028_i1_u003b_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_ var corners_1: array, 4>; var behind: bool; var formFactor: f32; - var local_18: f32; - var param_85: array, 4>; - var param_86: vec3; + var local_23: f32; + var param_91: array, 4>; + var param_92: vec3; var area: f32; var radiance: f32; - var local_19: f32; + var local_24: f32; var distance_2: f32; var ratio_1: f32; var window_1: f32; var mirror_1: vec3; var representative: vec3; - var param_87: vec3; - var param_88: vec3; - var param_89: vec3; - var param_90: vec3; - var param_91: vec3; + var param_93: vec3; + var param_94: vec3; + var param_95: vec3; + var param_96: vec3; + var param_97: vec3; var toPoint: vec3; var pointDistance: f32; - var local_20: vec3; + var local_25: vec3; var aim: vec3; var attenuation_1: f32; var toLight_1: vec3; var distance_3: f32; - var param_92: f32; - var param_93: f32; + var param_98: f32; + var param_99: f32; var cosAngle: f32; light_3.integrated = 0f; light_3.ltc = vec3(0f, 0f, 0f); - let _e219 = (*index_2); - if (_e219 < 8i) { - let _e221 = (*index_2); - let _e224 = frag_info.light_position[_e221]; - position = _e224; - let _e225 = (*index_2); - let _e228 = frag_info.light_color[_e225]; - color_1 = _e228; - let _e229 = (*index_2); - let _e232 = frag_info.light_direction[_e229]; - direction = _e232; + let _e231 = (*index_2); + if (_e231 < 8i) { let _e233 = (*index_2); - let _e236 = frag_info.light_cone[_e233]; - cone = _e236; - } else { + let _e236 = frag_info.light_position[_e233]; + position = _e236; let _e237 = (*index_2); - slot_5 = (_e237 - 8i); - let _e239 = Clustered_u0028_(); - clustered = _e239; - let _e240 = clustered; - if _e240 { - let _e241 = slot_5; - param_81 = _e241; - let _e242 = ClusterRow_u0028_i1_u003b((¶m_81)); - local_16 = _e242; + let _e240 = frag_info.light_color[_e237]; + color_1 = _e240; + let _e241 = (*index_2); + let _e244 = frag_info.light_direction[_e241]; + direction = _e244; + let _e245 = (*index_2); + let _e248 = frag_info.light_cone[_e245]; + cone = _e248; + } else { + let _e249 = (*index_2); + slot_5 = (_e249 - 8i); + let _e251 = Clustered_u0028_(); + clustered = _e251; + let _e252 = clustered; + if _e252 { + let _e253 = slot_5; + param_87 = _e253; + let _e254 = ClusterRow_u0028_i1_u003b((¶m_87)); + local_21 = _e254; } else { - let _e243 = slot_5; - param_82 = _e243; - let _e244 = LightListRow_u0028_i1_u003b((¶m_82)); - local_16 = _e244; - } - let _e245 = local_16; - listRow = _e245; - let _e246 = listRow; - let _e250 = light_list_info.list[2u]; - v_1 = ((_e246 + 0.5f) * _e250); - let _e254 = light_list_info.list[1u]; - u_1 = _e254; - let _e255 = u_1; - let _e257 = v_1; - let _e259 = textureSampleLevel(light_list_texture_tex, light_list_texture_smp, vec2((0.5f * _e255), _e257), 0f); - position = _e259; - let _e260 = u_1; - let _e262 = v_1; - let _e264 = textureSampleLevel(light_list_texture_tex, light_list_texture_smp, vec2((1.5f * _e260), _e262), 0f); - color_1 = _e264; - let _e265 = u_1; - let _e267 = v_1; - let _e269 = textureSampleLevel(light_list_texture_tex, light_list_texture_smp, vec2((2.5f * _e265), _e267), 0f); - direction = _e269; - let _e270 = u_1; - let _e272 = v_1; - let _e274 = textureSampleLevel(light_list_texture_tex, light_list_texture_smp, vec2((3.5f * _e270), _e272), 0f); - cone = _e274; - let _e275 = clustered; - if _e275 { - let _e276 = listRow; - param_83 = _e276; - let _e277 = InSlots_u0028_f1_u003b((¶m_83)); - local_17 = select(1f, 0f, _e277); + let _e255 = slot_5; + param_88 = _e255; + let _e256 = LightListRow_u0028_i1_u003b((¶m_88)); + local_21 = _e256; + } + let _e257 = local_21; + listRow = _e257; + let _e258 = listRow; + let _e262 = light_list_info.list[2u]; + v_1 = ((_e258 + 0.5f) * _e262); + let _e266 = light_list_info.list[1u]; + u_1 = _e266; + let _e267 = u_1; + let _e269 = v_1; + let _e271 = textureSampleLevel(light_list_texture_tex, light_list_texture_smp, vec2((0.5f * _e267), _e269), 0f); + position = _e271; + let _e272 = u_1; + let _e274 = v_1; + let _e276 = textureSampleLevel(light_list_texture_tex, light_list_texture_smp, vec2((1.5f * _e272), _e274), 0f); + color_1 = _e276; + let _e277 = u_1; + let _e279 = v_1; + let _e281 = textureSampleLevel(light_list_texture_tex, light_list_texture_smp, vec2((2.5f * _e277), _e279), 0f); + direction = _e281; + let _e282 = u_1; + let _e284 = v_1; + let _e286 = textureSampleLevel(light_list_texture_tex, light_list_texture_smp, vec2((3.5f * _e282), _e284), 0f); + cone = _e286; + let _e287 = clustered; + if _e287 { + let _e288 = listRow; + param_89 = _e288; + let _e289 = InSlots_u0028_f1_u003b((¶m_89)); + local_22 = select(1f, 0f, _e289); } else { - let _e279 = slot_5; - param_84 = _e279; - let _e280 = LightListScale_u0028_i1_u003b((¶m_84)); - local_17 = _e280; - } - let _e281 = local_17; - let _e283 = color_1[3u]; - color_1[3u] = (_e283 * _e281); - } - let _e287 = position[3u]; - type_39 = _e287; - let _e288 = type_39; - if (_e288 > 2.5f) { - let _e290 = direction; - halfWidth_1 = _e290.xyz; - let _e292 = cone; - halfHeight_1 = _e292.xyz; - let _e294 = position; - let _e296 = v_world_position_1; - toCentre = (_e294.xyz - _e296); - let _e298 = toCentre; - let _e299 = halfWidth_1; - let _e301 = halfHeight_1; - corners_1[0i] = ((_e298 - _e299) - _e301); - let _e304 = toCentre; - let _e305 = halfWidth_1; - let _e307 = halfHeight_1; - corners_1[1i] = ((_e304 + _e305) - _e307); + let _e291 = slot_5; + param_90 = _e291; + let _e292 = LightListScale_u0028_i1_u003b((¶m_90)); + local_22 = _e292; + } + let _e293 = local_22; + let _e295 = color_1[3u]; + color_1[3u] = (_e295 * _e293); + } + let _e299 = position[3u]; + type_39 = _e299; + let _e300 = type_39; + if (_e300 > 2.5f) { + let _e302 = direction; + halfWidth_1 = _e302.xyz; + let _e304 = cone; + halfHeight_1 = _e304.xyz; + let _e306 = position; + let _e308 = v_world_position_1; + toCentre = (_e306.xyz - _e308); let _e310 = toCentre; let _e311 = halfWidth_1; let _e313 = halfHeight_1; - corners_1[2i] = ((_e310 + _e311) + _e313); + corners_1[0i] = ((_e310 - _e311) - _e313); let _e316 = toCentre; let _e317 = halfWidth_1; let _e319 = halfHeight_1; - corners_1[3i] = ((_e316 - _e317) + _e319); + corners_1[1i] = ((_e316 + _e317) - _e319); let _e322 = toCentre; let _e323 = halfWidth_1; - let _e324 = halfHeight_1; - behind = (dot(_e322, cross(_e323, _e324)) >= 0f); - let _e328 = behind; - if _e328 { - local_18 = 0f; + let _e325 = halfHeight_1; + corners_1[2i] = ((_e322 + _e323) + _e325); + let _e328 = toCentre; + let _e329 = halfWidth_1; + let _e331 = halfHeight_1; + corners_1[3i] = ((_e328 - _e329) + _e331); + let _e334 = toCentre; + let _e335 = halfWidth_1; + let _e336 = halfHeight_1; + behind = (dot(_e334, cross(_e335, _e336)) >= 0f); + let _e340 = behind; + if _e340 { + local_23 = 0f; } else { - let _e329 = corners_1; - param_85 = _e329; - let _e331 = (*s_3).n; - param_86 = _e331; - let _e332 = RectangleFormFactor_u0028_vf3_u005b_4_u005d_u003b_vf3_u003b((¶m_85), (¶m_86)); - local_18 = _e332; - } - let _e333 = local_18; - formFactor = _e333; - let _e334 = halfWidth_1; - let _e335 = halfHeight_1; - area = (length(cross(_e334, _e335)) * 4f); - let _e339 = area; - if (_e339 > 0.000000001f) { - let _e341 = area; - local_19 = (1f / _e341); + let _e341 = corners_1; + param_91 = _e341; + let _e343 = (*s_4).n; + param_92 = _e343; + let _e344 = RectangleFormFactor_u0028_vf3_u005b_4_u005d_u003b_vf3_u003b((¶m_91), (¶m_92)); + local_23 = _e344; + } + let _e345 = local_23; + formFactor = _e345; + let _e346 = halfWidth_1; + let _e347 = halfHeight_1; + area = (length(cross(_e346, _e347)) * 4f); + let _e351 = area; + if (_e351 > 0.000000001f) { + let _e353 = area; + local_24 = (1f / _e353); } else { - local_19 = 0f; - } - let _e343 = local_19; - radiance = _e343; - let _e344 = toCentre; - distance_2 = length(_e344); - let _e347 = direction[3u]; - if (_e347 > 0f) { - let _e349 = distance_2; - let _e351 = direction[3u]; - ratio_1 = (_e349 / _e351); - let _e353 = ratio_1; - let _e354 = ratio_1; - let _e356 = ratio_1; - let _e358 = ratio_1; - window_1 = clamp((1f - (((_e353 * _e354) * _e356) * _e358)), 0f, 1f); - let _e362 = window_1; - let _e363 = window_1; - let _e365 = radiance; - radiance = (_e365 * (_e362 * _e363)); - } - let _e368 = (*s_3).v; - let _e371 = (*s_3).n; - mirror_1 = reflect(-(_e368), _e371); - let _e373 = position; - param_87 = _e373.xyz; - let _e375 = halfWidth_1; - param_88 = _e375; - let _e376 = halfHeight_1; - param_89 = _e376; - let _e377 = v_world_position_1; - param_90 = _e377; - let _e378 = mirror_1; - param_91 = _e378; - let _e379 = RectangleClosestPoint_u0028_vf3_u003b_vf3_u003b_vf3_u003b_vf3_u003b_vf3_u003b((¶m_87), (¶m_88), (¶m_89), (¶m_90), (¶m_91)); - representative = _e379; - let _e380 = representative; - let _e381 = v_world_position_1; - toPoint = (_e380 - _e381); - let _e383 = toPoint; - pointDistance = length(_e383); - let _e385 = pointDistance; - if (_e385 > 0.000001f) { - let _e387 = toPoint; - let _e388 = pointDistance; - local_20 = (_e387 / vec3(_e388)); + local_24 = 0f; + } + let _e355 = local_24; + radiance = _e355; + let _e356 = toCentre; + distance_2 = length(_e356); + let _e359 = direction[3u]; + if (_e359 > 0f) { + let _e361 = distance_2; + let _e363 = direction[3u]; + ratio_1 = (_e361 / _e363); + let _e365 = ratio_1; + let _e366 = ratio_1; + let _e368 = ratio_1; + let _e370 = ratio_1; + window_1 = clamp((1f - (((_e365 * _e366) * _e368) * _e370)), 0f, 1f); + let _e374 = window_1; + let _e375 = window_1; + let _e377 = radiance; + radiance = (_e377 * (_e374 * _e375)); + } + let _e380 = (*s_4).v; + let _e383 = (*s_4).n; + mirror_1 = reflect(-(_e380), _e383); + let _e385 = position; + param_93 = _e385.xyz; + let _e387 = halfWidth_1; + param_94 = _e387; + let _e388 = halfHeight_1; + param_95 = _e388; + let _e389 = v_world_position_1; + param_96 = _e389; + let _e390 = mirror_1; + param_97 = _e390; + let _e391 = RectangleClosestPoint_u0028_vf3_u003b_vf3_u003b_vf3_u003b_vf3_u003b_vf3_u003b((¶m_93), (¶m_94), (¶m_95), (¶m_96), (¶m_97)); + representative = _e391; + let _e392 = representative; + let _e393 = v_world_position_1; + toPoint = (_e392 - _e393); + let _e395 = toPoint; + pointDistance = length(_e395); + let _e397 = pointDistance; + if (_e397 > 0.000001f) { + let _e399 = toPoint; + let _e400 = pointDistance; + local_25 = (_e399 / vec3(_e400)); } else { - let _e392 = (*s_3).n; - local_20 = _e392; - } - let _e393 = local_20; - light_3.l = _e393; - let _e396 = light_3.l; - let _e398 = (*s_3).v; - light_3.h = normalize((_e396 + _e398)); - let _e402 = formFactor; - light_3.n_dot_l = _e402; - let _e405 = (*s_3).n; - let _e407 = light_3.h; - light_3.n_dot_h = max(dot(_e405, _e407), 0f); - let _e412 = (*s_3).v; - let _e414 = light_3.h; - light_3.v_dot_h = max(dot(_e412, _e414), 0f); - let _e418 = color_1; - let _e421 = color_1[3u]; - let _e423 = radiance; - light_3.radiance = ((_e418.xyz * _e421) * _e423); - let _e426 = light_3; - return _e426; - } - let _e427 = direction; - aim = normalize(_e427.xyz); + let _e404 = (*s_4).n; + local_25 = _e404; + } + let _e405 = local_25; + light_3.l = _e405; + let _e408 = light_3.l; + let _e410 = (*s_4).v; + light_3.h = normalize((_e408 + _e410)); + let _e414 = formFactor; + light_3.n_dot_l = _e414; + let _e417 = (*s_4).n; + let _e419 = light_3.h; + light_3.n_dot_h = max(dot(_e417, _e419), 0f); + let _e424 = (*s_4).v; + let _e426 = light_3.h; + light_3.v_dot_h = max(dot(_e424, _e426), 0f); + let _e430 = color_1; + let _e433 = color_1[3u]; + let _e435 = radiance; + light_3.radiance = ((_e430.xyz * _e433) * _e435); + let _e438 = light_3; + return _e438; + } + let _e439 = direction; + aim = normalize(_e439.xyz); attenuation_1 = 1f; - let _e430 = type_39; - if (_e430 < 0.5f) { - let _e432 = aim; - light_3.l = -(_e432); - } else { - let _e435 = position; - let _e437 = v_world_position_1; - toLight_1 = (_e435.xyz - _e437); - let _e439 = toLight_1; - distance_3 = length(_e439); - let _e441 = distance_3; - if (_e441 < 0.000001f) { - let _e444 = (*s_3).n; - light_3.l = _e444; - let _e447 = (*s_3).n; - light_3.h = _e447; + let _e442 = type_39; + if (_e442 < 0.5f) { + let _e444 = aim; + light_3.l = -(_e444); + } else { + let _e447 = position; + let _e449 = v_world_position_1; + toLight_1 = (_e447.xyz - _e449); + let _e451 = toLight_1; + distance_3 = length(_e451); + let _e453 = distance_3; + if (_e453 < 0.000001f) { + let _e456 = (*s_4).n; + light_3.l = _e456; + let _e459 = (*s_4).n; + light_3.h = _e459; light_3.radiance = vec3(0f, 0f, 0f); light_3.n_dot_l = 0f; light_3.n_dot_h = 0f; light_3.v_dot_h = 0f; - let _e453 = light_3; - return _e453; - } - let _e454 = toLight_1; - let _e455 = distance_3; - light_3.l = (_e454 / vec3(_e455)); - let _e459 = distance_3; - param_92 = _e459; - let _e461 = direction[3u]; - param_93 = _e461; - let _e462 = PunctualAttenuation_u0028_f1_u003b_f1_u003b((¶m_92), (¶m_93)); - attenuation_1 = _e462; - let _e463 = type_39; - if (_e463 > 1.5f) { - let _e465 = aim; - let _e467 = light_3.l; - cosAngle = dot(_e465, -(_e467)); - let _e470 = cosAngle; - let _e472 = cone[1u]; - let _e475 = cone[0u]; - let _e477 = cone[1u]; - let _e481 = attenuation_1; - attenuation_1 = (_e481 * clamp(((_e470 - _e472) / (_e475 - _e477)), 0f, 1f)); - } - } - let _e484 = light_3.l; - let _e486 = (*s_3).v; - light_3.h = normalize((_e484 + _e486)); - let _e491 = (*s_3).n; - let _e493 = light_3.l; - light_3.n_dot_l = max(dot(_e491, _e493), 0f); - let _e498 = (*s_3).n; - let _e500 = light_3.h; - light_3.n_dot_h = max(dot(_e498, _e500), 0f); - let _e505 = (*s_3).v; - let _e507 = light_3.h; - light_3.v_dot_h = max(dot(_e505, _e507), 0f); - let _e511 = color_1; - let _e514 = color_1[3u]; - let _e516 = attenuation_1; - light_3.radiance = ((_e511.xyz * _e514) * _e516); - let _e519 = light_3; - return _e519; + let _e465 = light_3; + return _e465; + } + let _e466 = toLight_1; + let _e467 = distance_3; + light_3.l = (_e466 / vec3(_e467)); + let _e471 = distance_3; + param_98 = _e471; + let _e473 = direction[3u]; + param_99 = _e473; + let _e474 = PunctualAttenuation_u0028_f1_u003b_f1_u003b((¶m_98), (¶m_99)); + attenuation_1 = _e474; + let _e475 = type_39; + if (_e475 > 1.5f) { + let _e477 = aim; + let _e479 = light_3.l; + cosAngle = dot(_e477, -(_e479)); + let _e482 = cosAngle; + let _e484 = cone[1u]; + let _e487 = cone[0u]; + let _e489 = cone[1u]; + let _e493 = attenuation_1; + attenuation_1 = (_e493 * clamp(((_e482 - _e484) / (_e487 - _e489)), 0f, 1f)); + } + } + let _e496 = light_3.l; + let _e498 = (*s_4).v; + light_3.h = normalize((_e496 + _e498)); + let _e503 = (*s_4).n; + let _e505 = light_3.l; + light_3.n_dot_l = max(dot(_e503, _e505), 0f); + let _e510 = (*s_4).n; + let _e512 = light_3.h; + light_3.n_dot_h = max(dot(_e510, _e512), 0f); + let _e517 = (*s_4).v; + let _e519 = light_3.h; + light_3.v_dot_h = max(dot(_e517, _e519), 0f); + let _e523 = color_1; + let _e526 = color_1[3u]; + let _e528 = attenuation_1; + light_3.radiance = ((_e523.xyz * _e526) * _e528); + let _e531 = light_3; + return _e531; } fn FindCluster_u0028_vf3_u003b(world_3: ptr>) { @@ -43207,393 +48833,416 @@ fn FindCluster_u0028_vf3_u003b(world_3: ptr>) { var tx: f32; var ty: f32; var tz: f32; - var local_21: f32; + var local_26: f32; var cell: f32; var row_3: f32; var header: vec4; - var param_94: f32; - var param_95: f32; + var param_100: f32; + var param_101: f32; - let _e181 = light_list_info.cluster_view_projection; - let _e182 = (*world_3); - clip_1 = (_e181 * vec4(_e182.x, _e182.y, _e182.z, 1f)); - let _e188 = clip_1; - let _e191 = clip_1[3u]; - ndc_1 = (_e188.xy / vec2(max(_e191, 0.000001f))); - let _e196 = light_list_info.cluster_grid; - grid = _e196.xyz; - let _e200 = light_list_info.cluster_depth[0u]; - near_1 = _e200; - let _e202 = ndc_1[0u]; - let _e206 = grid[0u]; - let _e210 = grid[0u]; - tx = clamp(floor((((_e202 * 0.5f) + 0.5f) * _e206)), 0f, (_e210 - 1f)); - let _e214 = ndc_1[1u]; - let _e218 = grid[1u]; - let _e222 = grid[1u]; - ty = clamp(floor((((_e214 * 0.5f) + 0.5f) * _e218)), 0f, (_e222 - 1f)); - let _e226 = clip_1[3u]; - let _e227 = near_1; - if (_e226 <= _e227) { - local_21 = 0f; - } else { - let _e230 = clip_1[3u]; - let _e231 = near_1; - let _e236 = light_list_info.cluster_depth[1u]; - let _e240 = grid[2u]; - local_21 = clamp(floor((log((_e230 / _e231)) * _e236)), 0f, (_e240 - 1f)); - } - let _e243 = local_21; - tz = _e243; - let _e244 = tx; - let _e245 = ty; - let _e247 = grid[0u]; - let _e250 = tz; - let _e252 = grid[0u]; - let _e255 = grid[1u]; - cell = ((_e244 + (_e245 * _e247)) + ((_e250 * _e252) * _e255)); - let _e258 = cell; - row_3 = floor((_e258 / 4f)); - let _e261 = cell; - let _e262 = row_3; - let _e267 = light_list_info.cluster_depth[2u]; - let _e268 = row_3; - param_94 = (_e261 - (_e262 * 4f)); - param_95 = (_e267 + _e268); - let _e270 = LightListTexel_u0028_f1_u003b_f1_u003b((¶m_94), (¶m_95)); - header = _e270; - let _e272 = header[0u]; - g_cluster_offset = _e272; - let _e274 = header[1u]; - g_cluster_count = _e274; + let _e193 = light_list_info.cluster_view_projection; + let _e194 = (*world_3); + clip_1 = (_e193 * vec4(_e194.x, _e194.y, _e194.z, 1f)); + let _e200 = clip_1; + let _e203 = clip_1[3u]; + ndc_1 = (_e200.xy / vec2(max(_e203, 0.000001f))); + let _e208 = light_list_info.cluster_grid; + grid = _e208.xyz; + let _e212 = light_list_info.cluster_depth[0u]; + near_1 = _e212; + let _e214 = ndc_1[0u]; + let _e218 = grid[0u]; + let _e222 = grid[0u]; + tx = clamp(floor((((_e214 * 0.5f) + 0.5f) * _e218)), 0f, (_e222 - 1f)); + let _e226 = ndc_1[1u]; + let _e230 = grid[1u]; + let _e234 = grid[1u]; + ty = clamp(floor((((_e226 * 0.5f) + 0.5f) * _e230)), 0f, (_e234 - 1f)); + let _e238 = clip_1[3u]; + let _e239 = near_1; + if (_e238 <= _e239) { + local_26 = 0f; + } else { + let _e242 = clip_1[3u]; + let _e243 = near_1; + let _e248 = light_list_info.cluster_depth[1u]; + let _e252 = grid[2u]; + local_26 = clamp(floor((log((_e242 / _e243)) * _e248)), 0f, (_e252 - 1f)); + } + let _e255 = local_26; + tz = _e255; + let _e256 = tx; + let _e257 = ty; + let _e259 = grid[0u]; + let _e262 = tz; + let _e264 = grid[0u]; + let _e267 = grid[1u]; + cell = ((_e256 + (_e257 * _e259)) + ((_e262 * _e264) * _e267)); + let _e270 = cell; + row_3 = floor((_e270 / 4f)); + let _e273 = cell; + let _e274 = row_3; + let _e279 = light_list_info.cluster_depth[2u]; + let _e280 = row_3; + param_100 = (_e273 - (_e274 * 4f)); + param_101 = (_e279 + _e280); + let _e282 = LightListTexel_u0028_f1_u003b_f1_u003b((¶m_100), (¶m_101)); + header = _e282; + let _e284 = header[0u]; + g_cluster_offset = _e284; + let _e286 = header[1u]; + g_cluster_count = _e286; return; } fn LightCount_u0028_() -> i32 { var tail: f32; - var param_96: vec3; + var param_102: vec3; - let _e170 = light_list_info.list[0u]; - tail = _e170; - let _e171 = Clustered_u0028_(); - if _e171 { - let _e172 = v_world_position_1; - param_96 = _e172; - FindCluster_u0028_vf3_u003b((¶m_96)); - let _e173 = g_cluster_count; - tail = _e173; + let _e182 = light_list_info.list[0u]; + tail = _e182; + let _e183 = Clustered_u0028_(); + if _e183 { + let _e184 = v_world_position_1; + param_102 = _e184; + FindCluster_u0028_vf3_u003b((¶m_102)); + let _e185 = g_cluster_count; + tail = _e185; } - let _e176 = frag_info.frame_params[1u]; - let _e180 = tail; - return (clamp(i32((_e176 + 0.5f)), 0i, 8i) + clamp(i32((_e180 + 0.5f)), 0i, 24i)); + let _e188 = frag_info.frame_params[1u]; + let _e192 = tail; + return (clamp(i32((_e188 + 0.5f)), 0i, 8i) + clamp(i32((_e192 + 0.5f)), 0i, 24i)); } -fn AccumulateLights_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b(s_4: ptr) -> vec3 { +fn AccumulateLights_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b(s_5: ptr) -> vec3 { var total_1: vec3; var count_1: i32; var i_8: i32; var light_4: LightSample; - var param_97: i32; - var param_98: Surface; + var param_103: i32; + var param_104: Surface; var visibility: f32; - var param_99: i32; - var local_22: f32; - var param_100: Surface; - var param_101: LightSample; - var param_102: i32; - var param_103: vec3; - var param_104: vec3; var param_105: i32; + var local_27: f32; var param_106: Surface; var param_107: LightSample; + var param_108: i32; + var param_109: vec3; + var param_110: vec3; + var param_111: i32; + var param_112: Surface; + var param_113: LightSample; total_1 = vec3(0f, 0f, 0f); - let _e184 = LightCount_u0028_(); - count_1 = _e184; + let _e196 = LightCount_u0028_(); + count_1 = _e196; i_8 = 0i; loop { - let _e185 = i_8; - if (_e185 < 32i) { - let _e187 = i_8; - let _e188 = count_1; - if (_e187 >= _e188) { + let _e197 = i_8; + if (_e197 < 32i) { + let _e199 = i_8; + let _e200 = count_1; + if (_e199 >= _e200) { break; } - let _e190 = i_8; - param_97 = _e190; - let _e191 = (*s_4); - param_98 = _e191; - let _e192 = SampleLight_u0028_i1_u003b_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b((¶m_97), (¶m_98)); - light_4 = _e192; - let _e194 = light_4.n_dot_l; - if (_e194 <= 0f) { + let _e202 = i_8; + param_103 = _e202; + let _e203 = (*s_5); + param_104 = _e203; + let _e204 = SampleLight_u0028_i1_u003b_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b((¶m_103), (¶m_104)); + light_4 = _e204; + let _e206 = light_4.n_dot_l; + if (_e206 <= 0f) { continue; } - let _e196 = i_8; - param_99 = _e196; - let _e197 = LightHasShadow_u0028_i1_u003b((¶m_99)); - if _e197 { - let _e198 = (*s_4); - param_100 = _e198; - let _e199 = light_4; - param_101 = _e199; - let _e200 = i_8; - param_102 = _e200; - let _e201 = LightVisibility_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b_struct_u002d_LightSample_u002d_vf3_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf31_u003b_i1_u003b((¶m_100), (¶m_101), (¶m_102)); - let _e202 = v_world_position_1; - param_103 = _e202; - let _e204 = (*s_4).n; - param_104 = _e204; - let _e205 = i_8; - param_105 = _e205; - let _e206 = PointShadowFactor_u0028_vf3_u003b_vf3_u003b_i1_u003b((¶m_103), (¶m_104), (¶m_105)); - local_22 = (_e201 * _e206); + light_transmittance = vec3(1f, 1f, 1f); + let _e208 = i_8; + param_105 = _e208; + let _e209 = LightHasShadow_u0028_i1_u003b((¶m_105)); + if _e209 { + let _e210 = (*s_5); + param_106 = _e210; + let _e211 = light_4; + param_107 = _e211; + let _e212 = i_8; + param_108 = _e212; + let _e213 = LightVisibility_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b_struct_u002d_LightSample_u002d_vf3_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf31_u003b_i1_u003b((¶m_106), (¶m_107), (¶m_108)); + let _e214 = v_world_position_1; + param_109 = _e214; + let _e216 = (*s_5).n; + param_110 = _e216; + let _e217 = i_8; + param_111 = _e217; + let _e218 = PointShadowFactor_u0028_vf3_u003b_vf3_u003b_i1_u003b((¶m_109), (¶m_110), (¶m_111)); + local_27 = (_e213 * _e218); } else { - local_22 = 1f; + local_27 = 1f; } - let _e208 = local_22; - visibility = _e208; - let _e209 = visibility; - if (_e209 <= 0f) { + let _e220 = local_27; + visibility = _e220; + let _e221 = visibility; + if (_e221 <= 0f) { continue; } - let _e211 = (*s_4); - param_106 = _e211; - let _e212 = light_4; - param_107 = _e212; - let _e213 = ShadeLight_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b_struct_u002d_LightSample_u002d_vf3_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf31_u003b((¶m_106), (¶m_107)); - let _e215 = light_4.radiance; - let _e218 = light_4.n_dot_l; - let _e220 = visibility; - let _e222 = total_1; - total_1 = (_e222 + (((_e213 * _e215) * _e218) * _e220)); + let _e223 = (*s_5); + param_112 = _e223; + let _e224 = light_4; + param_113 = _e224; + let _e225 = ShadeLight_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b_struct_u002d_LightSample_u002d_vf3_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf31_u003b((¶m_112), (¶m_113)); + let _e227 = light_4.radiance; + let _e230 = light_4.n_dot_l; + let _e232 = visibility; + let _e234 = light_transmittance; + let _e236 = total_1; + total_1 = (_e236 + ((((_e225 * _e227) * _e230) * _e232) * _e234)); continue; } else { break; } continuing { - let _e224 = i_8; - i_8 = (_e224 + 1i); + let _e238 = i_8; + i_8 = (_e238 + 1i); } } - let _e226 = total_1; - return _e226; -} - -fn SrgbToLinear_u0028_vf3_u003b(srgb: ptr>) -> vec3 { - let _e167 = (*srgb); - let _e170 = (*srgb); - let _e175 = (*srgb); - return mix((_e167 / vec3(12.92f)), pow(((_e170 + vec3(0.055f, 0.055f, 0.055f)) / vec3(1.055f)), vec3(2.4f, 2.4f, 2.4f)), step(vec3(0.04045f, 0.04045f, 0.04045f), _e175)); + let _e240 = total_1; + return _e240; } fn ImpostorRight_u0028_vf3_u003b(d_4: ptr>) -> vec3 { var up: vec3; - let _e169 = (*d_4)[1u]; - up = select(vec3(0f, 1f, 0f), vec3(0f, 0f, -1f), vec3((abs(_e169) > 0.999f))); - let _e174 = up; - let _e175 = (*d_4); - return normalize(cross(_e174, _e175)); + let _e181 = (*d_4)[1u]; + up = select(vec3(0f, 1f, 0f), vec3(0f, 0f, -1f), vec3((abs(_e181) > 0.999f))); + let _e186 = up; + let _e187 = (*d_4); + return normalize(cross(_e186, _e187)); } fn ImpostorDecode_u0028_vf2_u003b(uv_5: ptr>) -> vec3 { var p_1: vec2; var y_1: f32; - var s_5: vec2; + var s_6: vec2; var folded: vec2; var xz: vec2; - let _e172 = (*uv_5); - p_1 = ((_e172 * 2f) - vec2(1f, 1f)); - let _e176 = p_1[0u]; - let _e180 = p_1[1u]; - y_1 = ((1f - abs(_e176)) - abs(_e180)); - let _e184 = p_1[0u]; - let _e188 = p_1[1u]; - s_5 = vec2(select(-1f, 1f, (_e184 >= 0f)), select(-1f, 1f, (_e188 >= 0f))); - let _e192 = p_1; - let _e196 = s_5; - folded = ((vec2(1f, 1f) - abs(_e192.yx)) * _e196); - let _e198 = y_1; - let _e200 = p_1; - let _e201 = folded; - xz = select(_e201, _e200, vec2((_e198 >= 0f))); - let _e205 = xz[0u]; - let _e206 = y_1; - let _e208 = xz[1u]; - return normalize(vec3(_e205, _e206, _e208)); + let _e184 = (*uv_5); + p_1 = ((_e184 * 2f) - vec2(1f, 1f)); + let _e188 = p_1[0u]; + let _e192 = p_1[1u]; + y_1 = ((1f - abs(_e188)) - abs(_e192)); + let _e196 = p_1[0u]; + let _e200 = p_1[1u]; + s_6 = vec2(select(-1f, 1f, (_e196 >= 0f)), select(-1f, 1f, (_e200 >= 0f))); + let _e204 = p_1; + let _e208 = s_6; + folded = ((vec2(1f, 1f) - abs(_e204.yx)) * _e208); + let _e210 = y_1; + let _e212 = p_1; + let _e213 = folded; + xz = select(_e213, _e212, vec2((_e210 >= 0f))); + let _e217 = xz[0u]; + let _e218 = y_1; + let _e220 = xz[1u]; + return normalize(vec3(_e217, _e218, _e220)); } fn ImpostorViewUv_u0028_vf2_u003b_vf3_u003b(cell_1: ptr>, offset_3: ptr>) -> vec2 { var d_5: vec3; - var param_108: vec2; + var param_114: vec2; var right: vec3; - var param_109: vec3; + var param_115: vec3; var up_1: vec3; - var local_23: vec2; - - let _e174 = (*cell_1); - param_108 = (_e174 / vec2(7f)); - let _e177 = ImpostorDecode_u0028_vf2_u003b((¶m_108)); - d_5 = _e177; - let _e178 = d_5; - param_109 = _e178; - let _e179 = ImpostorRight_u0028_vf3_u003b((¶m_109)); - right = _e179; - let _e180 = d_5; - let _e181 = right; - up_1 = cross(_e180, _e181); - let _e183 = (*offset_3); - let _e184 = right; - let _e188 = (*offset_3); - let _e189 = up_1; - local_23 = vec2(((dot(_e183, _e184) * 0.5f) + 0.5f), (0.5f - (dot(_e188, _e189) * 0.5f))); - let _e194 = (*cell_1); - let _e195 = local_23; - return ((_e194 + clamp(_e195, vec2(0f, 0f), vec2(1f, 1f))) / vec2(8f)); + var local_28: vec2; + + let _e186 = (*cell_1); + param_114 = (_e186 / vec2(7f)); + let _e189 = ImpostorDecode_u0028_vf2_u003b((¶m_114)); + d_5 = _e189; + let _e190 = d_5; + param_115 = _e190; + let _e191 = ImpostorRight_u0028_vf3_u003b((¶m_115)); + right = _e191; + let _e192 = d_5; + let _e193 = right; + up_1 = cross(_e192, _e193); + let _e195 = (*offset_3); + let _e196 = right; + let _e200 = (*offset_3); + let _e201 = up_1; + local_28 = vec2(((dot(_e195, _e196) * 0.5f) + 0.5f), (0.5f - (dot(_e200, _e201) * 0.5f))); + let _e206 = (*cell_1); + let _e207 = local_28; + return ((_e206 + clamp(_e207, vec2(0f, 0f), vec2(1f, 1f))) / vec2(8f)); } fn ImpostorEncode_u0028_vf3_u003b(d_6: ptr>) -> vec2 { var a_4: vec3; var p_2: vec2; - var s_6: vec2; + var s_7: vec2; var folded_1: vec2; - let _e171 = (*d_6); - a_4 = abs(_e171); - let _e173 = (*d_6); - let _e176 = a_4[0u]; - let _e178 = a_4[1u]; - let _e181 = a_4[2u]; - p_2 = (_e173.xz / vec2(max(((_e176 + _e178) + _e181), 0.00000001f))); - let _e187 = p_2[0u]; - let _e191 = p_2[1u]; - s_6 = vec2(select(-1f, 1f, (_e187 >= 0f)), select(-1f, 1f, (_e191 >= 0f))); - let _e195 = p_2; - let _e199 = s_6; - folded_1 = ((vec2(1f, 1f) - abs(_e195.yx)) * _e199); - let _e202 = (*d_6)[1u]; - let _e204 = p_2; - let _e205 = folded_1; - return ((select(_e205, _e204, vec2((_e202 >= 0f))) * 0.5f) + vec2(0.5f, 0.5f)); + let _e183 = (*d_6); + a_4 = abs(_e183); + let _e185 = (*d_6); + let _e188 = a_4[0u]; + let _e190 = a_4[1u]; + let _e193 = a_4[2u]; + p_2 = (_e185.xz / vec2(max(((_e188 + _e190) + _e193), 0.00000001f))); + let _e199 = p_2[0u]; + let _e203 = p_2[1u]; + s_7 = vec2(select(-1f, 1f, (_e199 >= 0f)), select(-1f, 1f, (_e203 >= 0f))); + let _e207 = p_2; + let _e211 = s_7; + folded_1 = ((vec2(1f, 1f) - abs(_e207.yx)) * _e211); + let _e214 = (*d_6)[1u]; + let _e216 = p_2; + let _e217 = folded_1; + return ((select(_e217, _e216, vec2((_e214 >= 0f))) * 0.5f) + vec2(0.5f, 0.5f)); +} + +fn TowardsEye_u0028_() -> vec3 { + var local_29: vec3; + + let _e179 = Orthographic_u0028_(); + if _e179 { + let _e181 = fog_info.forward; + local_29 = -(_e181.xyz); + } else { + let _e185 = fog_info.eye; + let _e187 = v_world_position_1; + local_29 = normalize((_e185.xyz - _e187)); + } + let _e190 = local_29; + return _e190; } fn MaterialLodBias_u0028_() -> f32 { - let _e168 = frag_info.target_origin[1u]; - return _e168; + let _e180 = frag_info.target_origin[1u]; + return _e180; } fn ReadSurface_u0028_() -> Surface { var texel_4: vec4; - var s_7: Surface; - var param_110: vec3; - var param_111: vec3; + var s_8: Surface; + var param_116: vec3; + var param_117: vec3; var cutoff: f32; + var edge: f32; var anchored: vec3; var noise_1: f32; - let _e173 = v_texcoord_1; - let _e174 = MaterialLodBias_u0028_(); - let _e175 = textureSampleBias(base_color_texture_tex, base_color_texture_smp, _e173, _e174); - texel_4 = _e175; - let _e176 = texel_4; - param_110 = _e176.xyz; - let _e178 = SrgbToLinear_u0028_vf3_u003b((¶m_110)); - let _e180 = frag_info.base_color; - param_111 = _e180.xyz; - let _e182 = SrgbToLinear_u0028_vf3_u003b((¶m_111)); - let _e184 = v_color_1; - s_7.albedo = ((_e178 * _e182) * _e184.xyz); - let _e189 = texel_4[3u]; - let _e192 = frag_info.base_color[3u]; - let _e195 = v_color_1[3u]; - s_7.alpha = ((_e189 * _e192) * _e195); - let _e199 = s_7.albedo; - g_albedo = _e199; - let _e202 = frag_info.material2_[0u]; - cutoff = _e202; - let _e203 = cutoff; - if (_e203 >= 0f) { - let _e206 = s_7.alpha; - let _e207 = cutoff; - if (_e206 < _e207) { - discard; - } + let _e186 = v_texcoord_1; + let _e187 = MaterialLodBias_u0028_(); + let _e188 = textureSampleBias(base_color_texture_tex, base_color_texture_smp, _e186, _e187); + texel_4 = _e188; + let _e189 = texel_4; + param_116 = _e189.xyz; + let _e191 = SrgbToLinear_u0028_vf3_u003b((¶m_116)); + let _e193 = frag_info.base_color; + param_117 = _e193.xyz; + let _e195 = SrgbToLinear_u0028_vf3_u003b((¶m_117)); + let _e197 = v_color_1; + s_8.albedo = ((_e191 * _e195) * _e197.xyz); + let _e202 = texel_4[3u]; + let _e205 = frag_info.base_color[3u]; + let _e208 = v_color_1[3u]; + s_8.alpha = ((_e202 * _e205) * _e208); + let _e212 = s_8.albedo; + g_albedo = _e212; + let _e215 = frag_info.material2_[0u]; + cutoff = _e215; + let _e216 = cutoff; + if (_e216 > 1f) { + let _e218 = cutoff; + edge = (_e218 - 1f); + let _e221 = s_8.alpha; + let _e222 = edge; + let _e225 = s_8.alpha; + let _e226 = fwidth(_e225); + s_8.alpha = clamp((((_e221 - _e222) / max(_e226, 0.0001f)) + 0.5f), 0f, 1f); } else { - let _e209 = cutoff; - if (_e209 < -1.5f) { - let _e211 = v_world_position_1; - anchored = floor((_e211 * 16f)); - let _e214 = anchored; - noise_1 = fract((sin(dot(_e214, vec3(12.9898f, 78.233f, 37.719f))) * 43758.547f)); - let _e220 = s_7.alpha; - let _e221 = noise_1; - if (_e220 < _e221) { + let _e232 = cutoff; + if (_e232 >= 0f) { + let _e235 = s_8.alpha; + let _e236 = cutoff; + if (_e235 < _e236) { discard; } + s_8.alpha = 1f; + } else { + let _e239 = cutoff; + if (_e239 < -1.5f) { + let _e241 = v_world_position_1; + anchored = floor((_e241 * 16f)); + let _e244 = anchored; + noise_1 = fract((sin(dot(_e244, vec3(12.9898f, 78.233f, 37.719f))) * 43758.547f)); + let _e250 = s_8.alpha; + let _e251 = noise_1; + if (_e250 < _e251) { + discard; + } + } } } - let _e223 = cutoff; - let _e225 = cutoff; - g_premultiply = ((_e223 < 0f) && (_e225 > -0.75f)); - let _e228 = v_normal_1; - s_7.n = normalize(_e228); - let _e231 = gl_FrontFacing_1; - if !(_e231) { - let _e234 = s_7.n; - s_7.n = -(_e234); - } - let _e238 = frag_info.camera_position; - let _e240 = v_world_position_1; - s_7.v = normalize((_e238.xyz - _e240)); - let _e245 = s_7.n; - let _e247 = s_7.v; - s_7.n_dot_v = max(dot(_e245, _e247), 0.0001f); - let _e253 = frag_info.material[0u]; - s_7.metallic = clamp(_e253, 0f, 1f); - let _e258 = frag_info.material[1u]; - s_7.roughness = clamp(_e258, 0.02f, 1f); - let _e262 = frag_info.ambient_ground; - let _e265 = frag_info.ambient_sky; - let _e269 = s_7.n[1u]; - let _e276 = frag_info.material[2u]; - s_7.ambient = (mix(_e262.xyz, _e265.xyz, vec3(((_e269 * 0.5f) + 0.5f))) * _e276); - let _e281 = frag_info.frame_params[0u]; - s_7.exposure = max(_e281, 0f); - s_7.occlusion = 1f; - s_7.emissive = vec3(0f, 0f, 0f); - let _e286 = s_7; - return _e286; + let _e253 = cutoff; + let _e255 = cutoff; + g_premultiply = ((_e253 < 0f) && (_e255 > -0.75f)); + let _e258 = v_normal_1; + s_8.n = normalize(_e258); + let _e261 = gl_FrontFacing_1; + if !(_e261) { + let _e264 = s_8.n; + s_8.n = -(_e264); + } + let _e267 = TowardsEye_u0028_(); + s_8.v = _e267; + let _e270 = s_8.n; + let _e272 = s_8.v; + s_8.n_dot_v = max(dot(_e270, _e272), 0.0001f); + let _e278 = frag_info.material[0u]; + s_8.metallic = clamp(_e278, 0f, 1f); + let _e283 = frag_info.material[1u]; + s_8.roughness = clamp(_e283, 0.02f, 1f); + let _e287 = frag_info.ambient_ground; + let _e290 = frag_info.ambient_sky; + let _e294 = s_8.n[1u]; + let _e301 = frag_info.material[2u]; + s_8.ambient = (mix(_e287.xyz, _e290.xyz, vec3(((_e294 * 0.5f) + 0.5f))) * _e301); + let _e306 = frag_info.frame_params[0u]; + s_8.exposure = max(_e306, 0f); + s_8.occlusion = 1f; + s_8.emissive = vec3(0f, 0f, 0f); + let _e311 = s_8; + return _e311; } fn main_1() { - var s_8: Surface; + var s_9: Surface; var d_7: vec3; var right_1: vec3; - var param_112: vec3; + var param_118: vec3; var up_2: vec3; var offset_4: vec3; var g: vec2; - var param_113: vec3; + var param_119: vec3; var base: vec2; var f: vec2; var lower: bool; var c0_: vec2; - var local_24: vec2; + var local_30: vec2; var c1_: vec2; var c2_: vec2; var w: vec3; - var local_25: vec3; + var local_31: vec3; var uv0_: vec2; - var param_114: vec2; - var param_115: vec3; + var param_120: vec2; + var param_121: vec3; var uv1_: vec2; - var param_116: vec2; - var param_117: vec3; + var param_122: vec2; + var param_123: vec3; var uv2_: vec2; - var param_118: vec2; - var param_119: vec3; + var param_124: vec2; + var param_125: vec3; var a0_: vec4; var a1_: vec4; var a2_: vec4; @@ -43603,202 +49252,217 @@ fn main_1() { var wa: vec3; var alpha_3: f32; var srgb_1: vec3; - var local_26: vec3; + var local_32: vec3; var worldRight: vec3; var worldFacing: vec3; var worldUp: vec3; - var param_120: vec3; - var param_121: vec3; + var param_126: vec3; + var param_127: vec3; var ambient: vec3; - var param_122: Surface; - var param_123: vec3; - var param_124: f32; - var param_125: f32; + var lit_3: vec3; + var param_128: Surface; + var param_129: Surface; + var param_130: vec3; + var param_131: vec3; + var param_132: f32; + var param_133: f32; g_albedo = vec3(0f, 0f, 0f); g_debug_surface = vec3(0f, 0f, 0f); g_debug_surface_on = false; g_premultiply = false; + g_pass_through = 0f; g_cluster_offset = 0f; g_cluster_count = 0f; - let _e212 = ReadSurface_u0028_(); - s_8 = _e212; - let _e213 = v_color_1; - d_7 = normalize(_e213.xyz); - let _e216 = d_7; - param_112 = _e216; - let _e217 = ImpostorRight_u0028_vf3_u003b((¶m_112)); - right_1 = _e217; - let _e218 = d_7; - let _e219 = right_1; - up_2 = cross(_e218, _e219); - let _e221 = right_1; - let _e223 = v_texcoord_1[0u]; - let _e227 = up_2; - let _e229 = v_texcoord_1[1u]; - offset_4 = ((_e221 * ((_e223 * 2f) - 1f)) + (_e227 * (1f - (_e229 * 2f)))); - let _e234 = d_7; - param_113 = _e234; - let _e235 = ImpostorEncode_u0028_vf3_u003b((¶m_113)); - g = (_e235 * 7f); - let _e237 = g; - base = clamp(floor(_e237), vec2(0f, 0f), vec2(6f, 6f)); - let _e240 = g; - let _e241 = base; - f = (_e240 - _e241); - let _e244 = f[0u]; - let _e246 = f[1u]; - lower = ((_e244 + _e246) < 1f); - let _e249 = lower; - if _e249 { - let _e250 = base; - local_24 = _e250; - } else { - let _e251 = base; - local_24 = (_e251 + vec2(1f, 1f)); - } - let _e253 = local_24; - c0_ = _e253; - let _e254 = base; - c1_ = (_e254 + vec2(1f, 0f)); + light_transmittance = vec3(1f, 1f, 1f); + let _e227 = ReadSurface_u0028_(); + s_9 = _e227; + let _e228 = v_color_1; + d_7 = normalize(_e228.xyz); + let _e231 = d_7; + param_118 = _e231; + let _e232 = ImpostorRight_u0028_vf3_u003b((¶m_118)); + right_1 = _e232; + let _e233 = d_7; + let _e234 = right_1; + up_2 = cross(_e233, _e234); + let _e236 = right_1; + let _e238 = v_texcoord_1[0u]; + let _e242 = up_2; + let _e244 = v_texcoord_1[1u]; + offset_4 = ((_e236 * ((_e238 * 2f) - 1f)) + (_e242 * (1f - (_e244 * 2f)))); + let _e249 = d_7; + param_119 = _e249; + let _e250 = ImpostorEncode_u0028_vf3_u003b((¶m_119)); + g = (_e250 * 7f); + let _e252 = g; + base = clamp(floor(_e252), vec2(0f, 0f), vec2(6f, 6f)); + let _e255 = g; let _e256 = base; - c2_ = (_e256 + vec2(0f, 1f)); - let _e258 = lower; - if _e258 { - let _e260 = f[0u]; - let _e263 = f[1u]; - let _e266 = f[0u]; - let _e268 = f[1u]; - local_25 = vec3(((1f - _e260) - _e263), _e266, _e268); - } else { - let _e271 = f[0u]; - let _e273 = f[1u]; - let _e277 = f[1u]; - let _e280 = f[0u]; - local_25 = vec3(((_e271 + _e273) - 1f), (1f - _e277), (1f - _e280)); - } - let _e283 = local_25; - w = _e283; - let _e284 = c0_; - param_114 = _e284; - let _e285 = offset_4; - param_115 = _e285; - let _e286 = ImpostorViewUv_u0028_vf2_u003b_vf3_u003b((¶m_114), (¶m_115)); - uv0_ = _e286; - let _e287 = c1_; - param_116 = _e287; - let _e288 = offset_4; - param_117 = _e288; - let _e289 = ImpostorViewUv_u0028_vf2_u003b_vf3_u003b((¶m_116), (¶m_117)); - uv1_ = _e289; - let _e290 = c2_; - param_118 = _e290; - let _e291 = offset_4; - param_119 = _e291; - let _e292 = ImpostorViewUv_u0028_vf2_u003b_vf3_u003b((¶m_118), (¶m_119)); - uv2_ = _e292; - let _e293 = uv0_; - let _e294 = textureSampleLevel(base_color_texture_tex, base_color_texture_smp, _e293, 0f); - a0_ = _e294; - let _e295 = uv1_; - let _e296 = textureSampleLevel(base_color_texture_tex, base_color_texture_smp, _e295, 0f); - a1_ = _e296; - let _e297 = uv2_; - let _e298 = textureSampleLevel(base_color_texture_tex, base_color_texture_smp, _e297, 0f); - a2_ = _e298; - let _e299 = uv0_; - let _e300 = textureSampleLevel(normal_texture_tex, normal_texture_smp, _e299, 0f); - n0_ = _e300; - let _e301 = uv1_; - let _e302 = textureSampleLevel(normal_texture_tex, normal_texture_smp, _e301, 0f); - n1_ = _e302; - let _e303 = uv2_; - let _e304 = textureSampleLevel(normal_texture_tex, normal_texture_smp, _e303, 0f); - n2_ = _e304; - let _e305 = w; - let _e307 = a0_[3u]; - let _e309 = a1_[3u]; - let _e311 = a2_[3u]; - wa = (_e305 * vec3(_e307, _e309, _e311)); - let _e315 = wa[0u]; - let _e317 = wa[1u]; - let _e320 = wa[2u]; - alpha_3 = ((_e315 + _e317) + _e320); - let _e322 = alpha_3; - if (_e322 < 0.5f) { - discard; + f = (_e255 - _e256); + let _e259 = f[0u]; + let _e261 = f[1u]; + lower = ((_e259 + _e261) < 1f); + let _e264 = lower; + if _e264 { + let _e265 = base; + local_30 = _e265; + } else { + let _e266 = base; + local_30 = (_e266 + vec2(1f, 1f)); } - let _e324 = a0_; - let _e327 = wa[0u]; - let _e329 = a1_; + let _e268 = local_30; + c0_ = _e268; + let _e269 = base; + c1_ = (_e269 + vec2(1f, 0f)); + let _e271 = base; + c2_ = (_e271 + vec2(0f, 1f)); + let _e273 = lower; + if _e273 { + let _e275 = f[0u]; + let _e278 = f[1u]; + let _e281 = f[0u]; + let _e283 = f[1u]; + local_31 = vec3(((1f - _e275) - _e278), _e281, _e283); + } else { + let _e286 = f[0u]; + let _e288 = f[1u]; + let _e292 = f[1u]; + let _e295 = f[0u]; + local_31 = vec3(((_e286 + _e288) - 1f), (1f - _e292), (1f - _e295)); + } + let _e298 = local_31; + w = _e298; + let _e299 = c0_; + param_120 = _e299; + let _e300 = offset_4; + param_121 = _e300; + let _e301 = ImpostorViewUv_u0028_vf2_u003b_vf3_u003b((¶m_120), (¶m_121)); + uv0_ = _e301; + let _e302 = c1_; + param_122 = _e302; + let _e303 = offset_4; + param_123 = _e303; + let _e304 = ImpostorViewUv_u0028_vf2_u003b_vf3_u003b((¶m_122), (¶m_123)); + uv1_ = _e304; + let _e305 = c2_; + param_124 = _e305; + let _e306 = offset_4; + param_125 = _e306; + let _e307 = ImpostorViewUv_u0028_vf2_u003b_vf3_u003b((¶m_124), (¶m_125)); + uv2_ = _e307; + let _e308 = uv0_; + let _e309 = textureSampleLevel(base_color_texture_tex, base_color_texture_smp, _e308, 0f); + a0_ = _e309; + let _e310 = uv1_; + let _e311 = textureSampleLevel(base_color_texture_tex, base_color_texture_smp, _e310, 0f); + a1_ = _e311; + let _e312 = uv2_; + let _e313 = textureSampleLevel(base_color_texture_tex, base_color_texture_smp, _e312, 0f); + a2_ = _e313; + let _e314 = uv0_; + let _e315 = textureSampleLevel(normal_texture_tex, normal_texture_smp, _e314, 0f); + n0_ = _e315; + let _e316 = uv1_; + let _e317 = textureSampleLevel(normal_texture_tex, normal_texture_smp, _e316, 0f); + n1_ = _e317; + let _e318 = uv2_; + let _e319 = textureSampleLevel(normal_texture_tex, normal_texture_smp, _e318, 0f); + n2_ = _e319; + let _e320 = w; + let _e322 = a0_[3u]; + let _e324 = a1_[3u]; + let _e326 = a2_[3u]; + wa = (_e320 * vec3(_e322, _e324, _e326)); + let _e330 = wa[0u]; let _e332 = wa[1u]; - let _e335 = a2_; - let _e338 = wa[2u]; - let _e341 = alpha_3; - srgb_1 = ((((_e324.xyz * _e327) + (_e329.xyz * _e332)) + (_e335.xyz * _e338)) / vec3(_e341)); - let _e344 = n0_; - let _e349 = wa[0u]; - let _e351 = n1_; - let _e356 = wa[1u]; - let _e359 = n2_; - let _e364 = wa[2u]; - local_26 = (((((_e344.xyz * 2f) - vec3(1f, 1f, 1f)) * _e349) + (((_e351.xyz * 2f) - vec3(1f, 1f, 1f)) * _e356)) + (((_e359.xyz * 2f) - vec3(1f, 1f, 1f)) * _e364)); - let _e367 = local_26; - let _e368 = local_26; - let _e371 = local_26; - let _e372 = d_7; - local_26 = normalize(select(_e372, _e371, vec3((dot(_e367, _e368) > 0.000000000001f)))); - let _e376 = v_tangent_1; - worldRight = normalize(_e376.xyz); - let _e379 = v_normal_1; - worldFacing = normalize(_e379); - let _e381 = worldFacing; - let _e382 = worldRight; - worldUp = cross(_e381, _e382); - let _e384 = worldRight; - let _e385 = local_26; - let _e386 = right_1; - let _e389 = worldUp; - let _e390 = local_26; - let _e391 = up_2; - let _e395 = worldFacing; - let _e396 = local_26; - let _e397 = d_7; - s_8.n = normalize((((_e384 * dot(_e385, _e386)) + (_e389 * dot(_e390, _e391))) + (_e395 * dot(_e396, _e397)))); - let _e404 = s_8.n; - let _e406 = s_8.v; - s_8.n_dot_v = max(dot(_e404, _e406), 0.0001f); - let _e410 = srgb_1; - param_120 = _e410; - let _e411 = SrgbToLinear_u0028_vf3_u003b((¶m_120)); - let _e413 = frag_info.base_color; - param_121 = _e413.xyz; - let _e415 = SrgbToLinear_u0028_vf3_u003b((¶m_121)); - s_8.albedo = (_e411 * _e415); - s_8.alpha = 1f; - let _e420 = s_8.albedo; - g_albedo = _e420; - let _e422 = frag_info.ambient_ground; - let _e425 = frag_info.ambient_sky; - let _e429 = s_8.n[1u]; - let _e436 = frag_info.material[2u]; - s_8.ambient = (mix(_e422.xyz, _e425.xyz, vec3(((_e429 * 0.5f) + 0.5f))) * _e436); - let _e440 = s_8.albedo; - let _e442 = s_8.ambient; - ambient = (_e440 * _e442); - let _e444 = s_8; - param_122 = _e444; - let _e445 = AccumulateLights_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b((¶m_122)); - let _e446 = ambient; - param_123 = (_e445 + _e446); - param_124 = 1f; - param_125 = 1f; - WriteSurface_u0028_vf3_u003b_f1_u003b_f1_u003b((¶m_123), (¶m_124), (¶m_125)); + let _e335 = wa[2u]; + alpha_3 = ((_e330 + _e332) + _e335); + let _e337 = alpha_3; + if (_e337 < 0.5f) { + discard; + } + let _e339 = a0_; + let _e342 = wa[0u]; + let _e344 = a1_; + let _e347 = wa[1u]; + let _e350 = a2_; + let _e353 = wa[2u]; + let _e356 = alpha_3; + srgb_1 = ((((_e339.xyz * _e342) + (_e344.xyz * _e347)) + (_e350.xyz * _e353)) / vec3(_e356)); + let _e359 = n0_; + let _e364 = wa[0u]; + let _e366 = n1_; + let _e371 = wa[1u]; + let _e374 = n2_; + let _e379 = wa[2u]; + local_32 = (((((_e359.xyz * 2f) - vec3(1f, 1f, 1f)) * _e364) + (((_e366.xyz * 2f) - vec3(1f, 1f, 1f)) * _e371)) + (((_e374.xyz * 2f) - vec3(1f, 1f, 1f)) * _e379)); + let _e382 = local_32; + let _e383 = local_32; + let _e386 = local_32; + let _e387 = d_7; + local_32 = normalize(select(_e387, _e386, vec3((dot(_e382, _e383) > 0.000000000001f)))); + let _e391 = v_tangent_1; + worldRight = normalize(_e391.xyz); + let _e394 = v_normal_1; + worldFacing = normalize(_e394); + let _e396 = worldFacing; + let _e397 = worldRight; + worldUp = cross(_e396, _e397); + let _e399 = worldRight; + let _e400 = local_32; + let _e401 = right_1; + let _e404 = worldUp; + let _e405 = local_32; + let _e406 = up_2; + let _e410 = worldFacing; + let _e411 = local_32; + let _e412 = d_7; + s_9.n = normalize((((_e399 * dot(_e400, _e401)) + (_e404 * dot(_e405, _e406))) + (_e410 * dot(_e411, _e412)))); + let _e419 = s_9.n; + let _e421 = s_9.v; + s_9.n_dot_v = max(dot(_e419, _e421), 0.0001f); + let _e425 = srgb_1; + param_126 = _e425; + let _e426 = SrgbToLinear_u0028_vf3_u003b((¶m_126)); + let _e428 = frag_info.base_color; + param_127 = _e428.xyz; + let _e430 = SrgbToLinear_u0028_vf3_u003b((¶m_127)); + s_9.albedo = (_e426 * _e430); + s_9.alpha = 1f; + let _e435 = s_9.albedo; + g_albedo = _e435; + let _e437 = frag_info.ambient_ground; + let _e440 = frag_info.ambient_sky; + let _e444 = s_9.n[1u]; + let _e451 = frag_info.material[2u]; + s_9.ambient = (mix(_e437.xyz, _e440.xyz, vec3(((_e444 * 0.5f) + 0.5f))) * _e451); + let _e455 = s_9.albedo; + let _e457 = s_9.ambient; + ambient = (_e455 * _e457); + let _e459 = s_9; + param_128 = _e459; + let _e460 = AccumulateLights_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b((¶m_128)); + let _e461 = ambient; + lit_3 = (_e460 + _e461); + let _e463 = s_9; + param_129 = _e463; + let _e464 = lit_3; + param_130 = _e464; + let _e465 = WriteDebugView_u0028_struct_u002d_Surface_u002d_vf3_u002d_f1_u002d_vf3_u002d_vf3_u002d_f1_u002d_f1_u002d_f1_u002d_f1_u002d_vf3_u002d_vf3_u002d_f11_u003b_vf3_u003b((¶m_129), (¶m_130)); + if _e465 { + return; + } + let _e466 = lit_3; + param_131 = _e466; + param_132 = 1f; + param_133 = 1f; + WriteSurface_u0028_vf3_u003b_f1_u003b_f1_u003b((¶m_131), (¶m_132), (¶m_133)); return; } @fragment -fn main(@location(6) v_world_position: vec3, @location(2) v_normal: vec3, @builtin(front_facing) gl_FrontFacing: bool, @builtin(position) gl_FragCoord: vec4, @location(4) v_texcoord: vec2, @location(0) v_color: vec4, @location(3) v_tangent: vec4, @location(1) v_lightmap_uv: vec2) -> FragmentOutput { +fn main(@location(7) v_world_position: vec3, @location(3) v_normal: vec3, @builtin(front_facing) gl_FrontFacing: bool, @builtin(position) gl_FragCoord: vec4, @location(5) v_texcoord: vec2, @location(0) v_color: vec4, @location(4) v_tangent: vec4, @location(2) v_lightmap_uv: vec2) -> FragmentOutput { v_world_position_1 = v_world_position; v_normal_1 = v_normal; gl_FrontFacing_1 = gl_FrontFacing; @@ -43820,7 +49484,7 @@ fn main(@location(6) v_world_position: vec3, @location(2) v_normal: vec3[ WebGpuBlockMember(name: 'fog', offsetInBytes: 0, sizeInBytes: 16), WebGpuBlockMember(name: 'eye', offsetInBytes: 16, sizeInBytes: 16), @@ -43829,13 +49493,18 @@ fn main(@location(6) v_world_position: vec3, @location(2) v_normal: vec3[ WebGpuBlockMember( name: 'light_position', @@ -43932,6 +49601,11 @@ fn main(@location(6) v_world_position: vec3, @location(2) v_normal: vec3 attributes; final List blocks; final List samplers; + + /// What this stage declares, as the renderer asks a handle's `kept` — + /// `P8`. + /// + /// **Its own lists, because on this backend they are the binding.** The + /// pipeline's layout is built from them, so every block and sampler they + /// name has to be bound at a draw whether or not the WGSL reads it. The + /// engine's library has the same answer from its generated table; a loaded + /// one had none, and the renderer fell back to the lighting model's flags — + /// which say what a model reads, not what its WGSL declares. A lit material + /// from the material language declares the metal-rough map through + /// `material_maps.glsl` and reads nothing of it, so the map went unbound + /// and the draw was refused. + StageBindings get declared => ( + blocks: {for (final block in blocks) block.name}, + samplers: {for (final sampler in samplers) sampler.name}, + ); } /// What the section decodes to. diff --git a/packages/flutter3d_webgpu/lib/src/webgpu_device.dart b/packages/flutter3d_webgpu/lib/src/webgpu_device.dart index a151b3784..c9374d487 100644 --- a/packages/flutter3d_webgpu/lib/src/webgpu_device.dart +++ b/packages/flutter3d_webgpu/lib/src/webgpu_device.dart @@ -805,6 +805,11 @@ final class WebGpuDevice implements GraphicsDevice, WgslModuleCompiler { @override bool get supportsWireframe => false; + /// True — `P7`: a pipeline's `alphaToCoverageEnabled`, wherever the pass + /// multisamples, which the scene pass does at four. + @override + bool get supportsAlphaToCoverage => true; + @override bool get supportsStencil => true; diff --git a/packages/flutter3d_webgpu/lib/src/webgpu_encoder.dart b/packages/flutter3d_webgpu/lib/src/webgpu_encoder.dart index fb194e814..7c1a5d35a 100644 --- a/packages/flutter3d_webgpu/lib/src/webgpu_encoder.dart +++ b/packages/flutter3d_webgpu/lib/src/webgpu_encoder.dart @@ -213,6 +213,9 @@ final class WebGpuEncoder implements CommandEncoder { WindingOrder _winding = WindingOrder.counterClockwise; CompareFunction _depthCompare = CompareFunction.always; bool _depthWrite = true; + + /// `setAlphaToCoverage`, off at the pass's start — `P7`. + bool _alphaToCoverage = false; StencilState? _stencilFront; StencilState? _stencilBack; @@ -299,6 +302,12 @@ final class WebGpuEncoder implements CommandEncoder { @override void setDepthWrite(bool enabled) => _depthWrite = enabled; + /// Into the pipeline's signature: WebGPU sets it when a pipeline is built, + /// and only for one of more than one sample, where a pipeline of one would + /// be refused — so a pass of one sample builds the pipeline without it. + @override + void setAlphaToCoverage(bool enabled) => _alphaToCoverage = enabled; + @override void setDepthCompare(CompareFunction compare) => _depthCompare = compare; @@ -527,7 +536,12 @@ final class WebGpuEncoder implements CommandEncoder { // ------------------------------------------------------------- the draw @override - void draw({int instanceCount = 1}) { + void draw({int instanceCount = 1, int firstIndex = 0, int? indexCount}) { + final window = indexWindow( + _indexCount, + firstIndex: firstIndex, + indexCount: indexCount, + ); // Below one draws nothing, as it does on the other backends. A negative // count handed on would reach `drawIndexed` as an unsigned number in the // billions. @@ -579,7 +593,7 @@ final class WebGpuEncoder implements CommandEncoder { indices.offset, indices.length, ) - ..drawIndexed(_indexCount, instanceCount); + ..drawIndexed(window.count, instanceCount, window.first); } /// The signature this draw's state makes, which is what the cache is @@ -612,6 +626,7 @@ final class WebGpuEncoder implements CommandEncoder { colorFormats: _colorFormats, depthFormat: _depthFormat, sampleCount: _sampleCount, + alphaToCoverage: _alphaToCoverage && _sampleCount > 1, ); } @@ -710,7 +725,7 @@ final class WebGpuEncoder implements CommandEncoder { final multisample = GPUMultisampleState( count: _sampleCount, mask: 0xFFFFFFFF, - alphaToCoverageEnabled: false, + alphaToCoverageEnabled: signature.alphaToCoverage, ); final depthFormat = _depthFormat; diff --git a/packages/flutter3d_webgpu/lib/src/webgpu_loaded_shaders.dart b/packages/flutter3d_webgpu/lib/src/webgpu_loaded_shaders.dart index 1406a1625..994695268 100644 --- a/packages/flutter3d_webgpu/lib/src/webgpu_loaded_shaders.dart +++ b/packages/flutter3d_webgpu/lib/src/webgpu_loaded_shaders.dart @@ -155,7 +155,13 @@ final class WebGpuLoadedShaderLibrary implements LoadedShaderLibrary { final isVertex = _stages.vertex.containsKey(name); final stage = isVertex ? _stages.vertex[name] : _stages.fragment[name]; if (stage == null) return null; - return compileWebGpuStage(_compiler, name, stage, isVertex: isVertex); + return compileWebGpuStage( + _compiler, + name, + stage, + isVertex: isVertex, + kept: stage.declared, + ); } @override diff --git a/packages/flutter3d_webgpu/lib/src/webgpu_pipeline_cache.dart b/packages/flutter3d_webgpu/lib/src/webgpu_pipeline_cache.dart index cb4aadf9d..3d8368eb3 100644 --- a/packages/flutter3d_webgpu/lib/src/webgpu_pipeline_cache.dart +++ b/packages/flutter3d_webgpu/lib/src/webgpu_pipeline_cache.dart @@ -105,6 +105,7 @@ final class WebGpuPipelineSignature { required List colorFormats, required this.depthFormat, required this.sampleCount, + this.alphaToCoverage = false, this.vertexModule, this.fragmentModule, }) : blends = List.unmodifiable(blends), @@ -160,6 +161,10 @@ final class WebGpuPipelineSignature { final String? depthFormat; final int sampleCount; + /// `alphaToCoverageEnabled` — `P7`. Part of a pipeline in WebGPU, so a + /// masked material drawn with and without it is two. + final bool alphaToCoverage; + @override bool operator ==(Object other) => other is WebGpuPipelineSignature && @@ -176,6 +181,7 @@ final class WebGpuPipelineSignature { other.stencil == stencil && other.depthFormat == depthFormat && other.sampleCount == sampleCount && + other.alphaToCoverage == alphaToCoverage && _same(other.blends, blends) && _same(other.colorFormats, colorFormats); @@ -204,6 +210,7 @@ final class WebGpuPipelineSignature { Object.hashAll(colorFormats), depthFormat, sampleCount, + alphaToCoverage, ); @override @@ -214,7 +221,8 @@ final class WebGpuPipelineSignature { 'stencil: ${stencil ?? 'off'}, ' 'blend: ${blends.map((BlendState? b) => b == null ? 'off' : 'on').join('+')}, ' 'targets: ${colorFormats.join('+')}' - '${depthFormat == null ? '' : '/$depthFormat'}, x$sampleCount)'; + '${depthFormat == null ? '' : '/$depthFormat'}, x$sampleCount' + '${alphaToCoverage ? ', alpha to coverage' : ''})'; } /// The pipelines this device has built, by the signature that produced each. diff --git a/packages/flutter3d_webgpu/pubspec.yaml b/packages/flutter3d_webgpu/pubspec.yaml index 4dbf7bb76..fde3cd107 100644 --- a/packages/flutter3d_webgpu/pubspec.yaml +++ b/packages/flutter3d_webgpu/pubspec.yaml @@ -54,13 +54,6 @@ dev_dependencies: # source has to reach both generators, and this is the one package that has # both. Not a real dependency — nothing in `lib/` reaches it. flutter3d_core: ^0.8.2 - # For `resolveIncludes` in `glsl_translate.dart`, and for nothing else. The - # WebGL2 backend already answers "which headers does this shader really pull - # in", and a second answer to that question is how two backends drift apart in - # the one place neither of them can see. A dev dependency for the same reason - # `flutter3d_shaders` is: nothing in `lib/` reaches it, and two backends that - # depended on each other at run time would not be two backends. - flutter3d_webgl: ^0.8.2 # For `reflection_probe_test.dart` alone, which drives the real Renderer # through this backend so that a reflection probe is checked as a picture # rather than as a capability. The sibling WebGL2 package reaches for the diff --git a/packages/flutter3d_webgpu/test/cross_backend_test.dart b/packages/flutter3d_webgpu/test/cross_backend_test.dart index c119c5b22..d97cff502 100644 --- a/packages/flutter3d_webgpu/test/cross_backend_test.dart +++ b/packages/flutter3d_webgpu/test/cross_backend_test.dart @@ -127,6 +127,17 @@ const int _channel = 8; /// paragraph on the line where its name already is, which is how the two /// siblings grew every explanation they carry. const Map _budgets = { + 'material-language': 0.01, + 'material-light-hook': 0.01, + 'material-instance-data': 0.01, + 'widget-scene': 0.01, + // 0.565% measured, on the discs' rims and nowhere else: coverage here, + // the hard cutoff on Impeller — `Material.alphaToCoverage`, two of four. + 'alpha-to-coverage': 0.65, + 'orthographic-metal': 0.01, + 'orthographic-shadows': 0.01, + 'orthographic-particles': 0.01, + 'debug-view-split': 0.01, 'teapot-generated-normals': 0.01, 'shadow-teapot': 0.01, 'bloom-sphere': 0.01, @@ -182,6 +193,9 @@ const Map _budgets = { 'cascade-walk': 0.01, 'clearcoat-car-paint': 0.01, 'easu-half': 0.01, + 'lens-flare': 0.01, + 'lens-distortion': 0.01, + 'smaa-teapot': 0.01, 'evsm-soft': 0.33, 'fog-torches': 0.01, 'glass-stack-oit': 0.01, @@ -198,6 +212,9 @@ const Map _budgets = { 'sheen-fabric': 0.01, // 0.000% measured. It was 2.039%, and not sampling: the puff's hash used // 64-bit integers, so a browser baked a different sheet. + // `P5`. 0 of 172800 measured against Impeller on 2026-10-01, both. + 'sky-physical-dusk': 0.01, + 'sky-physical-night': 0.01, 'smoke-six-way': 0.01, 'splat-gltf': 0.01, // Was 40.38, over a reference that was a black frame: the hashed splats' @@ -222,6 +239,11 @@ const Map _budgets = { 'transmission-glass': 0.01, 'velocity-shapes': 0.01, 'window-interior': 0.01, + // `P3`, 0 of 172800 measured on 2026-10-01. + 'decal-floor': 0.01, + // `P4`. 0 of 172800 measured against Impeller on 2026-10-01. + 'planar-mirror': 0.01, + 'render-texture': 0.01, }; /// Scenes this set holds no picture of, and why the picture was refused. diff --git a/packages/flutter3d_webgpu/test/engine_shaders_test.dart b/packages/flutter3d_webgpu/test/engine_shaders_test.dart index de45a937d..14f0060ce 100644 --- a/packages/flutter3d_webgpu/test/engine_shaders_test.dart +++ b/packages/flutter3d_webgpu/test/engine_shaders_test.dart @@ -196,14 +196,12 @@ void main() { // for it, so a model that laid it out differently would read another // model's bytes at the wrong offsets. // - // **Per member and not per block, because one block name here is - // deliberately two shapes.** The three particle stages share none of the - // lit path's headers and declare a `FogInfo` of their own with the first - // two members and not the third — `lighting/particle.frag` says so at - // length: a particle writes no surface buffer, so it is never bound the - // `forward` the lit models measure their depths along. Two blocks with one - // name and different lengths is safe exactly as long as what they do share - // lands in the same place, which is what this measures. + // **Per member and not per block, because one block name may be declared + // in more than one shape.** The particle and splat stages share none of + // the lit path's headers and declare `FogInfo` through + // `contributor_eye.glsl` instead. Two declarations of one name are safe + // exactly as long as what they share lands in the same place, which is + // what this measures. final offsets = >{}; stages.forEach((name, entry) { for (final block in entry.stage.blocks) { @@ -227,12 +225,14 @@ void main() { expect(offsets['FragInfo']!['ambient_ground']!.$2, 848); expect(offsets['FogInfo']!['eye']!.$2, 16); - // The member the particle stages do not declare, from the stages that do. expect(offsets['FogInfo']!['forward']!.$2, 32); + // `P7`: the particle and splat stages declare the whole block through + // `contributor_eye.glsl` now, and their contributors bind all four. + expect(offsets['FogInfo']!['projection']!.$2, 48); expect( engineShaders.fragment['Particle']!.blocks.single.members.length, - 2, - reason: 'the particle FogInfo grew a member it is never bound', + 4, + reason: 'the particle FogInfo is the lit stages\' block, all of it', ); }); } diff --git a/packages/flutter3d_webgpu/test/glsl_to_wgsl_test.dart b/packages/flutter3d_webgpu/test/glsl_to_wgsl_test.dart index fa027556a..b7bd091c2 100644 --- a/packages/flutter3d_webgpu/test/glsl_to_wgsl_test.dart +++ b/packages/flutter3d_webgpu/test/glsl_to_wgsl_test.dart @@ -14,9 +14,7 @@ @TestOn('vm') library; -// ignore: implementation_imports -import 'package:flutter3d_webgl/src/glsl_translate.dart'; -import 'package:flutter3d_webgpu/src/glsl_to_wgsl.dart'; +import 'package:flutter3d_shaders/translate.dart'; import 'package:flutter_test/flutter_test.dart'; /// Includes resolved and then the stage prepared, the way diff --git a/packages/flutter3d_webgpu/test/goldens/alpha-to-coverage.png b/packages/flutter3d_webgpu/test/goldens/alpha-to-coverage.png new file mode 100644 index 000000000..94928d893 Binary files /dev/null and b/packages/flutter3d_webgpu/test/goldens/alpha-to-coverage.png differ diff --git a/packages/flutter3d_webgpu/test/goldens/debug-view-split.png b/packages/flutter3d_webgpu/test/goldens/debug-view-split.png new file mode 100644 index 000000000..d3015fd60 Binary files /dev/null and b/packages/flutter3d_webgpu/test/goldens/debug-view-split.png differ diff --git a/packages/flutter3d_webgpu/test/goldens/decal-floor.png b/packages/flutter3d_webgpu/test/goldens/decal-floor.png new file mode 100644 index 000000000..77cadccd1 Binary files /dev/null and b/packages/flutter3d_webgpu/test/goldens/decal-floor.png differ diff --git a/packages/flutter3d_webgpu/test/goldens/lens-distortion.png b/packages/flutter3d_webgpu/test/goldens/lens-distortion.png new file mode 100644 index 000000000..71610b149 Binary files /dev/null and b/packages/flutter3d_webgpu/test/goldens/lens-distortion.png differ diff --git a/packages/flutter3d_webgpu/test/goldens/lens-flare.png b/packages/flutter3d_webgpu/test/goldens/lens-flare.png new file mode 100644 index 000000000..a1385c14b Binary files /dev/null and b/packages/flutter3d_webgpu/test/goldens/lens-flare.png differ diff --git a/packages/flutter3d_webgpu/test/goldens/material-instance-data.png b/packages/flutter3d_webgpu/test/goldens/material-instance-data.png new file mode 100644 index 000000000..1ec1cf626 Binary files /dev/null and b/packages/flutter3d_webgpu/test/goldens/material-instance-data.png differ diff --git a/packages/flutter3d_webgpu/test/goldens/material-language.png b/packages/flutter3d_webgpu/test/goldens/material-language.png new file mode 100644 index 000000000..e8cc05a3b Binary files /dev/null and b/packages/flutter3d_webgpu/test/goldens/material-language.png differ diff --git a/packages/flutter3d_webgpu/test/goldens/material-light-hook.png b/packages/flutter3d_webgpu/test/goldens/material-light-hook.png new file mode 100644 index 000000000..e3e3409b6 Binary files /dev/null and b/packages/flutter3d_webgpu/test/goldens/material-light-hook.png differ diff --git a/packages/flutter3d_webgpu/test/goldens/orthographic-metal.png b/packages/flutter3d_webgpu/test/goldens/orthographic-metal.png new file mode 100644 index 000000000..8959e154e Binary files /dev/null and b/packages/flutter3d_webgpu/test/goldens/orthographic-metal.png differ diff --git a/packages/flutter3d_webgpu/test/goldens/orthographic-particles.png b/packages/flutter3d_webgpu/test/goldens/orthographic-particles.png new file mode 100644 index 000000000..eb9629eaa Binary files /dev/null and b/packages/flutter3d_webgpu/test/goldens/orthographic-particles.png differ diff --git a/packages/flutter3d_webgpu/test/goldens/orthographic-shadows.png b/packages/flutter3d_webgpu/test/goldens/orthographic-shadows.png new file mode 100644 index 000000000..c6486e909 Binary files /dev/null and b/packages/flutter3d_webgpu/test/goldens/orthographic-shadows.png differ diff --git a/packages/flutter3d_webgpu/test/goldens/planar-mirror.png b/packages/flutter3d_webgpu/test/goldens/planar-mirror.png new file mode 100644 index 000000000..b7974e170 Binary files /dev/null and b/packages/flutter3d_webgpu/test/goldens/planar-mirror.png differ diff --git a/packages/flutter3d_webgpu/test/goldens/render-texture.png b/packages/flutter3d_webgpu/test/goldens/render-texture.png new file mode 100644 index 000000000..d760b38e2 Binary files /dev/null and b/packages/flutter3d_webgpu/test/goldens/render-texture.png differ diff --git a/packages/flutter3d_webgpu/test/goldens/sky-physical-dusk.png b/packages/flutter3d_webgpu/test/goldens/sky-physical-dusk.png new file mode 100644 index 000000000..85006fcdb Binary files /dev/null and b/packages/flutter3d_webgpu/test/goldens/sky-physical-dusk.png differ diff --git a/packages/flutter3d_webgpu/test/goldens/sky-physical-night.png b/packages/flutter3d_webgpu/test/goldens/sky-physical-night.png new file mode 100644 index 000000000..8ea20ac80 Binary files /dev/null and b/packages/flutter3d_webgpu/test/goldens/sky-physical-night.png differ diff --git a/packages/flutter3d_webgpu/test/goldens/smaa-teapot.png b/packages/flutter3d_webgpu/test/goldens/smaa-teapot.png new file mode 100644 index 000000000..53cf1437d Binary files /dev/null and b/packages/flutter3d_webgpu/test/goldens/smaa-teapot.png differ diff --git a/packages/flutter3d_webgpu/test/goldens/widget-scene.png b/packages/flutter3d_webgpu/test/goldens/widget-scene.png new file mode 100644 index 000000000..003f1171f Binary files /dev/null and b/packages/flutter3d_webgpu/test/goldens/widget-scene.png differ diff --git a/packages/flutter3d_webgpu/test/material_language_generators_test.dart b/packages/flutter3d_webgpu/test/material_language_generators_test.dart index 56be9aa0e..0595708a0 100644 --- a/packages/flutter3d_webgpu/test/material_language_generators_test.dart +++ b/packages/flutter3d_webgpu/test/material_language_generators_test.dart @@ -13,18 +13,19 @@ /// language allowed and a generator cannot carry fails here rather than in a /// browser. /// -/// **Here rather than in `flutter3d_webgl`** because this is the one package -/// that already has both: its own generator, and the other backend's as a dev -/// dependency, for exactly the reason its `prepareStage` gives — two answers -/// to "which headers does this shader pull in" would drift where neither side -/// could see it. +/// Both generators live in `flutter3d_shaders` since P8; this file stays here +/// because it is the WebGPU half — `prepareStage` — that has the most to +/// refuse. `flutter3d_build`'s `material_build_test.dart` runs the same source +/// all the way into a bundle, through glslang and naga where a machine has +/// them. /// /// **Impeller is the one not exercised.** `impellerc` is a binary out of the -/// Flutter SDK's artifact cache, and `build_shaders_test.dart` next door stubs -/// it rather than depending on a machine having it. What this file can say is -/// that the emitted text is the same shape as every `.frag` that binary -/// already compiles — the same `#version`, the same one header, the same -/// prototypes — and that is stated rather than proved. +/// Flutter SDK's artifact cache, and `flutter3d_impeller`'s +/// `build_shaders_test.dart` stubs it rather than depending on a machine +/// having it. What this file can say is that the emitted text is the same +/// shape as every `.frag` that binary already compiles — the same `#version`, +/// the same one header, the same prototypes — and that is stated rather than +/// proved. /// /// **The VM only**, because resolving headers against the real tree is file /// access: `loadShaders` starts from `Directory.current`, which a browser @@ -34,13 +35,7 @@ library; import 'package:flutter3d_core/formats.dart'; -// The include resolver, and only the include resolver — the same -// `implementation_imports` `tool/generate_shaders.dart` takes, for the same -// reason. -// ignore: implementation_imports -import 'package:flutter3d_webgl/src/glsl_translate.dart'; -import 'package:flutter3d_webgpu/src/glsl_to_wgsl.dart'; -import 'package:flutter3d_webgpu/src/source_package.dart'; +import 'package:flutter3d_shaders/compile.dart'; import 'package:flutter_test/flutter_test.dart'; /// Everything the language has: a texture, a parameter of each shape, a diff --git a/packages/flutter3d_webgpu/test/open_test.dart b/packages/flutter3d_webgpu/test/open_test.dart index d3a34f500..6960496ed 100644 --- a/packages/flutter3d_webgpu/test/open_test.dart +++ b/packages/flutter3d_webgpu/test/open_test.dart @@ -39,6 +39,7 @@ const List<(String, String)> _pairs = <(String, String)>[ ('FullscreenVertex', 'Ssao'), ('SkyVertex', 'Sky'), ('SkyCubeVertex', 'SkyCube'), + ('SkyPhysicalVertex', 'SkyPhysical'), ('ParticleVertex', 'ParticleTextured'), ('DebugLineVertex', 'DebugLine'), ]; diff --git a/packages/flutter3d_webgpu/test/webgpu_loaded_shaders_test.dart b/packages/flutter3d_webgpu/test/webgpu_loaded_shaders_test.dart index befff58e5..b178a80e4 100644 --- a/packages/flutter3d_webgpu/test/webgpu_loaded_shaders_test.dart +++ b/packages/flutter3d_webgpu/test/webgpu_loaded_shaders_test.dart @@ -107,6 +107,46 @@ ByteData _withVersion(ByteData section, int? version) { ByteData _bytes(List raw) => Uint8List.fromList(raw).buffer.asByteData(); void main() { + test( + 'a loaded stage says what it declares, so the renderer binds it — P8', + () { + // A lit material from the material language declares the metal-rough + // map through `material_maps.glsl` and reads nothing of it. On this + // backend the layout is built from the section's lists, so the map must + // be bound; without `kept` the renderer asked the lighting model, which + // said no, and the draw was refused. + // + // Mutation: drop `kept: stage.declared` from the loaded library. + final section = encodeWebGpuSection( + vertex: const {}, + fragment: { + 'ToonHook': WebGpuStage( + wgsl: 'f0', + attributes: const [], + blocks: const [], + samplers: const [ + WebGpuSampler( + name: 'metallic_roughness_texture', + group: 1, + textureBinding: 0, + samplerBinding: 1, + dimension: WebGpuTextureDimension.twoDimensional, + ), + ], + ), + }, + ); + final library = WebGpuLoadedShaderLibrary.load( + _Compiler(), + _bundle(section: section, claims: ['ToonHook']), + ); + expect( + library['ToonHook']!.kept?.samplers, + contains('metallic_roughness_texture'), + ); + }, + ); + group('loading', () { test('answers the names the section carries', () { final library = WebGpuLoadedShaderLibrary.load( diff --git a/packages/flutter3d_webgpu/test/webgpu_shaders_test.dart b/packages/flutter3d_webgpu/test/webgpu_shaders_test.dart index f0bd2c5f9..19fbcf082 100644 --- a/packages/flutter3d_webgpu/test/webgpu_shaders_test.dart +++ b/packages/flutter3d_webgpu/test/webgpu_shaders_test.dart @@ -541,7 +541,7 @@ void main() { ], ), const BufferLayout( - strideInBytes: 64, + strideInBytes: 80, stepMode: VertexStepMode.instance, attributes: [ InputAttribute( @@ -563,6 +563,11 @@ void main() { format: VertexFormat.float32x4, offsetInBytes: 48, ), + InputAttribute( + name: 'i_data', + format: VertexFormat.float32x4, + offsetInBytes: 64, + ), ], ), ]), @@ -574,7 +579,7 @@ void main() { pipeline.buffers.last.attributes .map((WebGpuVertexAttribute a) => a.shaderLocation) .toList(), - [5, 6, 7, 8], + [5, 6, 7, 8, 9], ); }); diff --git a/packages/flutter3d_webgpu/test/wgsl_pipeline_test.dart b/packages/flutter3d_webgpu/test/wgsl_pipeline_test.dart index 0c0470050..2b8e3fceb 100644 --- a/packages/flutter3d_webgpu/test/wgsl_pipeline_test.dart +++ b/packages/flutter3d_webgpu/test/wgsl_pipeline_test.dart @@ -19,11 +19,7 @@ library; import 'dart:io'; -// ignore: implementation_imports -import 'package:flutter3d_webgl/src/glsl_translate.dart'; -import 'package:flutter3d_webgpu/src/glsl_to_wgsl.dart'; -import 'package:flutter3d_webgpu/src/source_package.dart'; -import 'package:flutter3d_webgpu/src/wgsl_compiler.dart'; +import 'package:flutter3d_shaders/compile.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { @@ -139,11 +135,14 @@ void main() { ); }); } - // The twenty names the manifest has today: `R1`'s velocity stages - // brought v_current, v_previous and v_depth, a family of their own. - // Stated so that a shader adding a twenty-first has to come back and - // read the family rule. - expect(seen.length, 20); + // The twenty-five names the manifest has today: `R1`'s velocity stages + // brought v_current, v_previous and v_depth, a family of their own, and + // the physical sky brought v_rayleigh, v_mie, v_planet and v_stars into + // the sky's family, through the v_ray, v_sun and v_disc it shares. + // `P8` brought v_instance into the mesh family. Stated so that a + // shader adding a twenty-sixth has to come back and read the family + // rule. + expect(seen.length, 25); }); test('keeps every family inside WebGPU\'s sixteen locations', () { @@ -153,10 +152,10 @@ void main() { ), isTrue, ); - // Five families and a widest of eight. One flat numbering would have - // needed twenty. - expect(locations.length, 20); - expect(locations.values.toSet().length, 8); + // Five families and a widest of twelve, the sky's. One flat numbering + // would have needed twenty-five. + expect(locations.length, 25); + expect(locations.values.toSet().length, 12); }); test('gives every stage its own bind group', () { diff --git a/packages/flutter3d_webgpu/test/wgsl_section_test.dart b/packages/flutter3d_webgpu/test/wgsl_section_test.dart index 130fe809c..6eac6ea73 100644 --- a/packages/flutter3d_webgpu/test/wgsl_section_test.dart +++ b/packages/flutter3d_webgpu/test/wgsl_section_test.dart @@ -29,10 +29,9 @@ import 'dart:convert'; import 'dart:typed_data'; import 'package:flutter3d_hardware/flutter3d_hardware.dart'; -import 'package:flutter3d_webgpu/src/glsl_to_wgsl.dart'; +import 'package:flutter3d_shaders/translate.dart'; import 'package:flutter3d_webgpu/src/webgpu_bundle_section.dart'; import 'package:flutter3d_webgpu/src/webgpu_loaded_shaders.dart'; -import 'package:flutter3d_webgpu/src/wgsl_section.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { diff --git a/packages/flutter3d_webgpu/tool/generate_compute_shaders.dart b/packages/flutter3d_webgpu/tool/generate_compute_shaders.dart index 27bbd9292..c1b5acf96 100644 --- a/packages/flutter3d_webgpu/tool/generate_compute_shaders.dart +++ b/packages/flutter3d_webgpu/tool/generate_compute_shaders.dart @@ -15,9 +15,7 @@ library; import 'dart:convert'; import 'dart:io'; -import 'package:flutter3d_webgl/src/glsl_translate.dart'; -import 'package:flutter3d_webgpu/src/source_package.dart'; -import 'package:flutter3d_webgpu/src/wgsl_compiler.dart'; +import 'package:flutter3d_shaders/compile.dart'; final RegExp _localSize = RegExp( r'layout\s*\(\s*local_size_x\s*=\s*(\d+)' diff --git a/packages/flutter3d_webgpu/tool/generate_shaders.dart b/packages/flutter3d_webgpu/tool/generate_shaders.dart index 815af8650..7beadf8b9 100644 --- a/packages/flutter3d_webgpu/tool/generate_shaders.dart +++ b/packages/flutter3d_webgpu/tool/generate_shaders.dart @@ -42,23 +42,13 @@ library; import 'dart:io'; -// The include resolver, and only the include resolver. A second one would be a -// second answer to "which headers does this shader really pull in", and the two -// backends would drift apart in the one place where drifting apart cannot be -// seen from either side. Called from here rather than from `prepareStage` -// because `flutter3d_webgl` is a dev dependency and a file under `lib/` may -// only import what ships. -// -// ignore: implementation_imports -import 'package:flutter3d_webgl/src/glsl_translate.dart'; -import 'package:flutter3d_webgpu/src/glsl_to_wgsl.dart'; -import 'package:flutter3d_webgpu/src/source_package.dart'; -import 'package:flutter3d_webgpu/src/wgsl_compiler.dart'; -// The std140 cross-check and the pair a finished stage is, shared with -// `tool/pack_wgsl_section.dart`. Two copies of the check would be two rules, -// and the loadable path — where the GLSL is somebody else's — is the one that -// needs it most. -import 'package:flutter3d_webgpu/src/wgsl_section.dart'; +// The translator, the include resolver WebGL2's generator also uses, the two +// compilers and the std140 cross-check — all in `flutter3d_shaders` since P8, +// shared with `tool/pack_wgsl_section.dart` and with `flutter3d_build`'s +// material step. A second include resolver would be a second answer to +// "which headers does this shader really pull in", and two copies of the +// check would be two rules. +import 'package:flutter3d_shaders/compile.dart'; void main(List args) { final ShaderSet shaders; diff --git a/packages/flutter3d_webgpu/tool/pack_wgsl_section.dart b/packages/flutter3d_webgpu/tool/pack_wgsl_section.dart index f5bca0360..aad030376 100644 --- a/packages/flutter3d_webgpu/tool/pack_wgsl_section.dart +++ b/packages/flutter3d_webgpu/tool/pack_wgsl_section.dart @@ -21,14 +21,12 @@ /// /// A bundle is one file with a section per backend, and `pack_shaders.dart` in /// `flutter3d_webgl` is what assembles it. It could have grown a third section -/// the way it grew the first two — except that everything the WGSL section is -/// made of lives here: the translator in `lib/src/glsl_to_wgsl.dart`, the two -/// external compilers in `lib/src/wgsl_compiler.dart`, and the shape of the -/// document in `lib/src/wgsl_section.dart`. Reaching for those from -/// the WebGL package's build script would make one backend's tooling depend on -/// another backend's library, which is the coupling four separate backends -/// exist to avoid — and `flutter3d_webgl` does not depend on this package, so -/// it would not even resolve. +/// the way it grew the first two — and since P8 it could, because the +/// translator, the two external compilers and the shape of the document all +/// live in `flutter3d_shaders` (`compile.dart`), where `flutter3d_build`'s +/// material step reaches them too. What stays here is the decision to run +/// glslang and naga at all: a machine without them still builds a bundle with +/// the other sections, and this program is the step it skips. /// /// So the section arrives at `pack_shaders.dart` already made, exactly the way /// impellerc's does: it is copied in under `--webgpu FILE` and that packer @@ -74,15 +72,7 @@ library; import 'dart:convert'; import 'dart:io'; -// The include resolver, and only the include resolver — the reason -// `tool/generate_shaders.dart` gives, and the same dev dependency. -// -// ignore: implementation_imports -import 'package:flutter3d_webgl/src/glsl_translate.dart'; -import 'package:flutter3d_webgpu/src/glsl_to_wgsl.dart'; -import 'package:flutter3d_webgpu/src/source_package.dart'; -import 'package:flutter3d_webgpu/src/wgsl_compiler.dart'; -import 'package:flutter3d_webgpu/src/wgsl_section.dart'; +import 'package:flutter3d_shaders/compile.dart'; void main(List args) { final options = _Options.parse(args); @@ -140,7 +130,7 @@ void main(List args) { } } - final entries = {}; + final entries = {}; manifest.forEach((name, spec) { final entry = spec as Map; final file = entry['file'] as String; @@ -165,7 +155,15 @@ void main(List args) { ); }); - final locations = _varyingLocations(engine, entries); + // Numbered against the engine's manifest as well as this one, and refused + // when that would move a number the engine already committed — see the + // header, and `bundleVaryingLocations`. + final Map locations; + try { + locations = bundleVaryingLocations(engine, entries.values); + } on WgslPrepareError catch (error) { + _fail(error.message); + } final vertex = {}; final fragment = {}; @@ -207,60 +205,6 @@ void main(List args) { ); } -/// One manifest entry, with its `#include`s already expanded. -typedef _Entry = ({String file, bool fragment, String resolved}); - -/// A location per varying name, agreeing with the engine's committed table. -/// -/// See the header: computed over both manifests, then held against the engine's -/// alone. The failure is a refusal to pack rather than a section that would -/// compile and read the wrong slot. -Map _varyingLocations(ShaderSet engine, Map mine) { - final theirs = >[]; - for (final entry in engine.stages.values) { - final String resolved; - try { - resolved = resolveIncludes( - engine.sources[entry.file]!, - engine.sources, - from: entry.file, - ); - } on GlslTranslateError catch (error) { - _fail('the engine\'s ${entry.file}: ${error.message}'); - } - theirs.add( - scanVaryings(resolved, from: entry.file, fragment: entry.fragment), - ); - } - final ours = >[ - for (final entry in mine.values) - scanVaryings(entry.resolved, from: entry.file, fragment: entry.fragment), - ]; - - final Map engineOnly; - final Map together; - try { - engineOnly = assignVaryingLocations(theirs); - together = assignVaryingLocations(>[...theirs, ...ours]); - } on WgslPrepareError catch (error) { - _fail(error.message); - } - - for (final entry in engineOnly.entries) { - if (together[entry.key] == entry.value) continue; - _fail( - 'this bundle moves the engine\'s varying "${entry.key}" from location ' - '${entry.value} to ${together[entry.key]}. A stage packed here is paired ' - 'with a stage the engine already compiled, and WebGPU joins the two by ' - 'location alone, so the numbering cannot be renegotiated by a bundle. ' - 'A varying declared beside the engine\'s in one stage joins their ' - 'family and renumbers it — give it a name of its own, or declare it in ' - 'a stage that shares nothing with the engine\'s.', - ); - } - return together; -} - /// `flutter3d_shaders`' root, or a failure naming what to run. String _shaderPackage(String from) { try { diff --git a/pubspec.lock b/pubspec.lock index fcf5db124..de6c5db73 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -895,7 +895,7 @@ packages: source: hosted version: "1.2.2" test: - dependency: transitive + dependency: "direct dev" description: name: test sha256: ca578dc12bb8b2f40b67b7d3bd2fac4f31c01a6ff7130a14e2597b919934507f diff --git a/pubspec.yaml b/pubspec.yaml index 2caad1564..89b8e03c9 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -9,6 +9,12 @@ publish_to: 'none' environment: sdk: ">=3.12.0 <4.0.0" +# For `test/structure_test.dart`, the one suite the root has: `dart test` run +# here used to find no test at all and exit 79, which a check that runs it +# reads as a failure rather than as an empty directory. +dev_dependencies: + test: ^1.25.0 + workspace: - packages/flutter3d - packages/flutter3d_core @@ -50,11 +56,13 @@ workspace: - packages/flutter3d_editor_core - packages/flutter3d_editor_widgets - packages/flutter3d_editor_mcp + - packages/flutter3d_editor_play - packages/flutter3d_game_shooter - packages/flutter3d_game_strategy - packages/flutter3d_game_platformer - packages/flutter3d_game_racing - packages/flutter3d_physics + - packages/flutter3d_physics_native - packages/flutter3d_stereo - packages/flutter3d_stereo/example - packages/pad_input diff --git a/site/assets/articles/habr-flame/14_model3d_robot.gif b/site/assets/articles/habr-flame/14_model3d_robot.gif new file mode 100644 index 000000000..6feaee35e Binary files /dev/null and b/site/assets/articles/habr-flame/14_model3d_robot.gif differ diff --git a/site/assets/articles/habr-flame/15_river_sortie.gif b/site/assets/articles/habr-flame/15_river_sortie.gif new file mode 100644 index 000000000..e6d8bde44 Binary files /dev/null and b/site/assets/articles/habr-flame/15_river_sortie.gif differ diff --git a/site/assets/articles/medium-flame/01_river_sortie.png b/site/assets/articles/medium-flame/01_river_sortie.png new file mode 100644 index 000000000..d894769a3 Binary files /dev/null and b/site/assets/articles/medium-flame/01_river_sortie.png differ diff --git a/site/assets/articles/medium-flame/02_two_layers.png b/site/assets/articles/medium-flame/02_two_layers.png new file mode 100644 index 000000000..ea974ce0e Binary files /dev/null and b/site/assets/articles/medium-flame/02_two_layers.png differ diff --git a/site/assets/articles/medium-flame/03_frame_loop.png b/site/assets/articles/medium-flame/03_frame_loop.png new file mode 100644 index 000000000..9cd745262 Binary files /dev/null and b/site/assets/articles/medium-flame/03_frame_loop.png differ diff --git a/site/assets/articles/medium-flame/04_transform_bridge.png b/site/assets/articles/medium-flame/04_transform_bridge.png new file mode 100644 index 000000000..8ac44f07a Binary files /dev/null and b/site/assets/articles/medium-flame/04_transform_bridge.png differ diff --git a/site/assets/articles/medium-flame/05_physics_bridge.png b/site/assets/articles/medium-flame/05_physics_bridge.png new file mode 100644 index 000000000..d9df761ab Binary files /dev/null and b/site/assets/articles/medium-flame/05_physics_bridge.png differ diff --git a/site/assets/articles/medium-flame/06_input_bridge.png b/site/assets/articles/medium-flame/06_input_bridge.png new file mode 100644 index 000000000..cd0b3e0d5 Binary files /dev/null and b/site/assets/articles/medium-flame/06_input_bridge.png differ diff --git a/site/assets/articles/medium-flame/07_buoy_run.png b/site/assets/articles/medium-flame/07_buoy_run.png new file mode 100644 index 000000000..06ec8b190 Binary files /dev/null and b/site/assets/articles/medium-flame/07_buoy_run.png differ diff --git a/site/assets/articles/medium-flame/08_river_bridge_down.png b/site/assets/articles/medium-flame/08_river_bridge_down.png new file mode 100644 index 000000000..982890030 Binary files /dev/null and b/site/assets/articles/medium-flame/08_river_bridge_down.png differ diff --git a/site/assets/articles/medium-flame/article.md b/site/assets/articles/medium-flame/article.md new file mode 100644 index 000000000..16ec36e70 --- /dev/null +++ b/site/assets/articles/medium-flame/article.md @@ -0,0 +1,366 @@ +# Keep your Flame game, get a 3D world: how I bridged Flame and flutter3d + +*How flame_flutter3d puts a real 3D renderer under a Flame game, what crosses between the two engines, and a step-by-step tutorial for your own project.* + +![River Sortie: a jet over a river, "+30" and "+60" over targets it hit, a helicopter going down in smoke. Jet model: Poly by Google, CC BY 3.0](https://flutter3d.pleion.dev/assets/articles/medium-flame/01_river_sortie.png) + +In my [previous article](https://medium.com/@dzolotov/i-wrote-a-3d-game-engine-for-flutter-from-scratch-and-you-can-ship-a-game-with-it-today-e635f1c1b8ed) I introduced flutter3d, a 3D engine for Flutter. This one is about making it work together with Flame. + +The screenshot above is River Sortie, a River Raid-style shooter you can [play in the browser](https://flutter3d.pleion.dev/river/demo/). Everything that moves in it is an ordinary Flame component with a Flame hitbox. Nothing in the game logic knows it is being drawn in 3D. + +The original River Raid was designed and programmed by Carol Shaw and published by Activision for the Atari 2600 in 1982. Shaw was one of the first women to design video games professionally, and her river was generated from a pseudo-random sequence, so the whole course came out the same on every play without the cartridge storing it. + +I built it to see whether that holds up in a real game. Flame is the 2D engine a lot of Flutter developers already write games with, and for a while it and flutter3d lived side by side without talking to each other. If you had a Flame game and wanted light, shadows, and depth, you were looking at a rewrite. flame_flutter3d is the package that joins them. Below is how it works, and then a small game built on it, from an empty project to a boat collecting buoys on a lake. + +## The idea: each engine keeps its job + +Both renderers stay as they are, and your game stays in Flame. Flame keeps running it: components, collisions, input, effects, the clock. flutter3d draws a 3D world under it. The bridge keeps the two in agreement about where things are, when a frame happens, and what the player pressed, and it rests on four decisions. + +### 1. Two layers in one Stack + +![Two engines in one Stack: Flame moves the orange square, flutter3d turns the cube, and nothing connects them yet](https://flutter3d.pleion.dev/assets/articles/medium-flame/02_two_layers.png) + +`Flutter3dFlameWidget` is a Flutter `Stack`. flutter3d's `SceneSurface` is at the bottom and Flame's own `GameWidget` is on top. Flame goes on top because it needs raw input: on the web the 3D surface is a platform view that swallows pointer events, so anything that wants touches and clicks has to sit above it. + +One side effect catches everyone once. `GameWidget` paints the game's background colour as an opaque rectangle, black by default, and in this Stack that rectangle covers the 3D layer completely. A game with the `HasFlutter3d` mixin returns a transparent background for you. If you use the widget without the mixin, extend `TransparentFlameGame`. + +### 2. One clock + +This was the decision I cared about most. Flame already has a ticker synced to vsync. A second ticker for the 3D side would drift from it sooner or later, and I don't know a reliable way to stop two timers drifting except not having two. So a bridged game runs on Flame's clock and nothing else. + +![One frame of a hybrid game: Flame's clock, and everything else stepped from it](https://flutter3d.pleion.dev/assets/articles/medium-flame/03_frame_loop.png) + +The widget adds a `BridgeClock` component to the game with a very high priority (`1 << 20`), so it updates after everything the game adds. Every frame goes like this: + +1. Flame updates its components. The ones that step physics and actors run here, and collision callbacks fire in Flame during this pass. +2. Bridge components copy positions between the layers. +3. `BridgeClock` calls `onTick`, the cameras are synced, and a redraw of the 3D layer is requested. +4. `SceneSurface` draws the 3D frame. + +The redraw in step 3 is deferred to the end of the frame, because asking Flutter to rebuild in the middle of a build throws. For a long time I assumed that cost a frame of lag and even wrote that in the docs. It doesn't. The deferred call only marks the 3D layer dirty, and the next frame starts with the tickers, so Flame's ticker updates the game before Flutter gets to the scene. Both layers always draw the same state. + +A second rule follows from the first: exactly one component steps any shared simulation. If a hundred physics-backed boxes each stepped the world, the world would step a hundred times a frame. + +### 3. One plane + +Flame thinks in `Vector2`, flutter3d in `Vector3`, and somewhere you have to decide which axis becomes which. I put that decision in one class, `BridgePlane`, and every bridged component takes one in its constructor. When each component invents its own convention, they drift apart. + +There are two planes. `BridgePlane.ground()` is a floor for top-down games: Flame's `y` becomes the scene's `z`, and height is constant. `BridgePlane.backdrop()` is a wall for side-scrollers: Flame's `y` stays `y` and depth is constant. Flame's `y` grows down the screen and the scene's grows up, so the backdrop flips it by default. + +![Two cubes: the blue one is moved by the scene and read by Flame, the orange one is moved by Flame and read by the scene. The map in the corner is Flame's view of both](https://flutter3d.pleion.dev/assets/articles/medium-flame/04_transform_bridge.png) + +Who writes and who reads is chosen once, when the component is created, with `SyncDirection.flameToScene` or `SyncDirection.sceneToFlame`. I didn't try to guess the direction from whatever changed last. Two systems can easily both decide the other one is reading, and that kind of guess fails silently. A physics body is written from the scene side by default, because its position belongs to the solver and Flame only reads it. + +### 4. One input + +`FlameInputBridge` takes the keys, drags, joystick, and buttons Flame receives and writes them into `Bindings` and `InputState` from `flutter3d_game`, the same objects a native flutter3d game reads. The bridge has no key table of its own. If a player remaps jump in your settings menu, the remap has to work in the Flame build too, and two tables that disagree look exactly like a remap that silently did nothing. + +![One key, read by both engines: Flame's key handler takes the press, and the flutter3d side sees the same action held](https://flutter3d.pleion.dev/assets/articles/medium-flame/06_input_bridge.png) + +On a phone this pays off. The on-screen stick is a plain Flame `JoystickComponent`, `followJoystick` feeds its deflection into the same move axis a gamepad stick would, and `bindButton` turns a Flame `HudButtonComponent` into an action. The code that moves the player never learns which it was. + +## What crosses the bridge + +With those four pieces in place, most of the package is components that carry one kind of thing across. + +Most of the work is done by `Object3dComponent`, a Flame `PositionComponent` that owns a scene node. Position, angle, scale, visibility, opacity, and tint cross. Flame's effects reach the scene in the frame they happen, a nested component lands where Flame draws it, and `elevation` lifts a component off its plane. It also exposes `visual`, a node under it that the game can turn freely and the bridge leaves alone, which is how the jet in River Sortie banks without Flame knowing. + +For physics there are two directions, and River Sortie and the arcade demo use one each. In River Sortie everything is decided by Flame: hitboxes are Flame hitboxes and `onCollisionStart` says who hit whom, while the 3D layer only draws. In the arcade demo collisions are computed by flutter3d's 3D physics, and `CollisionBridge` relays them to Flame's `CollisionCallbacks`, so a ram computed in 3D arrives in Flame as a normal `onCollisionStart`. + +![A crate falls in the 3D scene; when it lands on the pad, an ordinary Flame component hears it and the pad turns green on both layers](https://flutter3d.pleion.dev/assets/articles/medium-flame/05_physics_bridge.png) + +Two things don't line up there, and I chose not to paper over them. flutter3d reports a pair of colliders, while Flame wants a `PositionComponent`, so you say which component a collider belongs to with `resolveOther`. When it returns null (a level wall, say), the bridge calls nothing, because inventing a component would tell Flame it collided with something that doesn't exist for it. And Flame's callback has room for intersection points, not a normal or a depth, so the bridge passes one approximate point. The real normal and depth stay available on the flutter3d side. + +For cameras, `ChaseCamera` follows a bridged component in perspective and can shake, and `CameraSyncController` keeps an orthographic camera and Flame's `Viewfinder` framed the same, or lets Flame's own camera drive a perspective one when you give it an eye offset. Beyond those there are instanced components (a hundred shots in one draw call), particles on Flame's clock, Flame sprites and text standing in the 3D scene as billboards, taps on 3D objects under a perspective camera, a Tiled level stood up in 3D, and a separate package, `flame_flutter3d_audio`, for sound that comes from where things happen. + +## How River Sortie uses it + +River Sortie is the biggest user of the bridge, and it shaped a lot of it. The game mixes in `HasFlutter3d`, so it owns its 3D world: scene, device, camera, and renderer are fields of the game, and it builds the river once in `onOpen3d`. It also mixes in `HasFixedStep`, so its logic runs in fixed steps and a second of play comes out the same at any frame rate. Together with a seeded river generator, the river is the same on every run. + +Every tanker, helicopter, and fuel depot is an `Object3dComponent` on one `BridgePlane.ground()` laid over the water. Its Flame position is `x` across the river and `y` along it, and helicopters get an elevation that lifts them to flight height. The banks aren't hitboxes at all: the river generator answers whether any point is water or land, and the jet asks it every step. Stretches of river are built ahead of the jet and released behind it by `ChunkStreamer`. Shots are drawn as instances of one mesh, fire and smoke are 3D particles, reeds on the banks and the flash of an explosion are Flame sprites standing in the scene, and explosions are heard from where they happen. + +![A bridge hit in the middle breaks in two, each half going down on its own pier. The "+500" is Flame's text, drawn over a point in the scene](https://flutter3d.pleion.dev/assets/articles/medium-flame/08_river_bridge_down.png) + +The game also showed me what the bridge was missing, and most of what's listed above arrived because River Sortie needed it: the fixed step, particles, Flame sprites in the scene, and positional sound. + +## Tutorial: a boat, a lake, and some buoys + +We'll build a small game where you steer a boat across a lake and collect buoys. It's about 170 lines and uses every piece described above: a game that owns a 3D world, components with bodies in the scene, Flame collisions, the input bridge, a chase camera, and a fixed step. + +![The finished tutorial: a boat about to collect a buoy, with Flame's score text over the 3D scene](https://flutter3d.pleion.dev/assets/articles/medium-flame/07_buoy_run.png) + +### Step 1. Dependencies + +Start from any Flutter project and add: + +```yaml +dependencies: + flame: ^1.38.2 + flame_flutter3d: ^0.8.4 + flutter3d: ^0.8.3 + flutter3d_game: ^0.8.0 # Bindings, the key table + flutter3d_sim: ^0.8.1 # InputState and GameAction +``` + +### Step 2. Switch on Flutter GPU + +flutter3d draws through Flutter GPU, and Flutter GPU is switched on per app, in the platform settings. Skip this and the app starts, opens a window, and draws no 3D at all, because it can't load its shader library. + +On macOS and iOS, add to `macos/Runner/Info.plist` and `ios/Runner/Info.plist`: + +```xml +FLTEnableFlutterGPU + +FLTEnableImpeller + +``` + +On Android, inside `` in `AndroidManifest.xml`: + +```xml + +``` + +### Step 3. A game that owns a 3D world + +The game is a normal `FlameGame` with four mixins. `HasFlutter3d` gives it a scene, a device, and a camera, `HasFixedStep` gives it fixed steps, and the other two are plain Flame. + +```dart +class BuoyRun extends FlameGame + with HasFlutter3d, HasFixedStep, HasCollisionDetection, KeyboardEvents { + /// Flame's 2D world, laid flat on the water: Flame's y runs along the + /// scene's z. + static final BridgePlane water = BridgePlane.ground(); + + late final Boat boat; + final TextComponent score = TextComponent( + text: 'Buoys: 0', + position: Vector2(16, 16), + ); + int collected = 0; + + MeshNode _mesh(Shape shape, Vector4 colour) => MeshNode( + DeviceMesh.upload(device, shape.build()), + engine.Material(name: 'paint', baseColor: colour, roughness: 0.5), + ); +``` + +The world is built in `onOpen3d`, which runs once, after both the game has loaded and the 3D device is open. Before that there is no device to upload meshes to. + +```dart + @override + void onOpen3d() { + clearColor.setValues(0.55, 0.75, 0.95, 1); // the sky + scene + ..add( + _mesh(PlaneShape(width: 400, depth: 400), Vector4(0.15, 0.35, 0.55, 1)), + ) + ..add( + LightNode(name: 'sun', intensity: 3) + ..setLocalForward(Vector3(-0.4, -1, -0.3)), + ); + + boat = Boat( + node: _mesh( + CuboidShape(size: Vector3(0.8, 0.4, 1.6)), + Vector4(0.9, 0.9, 0.85, 1), + ), + scene: scene, + ); + add(boat); + for (final (x, y) in <(double, double)>[ + (3, -6), (-4, -12), (2, -18), (-2, -24), (5, -30), + ]) { + add( + Buoy( + node: _mesh(SphereShape(radius: 0.4), Vector4(0.95, 0.35, 0.1, 1)), + scene: scene, + position: Vector2(x, y), + ), + ); + } + add(score); + } +``` + +The meshes come from code: `PlaneShape`, `CuboidShape`, and `SphereShape` are flutter3d's built-in shapes. A glTF model works the same way, as the node a component owns. The score is a regular Flame `TextComponent`, drawn on Flame's layer over the scene. + +### Step 4. The widget + +The game is created once and kept in the state. A game created in `build` starts over on every rebuild and never draws a frame. + +```dart +void main() => runApp(const MaterialApp(home: BuoyRunScreen())); + +class BuoyRunScreen extends StatefulWidget { + const BuoyRunScreen({super.key}); + + @override + State createState() => _BuoyRunScreenState(); +} + +class _BuoyRunScreenState extends State { + final BuoyRun _game = BuoyRun(); + + @override + Widget build(BuildContext context) => + Scaffold(body: Flutter3dFlameWidget(game: _game)); +} +``` + +Because the game has `HasFlutter3d`, the widget needs nothing but the game. + +### Step 5. Components with a body in the scene + +A buoy is an `Object3dComponent`: a Flame component with a size, an anchor, and a hitbox, plus the scene node it owns. + +```dart +class Buoy extends Object3dComponent { + Buoy({required super.node, required super.scene, super.position}) + : super( + plane: BuoyRun.water, + direction: SyncDirection.flameToScene, + elevation: 0.2, + size: Vector2.all(0.8), + anchor: Anchor.center, + ); + + @override + Future onLoad() async { + await super.onLoad(); + add(CircleHitbox()); + } +} +``` + +Set `direction` explicitly. The default is `SyncDirection.sceneToFlame`, which suits a physics body whose position the solver decides, and with that default your Flame movement would be overwritten from the scene every frame. Size and hitbox are in the same units as the scene, metres here, because the plane maps Flame's units one to one. + +The boat is the same kind of component, with two Flame mixins added: + +```dart +class Boat extends Object3dComponent + with CollisionCallbacks, FixedStepUpdate, HasGameReference { + Boat({required super.node, required super.scene}) + : super( + plane: BuoyRun.water, + direction: SyncDirection.flameToScene, + elevation: 0.2, + size: Vector2(0.8, 1.6), + anchor: Anchor.center, + ); + + @override + Future onLoad() async { + await super.onLoad(); + add(RectangleHitbox()); + } +``` + +### Step 6. Collisions are just Flame collisions + +```dart + @override + void onCollisionStart( + Set intersectionPoints, + PositionComponent other, + ) { + super.onCollisionStart(intersectionPoints, other); + if (other is Buoy) game.collect(other); + } +} +``` + +And in the game: + +```dart + void collect(Buoy buoy) { + buoy.removeFromParent(); + score.text = 'Buoys: ${++collected}'; + } +``` + +Removing the component removes its node from the scene too. Nothing in this step is 3D-specific. The collision is Flame's own, between two hitboxes on a 2D plane, exactly as it would be in a flat game. + +### Step 7. Input and a fixed step + +The input bridge writes Flame's key events into an `InputState`, through a `Bindings` table that maps keys to actions: + +```dart + final InputState input = InputState(); + late final FlameInputBridge inputBridge = FlameInputBridge( + bindings: Bindings({ + InputSource.key(LogicalKeyboardKey.arrowUp.keyId): GameAction.moveForward, + InputSource.key(LogicalKeyboardKey.arrowDown.keyId): GameAction.moveBack, + InputSource.key(LogicalKeyboardKey.arrowLeft.keyId): GameAction.moveLeft, + InputSource.key(LogicalKeyboardKey.arrowRight.keyId): + GameAction.moveRight, + }), + inputState: input, + ); + + @override + KeyEventResult onKeyEvent( + KeyEvent event, + Set keysPressed, + ) => inputBridge.onGameKeyEvent(event, keysPressed); +``` + +The boat reads the move axis in `fixedUpdate`, which `FixedStepUpdate` calls at a fixed rate instead of once per frame, so the boat covers the same distance per second whether the screen runs at 60 Hz or 120: + +```dart + @override + void fixedUpdate(double dt) { + final axis = game.input.moveAxis; + // Forward is up the screen in Flame, which is -y. + position.add(Vector2(axis.x, -axis.y) * (6 * dt)); + } +``` + +To add touch controls, put a Flame `JoystickComponent` in `camera.viewport` and add `inputBridge.followJoystick(stick)` to the game. The boat code stays the same. + +### Step 8. A camera that follows the boat + +The last piece goes at the end of `onOpen3d`: + +```dart + add( + ChaseCameraComponent( + ChaseCamera( + camera: camera3d, + target: boat, + offset: Vector3(0, 6, 8), + lookOffset: Vector3(0, 0, -6), + ), + ), + ); +``` + +`camera3d` is the 3D camera `HasFlutter3d` created and added to the scene. The chase camera sits six metres up and eight behind the boat and looks six metres ahead of it. + +Run it with `flutter run -d macos` (or your platform), and the arrow keys sail the boat. Each buoy it touches disappears, and the score goes up. + +## When you see black instead of 3D + +These are the checks I'd go through first: + +- Flutter GPU isn't switched on for the platform you're running (step 2). +- The game uses the widget without `HasFlutter3d` and extends plain `FlameGame`, so Flame's opaque background covers the scene. Use `HasFlutter3d` or `TransparentFlameGame`. +- The game is created in `build` instead of once in `State`. +- The camera is inside an object. A bare `CameraNode` sits at the origin. +- An orthographic camera driven by Flame's `Viewfinder` has no zoom set. The bridge maps zoom to the camera's height as `1 / zoom`, and Flame's default zoom of 1 gives a frame one metre tall. + +## What it doesn't do yet + +Flame is always on top. The layers don't interleave by depth, so HUDs, maps, and sprites over the scene work, but a 3D pillar hiding a 2D sprite behind it does not. And Flame's collision callback carries no contact normal or depth, so when you need them you read them on the flutter3d side. + +## Try it + +- Play River Sortie in the browser: https://flutter3d.pleion.dev/river/demo/ +- The showcase has one page per mechanism, each with a live scene and a step-by-step guide: https://flutter3d.pleion.dev/showcase/ +- The package on pub.dev: https://pub.dev/packages/flame_flutter3d +- The source, including River Sortie and the package's minimal example: https://github.com/pleiondev/flutter3d +- A longer write-up with the arcade demo and more pitfalls, in Russian: https://habr.com/ru/articles/1087246/ + +If you put it under your own Flame game, tell me how it went, and especially where it broke. Bug reports and questions are welcome on [GitHub](https://github.com/pleiondev/flutter3d/issues), and there's also [r/flutter3d](https://www.reddit.com/r/flutter3d/) for sharing what you built. diff --git a/site/assets/articles/medium-flame/buoy_run/lib/main.dart b/site/assets/articles/medium-flame/buoy_run/lib/main.dart new file mode 100644 index 000000000..18abb134a --- /dev/null +++ b/site/assets/articles/medium-flame/buoy_run/lib/main.dart @@ -0,0 +1,175 @@ +import 'package:flame/collisions.dart'; +import 'package:flame/components.dart'; +import 'package:flame/events.dart'; +import 'package:flame/game.dart'; +import 'package:flame_flutter3d/flame_flutter3d.dart'; +import 'package:flutter/material.dart' hide Material; +import 'package:flutter/services.dart'; +import 'package:flutter3d/flutter3d.dart' hide Material; +import 'package:flutter3d/flutter3d.dart' as engine show Material; +import 'package:flutter3d_game/flutter3d_game.dart' show Bindings, InputSource; +import 'package:flutter3d_sim/flutter3d_sim.dart'; + +// Step 4: the widget. The game is made once, not in build(). +void main() => runApp(const MaterialApp(home: BuoyRunScreen())); + +class BuoyRunScreen extends StatefulWidget { + const BuoyRunScreen({super.key}); + + @override + State createState() => _BuoyRunScreenState(); +} + +class _BuoyRunScreenState extends State { + final BuoyRun _game = BuoyRun(); + + @override + Widget build(BuildContext context) => + Scaffold(body: Flutter3dFlameWidget(game: _game)); +} + +// Step 3: the game owns its 3D world. +class BuoyRun extends FlameGame + with HasFlutter3d, HasFixedStep, HasCollisionDetection, KeyboardEvents { + /// Where Flame's 2D world lies in the scene: flat on the water, Flame's y + /// running along the scene's z. + static final BridgePlane water = BridgePlane.ground(); + + final InputState input = InputState(); + late final FlameInputBridge inputBridge = FlameInputBridge( + bindings: Bindings({ + InputSource.key(LogicalKeyboardKey.arrowUp.keyId): GameAction.moveForward, + InputSource.key(LogicalKeyboardKey.arrowDown.keyId): GameAction.moveBack, + InputSource.key(LogicalKeyboardKey.arrowLeft.keyId): GameAction.moveLeft, + InputSource.key(LogicalKeyboardKey.arrowRight.keyId): + GameAction.moveRight, + }), + inputState: input, + ); + + late final Boat boat; + final TextComponent score = TextComponent( + text: 'Buoys: 0', + position: Vector2(16, 16), + ); + int collected = 0; + + MeshNode _mesh(Shape shape, Vector4 colour) => MeshNode( + DeviceMesh.upload(device, shape.build()), + engine.Material(name: 'paint', baseColor: colour, roughness: 0.5), + ); + + @override + void onOpen3d() { + clearColor.setValues(0.55, 0.75, 0.95, 1); // the sky + scene + ..add( + _mesh(PlaneShape(width: 400, depth: 400), Vector4(0.15, 0.35, 0.55, 1)), + ) + ..add( + LightNode(name: 'sun', intensity: 3) + ..setLocalForward(Vector3(-0.4, -1, -0.3)), + ); + + boat = Boat( + node: _mesh( + CuboidShape(size: Vector3(0.8, 0.4, 1.6)), + Vector4(0.9, 0.9, 0.85, 1), + ), + scene: scene, + ); + add(boat); + for (final (x, y) in <(double, double)>[ + (3, -6), + (-4, -12), + (2, -18), + (-2, -24), + (5, -30), + ]) { + add( + Buoy( + node: _mesh(SphereShape(radius: 0.4), Vector4(0.95, 0.35, 0.1, 1)), + scene: scene, + position: Vector2(x, y), + ), + ); + } + + add( + ChaseCameraComponent( + ChaseCamera( + camera: camera3d, + target: boat, + offset: Vector3(0, 6, 8), + lookOffset: Vector3(0, 0, -6), + ), + ), + ); + add(score); + } + + void collect(Buoy buoy) { + buoy.removeFromParent(); + score.text = 'Buoys: ${++collected}'; + } + + @override + KeyEventResult onKeyEvent( + KeyEvent event, + Set keysPressed, + ) => inputBridge.onGameKeyEvent(event, keysPressed); +} + +// Step 5: a Flame component with a body in the scene. +class Boat extends Object3dComponent + with CollisionCallbacks, FixedStepUpdate, HasGameReference { + Boat({required super.node, required super.scene}) + : super( + plane: BuoyRun.water, + direction: SyncDirection.flameToScene, + elevation: 0.2, + size: Vector2(0.8, 1.6), + anchor: Anchor.center, + ); + + @override + Future onLoad() async { + await super.onLoad(); + add(RectangleHitbox()); + } + + // Step 7: the same distance per second at any frame rate. + @override + void fixedUpdate(double dt) { + final axis = game.input.moveAxis; + // Forward is up the screen in Flame, which is -y. + position.add(Vector2(axis.x, -axis.y) * (6 * dt)); + } + + // Step 6: an ordinary Flame collision. + @override + void onCollisionStart( + Set intersectionPoints, + PositionComponent other, + ) { + super.onCollisionStart(intersectionPoints, other); + if (other is Buoy) game.collect(other); + } +} + +class Buoy extends Object3dComponent { + Buoy({required super.node, required super.scene, super.position}) + : super( + plane: BuoyRun.water, + direction: SyncDirection.flameToScene, + elevation: 0.2, + size: Vector2.all(0.8), + anchor: Anchor.center, + ); + + @override + Future onLoad() async { + await super.onLoad(); + add(CircleHitbox()); + } +} diff --git a/site/assets/articles/medium-flame/buoy_run/pubspec.yaml b/site/assets/articles/medium-flame/buoy_run/pubspec.yaml new file mode 100644 index 000000000..8bab597a9 --- /dev/null +++ b/site/assets/articles/medium-flame/buoy_run/pubspec.yaml @@ -0,0 +1,14 @@ +name: buoy_run +publish_to: 'none' +environment: + sdk: ^3.12.2 +dependencies: + flutter: + sdk: flutter + flame: ^1.38.2 + flame_flutter3d: ^0.8.4 + flutter3d: ^0.8.3 + flutter3d_game: ^0.8.0 + flutter3d_sim: ^0.8.1 +flutter: + uses-material-design: true diff --git a/site/assets/samples/platformer.f3drun b/site/assets/samples/platformer.f3drun index e8db3490f..c5009fce6 100644 --- a/site/assets/samples/platformer.f3drun +++ b/site/assets/samples/platformer.f3drun @@ -1 +1 @@ -{"version":1,"level":"assets/levels/ascent.json","levelHash":"99ef1386","run":{"version":1,"runner":{"body":{"at":[0.0,0.8999999761581421,-22.0],"velocity":[0.0,0.0,0.0],"grounded":false,"coyote":0.0,"jumpBuffer":0.0},"health":{"current":100.0,"armour":0.0,"mourned":false},"purse":{},"keys":[],"yaw":0.0,"coyote":0.12,"buffer":0.0,"airJumps":1,"dashCooldown":0.0,"wallCoyote":0.0,"wallAway":[0.0,0.0,0.0]},"random":1,"state":"running","respawn":[0.0,0.0,-22.0],"deaths":0,"lives":-1,"elapsed":0.0,"mechanisms":{"coin one":{"taken":false},"coin two":{"taken":false},"coin three":{"taken":false},"yard coin one":{"taken":false},"yard coin two":{"taken":false},"yard coin three":{"taken":false},"vault coin 1":{"taken":false},"vault coin 2":{"taken":false},"vault coin 3":{"taken":false},"vault coin 4":{"taken":false},"the green key":{"taken":false},"plateau coin one":{"taken":false},"plateau coin two":{"taken":false},"plateau coin three":{"taken":false},"the high pad":{},"plateau coin four":{"taken":false},"the first pad":{},"high coin":{"taken":false},"the brink":{"reached":false},"arc coin one":{"taken":false},"arc coin two":{"taken":false},"arc coin three":{"taken":false},"the first drop":{},"past the drop":{"reached":false},"the ruin spikes":{},"the ruin pad":{},"the shaft coin":{"taken":false},"the second shaft coin":{"taken":false},"the third shaft coin":{"taken":false},"the blue key":{"taken":false},"the canyon rim":{"reached":false},"the gulf":{},"the first barge":{"progress":0.0,"goal":1.0,"held":0.0,"wasMoving":false},"the second barge":{"progress":0.5468750000000017,"goal":1.0,"held":0.0,"wasMoving":false},"the third barge":{"progress":1.0,"goal":1.0,"held":0.4166666666666794,"wasMoving":false},"canyon coin one":{"taken":false},"canyon coin two":{"taken":false},"canyon coin three":{"taken":false},"the west ferry":{"progress":0.5624999999999991,"goal":1.0,"held":0.0,"wasMoving":false},"the west skiff":{"progress":1.0,"goal":1.0,"held":0.3166666666666723,"wasMoving":false},"the east ferry":{"progress":0.5624999999999991,"goal":1.0,"held":0.0,"wasMoving":false},"the east skiff":{"progress":1.0,"goal":1.0,"held":0.3166666666666723,"wasMoving":false},"the quarry pad":{},"the scaffold hoist":{"progress":0.0,"goal":1.0,"held":0.0,"wasMoving":false},"the scaffold shuttle":{"progress":0.25,"goal":1.0,"held":0.0,"wasMoving":false},"the forecourt":{"reached":false},"the tower lift":{"progress":0.0,"goal":0.0,"held":0.0,"wasMoving":false},"the lift plate":{"spent":false},"tower coin one":{"taken":false},"tower coin two":{"taken":false},"tower coin three":{"taken":false},"tower coin four":{"taken":false},"the blue gate":{"progress":0.0,"goal":0.0,"held":0.0,"wasMoving":false},"the blue gate's plate":{"spent":false},"the maze lamp 1":{"enabled":true},"the maze lamp 2":{"enabled":true},"the maze lamp 3":{"enabled":true},"the maze lamp 4":{"enabled":true},"the maze lamp 5":{"enabled":true},"the maze lamp 6":{"enabled":true},"the maze lamp 7":{"enabled":true},"the maze lamp 8":{"enabled":true},"the maze lamp 9":{"enabled":true},"maze coin 1":{"taken":false},"maze coin 2":{"taken":false},"maze coin 3":{"taken":false},"maze coin 4":{"taken":false},"maze coin 5":{"taken":false},"maze coin 6":{"taken":false},"maze coin 7":{"taken":false},"maze coin 8":{"taken":false},"maze coin 9":{"taken":false},"maze coin 10":{"taken":false},"maze coin 11":{"taken":false},"maze coin 12":{"taken":false},"maze coin 13":{"taken":false},"the green gate":{"progress":0.0,"goal":0.0,"held":0.0,"wasMoving":false},"the green gate's plate":{"spent":false},"the east lift":{"progress":0.0,"goal":0.0,"held":0.0,"wasMoving":false},"the east plate":{"spent":false},"the saw arm":{"progress":0.0,"goal":1.0,"held":0.0,"wasMoving":false},"the saw":{},"the gallery ladder":{"time":0.0},"the rope":{"time":0.0},"the cold":{"reached":false},"the crevasse":{},"the ferry":{"progress":0.0,"goal":1.0,"held":0.0,"wasMoving":false},"the west floe":{"progress":0.3124999999999995,"goal":1.0,"held":0.0,"wasMoving":false},"the east floe":{"progress":0.9375000000000041,"goal":1.0,"held":0.0,"wasMoving":false},"floe coin one":{"taken":false},"floe coin two":{"taken":false},"floe coin three":{"taken":false},"floe coin four":{"taken":false},"the far west floe":{"progress":0.49999999999999994,"goal":1.0,"held":0.0,"wasMoving":false},"the far east floe":{"progress":0.49999999999999994,"goal":1.0,"held":0.0,"wasMoving":false},"the shelf 1":{"since":null,"fallen":false,"away":0.0},"the shelf 2":{"since":null,"fallen":false,"away":0.0},"the shelf 3":{"since":null,"fallen":false,"away":0.0},"the shelf 4":{"since":null,"fallen":false,"away":0.0},"the shelf 5":{"since":null,"fallen":false,"away":0.0},"the spikes":{},"more spikes":{},"the far spikes":{},"the eastern spikes":{},"the cold pad":{},"the cold coin":{"taken":false},"ice coin one":{"taken":false},"ice coin two":{"taken":false},"the floe pad west":{},"the floe pad east":{},"past the ice":{"reached":false},"the chimney coin one":{"taken":false},"the chimney coin two":{"taken":false},"the dash coin":{"taken":false},"the coin over the gap":{"taken":false},"the foot of the stair":{"reached":false},"stair coin one":{"taken":false},"the last pad":{},"stair coin two":{"taken":false},"stair coin three":{"taken":false},"stair coin four":{"taken":false},"the summit lift":{"progress":0.0,"goal":0.0,"held":0.0,"wasMoving":false},"the summit plate":{"spent":false},"ring coin 1":{"taken":false},"ring coin 2":{"taken":false},"ring coin 3":{"taken":false},"ring coin 4":{"taken":false},"ring coin 5":{"taken":false},"ring coin 6":{"taken":false},"ring coin 7":{"taken":false},"ring coin 8":{"taken":false},"the summit":{"reached":false},"the cap 1":{"broken":false},"the cap 2":{"broken":false},"the cap 3":{"broken":false},"the west hoist":{"progress":0.0,"goal":0.0,"held":0.0,"wasMoving":false},"the west hoist plate":{"spent":false},"the east hoist":{"progress":0.0,"goal":0.0,"held":0.0,"wasMoving":false},"the east hoist plate":{"spent":false},"coin 019":{"taken":false},"coin 020":{"taken":false},"coin 021":{"taken":false},"coin 022":{"taken":false},"coin 023":{"taken":false},"coin 024":{"taken":false},"coin 025":{"taken":false},"coin 026":{"taken":false},"coin 027":{"taken":false},"coin 028":{"taken":false},"coin 029":{"taken":false},"coin 030":{"taken":false},"coin 031":{"taken":false},"coin 032":{"taken":false},"coin 033":{"taken":false},"coin 034":{"taken":false},"coin 035":{"taken":false},"coin 036":{"taken":false},"coin 037":{"taken":false},"coin 038":{"taken":false},"coin 039":{"taken":false},"coin 040":{"taken":false},"coin 041":{"taken":false},"coin 042":{"taken":false},"coin 043":{"taken":false},"coin 044":{"taken":false},"coin 045":{"taken":false},"coin 046":{"taken":false},"coin 047":{"taken":false},"coin 048":{"taken":false},"coin 049":{"taken":false},"coin 050":{"taken":false},"coin 051":{"taken":false},"coin 052":{"taken":false},"coin 053":{"taken":false},"coin 054":{"taken":false},"coin 055":{"taken":false},"coin 056":{"taken":false},"coin 057":{"taken":false},"coin 058":{"taken":false},"coin 059":{"taken":false},"coin 060":{"taken":false},"coin 061":{"taken":false},"coin 062":{"taken":false},"coin 063":{"taken":false},"coin 064":{"taken":false},"coin 065":{"taken":false},"coin 066":{"taken":false},"coin 067":{"taken":false},"coin 068":{"taken":false},"coin 069":{"taken":false},"coin 070":{"taken":false},"coin 077":{"taken":false},"coin 078":{"taken":false},"coin 079":{"taken":false},"coin 080":{"taken":false},"coin 081":{"taken":false},"coin 082":{"taken":false},"coin 083":{"taken":false},"coin 084":{"taken":false},"coin 085":{"taken":false},"coin 086":{"taken":false},"coin 087":{"taken":false},"coin 088":{"taken":false},"coin 089":{"taken":false},"coin 090":{"taken":false},"coin 091":{"taken":false},"coin 092":{"taken":false},"coin 093":{"taken":false},"coin 094":{"taken":false},"coin 095":{"taken":false},"coin 096":{"taken":false},"coin 097":{"taken":false},"coin 098":{"taken":false},"coin 099":{"taken":false},"coin 100":{"taken":false},"coin 101":{"taken":false},"coin 102":{"taken":false},"coin 103":{"taken":false},"coin 104":{"taken":false},"coin 105":{"taken":false},"coin 106":{"taken":false},"coin 107":{"taken":false},"coin 108":{"taken":false},"coin 109":{"taken":false},"coin 127":{"taken":false},"coin 128":{"taken":false},"coin 129":{"taken":false},"coin 130":{"taken":false},"coin 131":{"taken":false},"coin 132":{"taken":false},"coin 133":{"taken":false},"coin 134":{"taken":false},"coin 135":{"taken":false},"coin 136":{"taken":false},"coin 137":{"taken":false},"coin 138":{"taken":false},"coin 139":{"taken":false},"coin 140":{"taken":false},"coin 141":{"taken":false},"coin 142":{"taken":false},"coin 143":{"taken":false},"coin 144":{"taken":false},"coin 145":{"taken":false},"coin 146":{"taken":false},"coin 147":{"taken":false},"coin 148":{"taken":false},"coin 149":{"taken":false},"coin 150":{"taken":false},"coin 151":{"taken":false},"coin 152":{"taken":false},"coin 153":{"taken":false},"coin 154":{"taken":false},"coin 155":{"taken":false},"coin 156":{"taken":false},"coin 157":{"taken":false},"coin 158":{"taken":false},"coin 159":{"taken":false},"coin 160":{"taken":false},"coin 161":{"taken":false},"coin 162":{"taken":false},"coin 167":{"taken":false},"coin 168":{"taken":false},"coin 169":{"taken":false},"coin 170":{"taken":false},"coin 171":{"taken":false},"coin 175":{"taken":false},"coin 176":{"taken":false},"coin 177":{"taken":false},"coin 178":{"taken":false},"coin 179":{"taken":false},"coin 180":{"taken":false},"coin 181":{"taken":false},"coin 182":{"taken":false},"coin 183":{"taken":false},"coin 184":{"taken":false},"coin 185":{"taken":false},"coin 186":{"taken":false},"coin 187":{"taken":false},"coin 188":{"taken":false},"coin 189":{"taken":false},"coin 190":{"taken":false},"coin 207":{"taken":false},"coin 208":{"taken":false},"coin 209":{"taken":false},"coin 210":{"taken":false},"coin 211":{"taken":false},"coin 212":{"taken":false},"coin 213":{"taken":false},"coin 214":{"taken":false},"coin 215":{"taken":false},"coin 216":{"taken":false},"coin 217":{"taken":false},"coin 218":{"taken":false},"coin 219":{"taken":false},"coin 220":{"taken":false},"coin 221":{"taken":false},"coin 222":{"taken":false},"coin 223":{"taken":false},"coin 224":{"taken":false},"coin 225":{"taken":false},"coin 226":{"taken":false},"coin 227":{"taken":false},"coin 228":{"taken":false},"coin 229":{"taken":false},"coin 230":{"taken":false},"coin 231":{"taken":false},"coin 232":{"taken":false},"coin 233":{"taken":false},"coin 234":{"taken":false},"coin 235":{"taken":false},"coin 236":{"taken":false},"coin 237":{"taken":false},"coin 238":{"taken":false},"coin 239":{"taken":false},"coin 240":{"taken":false},"coin 241":{"taken":false},"coin 242":{"taken":false},"coin 243":{"taken":false},"coin 244":{"taken":false},"coin 245":{"taken":false},"coin 246":{"taken":false},"coin 247":{"taken":false},"coin 248":{"taken":false},"coin 249":{"taken":false},"coin 250":{"taken":false},"coin 251":{"taken":false},"coin 252":{"taken":false},"coin 253":{"taken":false},"coin 254":{"taken":false},"coin 255":{"taken":false},"coin 256":{"taken":false},"coin 257":{"taken":false},"coin 258":{"taken":false},"coin 259":{"taken":false}},"entities":{"generations":[0,0,0],"free":[],"components":{"body":{"0":{"at":[-10.0,0.3499999940395355,-4.0],"velocity":[0.0,0.0,0.0],"grounded":false,"coyote":0.0,"jumpBuffer":0.0},"1":{"at":[30.0,0.3499999940395355,-6.0],"velocity":[0.0,0.0,0.0],"grounded":false,"coyote":0.0,"jumpBuffer":0.0},"2":{"at":[-16.0,0.3499999940395355,74.0],"velocity":[0.0,0.0,0.0],"grounded":false,"coyote":0.0,"jumpBuffer":0.0}},"vitality":{"0":{"current":20.0,"armour":0.0,"mourned":false},"1":{"current":20.0,"armour":0.0,"mourned":false},"2":{"current":20.0,"armour":0.0,"mourned":false}},"facing":{"0":{"yaw":0.0,"turnRate":6.0,"eyeFraction":0.32},"1":{"yaw":0.0,"turnRate":6.0,"eyeFraction":0.32},"2":{"yaw":0.0,"turnRate":6.0,"eyeFraction":0.32}},"thinking":{"0":{"target":1,"waiting":0.0},"1":{"target":1,"waiting":0.0},"2":{"target":1,"waiting":0.0}}}},"actors":{"tick":0}},"tape":{"seed":1,"frames":[{"pressed":["moveForward","jump"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"released":["jump"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward","dropThrough"],"sy":1.0},{"pressed":["moveForward"],"released":["dropThrough"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"pressed":["moveForward","jump"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"released":["jump"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"pressed":["moveForward","jump"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"released":["jump"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward","dropThrough"],"sy":1.0},{"pressed":["moveForward"],"released":["dropThrough"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"pressed":["moveForward","jump"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"released":["jump"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward","dropThrough"],"sy":1.0},{"pressed":["moveForward"],"released":["dropThrough"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"pressed":["moveForward","jump"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"released":["jump"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"pressed":["moveForward","jump"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"released":["jump"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward","dropThrough"],"sy":1.0},{"pressed":["moveForward"],"released":["dropThrough"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"pressed":["moveForward","jump"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"released":["jump"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward","dropThrough"],"sy":1.0},{"pressed":["moveForward"],"released":["dropThrough"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"pressed":["moveForward","jump"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"released":["jump"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"pressed":["moveForward","jump"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"released":["jump"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward","dropThrough"],"sy":1.0},{"pressed":["moveForward"],"released":["dropThrough"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"pressed":["moveForward","jump"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"released":["jump"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward","dropThrough"],"sy":1.0},{"pressed":["moveForward"],"released":["dropThrough"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0}]},"buildStamp":"tpl-02-sample","checkpoints":{"every":25,"steps":[25,50,75,100,125,150,175,200,225,250,275,300,325,350,375,400,425,450,475,500,525,550,575,600],"digests":["972e8158","91ca445f","a61c6ae9","0fc234f2","8c514970","bbd009ae","64b68c7f","ce6e0665","11a863e7","bcc7df84","ea4a432d","3b1c63f1","dfa5ead2","e4736057","11966675","606b1465","87f8329d","04b2974c","3ef2ff17","86ac5737","8b08843a","309013ce","b06fd6e8","f47d6837"]}} \ No newline at end of file +{"version":1,"level":"assets/levels/ascent.json","levelHash":"1e491529","run":{"version":1,"runner":{"body":{"at":[0.0,0.8999999761581421,-22.0],"velocity":[0.0,0.0,0.0],"grounded":false,"coyote":0.0,"jumpBuffer":0.0},"health":{"current":100.0,"armour":0.0,"mourned":false},"purse":{},"keys":[],"yaw":0.0,"coyote":0.12,"buffer":0.0,"airJumps":1,"dashCooldown":0.0,"wallCoyote":0.0,"wallAway":[0.0,0.0,0.0]},"random":1,"state":"running","respawn":[0.0,0.0,-22.0],"deaths":0,"lives":-1,"elapsed":0.0,"mechanisms":{"coin one":{"taken":false},"coin two":{"taken":false},"coin three":{"taken":false},"yard coin one":{"taken":false},"yard coin two":{"taken":false},"yard coin three":{"taken":false},"vault coin 1":{"taken":false},"vault coin 2":{"taken":false},"vault coin 3":{"taken":false},"vault coin 4":{"taken":false},"the green key":{"taken":false},"plateau coin one":{"taken":false},"plateau coin two":{"taken":false},"plateau coin three":{"taken":false},"the high pad":{},"plateau coin four":{"taken":false},"the first pad":{},"high coin":{"taken":false},"the brink":{"reached":false},"arc coin one":{"taken":false},"arc coin two":{"taken":false},"arc coin three":{"taken":false},"the first drop":{},"past the drop":{"reached":false},"the ruin spikes":{},"the ruin pad":{},"the shaft coin":{"taken":false},"the second shaft coin":{"taken":false},"the third shaft coin":{"taken":false},"the blue key":{"taken":false},"the canyon rim":{"reached":false},"the gulf":{},"the first barge":{"progress":0.0,"goal":1.0,"held":0.0,"wasMoving":false},"the second barge":{"progress":0.5468750000000017,"goal":1.0,"held":0.0,"wasMoving":false},"the third barge":{"progress":1.0,"goal":1.0,"held":0.4166666666666794,"wasMoving":false},"canyon coin one":{"taken":false},"canyon coin two":{"taken":false},"canyon coin three":{"taken":false},"the west ferry":{"progress":0.5624999999999991,"goal":1.0,"held":0.0,"wasMoving":false},"the west skiff":{"progress":1.0,"goal":1.0,"held":0.3166666666666723,"wasMoving":false},"the east ferry":{"progress":0.5624999999999991,"goal":1.0,"held":0.0,"wasMoving":false},"the east skiff":{"progress":1.0,"goal":1.0,"held":0.3166666666666723,"wasMoving":false},"the quarry pad":{},"the scaffold hoist":{"progress":0.0,"goal":1.0,"held":0.0,"wasMoving":false},"the scaffold shuttle":{"progress":0.25,"goal":1.0,"held":0.0,"wasMoving":false},"the forecourt":{"reached":false},"the tower lift":{"progress":0.0,"goal":0.0,"held":0.0,"wasMoving":false},"the lift plate":{"spent":false},"tower coin one":{"taken":false},"tower coin two":{"taken":false},"tower coin three":{"taken":false},"tower coin four":{"taken":false},"the blue gate":{"progress":0.0,"goal":0.0,"held":0.0,"wasMoving":false},"the blue gate's plate":{"spent":false},"the maze lamp 1":{"enabled":true},"the maze lamp 2":{"enabled":true},"the maze lamp 3":{"enabled":true},"the maze lamp 4":{"enabled":true},"the maze lamp 5":{"enabled":true},"the maze lamp 6":{"enabled":true},"the maze lamp 7":{"enabled":true},"the maze lamp 8":{"enabled":true},"the maze lamp 9":{"enabled":true},"maze coin 1":{"taken":false},"maze coin 2":{"taken":false},"maze coin 3":{"taken":false},"maze coin 4":{"taken":false},"maze coin 5":{"taken":false},"maze coin 6":{"taken":false},"maze coin 7":{"taken":false},"maze coin 8":{"taken":false},"maze coin 9":{"taken":false},"maze coin 10":{"taken":false},"maze coin 11":{"taken":false},"maze coin 12":{"taken":false},"maze coin 13":{"taken":false},"the green gate":{"progress":0.0,"goal":0.0,"held":0.0,"wasMoving":false},"the green gate's plate":{"spent":false},"the east lift":{"progress":0.0,"goal":0.0,"held":0.0,"wasMoving":false},"the east plate":{"spent":false},"the saw arm":{"progress":0.0,"goal":1.0,"held":0.0,"wasMoving":false},"the saw":{},"the gallery ladder":{"time":0.0},"the rope":{"time":0.0},"the cold":{"reached":false},"the crevasse":{},"the ferry":{"progress":0.0,"goal":1.0,"held":0.0,"wasMoving":false},"the west floe":{"progress":0.3124999999999995,"goal":1.0,"held":0.0,"wasMoving":false},"the east floe":{"progress":0.9375000000000041,"goal":1.0,"held":0.0,"wasMoving":false},"floe coin one":{"taken":false},"floe coin two":{"taken":false},"floe coin three":{"taken":false},"floe coin four":{"taken":false},"the far west floe":{"progress":0.49999999999999994,"goal":1.0,"held":0.0,"wasMoving":false},"the far east floe":{"progress":0.49999999999999994,"goal":1.0,"held":0.0,"wasMoving":false},"the shelf 1":{"since":null,"fallen":false,"away":0.0},"the shelf 2":{"since":null,"fallen":false,"away":0.0},"the shelf 3":{"since":null,"fallen":false,"away":0.0},"the shelf 4":{"since":null,"fallen":false,"away":0.0},"the shelf 5":{"since":null,"fallen":false,"away":0.0},"the spikes":{},"more spikes":{},"the far spikes":{},"the eastern spikes":{},"the cold pad":{},"the cold coin":{"taken":false},"ice coin one":{"taken":false},"ice coin two":{"taken":false},"the floe pad west":{},"the floe pad east":{},"past the ice":{"reached":false},"the chimney coin one":{"taken":false},"the chimney coin two":{"taken":false},"the dash coin":{"taken":false},"the coin over the gap":{"taken":false},"the foot of the stair":{"reached":false},"stair coin one":{"taken":false},"the last pad":{},"stair coin two":{"taken":false},"stair coin three":{"taken":false},"stair coin four":{"taken":false},"the summit lift":{"progress":0.0,"goal":0.0,"held":0.0,"wasMoving":false},"the summit plate":{"spent":false},"ring coin 1":{"taken":false},"ring coin 2":{"taken":false},"ring coin 3":{"taken":false},"ring coin 4":{"taken":false},"ring coin 5":{"taken":false},"ring coin 6":{"taken":false},"ring coin 7":{"taken":false},"ring coin 8":{"taken":false},"the summit":{"reached":false},"the cap 1":{"broken":false},"the cap 2":{"broken":false},"the cap 3":{"broken":false},"the west hoist":{"progress":0.0,"goal":0.0,"held":0.0,"wasMoving":false},"the west hoist plate":{"spent":false},"the east hoist":{"progress":0.0,"goal":0.0,"held":0.0,"wasMoving":false},"the east hoist plate":{"spent":false},"coin 019":{"taken":false},"coin 020":{"taken":false},"coin 021":{"taken":false},"coin 022":{"taken":false},"coin 023":{"taken":false},"coin 024":{"taken":false},"coin 025":{"taken":false},"coin 026":{"taken":false},"coin 027":{"taken":false},"coin 028":{"taken":false},"coin 029":{"taken":false},"coin 030":{"taken":false},"coin 031":{"taken":false},"coin 032":{"taken":false},"coin 033":{"taken":false},"coin 034":{"taken":false},"coin 035":{"taken":false},"coin 036":{"taken":false},"coin 037":{"taken":false},"coin 038":{"taken":false},"coin 039":{"taken":false},"coin 040":{"taken":false},"coin 041":{"taken":false},"coin 042":{"taken":false},"coin 043":{"taken":false},"coin 044":{"taken":false},"coin 045":{"taken":false},"coin 046":{"taken":false},"coin 047":{"taken":false},"coin 048":{"taken":false},"coin 049":{"taken":false},"coin 050":{"taken":false},"coin 051":{"taken":false},"coin 052":{"taken":false},"coin 053":{"taken":false},"coin 054":{"taken":false},"coin 055":{"taken":false},"coin 056":{"taken":false},"coin 057":{"taken":false},"coin 058":{"taken":false},"coin 059":{"taken":false},"coin 060":{"taken":false},"coin 061":{"taken":false},"coin 062":{"taken":false},"coin 063":{"taken":false},"coin 064":{"taken":false},"coin 065":{"taken":false},"coin 066":{"taken":false},"coin 067":{"taken":false},"coin 068":{"taken":false},"coin 069":{"taken":false},"coin 070":{"taken":false},"coin 077":{"taken":false},"coin 078":{"taken":false},"coin 079":{"taken":false},"coin 080":{"taken":false},"coin 081":{"taken":false},"coin 082":{"taken":false},"coin 083":{"taken":false},"coin 084":{"taken":false},"coin 085":{"taken":false},"coin 086":{"taken":false},"coin 087":{"taken":false},"coin 088":{"taken":false},"coin 089":{"taken":false},"coin 090":{"taken":false},"coin 091":{"taken":false},"coin 092":{"taken":false},"coin 093":{"taken":false},"coin 094":{"taken":false},"coin 095":{"taken":false},"coin 096":{"taken":false},"coin 097":{"taken":false},"coin 098":{"taken":false},"coin 099":{"taken":false},"coin 100":{"taken":false},"coin 101":{"taken":false},"coin 102":{"taken":false},"coin 103":{"taken":false},"coin 104":{"taken":false},"coin 105":{"taken":false},"coin 106":{"taken":false},"coin 107":{"taken":false},"coin 108":{"taken":false},"coin 109":{"taken":false},"coin 127":{"taken":false},"coin 128":{"taken":false},"coin 129":{"taken":false},"coin 130":{"taken":false},"coin 131":{"taken":false},"coin 132":{"taken":false},"coin 133":{"taken":false},"coin 134":{"taken":false},"coin 135":{"taken":false},"coin 136":{"taken":false},"coin 137":{"taken":false},"coin 138":{"taken":false},"coin 139":{"taken":false},"coin 140":{"taken":false},"coin 141":{"taken":false},"coin 142":{"taken":false},"coin 143":{"taken":false},"coin 144":{"taken":false},"coin 145":{"taken":false},"coin 146":{"taken":false},"coin 147":{"taken":false},"coin 148":{"taken":false},"coin 149":{"taken":false},"coin 150":{"taken":false},"coin 151":{"taken":false},"coin 152":{"taken":false},"coin 153":{"taken":false},"coin 154":{"taken":false},"coin 155":{"taken":false},"coin 156":{"taken":false},"coin 157":{"taken":false},"coin 158":{"taken":false},"coin 159":{"taken":false},"coin 160":{"taken":false},"coin 161":{"taken":false},"coin 162":{"taken":false},"coin 167":{"taken":false},"coin 168":{"taken":false},"coin 169":{"taken":false},"coin 170":{"taken":false},"coin 171":{"taken":false},"coin 175":{"taken":false},"coin 176":{"taken":false},"coin 177":{"taken":false},"coin 178":{"taken":false},"coin 179":{"taken":false},"coin 180":{"taken":false},"coin 181":{"taken":false},"coin 182":{"taken":false},"coin 183":{"taken":false},"coin 184":{"taken":false},"coin 185":{"taken":false},"coin 186":{"taken":false},"coin 187":{"taken":false},"coin 188":{"taken":false},"coin 189":{"taken":false},"coin 190":{"taken":false},"coin 207":{"taken":false},"coin 208":{"taken":false},"coin 209":{"taken":false},"coin 210":{"taken":false},"coin 211":{"taken":false},"coin 212":{"taken":false},"coin 213":{"taken":false},"coin 214":{"taken":false},"coin 215":{"taken":false},"coin 216":{"taken":false},"coin 217":{"taken":false},"coin 218":{"taken":false},"coin 219":{"taken":false},"coin 220":{"taken":false},"coin 221":{"taken":false},"coin 222":{"taken":false},"coin 223":{"taken":false},"coin 224":{"taken":false},"coin 225":{"taken":false},"coin 226":{"taken":false},"coin 227":{"taken":false},"coin 228":{"taken":false},"coin 229":{"taken":false},"coin 230":{"taken":false},"coin 231":{"taken":false},"coin 232":{"taken":false},"coin 233":{"taken":false},"coin 234":{"taken":false},"coin 235":{"taken":false},"coin 236":{"taken":false},"coin 237":{"taken":false},"coin 238":{"taken":false},"coin 239":{"taken":false},"coin 240":{"taken":false},"coin 241":{"taken":false},"coin 242":{"taken":false},"coin 243":{"taken":false},"coin 244":{"taken":false},"coin 245":{"taken":false},"coin 246":{"taken":false},"coin 247":{"taken":false},"coin 248":{"taken":false},"coin 249":{"taken":false},"coin 250":{"taken":false},"coin 251":{"taken":false},"coin 252":{"taken":false},"coin 253":{"taken":false},"coin 254":{"taken":false},"coin 255":{"taken":false},"coin 256":{"taken":false},"coin 257":{"taken":false},"coin 258":{"taken":false},"coin 259":{"taken":false}},"entities":{"generations":[0,0,0],"free":[],"components":{"body":{"0":{"at":[-10.0,0.3499999940395355,-4.0],"velocity":[0.0,0.0,0.0],"grounded":false,"coyote":0.0,"jumpBuffer":0.0},"1":{"at":[30.0,0.3499999940395355,-6.0],"velocity":[0.0,0.0,0.0],"grounded":false,"coyote":0.0,"jumpBuffer":0.0},"2":{"at":[-16.0,0.3499999940395355,74.0],"velocity":[0.0,0.0,0.0],"grounded":false,"coyote":0.0,"jumpBuffer":0.0}},"vitality":{"0":{"current":20.0,"armour":0.0,"mourned":false},"1":{"current":20.0,"armour":0.0,"mourned":false},"2":{"current":20.0,"armour":0.0,"mourned":false}},"facing":{"0":{"yaw":0.0,"turnRate":6.0,"eyeFraction":0.32},"1":{"yaw":0.0,"turnRate":6.0,"eyeFraction":0.32},"2":{"yaw":0.0,"turnRate":6.0,"eyeFraction":0.32}},"thinking":{"0":{"target":1,"waiting":0.0},"1":{"target":1,"waiting":0.0},"2":{"target":1,"waiting":0.0}}}},"actors":{"tick":0},"dynamics":"RjNEUwgAAAAEAAAAsAAAAAABAAAYAAAAqAAAAFQAAAAsAAAA0AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADD9RzBAAAAAAAAAAAAAAAAAAAAADOTkkNgQqINCtejPQAAAD8iAAAAIgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACtejPAEAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAABAAIKAADgQDMzMz8AAIDAAAAAAAAAAAAAAAAAAACAPwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADMzMz8zMzM/MzMzPwAA8EGJiAg9zMwcQczMHEHMzBxBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD1KDxBZmaGPwAAAAAAAHpEZmZmPwAAAAAAAAAAAAAAAAAAAAAAAAAAAACAPzOTkkMAAAAAAAAAAAAAAAAAAAAAzcxMPwAAAAABAAAA/////wEAAAAAAAAAAQACCs3MDEEzMzM/AACAwAAAAAAAAAAAAAAAAAAAgD8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAzMzM/MzMzPzMzMz8AAPBBiYgIPczMHEHMzBxBzMwcQQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA9Sg8QWZmhj8AAAAAAAB6RGZmZj8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAgD8zk5JDAAAAAAAAAAAAAAAAAAAAAM3MTD8AAAAAAQAAAP////8BAAAAAAAAAAEAAgoAAOjBMzMzPwAAmMEAAAAAAAAAAAAAAAAAAIA/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMzMzPzMzMz8zMzM/AADwQYmICD3MzBxBzMwcQczMHEEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPUoPEFmZoY/AAAAAAAAekRmZmY/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAIA/M5OSQwAAAAAAAAAAAAAAAAAAAADNzEw/AAAAAAEAAAD/////AQAAAAAAAAABAAIKAAD4wTMzMz8AAJjBAAAAAAAAAAAAAAAAAACAPwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADMzMz8zMzM/MzMzPwAA8EGJiAg9zMwcQczMHEHMzBxBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD1KDxBZmaGPwAAAAAAAHpEZmZmPwAAAAAAAAAAAAAAAAAAAAAAAAAAAACAPzOTkkMAAAAAAAAAAAAAAAAAAAAAzcxMPwAAAAABAAAA/////wEAAAAAAAAAAQACCgAA8MEzMzM/AACowQAAAAAAAAAAAAAAAAAAgD8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAzMzM/MzMzPzMzMz8AAPBBiYgIPczMHEHMzBxBzMwcQQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA9Sg8QWZmhj8AAAAAAAB6RGZmZj8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAgD8zk5JDAAAAAAAAAAAAAAAAAAAAAM3MTD8AAAAAAQAAAP////8BAAAAAAAAAAEAAgoAAMBBMzMzPwAAsMEAAAAAAAAAAAAAAAAAAIA/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMzMzPzMzMz8zMzM/AADwQYmICD3MzBxBzMwcQczMHEEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPUoPEFmZoY/AAAAAAAAekRmZmY/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAIA/M5OSQwAAAAAAAAAAAAAAAAAAAADNzEw/AAAAAAEAAAD/////AQAAAAAAAAABAAIKAAAEQjMzMz8AALDBAAAAAAAAAAAAAAAAAACAPwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADMzMz8zMzM/MzMzPwAA8EGJiAg9zMwcQczMHEHMzBxBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD1KDxBZmaGPwAAAAAAAHpEZmZmPwAAAAAAAAAAAAAAAAAAAAAAAAAAAACAPzOTkkMAAAAAAAAAAAAAAAAAAAAAzcxMPwAAAAABAAAA/////wEAAAAAAAAAAQACCgAAKEIzMzM/AACwwQAAAAAAAAAAAAAAAAAAgD8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAzMzM/MzMzPzMzMz8AAPBBiYgIPczMHEHMzBxBzMwcQQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA9Sg8QWZmhj8AAAAAAAB6RGZmZj8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAgD8zk5JDAAAAAAAAAAAAAAAAAAAAAM3MTD8AAAAAAQAAAP////8BAAAAAAAAAAEAAgoAAExCMzMzPwAAsMEAAAAAAAAAAAAAAAAAAIA/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMzMzPzMzMz8zMzM/AADwQYmICD3MzBxBzMwcQczMHEEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPUoPEFmZoY/AAAAAAAAekRmZmY/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAIA/M5OSQwAAAAAAAAAAAAAAAAAAAADNzEw/AAAAAAEAAAD/////AQAAAAAAAAABAAIKAABQwjMzMz8AAPhBAAAAAAAAAAAAAAAAAACAPwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADMzMz8zMzM/MzMzPwAA8EGJiAg9zMwcQczMHEHMzBxBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD1KDxBZmaGPwAAAAAAAHpEZmZmPwAAAAAAAAAAAAAAAAAAAAAAAAAAAACAPzOTkkMAAAAAAAAAAAAAAAAAAAAAzcxMPwAAAAABAAAA/////wEAAAAAAAAAAQACCgAAMMIzMzM/AAD4QQAAAAAAAAAAAAAAAAAAgD8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAzMzM/MzMzPzMzMz8AAPBBiYgIPczMHEHMzBxBzMwcQQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA9Sg8QWZmhj8AAAAAAAB6RGZmZj8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAgD8zk5JDAAAAAAAAAAAAAAAAAAAAAM3MTD8AAAAAAQAAAP////8BAAAAAAAAAAEAAgoAABDCMzMzPwAA+EEAAAAAAAAAAAAAAAAAAIA/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMzMzPzMzMz8zMzM/AADwQYmICD3MzBxBzMwcQczMHEEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPUoPEFmZoY/AAAAAAAAekRmZmY/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAIA/M5OSQwAAAAAAAAAAAAAAAAAAAADNzEw/AAAAAAEAAAD/////AQAAAAAAAAABAAIKAABIwjMzMz8AAJRCAAAAAAAAAAAAAAAAAACAPwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADMzMz8zMzM/MzMzPwAA8EGJiAg9zMwcQczMHEHMzBxBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD1KDxBZmaGPwAAAAAAAHpEZmZmPwAAAAAAAAAAAAAAAAAAAAAAAAAAAACAPzOTkkMAAAAAAAAAAAAAAAAAAAAAzcxMPwAAAAABAAAA/////wEAAAAAAAAAAQACCgAAkMEzMzM/AACUQgAAAAAAAAAAAAAAAAAAgD8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAzMzM/MzMzPzMzMz8AAPBBiYgIPczMHEHMzBxBzMwcQQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA9Sg8QWZmhj8AAAAAAAB6RGZmZj8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAgD8zk5JDAAAAAAAAAAAAAAAAAAAAAM3MTD8AAAAAAQAAAP////8BAAAAAAAAAAEAAgoAAADBMzMzPwAAlEIAAAAAAAAAAAAAAAAAAIA/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMzMzPzMzMz8zMzM/AADwQYmICD3MzBxBzMwcQczMHEEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPUoPEFmZoY/AAAAAAAAekRmZmY/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAIA/M5OSQwAAAAAAAAAAAAAAAAAAAADNzEw/AAAAAAEAAAD/////AQAAAAAAAAABAAIKAAAAQTMzMz8AAJRCAAAAAAAAAAAAAAAAAACAPwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADMzMz8zMzM/MzMzPwAA8EGJiAg9zMwcQczMHEHMzBxBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD1KDxBZmaGPwAAAAAAAHpEZmZmPwAAAAAAAAAAAAAAAAAAAAAAAAAAAACAPzOTkkMAAAAAAAAAAAAAAAAAAAAAzcxMPwAAAAABAAAA/////wEAAAAAAAAAAQACCgAAkEEzMzM/AACUQgAAAAAAAAAAAAAAAAAAgD8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAzMzM/MzMzPzMzMz8AAPBBiYgIPczMHEHMzBxBzMwcQQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA9Sg8QWZmhj8AAAAAAAB6RGZmZj8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAgD8zk5JDAAAAAAAAAAAAAAAAAAAAAM3MTD8AAAAAAQAAAP////8BAAAAAAAAAAEAAgoAAEhCMzMzPwAAlEIAAAAAAAAAAAAAAAAAAIA/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMzMzPzMzMz8zMzM/AADwQYmICD3MzBxBzMwcQczMHEEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPUoPEFmZoY/AAAAAAAAekRmZmY/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAIA/M5OSQwAAAAAAAAAAAAAAAAAAAADNzEw/AAAAAAEAAAD/////AQAAAAAAAAABAAIKAABYwjMzMz8AALRCAAAAAAAAAAAAAAAAAACAPwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADMzMz8zMzM/MzMzPwAA8EGJiAg9zMwcQczMHEHMzBxBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD1KDxBZmaGPwAAAAAAAHpEZmZmPwAAAAAAAAAAAAAAAAAAAAAAAAAAAACAPzOTkkMAAAAAAAAAAAAAAAAAAAAAzcxMPwAAAAABAAAA/////wEAAAAAAAAAAQACCgAAWMIzMzM/AAC+QgAAAAAAAAAAAAAAAAAAgD8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAzMzM/MzMzPzMzMz8AAPBBiYgIPczMHEHMzBxBzMwcQQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA9Sg8QWZmhj8AAAAAAAB6RGZmZj8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAgD8zk5JDAAAAAAAAAAAAAAAAAAAAAM3MTD8AAAAAAQAAAP////8BAAAAAAAAAAEAAgoAAFjCMzMzPwAAyEIAAAAAAAAAAAAAAAAAAIA/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMzMzPzMzMz8zMzM/AADwQYmICD3MzBxBzMwcQczMHEEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPUoPEFmZoY/AAAAAAAAekRmZmY/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAIA/M5OSQwAAAAAAAAAAAAAAAAAAAADNzEw/AAAAAAEAAAD/////AQAAAAAAAAABAAIKAABIwjMzMz8AALRCAAAAAAAAAAAAAAAAAACAPwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADMzMz8zMzM/MzMzPwAA8EGJiAg9zMwcQczMHEHMzBxBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD1KDxBZmaGPwAAAAAAAHpEZmZmPwAAAAAAAAAAAAAAAAAAAAAAAAAAAACAPzOTkkMAAAAAAAAAAAAAAAAAAAAAzcxMPwAAAAABAAAA/////wEAAAAAAAAAAQACCgAASMIzMzM/AAC+QgAAAAAAAAAAAAAAAAAAgD8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAzMzM/MzMzPzMzMz8AAPBBiYgIPczMHEHMzBxBzMwcQQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA9Sg8QWZmhj8AAAAAAAB6RGZmZj8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAgD8zk5JDAAAAAAAAAAAAAAAAAAAAAM3MTD8AAAAAAQAAAP////8BAAAAAAAAAAEAAgoAAEjCMzMzPwAAyEIAAAAAAAAAAAAAAAAAAIA/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMzMzPzMzMz8zMzM/AADwQYmICD3MzBxBzMwcQczMHEEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPUoPEFmZoY/AAAAAAAAekRmZmY/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAIA/M5OSQwAAAAAAAAAAAAAAAAAAAADNzEw/AAAAAAEAAAD/////AQAAAAAAAAABAAIKAAA4wjMzMz8AALRCAAAAAAAAAAAAAAAAAACAPwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADMzMz8zMzM/MzMzPwAA8EGJiAg9zMwcQczMHEHMzBxBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD1KDxBZmaGPwAAAAAAAHpEZmZmPwAAAAAAAAAAAAAAAAAAAAAAAAAAAACAPzOTkkMAAAAAAAAAAAAAAAAAAAAAzcxMPwAAAAABAAAA/////wEAAAAAAAAAAQACCgAAOMIzMzM/AAC+QgAAAAAAAAAAAAAAAAAAgD8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAzMzM/MzMzPzMzMz8AAPBBiYgIPczMHEHMzBxBzMwcQQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA9Sg8QWZmhj8AAAAAAAB6RGZmZj8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAgD8zk5JDAAAAAAAAAAAAAAAAAAAAAM3MTD8AAAAAAQAAAP////8BAAAAAAAAAAEAAgoAADjCMzMzPwAAyEIAAAAAAAAAAAAAAAAAAIA/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMzMzPzMzMz8zMzM/AADwQYmICD3MzBxBzMwcQczMHEEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPUoPEFmZoY/AAAAAAAAekRmZmY/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAIA/M5OSQwAAAAAAAAAAAAAAAAAAAADNzEw/AAAAAAEAAAD/////AQAAAAAAAAABAAIKAAAowjMzMz8AALRCAAAAAAAAAAAAAAAAAACAPwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADMzMz8zMzM/MzMzPwAA8EGJiAg9zMwcQczMHEHMzBxBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD1KDxBZmaGPwAAAAAAAHpEZmZmPwAAAAAAAAAAAAAAAAAAAAAAAAAAAACAPzOTkkMAAAAAAAAAAAAAAAAAAAAAzcxMPwAAAAABAAAA/////wEAAAAAAAAAAQACCgAAKMIzMzM/AAC+QgAAAAAAAAAAAAAAAAAAgD8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAzMzM/MzMzPzMzMz8AAPBBiYgIPczMHEHMzBxBzMwcQQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA9Sg8QWZmhj8AAAAAAAB6RGZmZj8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAgD8zk5JDAAAAAAAAAAAAAAAAAAAAAM3MTD8AAAAAAQAAAP////8BAAAAAAAAAAEAAgoAACjCMzMzPwAAyEIAAAAAAAAAAAAAAAAAAIA/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMzMzPzMzMz8zMzM/AADwQYmICD3MzBxBzMwcQczMHEEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPUoPEFmZoY/AAAAAAAAekRmZmY/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAIA/M5OSQwAAAAAAAAAAAAAAAAAAAADNzEw/AAAAAAEAAAD/////AQAAAAAAAAABAAIKAACAwTMzMz8AABRDAAAAAAAAAAAAAAAAAACAPwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADMzMz8zMzM/MzMzPwAA8EGJiAg9zMwcQczMHEHMzBxBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD1KDxBZmaGPwAAAAAAAHpEZmZmPwAAAAAAAAAAAAAAAAAAAAAAAAAAAACAPzOTkkMAAAAAAAAAAAAAAAAAAAAAzcxMPwAAAAABAAAA/////wEAAAAAAAAAAQACCgAAgMEzMzM/AAAgQwAAAAAAAAAAAAAAAAAAgD8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAzMzM/MzMzPzMzMz8AAPBBiYgIPczMHEHMzBxBzMwcQQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA9Sg8QWZmhj8AAAAAAAB6RGZmZj8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAgD8zk5JDAAAAAAAAAAAAAAAAAAAAAM3MTD8AAAAAAQAAAP////8BAAAAAAAAAAEAAgoAAIBBMzMzPwAAFEMAAAAAAAAAAAAAAAAAAIA/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMzMzPzMzMz8zMzM/AADwQYmICD3MzBxBzMwcQczMHEEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPUoPEFmZoY/AAAAAAAAekRmZmY/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAIA/M5OSQwAAAAAAAAAAAAAAAAAAAADNzEw/AAAAAAEAAAD/////AQAAAAAAAAABAAIKAACAQTMzMz8AACBDAAAAAAAAAAAAAAAAAACAPwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADMzMz8zMzM/MzMzPwAA8EGJiAg9zMwcQczMHEHMzBxBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD1KDxBZmaGPwAAAAAAAHpEZmZmPwAAAAAAAAAAAAAAAAAAAAAAAAAAAACAPzOTkkMAAAAAAAAAAAAAAAAAAAAAzcxMPwAAAAABAAAA/////w=="},"tape":{"seed":1,"frames":[{"pressed":["moveForward","jump"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"released":["jump"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward","dropThrough"],"sy":1.0},{"pressed":["moveForward"],"released":["dropThrough"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"pressed":["moveForward","jump"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"released":["jump"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"pressed":["moveForward","jump"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"released":["jump"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward","dropThrough"],"sy":1.0},{"pressed":["moveForward"],"released":["dropThrough"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"pressed":["moveForward","jump"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"released":["jump"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward","dropThrough"],"sy":1.0},{"pressed":["moveForward"],"released":["dropThrough"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"pressed":["moveForward","jump"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"released":["jump"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"pressed":["moveForward","jump"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"released":["jump"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward","dropThrough"],"sy":1.0},{"pressed":["moveForward"],"released":["dropThrough"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"pressed":["moveForward","jump"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"released":["jump"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward","dropThrough"],"sy":1.0},{"pressed":["moveForward"],"released":["dropThrough"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"pressed":["moveForward","jump"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"released":["jump"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"pressed":["moveForward","jump"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"released":["jump"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward","dropThrough"],"sy":1.0},{"pressed":["moveForward"],"released":["dropThrough"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"pressed":["moveForward","jump"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"released":["jump"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward","dropThrough"],"sy":1.0},{"pressed":["moveForward"],"released":["dropThrough"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"pressed":["moveForward"],"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0},{"sy":1.0}]},"buildStamp":"tpl-02-sample","checkpoints":{"every":25,"steps":[25,50,75,100,125,150,175,200,225,250,275,300,325,350,375,400,425,450,475,500,525,550,575,600],"digests":["893f9ae2","a26efa8b","92a1fac5","abd845c6","d747ce77","2f349d17","71c6e765","07ba22b2","4d524c80","d7e34933","0856721b","57694edc","0b3adba1","438cee90","e50dfa6a","6cd81810","1a637ea7","754e26fb","34dbbd1d","3656f8dc","49e0cd0a","f8622ce0","db278984","50a1e25d"]}} \ No newline at end of file diff --git a/site/content/core/architecture.md b/site/content/core/architecture.md index 050941ae6..76e0bc33b 100644 --- a/site/content/core/architecture.md +++ b/site/content/core/architecture.md @@ -53,7 +53,7 @@ Each exists for a different reason, and none of them is a fallback for another. Its shaders are GLSL ES 3.00 generated from `flutter3d_shaders`. Nothing checked that the generated file was current for a long time, and it cost two failures that reported nothing: a uniform member the browser's copy had never heard of, and a sky shader from before the sky was rewritten. `tool/ci.sh` regenerates it and fails on the diff now, and the compiled Impeller bundle, which cannot be diffed, is held by a freshness rule instead. -**`flutter3d_cpu`** is the one that makes the agreement mean something. Two hardware backends agreeing proves less than it looks like: both are driven by a C API and both rasterise on a GPU, so an assumption shared by graphics hardware would be invisible to the pair of them. This one shares nothing with either, no driver, no shading language, no command buffer. It is also what makes 78 golden scenes checkable in a headless run, and what caught three bugs that every simulation test passed. +**`flutter3d_cpu`** is the one that makes the agreement mean something. Two hardware backends agreeing proves less than it looks like: both are driven by a C API and both rasterise on a GPU, so an assumption shared by graphics hardware would be invisible to the pair of them. This one shares nothing with either, no driver, no shading language, no command buffer. It is also what makes 95 golden scenes checkable in a headless run, and what caught three bugs that every simulation test passed. **`flutter3d_webgpu`** asks a question none of the other three could: whether the *shader* half of the contract is a seam. The first three all read one text (`impellerc` compiles the GLSL, the WebGL generator translates it, the software rasteriser transcribes it into Dart by hand), and WebGPU cannot, because WGSL is a different language and a browser will not take SPIR-V. So its table is generated down a second toolchain, the same manifest through `glslangValidator` and then `naga`, and all thirty-nine stages come out. What it declines it declines by name: no blend constant (WebGPU has no colour/alpha pair for one) and no wireframe (no polygon fill mode in the API at all). Two more used to be on that list. Rendering into a mip is on now and cost no code: a reflection probe convolves its own chain, and a face and a level are `baseArrayLayer` and `baseMipLevel` on an ordinary view here. Block compression is on too, and what it cost was the asking: a WebGPU device gets exactly the features it requested, and requesting one the adapter does not carry rejects the device instead of handing back a lesser one, so the adapter is asked, the intersection is requested, and the capability answers from what was granted. @@ -62,7 +62,7 @@ A browser build opens WebGL2 unless it says otherwise. That is a decision about Any new backend has to pass `flutter3d_conformance` before it counts as one.
-

Writing a fifth one is a documented job rather than an archaeology exercise: Writing a HAL backend covers the whole contract, the ten semantics that appear in no signature, the conformance suite you can run before compiling a single shader, and the eighty-three shader entry points your bundle has to answer to.

+

Writing a fifth one is a documented job rather than an archaeology exercise: Writing a HAL backend covers the whole contract, the ten semantics that appear in no signature, the conformance suite you can run before compiling a single shader, and the ninety-six shader entry points your bundle has to answer to.

### What the HAL actually names diff --git a/site/content/core/backends.md b/site/content/core/backends.md index 6ca5d4729..6b270cb2b 100644 --- a/site/content/core/backends.md +++ b/site/content/core/backends.md @@ -20,7 +20,7 @@ What could **not** be written from the contract is the shaders. That limit is re
  • The semantics that are part of the contract and appear in no signature
  • What the 0.8 members ask, compute among them, and which backend answers yes
  • The conformance suite, and how to run it before you have a single shader
  • -
  • The eighty-three shader entry points your bundle must answer to
  • +
  • The ninety-six shader entry points your bundle must answer to
  • @@ -345,7 +345,7 @@ The Impeller backend's translation asserts that each enum value maps to the `flu ## Run the conformance suite first -`flutter3d_conformance` turns those semantics into executable checks, in **two tiers**. `coreChecks` works with clears, uploads and readback alone, so you can run it before you have a single shader compiled, which is when the answers are cheapest to act on. `shaderChecks` needs the bundle: thirty-one shader checks against eight that ask for none, and one more for a backend that can pack its own shaders as a loadable bundle: thirty-one of the forty-one link stages and draw. Among the thirty-one: that a pass starts covering its own attachment and nothing else, that its initial viewport covers the *level* rather than the base, that it inherits no clipping from the pass before it and starts with the stencil test off, that a binding made for one pipeline does not follow the next, that a block missing a member the caller named is refused, that a pass renders into a cube face and a mip, that a blend constant reaches the blend or is refused rather than drawn as zero, that a multisample resolve resolves, that an object id survives the draw and the readback, that a geometry overwrite draws what a fresh upload draws and leaves its neighbours untouched, and that wireframe and every primitive type are each drawn as themselves or refused, never substituted without a word. The lists are the authority (`coreChecks` and `shaderChecks` in `flutter3d_conformance.dart`), and the counts in this paragraph are held to them by `tool/structure.dart`, because the last time they were not, this page said fifteen. +`flutter3d_conformance` turns those semantics into executable checks, in **two tiers**. `coreChecks` works with clears, uploads and readback alone, so you can run it before you have a single shader compiled, which is when the answers are cheapest to act on. `shaderChecks` needs the bundle: thirty-two shader checks against eight that ask for none, and one more for a backend that can pack its own shaders as a loadable bundle: thirty-two of the forty-two link stages and draw. Among the thirty-two: that a pass starts covering its own attachment and nothing else, that its initial viewport covers the *level* rather than the base, that it inherits no clipping from the pass before it and starts with the stencil test off, that a binding made for one pipeline does not follow the next, that a draw of a window of the index buffer draws that window and refuses one past its end, that a block missing a member the caller named is refused, that a pass renders into a cube face and a mip, that a blend constant reaches the blend or is refused rather than drawn as zero, that a multisample resolve resolves, that an object id survives the draw and the readback, that a geometry overwrite draws what a fresh upload draws and leaves its neighbours untouched, and that wireframe and every primitive type are each drawn as themselves or refused, never substituted without a word. The lists are the authority (`coreChecks` and `shaderChecks` in `flutter3d_conformance.dart`), and the counts in this paragraph are held to them by `tool/structure.dart`, because the last time they were not, this page said fifteen. **A check a backend cannot be asked is reported as a decline, not as a pass.** Multisampling ends that way on the software rasteriser, which answers `supportsOffscreenMsaa` false and does not multisample at all; the blend constant ends that way on Impeller, where flutter_gpu exposes no setter, and on WebGPU, where the API has `"constant"` and `"one-minus-constant"` and no colour/alpha split to form `BlendFactor.blendAlpha` with; and the uniform-member rule is one only a backend that reflects its shaders can keep. WebGPU declines two in all, the blend constant and wireframe, which is how its run reads 41 of 41 without claiming anything it cannot do. It used to decline two more, rendering into a mip and the block-compressed formats, and the first of those is the caution this paragraph needs beside it: two checks read `supportsRenderToMip` and, finding it false, went on to ask a *smaller* question instead of skipping: one allocated a single mip level instead of two, the other returned before it drew. Neither showed up in the tally, because a check that answers half of itself still passes. The capability is true now and both ask the whole question, at exactly the same 41 of 41. The runner's tally line says `N passed, M failed, K declined` for exactly this reason: "the suite is green" and "the suite is green, and here is what it never asked" are different sentences, and a third party reading this page to decide what conformance buys them needs the second one. @@ -398,16 +398,19 @@ for (final RequiredShader shader in kRequiredShaders) { } ``` -Eighty-three entry points. `kRequiredShaders` and the bundle manifest are kept in step with each other by `flutter3d_shaders/test/manifest_test.dart`, and this table is kept in step with both by the `the site names every shader a bundle must answer to` rule. It was eleven names short for as long as nothing compared it with anything, and a missing name is not a degraded picture: `Renderer.create` throws on the first one it cannot find. +Eighty-five entry points. `kRequiredShaders` and the bundle manifest are kept in step with each other by `flutter3d_shaders/test/manifest_test.dart`, and this table is kept in step with both by the `the site names every shader a bundle must answer to` rule. It was eleven names short for as long as nothing compared it with anything, and a missing name is not a degraded picture: `Renderer.create` throws on the first one it cannot find. | Stage | Names | |---|---| | Vertex | `MeshVertex`, `MeshSkinnedVertex`, `MeshInstancedVertex`, `MeshLightmappedVertex`, `FullscreenVertex`, `DebugLineVertex`, `ParticleVertex`, `ParticleMeshVertex`, `PolylineVertex`, `ShadowTileResetVertex`, `SkyVertex`, `SkyCubeVertex`, `ImpostorVertex`, `VelocityVertex`, `VelocitySkinnedVertex`, `VelocityInstancedVertex` | +| Lighting | `Unlit`, `Lambert`, `BlinnPhong`, `Pbr`, `Toon`, `Normals`, `PbrLayered`, `Impostor`, `PlanarReflection` | +| Vertex | `MeshVertex`, `MeshSkinnedVertex`, `MeshInstancedVertex`, `MeshLightmappedVertex`, `FullscreenVertex`, `DebugLineVertex`, `ParticleVertex`, `ParticleMeshVertex`, `PolylineVertex`, `ShadowTileResetVertex`, `SkyVertex`, `SkyCubeVertex`, `SkyPhysicalVertex`, `ImpostorVertex`, `VelocityVertex`, `VelocitySkinnedVertex`, `VelocityInstancedVertex` | | Lighting | `Unlit`, `Lambert`, `BlinnPhong`, `Pbr`, `Toon`, `Normals`, `PbrLayered`, `Impostor` | -| Shadows | `ShadowDepth`, `ShadowDistance`, `ShadowDepthMasked`, `ShadowDistanceMasked`, `ShadowTileReset`, `EvsmFilter`, `ShadowCopy` | -| Post | `BloomThreshold`, `BloomDownsample`, `BloomUpsample`, `Composite`, `Fxaa`, `Reflections`, `Ssao`, `SsaoBlur`, `ContactShadow`, `ContactShadowResolve`, `Splat`, `LightShafts`, `DepthOfField`, `ViewportShade`, `Luminance`, `ProbePrefilter`, `CameraVelocity`, `Velocity`, `VelocityTileMax`, `VelocityNeighborMax`, `MotionBlur`, `TemporalResolve`, `TemporalAccumulate`, `Reactive`, `Easu`, `LocalExposure`, `LocalExposureBlur`, `VolumetricFog`, `VolumetricFogUpsample`, `DepthPyramid`, `SceneColourCopy`, `WboitResolve`, `DofTileMax` | +| Shadows | `ShadowDepth`, `ShadowDistance`, `ShadowDepthMasked`, `ShadowDistanceMasked`, `ShadowTileReset`, `EvsmFilter`, `ShadowCopy`, `ShadowTransmittance`, `CausticSurface`, `CausticPhotonVertex`, `CausticPhoton` | +| Post | `BloomThreshold`, `BloomDownsample`, `BloomUpsample`, `Composite`, `Fxaa`, `SmaaEdges`, `SmaaWeights`, `SmaaBlend`, `LensFlare`, `Reflections`, `Ssao`, `SsaoBlur`, `ContactShadow`, `ContactShadowResolve`, `Splat`, `LightShafts`, `DepthOfField`, `ViewportShade`, `Luminance`, `ProbePrefilter`, `CameraVelocity`, `Velocity`, `VelocityTileMax`, `VelocityNeighborMax`, `MotionBlur`, `TemporalResolve`, `TemporalAccumulate`, `Reactive`, `Easu`, `LocalExposure`, `LocalExposureBlur`, `VolumetricFog`, `VolumetricFogUpsample`, `DepthPyramid`, `SceneColourCopy`, `WboitResolve`, `DofTileMax`, `Decal` | +| Post | `BloomThreshold`, `BloomDownsample`, `BloomUpsample`, `Composite`, `Fxaa`, `Reflections`, `Ssao`, `SsaoBlur`, `ContactShadow`, `ContactShadowResolve`, `Splat`, `LightShafts`, `DepthOfField`, `ViewportShade`, `Luminance`, `ProbePrefilter`, `CameraVelocity`, `Velocity`, `VelocityTileMax`, `VelocityNeighborMax`, `MotionBlur`, `TemporalResolve`, `TemporalAccumulate`, `Reactive`, `Easu`, `LocalExposure`, `LocalExposureBlur`, `VolumetricFog`, `VolumetricFogUpsample`, `DepthPyramid`, `SceneColourCopy`, `WboitResolve`, `DofTileMax`, `RenderTextureEncode` | | Particles | `Particle`, `ParticleTextured`, `ParticleMesh`, `ParticleSixWay`, `ReactiveSprite`, `SplatHashed`, `ParticleSoft`, `ParticleTexturedSoft`, `ParticleSixWaySoft` | -| Sky | `Sky`, `SkyCube` | +| Sky | `Sky`, `SkyCube`, `SkyPhysical` | | Debug | `DebugLine`, `MrtProbe`, `ObjectId`, `Xray` | | Probes | `VertexTextureProbeVertex`, `VertexTextureProbe`, `IrradianceConvolve`, `FieldDecay` | diff --git a/site/content/core/index.md b/site/content/core/index.md index 4298a3978..4ddf30096 100644 --- a/site/content/core/index.md +++ b/site/content/core/index.md @@ -17,7 +17,7 @@ Nothing in this section is genre knowledge. That property is what made the secon | [`flutter3d_sim`](/core/simulation/) | Fixed step, input, levels and holes in them, mechanisms, actors, navigation and the automap, ECS, snapshots, demos, rewind | `flutter3d_physics`, `vector_math`. Plain Dart, no Flutter | | [`flutter3d_app`](/core/session/) | The backend choice, the surface a frame reaches Flutter through, a level loaded into a scene, storage | `flutter3d`, `flutter3d_sim` and Flutter | | `flutter3d_game` | The devices, the run, the settings and save screens, actor and fixture visuals | `flutter3d_app`, `flutter3d_sim` and Flutter | -| [`flutter3d_physics`](/core/physics/) | Shapes, broadphase, sweeps, rays, character controller, rigid bodies | nothing. Plain Dart | +| [`flutter3d_physics`](/core/physics/) | Shapes, broadphase, sweeps, rays, character controller, rigid bodies, [liquids](/core/liquids/) | nothing. Plain Dart | **Four backends implement the HAL**, and an application names exactly one of them in its pubspec: @@ -25,7 +25,7 @@ Nothing in this section is genre knowledge. That property is what made the secon |---|---| | `flutter3d_impeller` | `flutter_gpu`, over Metal and Vulkan. The production one | | `flutter3d_webgl` | WebGL2 in the browser. Runs all three games at a fixed resolution and a lower frame rate, and is what an ordinary web build opens | -| `flutter3d_cpu` | Nothing: it rasterises in Dart, so 78 golden scenes stay checkable with no GPU in the room | +| `flutter3d_cpu` | Nothing: it rasterises in Dart, so 95 golden scenes stay checkable with no GPU in the room | | `flutter3d_webgpu` | WebGPU in a browser that has an adapter. Reached by `--dart-define=FLUTTER3D_WEBGPU=true`, because a build that can try it ships it | `flutter3d_conformance` is the suite each of them passed before it belonged in that table, and [Writing a HAL backend](/core/backends/) is the guide for writing the fifth. diff --git a/site/content/core/liquids.md b/site/content/core/liquids.md new file mode 100644 index 000000000..ef8a8ab48 --- /dev/null +++ b/site/content/core/liquids.md @@ -0,0 +1,88 @@ +--- +description: Incompressible liquid in vessels of any shape, in SI units under the world's gravity. Waves from the vessel's own modes, weirs and orifices, streams that keep their continuity and break into drops, capillarity, pipes between vessels, layers that do not mix, and bodies that float. +--- + +# Liquids + +The liquid part of `flutter3d_physics` lives in `lib/src/fluid/` and knows nothing about any particular game. It is in metres, kilograms and seconds, and gravity is whatever the world says, so the same tube on the Moon pours six times slower and its meniscus stands six times taller. + +It is a hybrid, because no single method is right everywhere. Liquid standing in a vessel is a volume with a free surface on it, and the surface moves by its modes. Liquid leaving a vessel is a stream of parcels. A stream that breaks becomes drops, and drops are particles. Each part hands volume to the next one exactly, and the world can tell you where every cubic millimetre is. + +```dart +final world = FluidWorld( + gravity: Vector3(0, -9.81, 0), + floor: [PlaneObstacle(normal: Vector3(0, 1, 0), offset: 0)], +); +final tube = LiquidBody( + shape: RevolvedVessel(tubeInside), // any lathe profile, or a MeshVessel + medium: FluidMedium.water, + volume: 8e-6, // eight millilitres + concentrations: {'dye': 1.0}, + wallThickness: 0.0005, +); +world.bodies.add(tube); + +// Every frame: where the glass is, then the time that passed. +tube.place(rotation, position); +world.advance(dt); +``` + +Draw it with `liquidMeshes(tube)`, `jetMesh(jet)` and `particleMesh(...)` from `flutter3d_core`'s geometry. The chemistry bench is built this way; its [article](/education/chemlab/) shows what that looks like. + +## Volume first + +A vessel is a `VesselShape`: something that can say how much of it lies under a plane. `RevolvedVessel` does it exactly upright, as a sum of frustums, and tilted as a sum of circular segments. `MeshVessel` takes any closed mesh and does it with the divergence theorem over the triangles the plane cuts. Everything else is built on that one question. The surface's height is the plane that leaves the liquid's volume under it, and the most a tipped glass holds is the volume under its lowest point of rim. + +So the volume is never derived from the surface. It is the number the liquid has, and the surface follows from it. That is how it stays conserved to rounding through any amount of tilting, and the tests hold it to a millionth. + +## The surface + +The surface is the plane level in the gravity the liquid feels: the world's, less the vessel's acceleration, which the body works out from where it was placed and when. Swing a glass and the plane swings with the felt gravity. + +On that plane the liquid rocks. The waves are the modes of the surface in that particular vessel: the plane's cut through the vessel is laid out on a grid, and the lowest eigenvectors of its Laplacian, with no flow through the wall, are found by Lanczos iteration on the shifted inverse. Each mode is an oscillator at ω² = (gk + σk³/ρ)·tanh(kh), the dispersion relation of capillary-gravity waves in liquid of depth h, stepped exactly, not by Euler. For a cylinder that comes out within four percent of the Bessel-function answer on the default grid, and the tests check that it does. For a flask, or anything else, there is no closed form to check against, and the same code gives its modes. + +Damping is the boundary layer at the wall, after Stephens and Dodge, which grows with the root of the frequency, plus 2νk² from the bulk. In glycerol, waves barely start. + +A knock is a heap of liquid where it lands, projected onto the modes; the modes take it from there. + +## Out over the edge + +Where the surface stands above the rim, liquid leaves over it as over a sharp-crested weir, C_d·(2/3)·√(2g)·depth^(3/2) per metre of rim, with C_d = 0.62. It comes from the top layer and carries that layer's solutes. A hole in a wall is Torricelli's orifice with the same coefficient, and `pipeFlow` takes the lesser of Poiseuille's flow and the inertial limit. + +## Streams + +What leaves goes into a `Jet`, as parcels each holding one step's flow. A parcel's section is its volume over the distance it moves in a step. That is the continuity equation, and nothing enforces it separately: a falling stream thins as it speeds up because its parcels stretch. + +- The sheet off a lip draws in at the Taylor–Culick speed, so it rolls up into a round thread. +- A thread grows Rayleigh–Plateau ripples at Weber's rate, the Grant–Middleman form of it, and breaks into drops when they have grown e¹² times. On a wall it does not break. +- Where it meets glass the liquid wets, it runs down the wall as a rivulet. Too slow, and it runs down the outside of the glass it came from: the teapot effect, with cling speed √(σ(1 + cos θ)/(ρe)). +- A fast drop that lands on a surface splashes when Mundo's K = Oh·Re^1.25 is over 57.7. + +The world keeps the accounts: every jet's `emitted` is `inFlight + landed + dropped`. + +## Drops + +Drops are `ParticleFluid`, position-based fluids on the CPU, in double precision. Particles are held to the rest density only where they are compressed, so a surface does not pull itself inward. Cohesion follows Akinci, with the coefficient worked out so that pulling a slab of particles apart costs 2σ per square metre, which is what surface tension is. A particle is stepped in substeps short against the capillary time √(ρs³/σ), a third of a millisecond for millimetre water, and short enough that nothing passes through glass between one look and the next. Drops that land in a vessel become its liquid, with what was dissolved in them. + +## Capillarity + +`TubeMeniscus` solves the Young–Laplace equation for a tube's radius and the liquid's contact angle by shooting, so a narrow tube's meniscus is the right curve, not a fillet. `jurinHeight` is the rise in a thin tube, and the tests check one against the other. A `LiquidBody` lifts its drawn surface by the meniscus and adds the capillary pressure where it matters, in pipes. + +## Pipes between vessels + +A `Pipe` joins two bodies at given points. The column in it has inertia, so two vessels joined by a long pipe swing towards the same level and overshoot, as a U-tube does, and friction is Poiseuille's plus minor losses. Pressure at each end counts every layer above it and the capillary pressure, so a narrow tube joined to a wide one stands higher by the difference of their capillary rises, not at the same height. + +## Layers + +A body holds layers. Liquids that mix are one layer, keeping amounts of solutes, so pouring adds them. Liquids that do not mix lie in separate layers by density, oil on water, and each has its own top. `layerTops` gives them and `pressureAt` sums them. + +## Floating things + +`FloatingBody` wraps a rigid body. The liquid pushes up with ρgV of what it displaces, exactly for spheres, boxes and capsules, and drags with White's coefficient for the part that is under. The displaced volume raises the level. A small ball sinking in glycerol settles at Stokes's speed; the tests check it within three percent. + +## Being honest about it + +- The surface is linear, so it rocks harder where real water would break. +- Streams and drops collide with revolved vessels and planes; a `MeshVessel` holds liquid but does not yet stop a stream. +- Particles are on the CPU, so dozens are cheap and thousands are not. +- Everything that is stepped uses `Portable` maths, so the same inputs give the same bits on every platform. diff --git a/site/content/core/rendering.md b/site/content/core/rendering.md index cc2b879df..9204df5e7 100644 --- a/site/content/core/rendering.md +++ b/site/content/core/rendering.md @@ -1,5 +1,5 @@ --- -description: Render views, the pass order, instanced batches, precomputed visibility, baked lightmaps, HDR and tone mapping, auto and local exposure, HDR output, bloom, cascaded and point shadows, the sky, reflection probes, fog, screen-space reflections, ambient occlusion, colour grading, temporal anti-aliasing, glass and transparency, material layers, occlusion culling, the frame budget, picking by pixel, and the frame graph that schedules them. +description: Render views, the pass order, instanced batches, precomputed visibility, baked lightmaps, HDR and tone mapping, auto and local exposure, HDR output, bloom, cascaded and point shadows, the sky, reflection probes, planar reflections and cameras into textures, fog, screen-space reflections, ambient occlusion, colour grading, temporal anti-aliasing, glass and transparency, material layers, occlusion culling, the frame budget, picking by pixel, and the frame graph that schedules them. --- # The frame @@ -487,7 +487,7 @@ It is marched at half resolution and brought up by depth, so a halo behind a pil ```dart SkySettings( - enabled: true, // off by default: seventy-eight goldens are recorded against none + enabled: true, // off by default: ninety-five goldens are recorded against none zenith: Vector3(0.10, 0.22, 0.52), horizon: Vector3(0.42, 0.50, 0.62), nadir: Vector3(0.06, 0.06, 0.07), // what fills the frame looking down at nothing @@ -534,6 +534,43 @@ What a rolling one costs was measured on the racing demo's frame (the player's c

    A cube map is addressed by a left-handed table (on the +X face, column zero looks along +Z), and a right-handed camera puts every face's left on the right, so each view is drawn through a mirror and the winding is flipped with it; a backend whose row zero is at the bottom negates y as well, which makes the two a half turn and leaves the winding alone. Nothing in a picture says whether a face is mirrored, which is why the face table is tested by projecting known directions on both origins, and why the conformance suite clears one face of one level and reads it back through the very stage that fills the chain.

    +## Planar reflections + +A probe is a cube seen from one point, which is right for a ball and wrong for a floor: the reflection in a floor depends on where you stand. A `PlanarReflectorNode` draws the world again through the view's own camera mirrored in a plane, so a mirror, a polished hall or a still pool shows what is actually above it from where the eye actually is. + +```dart +final mirror = MeshNode(DeviceMesh.upload(device, const PlaneShape(width: 3, depth: 2).build()), floorMaterial); +scene + ..add(mirror) + ..add(PlanarReflectorNode(surfaces: [mirror], reflectance: 1.0)); // 0.02 for water +renderer.render(/* … */, settings: const RenderSettings( + planarReflections: PlanarReflectionSettings(enabled: true), +)); +``` + +{{golden planar-mirror | A black mirror set into a lit floor, with a red box, a blue ball and a yellow post standing on and around it. The reflection is the scene drawn through the camera mirrored in the floor, lit and shadowed by the same sun, at half the view's resolution and tinted slightly blue.}} + +The plane goes through the node's origin, with its local +Y as the side it is seen from; a camera behind it sees no reflection. The surfaces keep their own materials, and the reflection is laid over each of them straight after the opaque half of the scene pass, on exactly the pixels the surface won, weighted by Schlick's Fresnel from `reflectance`. With one the surface is a mirror at every angle; with water's 0.02 it reflects little looking down and nearly everything along the horizon. The picture is read by the pixel's place on screen, which is why it is drawn per view and at the view's own projection, at `resolution` of its size (half by default). + +Nothing below the plane may come up through it, and no stage here has a clip distance to write. So the mirrored camera's near plane is moved onto the mirror itself, the oblique frustum of Lengyel's 2005 paper: the rasteriser's own near clip does the cut, and what it costs is the far plane, which tilts. `clipOffset` lowers the cut a centimetre so a wall standing on the floor keeps its foot in the reflection. The software backend used to clip only at the eye and had to learn to drop fragments outside the depth range, as every GPU does, before it drew the same picture. + +It is off by default and costs a second drawing of the scene per view while it is on. Neither particles nor anything else a contributor draws is in the reflection, nothing is reflected twice, and a surface that blends is drawn after its reflection, so on transparent water the picture lies under the water rather than on it. + +## Cameras into textures + +The same pass, pointed through an ordinary camera, is public as `RenderTexture`: a monitor, a portal, a minimap. + +```dart +final feed = RenderTexture.create(device, camera: securityCamera, width: 256, height: 192); +feed.excluded.add(monitor); // a screen must not film itself +scene.addRenderTexture(feed); +monitor.material = Material(lighting: LightingModel.unlit, albedo: feed.texture); +``` + +{{golden render-texture | The same props on the same floor, and a monitor on a stand showing what a second camera off to the left sees of them, drawn earlier in the same frame.}} + +It is drawn before the scene, so a material shows the picture in the frame it was taken. The texture holds sRGB bytes with the top of the picture in its first row on every backend, the way an uploaded image does, so it goes into an albedo or emissive slot like any picture: unlit, it shows what the camera saw; lit, it is lit again, like a printed photograph. The light is multiplied by `exposure` and clipped, and tone mapping is left to the frame that shows it. `refreshEveryFrame: false` draws it once and then only after `invalidate()`. No post chain runs on it. + ## The look Grading, vignette, grain and dispersion, applied inside the composite after the tone map. Everything defaults to doing nothing *exactly*: a vignette of zero multiplies by one and grain of zero adds zero, so a scene that asks for none of it composites to the same bytes it did before this existed. @@ -640,6 +677,31 @@ RenderSettings( It runs after the tone map, because in HDR a highlight rings around any lobed filter, and only while `renderScale` is below one and the temporal resolve is off; `SpatialUpscaleSettings.runsFor(settings)` answers whether a frame will use it. +## Decals + +```dart +final decal = DecalNode( + texture: scorch, // sRGB, like a base colour map; null paints the tint alone + color: Vector4(1.0, 1.0, 1.0, 0.9), // sRGB tint, and the opacity in w + emissive: Vector3.zero(), // light the painted colour gives off, as a multiple of it + order: 0, // higher is painted over lower where two overlap + angleLimit: 1.3, // radians from the box's up past which a surface is left alone +) + ..setScale(2.0, 0.5, 2.0) // the picture's size in x and z, how deep it reaches in y + ..setPosition(0.0, 0.0, 0.0); +scene.add(decal); + +RenderSettings(decals: DecalSettings(enabled: true)) // off by default +``` + +A decal is a box, its node's unit cube, and it paints its picture onto whatever geometry stands inside it, stamped down the box's y axis. A decal with no rotation lies on a floor; to put one on a wall, rotate the box until its up points out of the wall. `region` picks a cell of an atlas. + +The decal pass runs after the opaque half of the scene and before the transparent half. A depth attachment cannot be sampled, so it finds the point under each pixel the way reflections and occlusion do, from the depth the surface buffer holds. It then reads back the light that point was lit by, through the albedo buffer, and lays the decal's colour under that light: a decal in a shadow is in the shadow, and a decal under a red lamp is red. The surface's normal and roughness are not changed. Where a surface wrote no albedo, because it is unlit or black, there is no light to read back, and the decal is drawn at its own colour. The surface's own highlight, emission and fog are scaled along with the light, which is close on a matte wall and wrong on a mirror. + +Sixteen decals and four pictures fit in one draw. Past that the pass draws again, and where a decal of the second draw lies over one of the first, the light under the first is read through the surface's colour rather than the first decal's. + +What it costs: two fullscreen draws per batch, each cut to the screen rectangle its boxes cover. It reads the surface and albedo buffers, so it needs a device that opens three colour attachments, and it turns MSAA off. A frame with a visible decal splits the scene pass the way glass does, so glass in front of a decal is drawn over it rather than painted. A frame whose decals are all hidden registers the pass, finds it inactive and draws what it drew without them. + ## Glass and transparency ### Glass that sees the scene diff --git a/site/content/education/chemlab.md b/site/content/education/chemlab.md index 1f6778ab2..7286be483 100644 --- a/site/content/education/chemlab.md +++ b/site/content/education/chemlab.md @@ -25,6 +25,9 @@ Five labelled test tubes, a graduated cylinder, a beaker and an Erlenmeyer flask
    Scroll
    Come closer or step back
    Vessel chips
    Pick what to pour into
    Pour slider
    Fill or empty the vessel you picked
    +
    Tilt slider
    Lean it towards you or away; move it quickly and the liquid sloshes
    +
    Tap
    Knock on the glass and watch the rings
    +
    Share with clean tube
    Pour the vessel you picked into the empty tube at the front until the two hold the same
    ## Glass from profiles @@ -32,64 +35,114 @@ Five labelled test tubes, a graduated cylinder, a beaker and an Erlenmeyer flask Almost everything on a bench is round, so none of it is modelled. Each piece is half of its outline, a profile in the (radius, height) plane ordered bottom to top, swept round the Y axis by `LatheShape`. A test tube is a quarter circle for the bottom, a straight wall and a slight flare at the mouth. A repeated point is a hard edge. ```dart -List tubeProfile({double radius = 0.08, double height = 0.9}) => [ +List tubeProfile({double radius = 0.008, double height = 0.09}) => [ for (var i = 0; i <= 8; i++) // the round bottom Vector2(radius * sin(i / 16 * pi), radius - radius * cos(i / 16 * pi)), Vector2(radius, height), // the wall - Vector2(radius * 1.06, height + 0.01), // the flare + Vector2(radius * 1.06, height + 0.001), // the flare ]; ``` -That outline is only the outside. Swept as it is, it ends in a bare edge, and from above the mouth of a tube read as a flat paper ring. `glassWall` gives it a wall: the inside is the outline moved in along its normal by four millimetres and walked back down, so its normals face the cavity, and a half circle joins the two as the rim that catches the light. Glass also shows almost nothing of itself, only what is round it, so the scene has a sky for an environment: a bright ceiling, a pale horizon and a darker floor for the glass to reflect. +Everything is life size, in metres: a test tube is sixteen millimetres across and nine centimetres tall, with half a millimetre of glass. It was ten times that at first, which looked the same and was wrong in every number the liquid depends on; surface tension does not scale with the glass. + +That outline is only the outside. Swept as it is, it ends in a bare edge, and from above the mouth of a tube read as a flat paper ring. `glassWall` gives it a wall: the inside is the outline moved in along its normal by half a millimetre and walked back down, so its normals face the cavity, and a half circle joins the two as the rim that catches the light. Glass also shows almost nothing of itself, only what is round it, so the scene has a sky for an environment: a bright ceiling, a pale horizon and a darker floor for the glass to reflect. ## Liquid that reads as liquid -The liquid is a second lathe a little inside the glass, cut at the fill level. Pouring builds a new profile with a different level and swaps the mesh, letting the old one go after the frames still drawing it. +The liquid fills the inside of the glass up to its surface. Every frame something moves, its mesh is built again from the physics (`liquidMeshes` in `flutter3d_core`) and swapped in, and the old one is let go after the frames still drawing it. -The first version was a coloured solid with a flat lid, and that is exactly how it looked: painted plastic. Four small changes fixed that. +The first version was a coloured solid with a flat lid, and that is exactly how it looked: painted plastic. Several changes fixed that, and the one that mattered most was not in the liquid at all. -- **A meniscus.** Water wets glass and climbs the wall a few millimetres, so the top of the profile rises at the wall and dips towards the middle. The curve catches a thin bright line where it meets the glass, which a flat cap never does. -- **Light goes through it.** The material is the layered model with some transmission and water's index of refraction, 1.33, so you see through the colour. -- **Thickness changes the colour.** The solution's colour is also its attenuation colour, so light that crosses more of it comes out deeper. The round bottom of a tube and its edges read darker than the middle, as they do on a real bench. -- **A wet surface.** A clear coat with almost no roughness over the colour gives the sharp highlight a liquid has and a painted surface does not. +- **A meniscus.** Water wets glass and climbs the wall, so the surface rises at the wall and dips towards the middle. Its shape is not drawn: the physics solves the Young–Laplace equation for the tube's radius and water's contact angle, and in a tube this narrow the whole surface is curved. The curve catches a thin bright line where it meets the glass, which a flat cap never does. +- **Light goes through all of it.** The material is the layered model with full transmission and water's index of refraction, 1.33. Its colour is the volume's, an attenuation colour over thirty centimetres, and the base colour is nearly white so the colour is not given twice. +- **It is a lens.** A column of liquid is a convex body, and `MaterialExtensions.convexVolume` tells the engine so: the thickness is the depth through the middle, and the path a ray takes inside is that depth times how squarely the bent ray meets the surface. Both the colour and how far behind the liquid the scene is read from follow that path. +- **A wet surface.** A clear coat with no roughness over the colour gives the sharp highlight a liquid has and a painted surface does not. ```dart -Material liquid(Vector3 colour) => Material( +Material liquid(Vector3 colour, {required double depth}) => Material( lighting: LightingModel.pbrLayered, - baseColor: Vector4(colour.x, colour.y, colour.z, 0.8), - roughness: 0.03, + baseColor: Vector4(0.75 + 0.25 * colour.x, 0.75 + 0.25 * colour.y, + 0.75 + 0.25 * colour.z, 1.0), + roughness: 0.02, alphaMode: MaterialAlphaMode.blend, + depthWrite: true, extensions: MaterialExtensions( - ior: 1.33, transmission: 0.35, thickness: 0.06, - attenuationColor: colour, attenuationDistance: 0.08, - clearcoat: 1.0, clearcoatRoughness: 0.02, + ior: 1.33, transmission: 1.0, thickness: depth, convexVolume: true, + attenuationColor: colour, attenuationDistance: 0.3, + clearcoat: 1.0, clearcoatRoughness: 0.0, ), ); ``` -## Shadows: the glass casts none +I expected the convex volume to make the edges of a column paler than its middle, and it hardly does. A ray entering a cylinder of water near its edge is bent towards the axis, so even there it crosses two thirds of the middle's depth. That is true of real tubes too, and it is not what tells liquid from plastic. What does is the world seen through it: the bench, the horizon and the shadows behind a tube, flipped and stretched. For a long time the liquids showed none of that, and the reason was the table. It was slightly transparent, for the reflections under it, so it was drawn after the liquids and was missing from the copy of the scene they read what is behind them from. Through a column of water they saw the sky behind the bench, and clear water came out as a white rod. Made opaque, the table is in the copy, and every tube became a lens. -A shadow map knows only whether something is between a point and the light, not how much light that something lets through. Every caster darkens the bench as much as a brick would. Clear glass that does this looks wrong straight away: an empty tube threw a shadow as dark as a full one. +The glass needed a fix in the engine too. A thin wall bends nothing, so what is behind it is exactly what the frame already holds where it is drawn. The engine used to read it from the copy instead, and the copy is taken before any transmissive draw, so a tube's wall showed the table where its liquid stood and washed the colour out. A blended material that transmits and has no thickness is now composited over the frame: it adds what it reflects and lets the rest through by its alpha, so the liquid shows through its glass. -So the glass is marked `castsShadow = false`, and the liquid inside it is what casts. The empty top of a tube leaves the bench lit, and the shadow starts where the liquid does. It is not physically right either. Real glass dims the light a little, and a coloured solution throws a tinted shadow; neither is possible until the shadow pass can record partial coverage, which is work for the engine; I would rather leave the shadow honest than fake it in a demo. +## Liquid that moves -## Refraction, caustics and reflections +The bench has no liquid code of its own. Each vessel's contents are a `LiquidBody` from `flutter3d_physics`, the bench is a `FluidWorld` under Earth's gravity, and the bench's part is to put the glass where the hand says and draw what comes back. The module is general; chemistry is just the first thing to use it. I wrote it up in [Liquids](/core/liquids/). + +- **Tilting.** The surface keeps to the plane level in the gravity the liquid feels, which is the world's less the glass's own acceleration, so a vessel lifted quickly sloshes. The waves on it are the modes of that surface in that vessel, found numerically for whatever cut the plane makes, each ringing at ω² = (gk + σk³/ρ)·tanh(kh). For a test tube the first one rocks about five times a second. +- **Tapping.** A knock is a small heap of water where the knock lands, laid out over the modes, which carry it off as rings. +- **Damping.** The boundary layer at the glass takes most of it, as Stephens and Dodge measured, and viscosity takes the finer waves. Water in a tube this size rocks a few times and is still. + +When every mode is under a tenth of a millimetre the ticker stops and the bench stops drawing. + +A vessel leans in the picture, about the horizontal axis the camera looks along, and is lifted out of the row just far enough that its underside clears the glass it leans over, as a hand would lift it; set back upright, it is put down again. One vessel is in the hand at a time. It leans no further than where what it holds would reach the lip. The cut-by-cut shadows are worked out for upright glass, so while a vessel leans its card is put away and the glass and liquid cast for themselves. + +## Shadows that know what the material lets through + +A shadow map holds one depth per texel: whether something stands between a point and the light, and nothing about how much light that something lets through. So every caster darkened the bench as much as a brick would, and an empty tube threw a shadow as dark as a full one. + +For a while the glass was simply marked `castsShadow = false`. I didn't like that, and in the end it went into the engine as `ShadowSettings.translucentCasters`. The atlas is a four-channel texture of which only red held depth, so the other three now hold what see-through casters let through, per colour. A glass or a liquid is drawn into it after the opaque casters, tested against their depth and blended so that layers combine, and the lit pass multiplies the sun by it. + +What one surface lets through comes from its material: the share that is not there at all (one minus its opacity), what its transmission passes of the rest, tinted by its colour, and what is not reflected away at its surface, from the Fresnel term of its index of refraction. That last part is why clear glass casts a faint shadow with darker edges: light meets the walls of a tube at a grazing angle near its sides and is mostly reflected there. A coloured solution casts a shadow of its own colour, and light through the walls and the liquid gets all three. + +```dart +final settings = RenderSettings( + shadows: ShadowSettings(translucentCasters: true), +); +``` -**Refraction comes from the engine.** When a frame holds a material with transmission, the renderer draws the opaque scene first, copies it, and the glass and the liquids read that copy offset along the ray their index bends. Look at the bench through a tube and its edge kinks. Two limits come with it. It is screen-space, so it can only bend what is already on the screen. And the liquid is not in the copy, since it is drawn after it, so the glass in front of a liquid bends the bench behind it but not the liquid. The glass has a centimetre of thickness for this; with none it is a sheet that bends nothing. +The first coloured shadows on this bench were hard to see, and the engine was not the reason. Only the sun's share of the light is tinted, and the room was lit by a sky as bright as the sun, so a shadow lost a third of its light and kept most of its grey. The tabletop was dark grey too, and colour does not read on dark grey. The bench now has a light, warm top, a stronger sun and a dimmer sky, and the solutions let more light through, as real ones do: their transmission is 0.85, so a tube's shadow is mostly the colour of what is in it. + +That model looks at one surface at a time, so it knows how much light a tube takes and not where the rest goes, and where it goes is most of what a tube's shadow is: a bright line down the middle where the liquid focuses the sun, dark edges, and a little light thrown out past the sides. So the bench works the shadows out with geometric optics instead. Every vessel is a surface of revolution, so cut across at any height it is a set of circles round one centre: the outside of the glass, its inside four millimetres in, and the liquid's edge. A sunbeam crossing the cut is bent at each circle by Snell's law, partly reflected by Fresnel's, totally reflected where it meets a thinner medium too steeply, and dimmed in the liquid by Beer and Lambert over the length it actually travels there. The sun comes down at an angle to the tube, so in the cut each medium bends the ray as if it had Bravais's index, while the reflection is worked out at the angle the ray really meets the glass. Sixteen hundred beams per cut and sixty-four cuts per vessel, followed to the bench and counted where they land, give a picture of the light under it, recomputed in a few milliseconds whenever you pour. + +The picture goes back into the engine through the same atlas. It is painted on a card lying on the bench along the vessel's shadow, which casts into the shadow map and nothing else, and the engine lets a see-through caster's colour map scale what it lets through, past one where light was gathered. The glass and the liquid cast nothing of their own any more, and the card does not shade them or the labels, which stand over that light, not under it. + +The same idea went into the engine as well, for shapes that are not lathes: `ShadowSettings.caustics`. A caster with a volume is drawn from the sun into two small maps, its near faces and its far faces, and one photon per texel is bent in at one and out at the other, followed to whatever lies below, and splatted there. How big each splat is comes from where its neighbouring photons land, so a beam the caster spreads comes out wide and faint and one it focuses comes out small and bright, and light is neither made nor lost. It is general and it is an approximation: it sees two surfaces per caster, so a liquid in a thin glass is followed as the liquid, which is why the glass here is thin-walled, as glTF means it. The demo is lit that way. The cut-by-cut optics above are still in the bench, `Bench(photons: false)`, exact for these vessels and nothing else, and I keep them to check the photons against. + +It has limits. The atlas keeps the colour, not where along the light it was picked up, so something standing between the sun and a glass would be shaded as if it stood behind it; a bench has nothing like that. It does nothing with the moments filter, which uses those channels for itself. And a see-through surface that casts is not shaded by it, so a glass does not darken itself; the tabletop, which lets a little of the reflections under it through, casts nothing and is shaded like any floor. + +## Pouring one tube into another + +There is an empty tube at the front of the bench. Pick a solution and press **Share with clean tube**: the tube rises over its neighbours, comes over the clean one, tips until it pours, and stops when both hold the same. Then it goes back to its place and rocks for a second after it is put down. + +Only the hand is the bench's. It lifts the tube, brings the lip over the clean tube's mouth from the side and tips it across the picture, about the axis the camera looks along; it used to tip towards the camera, and from the front a tube tipped at you only seems to rise and shorten. Everything the liquid does after that is the physics. + +The hand tips the glass to where what it holds below its lip is what is in it less the next eighth of a second's worth, so that much stands over the lip and runs. The flow over the lip is a weir's: over each stretch of the rim the surface stands above, C_d·(2/3)·√(2g)·depth^(3/2) per metre, with the measured C_d of 0.62. No head, no flow: tip the glass back and the liquid draws away from the edge and stops. The hand wants less as the pour nears half, as the root of what is left, so it ends with the glass at the edge of pouring. + +My first hand answered the flow it saw instead, and it was always late. A glass this small, nearly on its side, empties in the time it takes to notice. Steering by the volume ahead of it fixed that. + +What leaves the lip is a stream of parcels, each one step's flow, and its section anywhere is a parcel's volume over how far it travels in a step: the continuity equation, without being told. Its edges draw in under surface tension at the Taylor–Culick speed, so the sheet off the lip pulls into a thread within a couple of centimetres. A thread of water a millimetre across is unstable, and it breaks into drops after about forty diameters, close to five centimetres, which in a nine-centimetre tube is about where it does. Before it was life size the stream never broke; now the bottom of the pour is drops, as it is in a real one. Drops are particles (position-based fluids, with cohesion set so that pulling water apart costs 2σ per square metre), and they land in the clean tube and become its liquid. Where the stream meets glass it does not bounce, since water wets glass; it runs down as a rivulet. Poured slowly enough, it runs down the outside of the glass it came from, which is the teapot effect and the reason a chemist pours briskly. + +Everything that lands carries what was dissolved in it. Each solution is a dye at unit strength whose absorbance gives its colour over three centimetres, by Beer and Lambert; poured together, amounts add, and so do absorbances, so blue into orange comes out as dark as light through both would be, not as a paint mix of their colours. Nothing is lost on the way: what left one tube is in the other, in the air, or on the bench, to fifteen places. + +## Refraction, caustics and reflections -**The caustics are lights.** A column of liquid is a lens, and sunlight through it lands as a bright, tinted patch inside its own shadow. The engine traces no photons, so each vessel carries a small spot light of its solution's colour, aimed along the sunlight at the middle of the liquid's shadow. Light channels keep it on the bench: the glass and the liquids are on channel 0, the bench on channel 1, and the spot reaches only channel 1. Pouring moves the patch with the height of the column and makes it stronger. +**Refraction comes from the engine.** When a frame holds a material with transmission, the renderer draws the opaque scene first, copies it, and the liquids read that copy offset along the ray their index bends. It is screen-space, so it can only bend what is already on the screen, and one liquid does not see another through itself: both read the same copy. The glass is thin-walled and bends nothing, so it is composited over whatever is behind it, liquid included. -The engine lights each object with at most eight lights, which I found out by losing the sun's shadow. The bench has the sun and a fill, so eight caustics would make ten, and the sun was the one pushed out. Six fit; the measuring cylinder and the flask go without. +**The caustics come from the optics** described above: the bright line the liquid focuses is where the traced beams pile up on the bench. An earlier version put a small coloured spot light in each shadow instead, which looked right from a distance and was wrong in every detail, and cost a light per vessel out of the eight the engine gives an object. -**The reflections in the tabletop are geometry.** Screen-space reflections were the first try, and they reflected only the labels: the pass reads what the opaque pass drew, and the glass and the liquids come after it. A flat mirror, though, has an exact answer. Each liquid and label is drawn again, scaled by -1 in height so it hangs under the tabletop, flat and a little darker, and the top lets a twelfth of what is under it through. The copies are opaque, so they are drawn before the top and never sorted against it. Blended surfaces write no depth, so the top first laid itself over the lower half of every liquid; the liquids now write theirs. +**The reflections in the tabletop are geometry.** Screen-space reflections were the first try, and they reflected only the labels: the pass reads what the opaque pass drew, and the glass and the liquids come after it. A flat mirror, though, has an exact answer. Each liquid and label is drawn again, scaled by -1 in height so it hangs under the tabletop, flat and a little darker. The copies used to show through a top a twelfth transparent; now the top is opaque, for the refraction above, and the copies are laid over it at a twelfth of their strength with `CompareFunction.greater`, which draws them only where they are behind the top. That is where a mirror shows them, and nowhere else. ## Labels without a decal A lathe's u runs with the angle and its v with the distance along the profile. A label is therefore another strip of the same surface, just outside the glass, and its texture lands on it edge to edge with no stretching. Like a real label it goes more than half way round, so the paper shows from the side; the writing keeps to the middle, which faces the front. ```dart -LatheShape labelBand({double radius = 0.08, double wrap = 3.4}) => LatheShape( - profile: [Vector2(radius + 0.002, 0.52), Vector2(radius + 0.002, 0.66)], +LatheShape labelBand({double radius = 0.008, double wrap = 3.4}) => LatheShape( + profile: [Vector2(radius + 0.0002, 0.052), Vector2(radius + 0.0002, 0.066)], startAngle: pi / 2 - wrap / 2, sweepAngle: wrap, ); @@ -105,10 +158,11 @@ The camera is the engine's `OrbitController`, with tighter limits than its defau ## What it does not do yet -- **Real caustics.** The patches are placed, not traced: they do not sharpen into the bright line a real tube throws, and glass without liquid throws none. +- **Shadows of everything else.** The optics are worked out for surfaces of revolution under one sun. A vessel's light passing through another vessel is not followed, and a lamp casts ordinary shadows. - **The glass in the mirror.** The reflections carry the liquids and the labels; the glass itself is left out, since a copy of something nearly invisible is nearly invisible. -- **Movement.** When you pour, the surface should rock and settle. A meniscus that sways for a second after the level changes would do it. +- **Waves that break.** The modes are linear, so a hard enough jerk makes a surface that would in reality splash simply rock harder. +- **Speed.** The drops are worked out on the CPU, a few dozen at a time, and the slowest part of a pour is stepping them as finely as surface tension at this size needs: a third of a millisecond at most. ## Tested without a GPU -The package's tests check the profiles, that the label strip's UVs run edge to edge, the half turn, that pouring stays inside the glass, and a picture of the whole bench drawn by the software backend. That picture leaves the labels off, because text is rasterised by the platform and differs between machines; a separate test reads the label card's own pixels. +The package's tests check the profiles, that the label strip's UVs run edge to edge, the half turn, that filling stops at the glass's limit, that a tap's rings die away, that sharing pours half and loses nothing, that mixed dyes take the colour light through both would, and a picture of the whole bench drawn by the software backend. That picture leaves the labels off, because text is rasterised by the platform and differs between machines; a separate test reads the label card's own pixels. diff --git a/site/content/index.md b/site/content/index.md index d1ff2571d..f9f1a63f6 100644 --- a/site/content/index.md +++ b/site/content/index.md @@ -143,7 +143,7 @@ flowchart TB simp --> physics ``` -Three more packages are left out of the diagram on purpose, because none of them changes what an app may know. `pad_input` and `pointer_lock` are gamepad and mouse capture, read once per frame by `flutter3d_game`, and `flutter3d_conformance` is test-only: it is what a backend has to pass before it can appear in the table below. `flutter3d_app` makes one decision of its own, which device to open (web or native at compile time, and which of the two on each side at run time), so the conditional import an app needs is written once and not per project. [Assembling an application](/core/session/) walks through all of it with the real code that uses them. The rules the diagram states live outside any package, in `tool/structure.dart`: thirty-five checks that read source text and run before a build. +Three more packages are left out of the diagram on purpose, because none of them changes what an app may know. `pad_input` and `pointer_lock` are gamepad and mouse capture, read once per frame by `flutter3d_game`, and `flutter3d_conformance` is test-only: it is what a backend has to pass before it can appear in the table below. `flutter3d_app` makes one decision of its own, which device to open (web or native at compile time, and which of the two on each side at run time), so the conditional import an app needs is written once and not per project. [Assembling an application](/core/session/) walks through all of it with the real code that uses them. The rules the diagram states live outside any package, in `tool/structure.dart`: thirty-six checks that read source text and run before a build. Three of those rules hold the picture up: diff --git a/site/content/quickstart.md b/site/content/quickstart.md index ed7da3590..af7038761 100644 --- a/site/content/quickstart.md +++ b/site/content/quickstart.md @@ -25,7 +25,7 @@ This takes about fifteen minutes, from a fresh checkout to a lit mesh turning on ## Resolve the workspace -The repository is a [pub workspace](https://dart.dev/tools/pub/workspaces): one resolve covers all forty-one packages and twelve applications against a single lock file. Without it, packages that depend on each other by path drift apart at the first version bump, and the drift only shows up as an unbuildable checkout on somebody else's machine. +The repository is a [pub workspace](https://dart.dev/tools/pub/workspaces): one resolve covers all forty-two packages and twelve applications against a single lock file. Without it, packages that depend on each other by path drift apart at the first version bump, and the drift only shows up as an unbuildable checkout on somebody else's machine. ```bash git clone https://github.com/pleiondev/flutter3d.git @@ -91,7 +91,7 @@ tool/ci.sh # shaders, analyze, every test (cd packages/flutter3d_physics && dart test) # plain Dart, no Flutter needed ``` -There are 10789 tests across 42 packages and twelve applications, and only about thirty need a GPU: the Impeller half of the golden set. The other half renders through the software backend, so seventy-eight scenes stay checkable in a headless run. +There are 11490 tests across 44 packages and twelve applications, and only about thirty need a GPU: the Impeller half of the golden set. The other half renders through the software backend, so ninety-five scenes stay checkable in a headless run. ## Your own application @@ -167,6 +167,36 @@ Scene buildScene(GraphicsDevice device) { } ``` +### Or write the scene as widgets + +`flutter3d_app` has the same scene as widgets. `Scene3D` opens the device, makes the renderer and draws a frame, and its children become nodes of an ordinary `Scene`. A rebuild that keeps a widget's key keeps its node, so anything you set on the node imperatively survives it. + +```dart +import 'package:flutter/material.dart' hide Material; +import 'package:flutter3d/flutter3d.dart' hide Material; +import 'package:flutter3d_app/flutter3d_app.dart'; +import 'package:vector_math/vector_math.dart' show Vector3, Vector4; + +Widget ball({required bool polished}) => Scene3D( + children: [ + Camera3D(position: Vector3(0.0, 1.2, 3.5), target: Vector3.zero()), + Light3D.point(position: Vector3(2.0, 2.5, 2.0), intensity: 16.0, range: 20.0), + // Every Mesh3D below without a material of its own is drawn with this + // one. A rebuild writes the new values into the same material object. + Material3D( + key: const ValueKey('ball'), + baseColor: polished ? Vector4(0.25, 0.45, 0.9, 1.0) : Vector4(0.9, 0.42, 0.28, 1.0), + roughness: polished ? 0.1 : 0.35, + children: const [Mesh3D(shape: SphereShape(radius: 1.0))], + ), + ], +); +``` + +There are widgets for groups (`Node3D`), models with their animation (`Model3D`), lights, cameras, reflection probes (`ReflectionProbe3D`), decals (`Decal3D`), mirrors (`Mirror3D`, whose child meshes are its surfaces) and particles (`Particles3D`). `Contributor3D` adds any pass contributor for as long as it is in the tree. `example/lib/widgets_main.dart` is the runnable version of the snippet above. + +If your game already has its own loop and renderer, `SceneWidgets.mount` builds the same widgets into a scene you draw yourself, without a screen. The golden runner draws the `widget-scene` reference that way, on all four backends. +

    None of the shipped games open a device this way. This page hand-rolls GpuRenderBackend.create() and a bare Ticker because that is what happens underneath, but by the second game the same conditional import, frame surface and level lifecycle had been copy-pasted three times. Assembling an application covers the pattern the games use instead: flutter3d_app and flutter3d_game.

    diff --git a/site/content/reference/glossary.md b/site/content/reference/glossary.md index aaa35893a..e1f1ca56b 100644 --- a/site/content/reference/glossary.md +++ b/site/content/reference/glossary.md @@ -94,7 +94,7 @@ A level is JSON. These are its parts, and a game reads all of them through `Leve
    Golden
    A reference image a scene is compared against. There are three complete independent sets (Impeller, software and WebGL2), each held to zero differing pixels against its own, with a fourth being recorded for WebGPU
    Parity fixture
    One scene drawn by two backends and compared as a grid of average brightness. Answers "do these two draw the same picture", which a golden cannot
    Conformance
    The suite a backend has to pass before it counts as one. Split in two: what needs no shaders, and the rest
    -
    Structure rule
    One of thirty-five scans in tool/structure.dart. They read source text and hold the architecture: that a genre package stays out of another genre, that the documents' numbers are true
    +
    Structure rule
    One of thirty-six scans in tool/structure.dart. They read source text and hold the architecture: that a genre package stays out of another genre, that the documents' numbers are true
    ## Next diff --git a/site/content/reference/packages.md b/site/content/reference/packages.md index f7a0fc8f8..110ffe36f 100644 --- a/site/content/reference/packages.md +++ b/site/content/reference/packages.md @@ -60,12 +60,12 @@ A rasteriser written in Dart. `CpuDevice` implements the same HAL, plus PNG outp Not a fallback. Two hardware backends agreeing proves less than it looks like: both are driven by a C API and both rasterise on a GPU, so an assumption shared by graphics hardware would be invisible to the pair of them. This one shares nothing with either, no driver, no shading language, no command buffer. -It is how seventy-eight golden scenes are checkable with no GPU in the room, and it is a dev dependency of every game because three shipped bugs would have been caught by rendering a single frame in a test. It stopped being *only* a dev dependency once `flutter3d_app` started reaching for it as the runtime fallback when Impeller will not start, which makes it a real production path now, if a last-resort one. +It is how ninety-five golden scenes are checkable with no GPU in the room, and it is a dev dependency of every game because three shipped bugs would have been caught by rendering a single frame in a test. It stopped being *only* a dev dependency once `flutter3d_app` started reaching for it as the runtime fallback when Impeller will not start, which makes it a real production path now, if a last-resort one. ### `flutter3d_conformance` The suite any fourth backend would have to pass before it counted as one, plus the cross-backend comparison with per-scene budgets. -Two tiers, and the split is a correction. The library said it was shader-free as a whole, and that stopped being true the day a check needed a pipeline: thirty-one of the forty-one link stages and draw. `coreChecks` is what runs on clears, uploads and readback alone, so a backend can ask it before compiling a single shader; `shaderChecks` is the rest. A backend that believed the old promise would have met every one of those failures with nothing it could do about them yet. The phrasing here is the one `tool/structure.dart` holds to the lists themselves, so the sentence cannot go stale again without the scan saying so. +Two tiers, and the split is a correction. The library said it was shader-free as a whole, and that stopped being true the day a check needed a pipeline: thirty-two of the forty-two link stages and draw. `coreChecks` is what runs on clears, uploads and readback alone, so a backend can ask it before compiling a single shader; `shaderChecks` is the rest. A backend that believed the old promise would have met every one of those failures with nothing it could do about them yet. The phrasing here is the one `tool/structure.dart` holds to the lists themselves, so the sentence cannot go stale again without the scan saying so. → [Writing a HAL backend](/core/backends/) @@ -128,9 +128,9 @@ There is no package for this. The rules about how the repository is arranged (wh dart run tool/structure.dart ``` -Thirty-five rules, under a second, no `pub get` and no device: every one of them reads source text. They were a `boundaries_test.dart` in each package until thirteen packages of twenty-one turned out to have none, all thirteen clean and not one of them checked. A runner that walks `packages/` covers a package the day it exists. +Thirty-six rules, under a second, no `pub get` and no device: every one of them reads source text. They were a `boundaries_test.dart` in each package until thirteen packages of twenty-one turned out to have none, all thirteen clean and not one of them checked. A runner that walks `packages/` covers a package the day it exists. -The detectors prove they fire before a single file is scanned, and a broken detector stops the run rather than letting thirty-five green scans be reported behind it. See [Testing](/reference/testing/). +The detectors prove they fire before a single file is scanned, and a broken detector stops the run rather than letting thirty-six green scans be reported behind it. See [Testing](/reference/testing/). ## Assembling an application diff --git a/site/content/reference/testing.md b/site/content/reference/testing.md index b3c5852e0..7c634e97b 100644 --- a/site/content/reference/testing.md +++ b/site/content/reference/testing.md @@ -1,46 +1,47 @@ --- -description: Four independent golden sets, mutation-checking every new test, determinism and snapshots, and why only about thirty of 10789 tests need a GPU. +description: Four independent golden sets, mutation-checking every new test, determinism and snapshots, and why only about thirty of 11490 tests need a GPU. --- # Testing -10789 tests across 42 packages and nine applications, counted the same way the `the document says how many tests there are` rule does: a scan of every `test(`/`testWidgets(` call. The rule holds `ARCHITECTURE.md` §13, the README and this page to the answer. The README went on saying 1242 across thirteen packages for as long as nothing compared it with anything. About thirty need a GPU; the [architecture](/core/architecture/) is what keeps the number that low. +11490 tests across 44 packages and nine applications, counted the same way the `the document says how many tests there are` rule does: a scan of every `test(`/`testWidgets(` call. The rule holds `ARCHITECTURE.md` §13, the README and this page to the answer. The README went on saying 1242 across thirteen packages for as long as nothing compared it with anything. About thirty need a GPU; the [architecture](/core/architecture/) is what keeps the number that low. | Package | Tests | | Package | Tests | |---|---|---|---|---| -| `flutter3d` | 1616 | | | | +| `flutter3d` | 1666 | | | | | | | | `flutter3d_mesh` | 599 | | | | | `apps/flutter3d_modeler` | 1750 | -| `flutter3d_sim` | 579 | | `pad_input` | 67 | -| `flutter3d_lab` | 15 | | `flutter3d_core` | 735 | +| `flutter3d_sim` | 695 | | `pad_input` | 67 | +| `flutter3d_lab` | 15 | | `flutter3d_core` | 783 | | `flutter3d_lti` | 26 | | `apps/flutter3d_lab_pendulum` | 7 | | | | | `apps/flutter3d_lab_incident` | 13 | | `flutter3d_game_shooter` | 340 | | `flutter3d_audio_core` | 55 | | | | | `flutter3d_audio` | 4 | | `flutter3d_game_racing` | 223 | | `flutter3d_webgl` | 62 | -| `flutter3d_game_platformer` | 221 | | `flutter3d_hardware` | 63 | -| `apps/flutter3d_demo_platformer` | 199 | | `flutter3d_impeller` | 64 | -| `flutter3d_cpu` | 328 | | `apps/flutter3d_demo_strategy` | 50 | -| `apps/flutter3d_editor` | 215 | | `apps/flutter3d_demo_arcade` | 20 | +| `flutter3d_game_platformer` | 221 | | `flutter3d_hardware` | 70 | +| `apps/flutter3d_demo_platformer` | 213 | | `flutter3d_impeller` | 64 | +| `flutter3d_cpu` | 382 | | `apps/flutter3d_demo_strategy` | 50 | +| `apps/flutter3d_editor` | 234 | | `apps/flutter3d_demo_arcade` | 20 | | `apps/flutter3d_demo_racing` | 162 | | `pointer_lock` | 28 | -| `flutter3d_physics` | 187 | | `flutter3d_webgpu` | 193 | -| `flutter3d_game_strategy` | 133 | | `flutter3d_editor_mcp` | 28 | -| `apps/flutter3d_demo_river` | 53 | | `flutter3d_testing` | 27 | -| `flutter3d_editor_core` | 141 | | `flutter3d_editor_widgets` | 90 | -| `apps/flutter3d_demo_dungeon` | 115 | | `flutter3d_app` | 118 | -| `flutter3d_game` | 255 | | `flutter3d_shaders` | 5 | -| `flutter3d_particles` | 97 | | `flutter3d_stereo` | 50 | +| `flutter3d_physics` | 272 | | `flutter3d_webgpu` | 194 | +| `flutter3d_physics_native` | 91 | | | | +| `flutter3d_game_strategy` | 133 | | `flutter3d_editor_mcp` | 36 | +| `apps/flutter3d_demo_river` | 54 | | `flutter3d_testing` | 49 | +| `flutter3d_editor_core` | 144 | | `flutter3d_editor_widgets` | 91 | +| `apps/flutter3d_demo_dungeon` | 115 | | `flutter3d_app` | 184 | +| `flutter3d_game` | 325 | | `flutter3d_shaders` | 5 | +| `flutter3d_particles` | 99 | | `flutter3d_stereo` | 50 | | `flutter3d_model_core` | 1172 | | `flutter3d_model_mcp` | 196 | -| | | | `flutter3d_net` | 15 | +| `flutter3d_editor_play` | 28 | | `flutter3d_net` | 15 | | | | | `flutter3d_net_webrtc` | 2 | -| | | | `flutter3d_sim_mcp` | 25 | -| `flutter3d_mcp_kit` | 2 | | `flutter3d_build` | 127 | +| | | | `flutter3d_sim_mcp` | 26 | +| `flutter3d_mcp_kit` | 2 | | `flutter3d_build` | 136 | | | | | `apps/flutter3d_lesson_viewer` | 48 | | | | | `apps/flutter3d_stereo_lesson_viewer` | 6 | -| `flame_flutter3d` | 140 | | `apps/flutter3d_showcase` | 85 | +| `flame_flutter3d` | 140 | | `apps/flutter3d_showcase` | 88 | | `flame_multiplayer` | 9 | | `flame_multiplayer_dashwire` | 3 | -The rows sum to 10763 rather than 10789: the remaining 26 live in `packages/*/example/test`, which the count includes and this table does not, among them the two seeds a new project starts from, `packages/flutter3d_app/example` and `packages/flutter3d_game/example`. +The rows sum to 11462 rather than 11490: the remaining 26 live in `packages/*/example/test`, which the count includes and this table does not, among them the two seeds a new project starts from, `packages/flutter3d_app/example` and `packages/flutter3d_game/example`. `flutter3d_samples` is not in the table and has no `test/` at all: it is test data with two path constants over it, and other packages' decoder tests are what exercise it. `flutter3d_conformance` is missing for a different reason: it is invoked as a script harness rather than through `flutter test`, so it does not surface in a grep of `test(` calls either. See below for what that cost once. @@ -52,7 +53,7 @@ tool/ci.sh # shaders, analyze, every test ## Four independent golden sets, not one -Seventy-eight scenes are rendered four times: through Impeller, through the software rasteriser in `flutter3d_cpu`, through WebGL2, and through WebGPU, the last two in a driven browser. Each backend is held to zero differing pixels against its own set, with a per-channel tolerance of 8. +Ninety-five scenes are rendered four times: through Impeller, through the software rasteriser in `flutter3d_cpu`, through WebGL2, and through WebGPU, the last two in a driven browser. Each backend is held to zero differing pixels against its own set, with a per-channel tolerance of 8. The browser sets are recorded when a branch lands, not beside it (`golden_web.sh` holds one fixed port for the whole of its run), so a new scene is in the other sets for as long as that takes. Which scenes, and what they are waiting for, is `_provisional` in `flutter3d_webgl/test/cross_backend_test.dart`: the comparison is skipped with the reason printed instead of quietly missing, and the check beside it fails the moment a reference lands and the name is still there. @@ -65,7 +66,7 @@ The browser sets are recorded when a branch lands, not beside it (`golden_web.sh {{golden3 shadow-teapot | One scene, three sets: a GPU through Metal, a rasteriser written in Dart, and a browser. The pictures on this site are the Impeller set.}}
    -

    Independently written implementations agreeing is evidence; one implementation agreeing with itself is not. The software set also keeps seventy-eight scenes checkable in a headless run: recording the other two takes a GPU or a browser, but comparing the committed sets takes neither.

    +

    Independently written implementations agreeing is evidence; one implementation agreeing with itself is not. The software set also keeps ninety-five scenes checkable in a headless run: recording the other two takes a GPU or a browser, but comparing the committed sets takes neither.

    `cross_backend_test.dart` compares them with per-scene budgets, and any new backend has to pass `flutter3d_conformance` before it counts as one. @@ -153,6 +154,22 @@ What this settles: a run submitted to a server can be replayed bit for bit on a

    why: a digest and not a comparison. Two machines cannot compare their worlds by sending each other their worlds: a snapshot is tens of kilobytes and a run is thousands of steps. StateDigest is 32-bit FNV-1a taken over bits rather than text, with the multiply done in halves so that no intermediate passes 2^53 and a browser gets the same number. DigestTrace takes a checkpoint every so many steps and names the first one two runs disagree at, which turns "the replay diverged" into an interval to bisect.

    +## A replay test for your game + +The same tape is also the cheapest regression test a game can have. Play a level once with the recorder on, commit the `.f3drun`, and `testReplay` in `flutter3d_testing` plays it back on the software backend in CI: + +```dart +void main() { + testReplay( + 'test/tapes/ascent.f3drun', + start: Ascent.open, + goldensAt: [120, 600], + ); +} +``` + +`start` builds the game against the device and the input it is handed, and returns a `ReplaySubject`: four methods, `restore`, `step`, `save` and `frame`, plus the level's hash. At every checkpoint the tape holds (or only those named in `digestAt`) the digest of `save()` has to equal the recorded one, and a mismatch names the step and the last one that still agreed. At each step in `goldensAt` the frame is compared with `test/goldens/-.png`, recorded on the first local run. A level edited since the recording fails as exactly that, before a step is taken, rather than as a divergence at step 25 that sends somebody into the physics. That check found its first stale tape the day it was written: the platformer's sample on this site had been recorded before the level was last changed. `apps/flutter3d_demo_platformer/test/replay_test.dart` is the whole example, about a hundred lines with the comments. + ## What can be tested without a device Everything except the Impeller goldens. In practice that means: @@ -186,7 +203,7 @@ import 'package:flutter3d_game/testing.dart'; // creditGaps

    A package cannot import another package's test/, which is why there were two copies rather than one. lib/testing.dart is what a package can import.

    -`cpuTestDevice` stops short of building the `Renderer`, deliberately: `flutter3d_cpu` must not depend on `flutter3d`. A backend that could not be compiled without the engine would not be an implementation of an interface, it would be part of the engine. That is a rule, and one of the thirty-five checks it. +`cpuTestDevice` stops short of building the `Renderer`, deliberately: `flutter3d_cpu` must not depend on `flutter3d`. A backend that could not be compiled without the engine would not be an implementation of an interface, it would be part of the engine. That is a rule, and one of the thirty-six checks it. ## Play the game in a test @@ -238,7 +255,7 @@ They ask how the code is *arranged*: who imports what, what a name says, where a dart run tool/structure.dart ``` -Thirty-five rules, under a second. Nothing they read needs `pub get`, a shader bundle or a device, so finding out in minute four that a package imports a genre was finding out late what was knowable in second one. +Thirty-six rules, under a second. Nothing they read needs `pub get`, a shader bundle or a device, so finding out in minute four that a package imports a genre was finding out late what was knowable in second one. | Rule | What it refuses | |---|---| @@ -256,7 +273,7 @@ Thirty-five rules, under a second. Nothing they read needs `pub get`, a shader b | `every exemption names a file that is there` | An allowlist entry whose file has moved, or whose case only resolves on macOS | | `the compiled shader bundle is not older than its sources` | A bundle built before the GLSL was edited, which fails as `failed to bind texture` rather than as a shader behaving oddly | -Twenty-two more check the lists against the workspace, a plain Dart package for a dependency (its own, or a sibling's) that would resolve the Flutter SDK, every pubspec's floors and sibling constraints, a package for a dependency on an application, a simulation package for an import of Flutter, the hardware layer for one of its own, the applications for a silenced `print` and for the flag that turns the GPU on, the Impeller runners (conformance and the surface probe) for rot, every picture this site shows for a golden that is actually recorded, the publishing order for a package it forgot, a public member nothing calls for a sentence saying who it is for, the shader table on the backends page for a stage a bundle must answer to, a vertex stage for a `texelFetch` that aborts impellerc with no diagnostic, the surface buffer for the depth-in-metres channel four passes read and no upstream filing may tempt anybody to drop, every package's agent skills for a directory the skills CLI would install for nobody without saying so, and five numbers that go stale on their own: the test count, the golden scene count, the structure-rule count, the number of checks the conformance suite says it runs, and the number of enums the hardware layer promises not to rename, each compared against the tree; plus one rule that refuses an enum in a published package unless a table says why it is machinery. A number in prose is a number nobody recounts, so the counting rules read this site's pages too, and the sentence you are reading is one of them: the rule counts the table above and requires the rest to be the rest. +Twenty-three more check the lists against the workspace, a plain Dart package for a dependency (its own, or a sibling's) that would resolve the Flutter SDK, every pubspec's floors and sibling constraints, a package for a dependency on an application, a simulation package for an import of Flutter, the hardware layer for one of its own, the applications for a silenced `print` and for the flag that turns the GPU on, the Impeller runners (conformance and the surface probe) for rot, every picture this site shows for a golden that is actually recorded, the publishing order for a package it forgot, a public member nothing calls for a sentence saying who it is for, the shader table on the backends page for a stage a bundle must answer to, a vertex stage for a `texelFetch` that aborts impellerc with no diagnostic, the surface buffer for the depth-in-metres channel four passes read and no upstream filing may tempt anybody to drop, every package's agent skills for a directory the skills CLI would install for nobody without saying so, a package that says it runs on the web for a `dart:io`, `dart:isolate` or `dart:ffi` its browser build would still reach — which is how pub.dev decides, and five numbers that go stale on their own: the test count, the golden scene count, the structure-rule count, the number of checks the conformance suite says it runs, and the number of enums the hardware layer promises not to rename, each compared against the tree; plus one rule that refuses an enum in a published package unless a table says why it is machinery. A number in prose is a number nobody recounts, so the counting rules read this site's pages too, and the sentence you are reading is one of them: the rule counts the table above and requires the rest to be the rest.

    These were a boundaries_test.dart in each package, and thirteen packages of twenty-one had none: all thirteen clean, and not one of them checked. A runner that walks packages/ itself covers a package the day it exists rather than the day somebody remembers to add a file to it.

    diff --git a/site/tool/build.mjs b/site/tool/build.mjs index c73452ee3..e0e2f1206 100644 --- a/site/tool/build.mjs +++ b/site/tool/build.mjs @@ -163,6 +163,7 @@ const NAV = [ { file: 'core/assets.md', url: '/core/assets/', title: 'Assets & animation' }, { file: 'core/simulation.md', url: '/core/simulation/', title: 'Simulation layer' }, { file: 'core/physics.md', url: '/core/physics/', title: 'Collision & physics' }, + { file: 'core/liquids.md', url: '/core/liquids/', title: 'Liquids' }, { file: 'core/extras.md', url: '/core/extras/', title: 'Particles & audio' }, { file: 'core/tutorial.md', url: '/core/tutorial/', title: 'Tutorial: first scene', kind: 'tutorial' }, { file: 'core/session.md', url: '/core/session/', title: 'Assembling an application', kind: 'guide' }, diff --git a/tasks/0.9-engine-roadmap.md b/tasks/0.9-engine-roadmap.md new file mode 100644 index 000000000..ee7da02a2 --- /dev/null +++ b/tasks/0.9-engine-roadmap.md @@ -0,0 +1,1682 @@ +# flutter3d 0.9 — parity and past it + +Date: 2026-09-30. Branch `0.9.0`, cut from `river-0.8.3` at `56828864` +(0.8.4 published and tagged at `a796ecab`). Written from four parallel +research passes, each made to read code rather than READMEs: a feature +inventory of flutter_scene at its published 0.23.0 and at `master` (`b473543`, +an unreleased 0.24.0), an inventory of this repository, a study of hot reload +in flutter_scene, in Flutter 3.47 and here, and a search for features +flutter_scene does not have. Then narrowed by the owner's answers to twenty +questions (§1), and four more the same day (decisions 21–24). + +The previous plans stay the record of 0.8: [`0.8-engine-roadmap.md`](0.8-engine-roadmap.md) +and [`engine_0_8_plan.md`](engine_0_8_plan.md). What they left open is folded +in below (§8), and `doc/engine-gap-analysis.md`'s milestones M1–M5 are the +source of several items here. + +## 0. In short + +1. **0.9.0 catches up with flutter_scene as it will be when it ships its + 0.24**, not with the 0.23 on pub.dev: hot reload of everything, rotating + bodies and joints, a declarative widget API, the renderer's missing rows, + user materials, and an editor that runs the game. +2. **And it adds twelve things flutter_scene does not have**, most of them in + the simulation, where four backends agree without trying: an animation + graph, navmeshes, behaviour trees, a sequencer, a time-travel debugger, + replay tests for game developers, saves, telemetry, a photo mode, colour + accessibility, sharing, procedural generation. Plus voxels and networking + past two players, which were out of scope until now. +3. **Hot reload and Play in the editor come first.** It is the most visible + gap and the one with the cheapest start: a shader edit today updates the + library and never the picture, and fixing that is one call. +4. **One release.** 0.9.0 goes out when all of it is in (the owner's call; §10 + says what that costs). Every effect still lands off by default until its + goldens exist in all four sets. + +## 1. Decisions (2026-09-30) + +| # | Question | Decision | +|---|---|---| +| 1 | Which flutter_scene to match | Its `master`, i.e. 0.24 | +| 2 | Hot reload scope | All five tiers (§3) | +| 3 | Physics | Rotation, inertia, joints and CCD in pure Dart; no native backend | +| 4 | Declarative widget API | Yes, full | +| 5 | Renderer gaps | SMAA, lens flare and distortion, `.cube` LUT; projected decals; planar reflections and render-to-texture; physical sky; debug views and render stats | +| 6 | User materials | Compile our own material language to every GPU backend | +| 7 | Editor | Play with hot reload; Windows and Linux; docking panels, inspector, console, palette, render-graph view; published | +| 8 | Agent tooling | Render inspection in a running game; build, run and reload from MCP; skills in packages; desync bisect by an agent | +| 9–11 | New features | All twelve candidates (§5) | +| 12 | Old exclusions lifted | Behaviour on objects, level generators, voxels, networking past two | +| 13 | Release | One large 0.9.0 | +| 14 | First in line | Hot reload and editor Play | +| 15 | Networking past two | Both models: rollback for N players and an authoritative server | +| 16 | Platforms checked live | Windows and Linux; Android with measured numbers on the Galaxy A55; WebGPU as the browser default | +| 17 | Branch | `0.9.0` from `river-0.8.3`, now | +| 18 | Four backends agree | Kept, no exceptions | +| 19 | Comparison page | Fix what is stale now; revise it once for 0.9.0 | +| 20 | Voxels | A `flutter3d_voxel` package plus a demo | +| 21 | Lighting hooks in user materials | In 0.9.0, after surface outputs work on all four backends | +| 22 | Replication for the authoritative model | Our own, in `flame_multiplayer`, over `PeerWire` and our snapshots | +| 23 | Service for sharing and telemetry | Adapters in the packages plus a reference server in `cloud/`; our demos use our instance | +| 24 | Editor publication | Web build (without Play of another project) plus desktop downloads with full Play | + +Not chosen, and so not in this plan: iOS on a device (still simulator only). + +## 2. Constraints, unchanged since 0.8 + +- No compute on flutter_gpu 3.47 (flutter#188474, #188480), none on WebGL2. + Compute runs on WebGPU and the CPU backend; `FieldPass` is the substitute + elsewhere. +- Shaders are compiled ahead of time and a bundle is tied to its SDK. There + is no runtime shader compiler on device, which shapes §4.2 and §3.4. +- One render pass per command buffer; depth cannot be sampled; MRT is probed; + the surface buffer turns MSAA off; readback is one or two frames late and + RGBA8. +- No GPU timers on Impeller or WebGL. +- The simulation step uses `Portable` maths and no isolates, so everything in + §5 that runs in the step is deterministic by construction and tested by + state digest. +- Four backends agree, and every effect has a CPU mirror (decision 18). + +## 3. Hot reload — first + +flutter_scene's hot reload (`lib/src/hot_reload/hot_reload_coordinator.dart`) +rides Flutter's own: `SceneView.reassemble()` calls a debug-only coordinator +that re-hashes registered assets and reloads, in order, shader bundles, `.fmat` +sidecars, scenes (patched in place by a node-id diff), textures and +environments. New bytes arrive because `flutter run` reruns build hooks on hot +reload (flutter_tools `run_hot.dart:486-500`). Its editor saves a scene and +calls `ext.flutter_scene.reloadScene`; on a device, where the source root is +not visible, that falls back to a hot restart and the game state is lost. Its +inspector sliders preview only inside the editor. + +What this repository already has: + +- `LoadedShaderLibrary.refresh` keeps handle identity + (`flutter3d_hardware/lib/src/shader.dart:133-175`), implemented on Impeller + with `reinitializeFromBytes` and on WebGL with mutable handles. + `Renderer.relinkShaders()` clears the pipeline caches + (`flutter3d_core/.../render/renderer.dart:855-900`). +- The engine bundle is loaded with `ShaderLibrary.fromAsset` + (`flutter3d_impeller/lib/src/gpu_device.dart:113-119`), which Flutter 3.47 + already reinitializes on hot reload. **Nothing calls `relinkShaders` then**, + so the library changes and the frame does not. +- `flutter3d_build` declares every source as a hook dependency + (`build_assets.dart:337-390`), so hooks already rerun on hot reload; the + runtime just never re-reads their output. +- `ext.flutter3d.timeline.*` (pause, step, rewind, branch, history) and the + editor's `timeline_client.dart` are a live link to a running game already. + +**HR1. Shaders and Dart code without losing the world (S).** A debug-only +`HotSwap` in `flutter3d_app` (not `Reload…`: CONTRIBUTING.md keeps `reload` +out of the packages as a weapon's word) with WeakReference registrations and +injected file access, so its logic is plain unit tests. `SceneSurface`, which +the Flame bridge draws through too, overrides `reassemble()` and calls it; it +is also reachable as `ext.flutter3d.hotSwap`. On every swap: `relinkShaders()`, +which now reaches contributors through `PassContributor.relinkShaders` +(particles, mesh particles, splats, the debug overlay). +Custom `.f3dshaders` loaded from bytes are refreshed by the coordinator, which +also covers the web, where flutter_tools does not reinitialize bundles. +Documented limit: a stage newly added under a name already looked up needs a +restart (`gpu_loaded_shaders.dart:62-69`). +*Tests:* refresh then relink draws the new colour on every backend; a refused +bundle keeps the old frame. + +**HR2. Models, textures, environments (M).** *Models done:* +`HotSwap.loadModel` reads the converted `.f3d` itself (the device class's +file first, as `loadModelAsset` does), fingerprints it, and a swap after the +file changed rebuilds the asset and has every instance made through the +returned `SwappableModel` `adopt` it: `ModelInstance.adopt` matches surfaces +by node name and slot, hands each matched `MeshNode` the new geometry and +material and keeps the nodes, so transforms, animation and whatever the game +hung from them survive. What the new file adds or drops is reported, not +guessed at. `ext.flutter3d.assets.put {path, bytes}` does the same from bytes +sent over the VM service, so a phone gets the new model with no restart; the +file on disk wins again once it changes. A bundle or model that does not build +keeps the last version that did. +*Textures done, without touching the backends:* `HotSwap.loadTexture` and +`registerTexture` keep a texture with the file it came from, and a swap +after the file changed uploads it again as a texture of its own, puts it in +every slot of every material in the registered scenes (and a scene's +environment) that held the old one, and hands the old one to +`Renderer.releaseTextureAfterFrame`. So size, format and mips may all change; +writing into the old handle would have left its smaller mips showing the old +picture, and mutable handles across four backends were not needed after all. +`SwappableTexture.changes` is for holders outside a material. +`LevelLoader` registers every map a level names and keeps +`materialTextures` in step; `LoadedLevel.dispose` stops watching them. +`ext.flutter3d.assets.put` takes an image too. +*Environments done:* `HotSwap.loadEnvironment` reads a panorama asset (a +`.hdr` by its header, anything else through `defaultImageDecoder`, joined in +`EnvironmentMap.fromEncoded`), prefilters it, and registers the result. The +cube's roughest level is its irradiance, so one build makes both. A swap +after the file changed prefilters it again at the size and level count it +was loaded with, puts the new cube on every registered scene whose +environment was the old one and sets `environmentLevels` with it, since the +shader reads roughness against that count; the old cube goes through +`releaseTextureAfterFrame`. `registerEnvironment` takes one built some other +way, `SwappableEnvironment.changes` is for holders outside a scene, and +`assets.put` takes a panorama. The prefilter runs where the swap runs, which +at the default thirty-two is a fraction of a millisecond. +*Left:* a model's embedded images, which swap with the model; nothing loads +a level's or a project's panorama through `loadEnvironment` yet (`LevelLoader` +names no sky file, and the modeller's `PanoramaSync` rebuilds from the +document, not from a file); a reflection probe's cube is drawn on the device +and is not a file. + +**HR3. A level from the editor into a running game (L).** *Done:* saving in +the editor while `PlayScreen` has the project running sends the document and +its `Level.digestHex` to `ext.flutter3d.level.apply`, on the isolate that +registered it. The game's `LiveLevel` checks the digest (a mismatch is +refused, naming both), and `diffLevel` splits the change: lights, materials, +fog and music go to the game's `present`; brushes, entities, the ground, +recipes and `next` go through `RunTimeline.swapLevel`, which restores the last +keyframe, runs the game's `rebuild`, replays the recorded input to now and +rebases the rewind buffer there. `TimelineLevelSwapped(step, digest)` lands in +the history (`level::` over the wire). A game with no timeline +is rebuilt in place and the answer says so. There are no entity ids in the +format, so parts are compared by document, conservatively: a brush that only +changed material is still the simulation's. +*Tests:* classifier units; a run swapped at step K arrives where a fresh run +with the new level from K does; the extension end to end over a real VM +service (`run_timeline_extensions_test.dart`). +The platformer demo takes edits: `PlatformerRun.prepareEdit` builds the +edited document ahead (textures, meshes, a simulation of its own, through +`LiveLevel(prepare:)`), `RunSession.replaceLevel` swaps it in inside the +replay with the run carried over, and the widget hears of it once the run is +back at now. A document for another level, or one that does not build, is +refused and the level on screen stays. +A saved `.f3drun` replays the swap. `Demo.levelSwaps` holds each edit as the +step it took effect before and the whole document, checked against its hash +on reading; a file with swaps is format 2, so an older build refuses it, and +one without is still 1. `DemoRecording` is the run being written down, and +`LiveLevel(swapped:)` hands it the timeline's step, which `levelSwapped` +turns into a step on its own tape, dropping a swap and the checkpoints the +new one lived again. `replayDemo` plays a file with the swaps in place and +compares checkpoints by step. The platformer writes edits into the demo it +is recording instead of starting another (`PlatformerRun.onLevelEdited`); +an edit that took effect before the demo began, within a keyframe of the +load, starts the demo again from now with the edit at step 0. +`PlatformerRun.replay` builds every edited level before the first step and +swaps them in through `replaceLevel`, as live. +*Tests:* in the platformer, a run edited at step 150 (swapped at the +keyframe at 120), saved through JSON and replayed in a game of its own, +arrives at the live run's bytes and keeps to every checkpoint, and the same +file with the swap removed ends elsewhere (`demo_level_swap_test.dart`); the +recording's step arithmetic, a second edit within a keyframe replacing the +first, and the refusals on a toy (`demo_recording_test.dart`); the format's +round trip and refusals (`flutter3d_sim/test/demo_test.dart`). +A save after a save goes as a patch. `LevelPatch.between` names a row of +brushes, lights or entities by its place and the digest of what it was +(ids in the format were refused: every document would grow a key per row, +recipes expand without them, and the editor's whole-document undo would +have to keep them stable); rows are aligned by digest, so a brush deleted +from the middle is one edit. Materials go by name, every other key whole. +`ext.flutter3d.level.patch` applies it only to the level it was made +against and only when the result digests to what the editor saved, and +hands `LiveLevel` the patch's own diff; a refusal comes back as +`LevelPatch.staleCode`, and `pushLevel(base:)` then sends the whole +document and says why in the status strip. The editor's base is the +document it last read or wrote; a game started from another version costs +one refusal. +*Tests:* a random shuffle of rows always patches to the edited level; a +row that is not the one named, and a level that is not the base, are +refused; a stale patch is told apart from a level that does not build; the +patch and the stale code over a real VM service. +*Left:* scrubbing across a swap in a loaded `.f3drun` (`rewindBufferFromDemo` +refuses one, since its keyframes do not know which level they belong to); a `.f3drun` with swaps verified by +`flutter3d_sim_mcp`, which refuses one because it has no game to build the +edited level with; a look-only edit is not written into the demo, so the +replay is right in what it simulates and shows the old lights and fog; a +branch through the timeline (`releaseAt`) still leaves the demo's tape on +the old future; the editor MCP driving the same path (its +`PlaySession.sendSaved` still sends whole documents, and has no base to +patch from); the platformer's `prepareEdit` still builds the whole edited +level, so a patch saves the wire and the comparison, not the build. + +**HR4. Live parameters and replay under new code (S–M).** *Done:* +`ext.flutter3d.material.set {name, fields}` sets base colour, emissive, +roughness, metallic, normal scale or alpha cutoff on every material of that +name in the scenes `SceneSurface` registers, next frame, without a save; the +values are kept as overrides and put back after every model swap, and +`clearMaterial` drops them. `ext.flutter3d.cvar.set {name, value}` goes +through `InputState.tune`, so a tunable changed mid-run is on the tape +(`InputFrame.tunes`), taken by `Tunables` before the step reads it and kept +in the snapshot; `cvar.list` answers values and defaults. +`RunTimeline.replayUnderNewCode` (over the wire, +`ext.flutter3d.timeline.replayUnderNewCode {seconds}`) lives the last seconds +again from the nearest keyframe under the code running now and names where +that run first parts from the old one: the field, and the two keyframes it +parted between, since the old run is only held at keyframes. +(`reload` stays out of the names: CONTRIBUTING.md keeps it for weapons.) +The editor's material panel (a palette button beside the step panel; it had +never been mounted) sends every value a slider passes, and every value +written, to the game Play is running: in the engine's words +(`liveMaterialFields`), over one kept VM service connection, merged and at +most thirty times a second with the last value always sent. +`replayAfterHotSwap` replays the last seconds after every `HotSwap` and +reports where the new code parts from the old; the platformer calls it. +`.fmat` parameters take the same path: `material.set` accepts +`parameters/` and writes into the list `Material.parameters` already +holds, which the renderer binds every frame; a parameter the material was +not loaded with, or one of another length, is refused naming what it has, +since a member the compiled block lacks would throw on every frame. The +panel's parameter rows send while the thumb moves (`liveParameterFields`), +and a material that defers to a file no longer sends the level's own +fields, which the next load would undo. `LevelLoader` binds a deferred +`.fmat` under the level's name for it (`bindMaterial(name:)`), the name the +editor sends; under the file's own it was unreachable. +*Left:* the `.fmat`'s own surface fields (its base colour, roughness and so +on, under the panel's file heading) are written to the file but not sent +live; a parameter the shader gained since the game loaded needs a reload, +since it is refused rather than added; and no test drives the editor and a +running game together — the panel, the throttle and the game's half are +each tested, the VM service connection between them (`GameMaterials`) is +not. + +**HR5. Editor Play (M), with §6.** *Done on desktop:* the toolbar's play +button finds the project above the open level (the nearest `pubspec.yaml`) +and runs it with `flutter run --machine`; `PlayScreen` shows the console and +drives start, stop, hot reload and hot restart through the daemon protocol, +and opens the timeline panel on the VM service the run reports. The run +belongs to the editor, not the panel, so closing the panel leaves the game +running. The protocol was checked against a real run of +`packages/flutter3d/example` on macOS (`app.start`, `app.debugPort` with +`wsUri`, `app.restart` answering `{code, message}`, `app.stop`). +Play moved into a package of its own, `flutter3d_editor_play` (plain Dart +with `dart:io`, `Watched` in place of `ValueNotifier`), so the editor's MCP +server runs the same code: `play`, `play_status`, `play_swap`, `play_stop` +and `play_devices`, and a `save` while the game runs sends it the level. +The panel picks the device between runs from `flutter devices --machine`. +*Done without a process:* `AttachedRun` plays a game somebody else started, +by its VM service address, and `package:flutter3d_editor_play/attach.dart` +is that half of Play with no `dart:io` under it (a test walks the imports). +The console comes off the `Stdout`, `Stderr` and `Logging` streams, with what +the game printed before the attach, which DDS replays; hot reload and restart +call the `reloadSources` and `hotRestart` services the flutter tool +registers on the game, as DevTools does, and say what to run when no tool is +connected; stop is a detach. `connectVmService` (over `web_socket_channel`) +replaces `vmServiceConnectUri` in `pushLevel`, the timeline client and the +live materials, so a save, the timeline and a dragged material reach the +game from a browser too. `PlayScreen` shows any `PlayedGame` and imports +only `attach.dart`, the device picker moved to a file of its own, and the +editor's attach button now opens it rather than only the timeline. Checked +against a real macOS game from the Dart VM and from Chrome on a `localhost` +page: attach, console with history, swap (new code after restart), restart, +the level call answered, detach with the game still running. +*Left:* the web build of the editor itself (P11: a web backend, documents +without the disk, `main.dart`'s `dart:io` behind the Play wiring); and from +an `https` page, a `ws://127.0.0.1` socket is mixed content, which browsers +treat differently for loopback; only a `localhost` page has been checked, +and the published web editor needs it checked from its own address. + +## 4. Parity with flutter_scene 0.24 + +### 4.1 Renderer + +| Id | Item | Theirs | Ours today | Effort | +|---|---|---|---|---| +| P1 | SMAA 1x | `render/smaa_pass.dart` | done, off by default; see below | M | +| P2 | Lens flare, lens distortion, `.cube` LUT import | `bloom_pass.dart` flares, `screen_distortion.dart`, `post_process/color_lut.dart` | done, off by default; see below | S–M | +| P3 | Projected box decals painting a material | `decal.dart` (0.24) | done, off by default; see below | M | +| P4 | Planar reflections; public render-to-texture camera | `components/planar_reflector_component.dart`, `render_texture.dart` | done, reflections off by default; see below | M | +| P5 | Physical sky (atmospheric scattering), stars, height fog | `sky_sources.dart` `PhysicalSkySource` | done, off by default; see below | M | +| P6 | Debug views (material channels, UV, NaN, split wipe) and render stats | `render/debug_view.dart`, `render_stats.dart` (0.24) | done, off by default; see below | S | +| P7 | Orthographic everywhere, per-draw ranges, alpha to coverage, render order | 0.24 commits | done | S | + +**P1 done.** `AntiAliasSettings(method: EdgeSmoothing.smaa)`: edges, area +weights and neighbourhood blend as three stages on all four backends, the +software ones mirrored line for line, the orthogonal area table generated by +`tool/make_tables.dart` as the engine's `smaaArea`. `smaa-teapot` is recorded +in all four sets (WebGPU matches Impeller to the pixel, WebGL2 at 0.59% and +the software set at 0.778%, all on edges; the software reference was first +recorded from an Impeller build — `golden.sh --cpu --no-build` after an +Impeller build draws with Impeller — and re-recorded from a software one). *Left:* the diagonal search and corner rounding of the paper, +and SMAA's 2x and 4x modes, which ride on the temporal jitter. + +**P2 done.** `LookSettings.distortion` (radial, barrel or pincushion, held +on the frame's border; everything over the scene bends with it, the film +does not), `BloomSettings.lensFlare` (ghosts and a halo drawn from the glow, +added to it before the composite) and `CubeLut` (`.cube`, 3D or 1D with a +domain, into the composite's strip, `upload` onto a device). `lens-flare` +and `lens-distortion` are recorded in all four sets (the software ones +re-recorded from a software build, at 0.028% and 0.725% on edges). *Left:* the +anamorphic streak, and a starburst from an aperture texture. + +**P3. Projected decals.** *Done:* a `DecalNode` is its node's unit cube and +paints a picture onto whatever geometry stands inside it, stamped down the +box's y axis: a texture or none, an sRGB tint with an opacity, an emission, a +region of an atlas, an `order`, and an angle limit with a fade past which a +surface turned away from the box's up is left alone, plus a fade at the top +and bottom faces. `RenderSettings.decals` is off by default. Depth cannot be +sampled, so `post/decal.frag` takes the point under each pixel from the +surface buffer, as reflections and occlusion do, and the light that point was +lit by from the lit colour over the albedo buffer: the decal's colour is laid +under that light, so it sits in shadows, under coloured lamps and in the +irradiance as the surface did. A fullscreen pass per batch of sixteen decals +and four pictures, two draws each (a multiplied factor, an added term for +unlit surfaces and emission), scissored to the boxes' screen rectangle, +drawn per view with each view's camera. Order is exact inside a batch. The +pass runs between the opaque half of the scene and the transparent one, +splitting the frame as glass does, so glass in front of a decal is drawn +over it. The mip level is chosen from neighbouring texels of the surface +buffer rather than `dFdx`, so the software backend picks the same one. CPU +mirror `DecalShader`; `decal_test.dart` (twelve claims, among them the ratio +to the light in and out of a shadow, the angle limit, order, batches past +sixteen and four, an unlit floor, emission, glass, and a decal in the top +rows the scissor must not cut); the binding contract on +`FakeBackend`; the `decal-floor` golden in all four sets, WebGL and WebGPU at +0 of 172800 pixels from Impeller and the software set at 0.067%. +*Left:* decals that change the normal or the roughness (the light is read +back, not recomputed, so a normal map would need the surface buffer written +again and the surface lit again); decals on glass and other transparent +draws, which are drawn after the pass; exact order across batches (a second +batch swaps from the surface's albedo, not the first batch's); a surface that +wrote no albedo gets the decal at its own colour, and the surface's highlight, +emission and fog are scaled with the light it borrowed, both stated in +`decal.frag`; a device with two colour attachments draws none; a layer mask +per decal; decals in the level format, the editor and the MCP tools; the +comparison page row, with the 0.9.0 revision (decision 19). +**P4. Planar reflections and a camera into a texture.** *Done:* one pass, +`_captureView` in `renderer_planar_pass.dart`, draws the scene's meshes into +a 2D target through any matrix, with the frame's lights and shadows and the +sky behind them, culled by the frustum and a layer mask: the reflection +probe's capture, generalised. Two things point through it, as two graph +nodes before `scene` (`render textures`, `planar reflections`), each keeping +one name the scene optionally reads so they are ordered before what shows +them. +A `PlanarReflectorNode` is a plane (its origin, local +Y the front) and the +meshes lying in it. Per view that sees the front, the view's camera is +mirrored in the plane (`mirrorAcrossPlane`) and its near plane moved onto +the plane by Lengyel's oblique frustum (`obliqueNearPlane`), since no stage +has a clip distance: the rasteriser's near clip makes the cut, and the far +plane tilts. The picture, linear light at `resolution` of the view (half by +default), is laid over the surfaces inside the scene pass straight after +the opaque half, through `x-ray`'s override path with the new +`PlanarReflection` stage: depth `lessEqual`, no depth write, one output so +the surface buffer keeps describing the surface, read by the fragment's +place in its view, weighted by Schlick's Fresnel from `reflectance`, +tinted, fogged as the surface is. `RenderSettings.planarReflections` is off +by default (decision 18 and §10; the cost is a second scene per view). +`RenderTexture.create` makes an RGBA8 texture for a camera; added with +`Scene.addRenderTexture` it is drawn every frame, or once and after +`invalidate()`, then encoded by `RenderTextureEncode` into sRGB with +`exposure` and no tone curve, top row first on every backend (WebGL2 turns +its rows over), so a material's albedo or emissive slot shows it in the +frame it was taken. It is opt-in by construction: a scene without one culls +the pass. +The software rasteriser now drops fragments outside the depth range, as +GPUs clip them; it clipped at `w` only, and the oblique near plane was the +first to show it. No golden of any set moved. +CPU mirrors `PlanarReflectionShader`, `RenderTextureEncodeShader`; +`planar_reflection_test.dart` (ten claims: off by default, hidden reflector, +a mirror equals the world mirrored and seen directly, nothing below the +plane reflected, water's Fresnel by angle, a camera behind the plane, a +render texture's sRGB, shown in the same frame, refresh once and on asking, +no pass without one); `mirror_view_test.dart` (the reflection and the +oblique projection); the binding contract on `FakeBackend`; the linking +check. Goldens `planar-mirror` and `render-texture` in all four sets: +WebGPU at 0 of 172800 pixels from Impeller, WebGL at 0.17% and 0.19%, the +software set at 0.84% and 0.96%, all of it single pixels on silhouette +edges, where those two do not multisample as Impeller does. +*Left:* the reflection is one mirror deep (nothing is reflected in a +reflection) and holds no particles, splats or anything else a contributor +draws, nor decals or post effects; a blended surface draws over its +reflection rather than under it; no roughness blur or normal-map +distortion of the picture; a reflector's surfaces must lie in its plane, +which nothing checks; one picture per reflector per view, so a split screen +pays per view; a render texture has no post chain, no multisampling and no +mips, and its aspect is the texture's; reflectors and render textures in +the level format, the editor and the MCP tools; the comparison page row, +with the 0.9.0 revision (decision 19). + +**P5. Physical sky, stars, height fog.** *Done:* `SkySettings.physical` +takes a `PhysicalSky` (molecular and haze scattering with their scale +heights, haze absorption and anisotropy, planet and air radii, the eye's +altitude, the sunlight entering the air, a ground albedo, and the stars' +brightness, density and grid), beside the gradient and the cube map, which +still wins; null, the default, draws the gradient. `sky_physical.frag` marches +single scattering per pixel, sixteen view samples spaced quadratically and +eight towards the sun from each, in kilometres, with the planet's shadow; the +disc is drawn white through the air in front of it, so it reddens and goes +out by itself; the ground below the horizon is lit by what the air leaves of +the sun; stars on a hashed cube grid (no sine, so every backend puts them in +the same cells) fade in from the sun six degrees up to eleven below. The air +travels on the vertices like the gradient's preset, same stride. +`SkySettings.sample` answers from the Dart copy (`PhysicalSky.look`) without +the stars, so `EnvironmentMap.fromSky` and the hemispheric ambient follow the +sun (the ambient is kept while the air and the sun do not change); +`PhysicalSky.sunlight` gives a directional light its colour. Height fog: +`FogSettings.heightFalloff` and `baseHeight`, integrated along the ray in +closed form in `ApplyFog` (density at the eye in `fog.w`, falloff in the +spare `eye.w`), nought being the flat fog to the bit; `Atmosphere` carries +and blends both. CPU mirrors `SkyPhysicalShader` (the stars' hash rounded to +single precision) and `applyFog`; `sky_physical_test.dart` (eleven claims: +off draws nothing, the software stage against the model in four directions, +noon blue and dusk red, the disc reddening, the ground lit and dark, stars by +night only, no stars in `sample`, the vertex depth, and for the fog the +density law, the closed form against a numerical integral, and nought being +flat); the `sky-physical-dusk` and `sky-physical-night` goldens in all four +sets, WebGPU at 0 of 172800 pixels from Impeller, WebGL at 11 and 0, the +software set at 0.451% (silhouettes and the floor's far edge, which Impeller +multisamples) and 0.028%. +*Left:* switching the physical sky and the height fog on by default: the +goldens now exist in all four sets, which decision 18 asks for first, but +`SkySettings.enabled`, `physical` and `heightFalloff` all still default to +off, and turning them on re-records every scene that draws a sky or fog, so +it is its own change; a precomputed scattering table (`PhysicalSky`'s +docstring says "not done yet"), which would make the sky a texture read on +phones instead of a 16 × 8 march per pixel; multiple scattering (the noon +horizon is pale cyan, not white); a moon; ozone; the camera's height moving +the sky; height fog on particles and splats, which fog as a flat fog at the +camera's density; an HDR environment from the sky (`fromSky` is eight bits, +so a bright horizon clips); the sky and fog in the +level format, the editor and the MCP tools; timing on the Galaxy A55; the +comparison page row, with the 0.9.0 revision (decision 19). Two claims +needed fixing once the tests ran: towards and away from a setting sun are now +compared at one height above the horizon, and the drawn frames are odd-sized +so the centre pixel looks along the camera's axis. + +**P6. Debug views and render stats.** *Done:* `RenderSettings.debugView` +takes a `DebugViewSettings` — a `DebugView` (albedo, the shading normal, +roughness, metalness, occlusion, emission, the base map's UV, and +`nonFinite`, magenta where the light is NaN or infinite over its luminance in +grey) and a `split`, the share of the width left lit. The lit models ask +`WriteDebugView` in `surface.glsl` before writing their light, so a channel +shows what the maps did, not what the surface buffer kept; `FragInfo` grew +`debug_view` (code, split, the scene target's width) at its end. The +composite passes the debug side through its encode alone, decided at the end +of `main` so WGSL's uniform control flow holds (`CompositeInfo.lens` y and +z). Reflections and probes clear it, so neither bakes a channel in. NaN is +found by comparison: `floatBitsToUint` took `impellerc` down in its GLSL ES +output. `FrameResult.targetBytes` counts every texture the frame's graph +touched once, base level times slices times samples (`textureBytes`); the +draws, triangles, instances, pipeline switches and per-pass costs were +already on `FrameResult`. CPU mirrors in every lit model, the impostor and +the composite. Tests: `debug_view_test.dart` (off to the bit, each channel +as the material's number under a doubled exposure, the UV gradient, the +split, NaN magenta and a sound surface grey; mutation-checked on the +composite's flag) and `render_stats_test.dart` (the byte law, a frame's +targets, the glow's chain counted once, draws and triangles growing with +cubes); `debug-view-split` (the teapot lit left, its normal right) in all +four sets, WebGPU at 0 of 172800 pixels from Impeller, WebGL at 0.359% and +the software set at 0.567%, on the silhouette and the floor's far edge. +*Left:* whether Metal, compiled with fast math, keeps the NaN comparison is +not checked — no golden draws a NaN; stages that are not lit materials (sky, +particles, splats, glass's own pass) draw as ever and reach the debug side +untoned; effects between the scene and the composite (reflections, depth of +field, motion blur, a temporal resolve) still run over the channel; a mip +chain is not in `targetBytes`; the view in the editor's viewport and the MCP +tools; the comparison page row (decision 19). + +**P7. Orthographic everywhere, per-draw ranges, alpha to coverage, render +order.** *Done:* per-draw index ranges (`PassEncoder.draw` takes +`firstIndex` and `indexCount`, refused past the binding by `indexWindow` on +all four backends). The orthographic camera, after an audit of every pass for +a perspective assumption: `FogInfo.projection` says which lens it is +(`isOrthographic` reads it off the matrix, so a mirror and a probe face need +no camera), and `TowardsEye` in `color.glsl` is the view axis through an +orthographic lens — the lit models' `s.v`, the planar reflection's Fresnel — +so highlights stop sliding across the frame; `EyeDistance` is the depth from +the eye's plane there, so fog lies flat; the sky is seen through a +sixty-degree lens built from the matrix's own axes, so it is a gradient +rather than one colour; the light shafts start on the eye's plane, as the +volumetric fog already did. Already right and left alone: SSAO, SSR, +contact shadows, decals, camera velocity, the volumetric fog, weighted +blended transparency, soft particles, impostors, splats' lens, LOD, Hi-Z, +mirrors. Tests: `orthographic_test.dart` (the matrix told apart in both depth +ranges, a metal wall even across the frame and the same wherever the camera +stands along its axis, fog flat on a wall, the sky a gradient; +mutation-checked on the sky) and `orthographic-metal` in all four sets, +WebGPU at 0 of 172800 pixels from Impeller, WebGL at 0.394% and the software +set at 0.575%, on the edges. +An explicit render order between nodes: `MeshNode.drawOrder`, added to the +material's `drawBucket` in the sort key's leading field, honoured in both +halves, and a boundary the batching does not merge across +(`draw_order_test.dart`, mutation-checked on the batching). +Alpha to coverage, where a device has it: `Material.alphaToCoverage` on a +masked material, `GraphicsDevice.supportsAlphaToCoverage` and +`PassEncoder.setAlphaToCoverage` (traced, faked, on every backend). WebGL2 +enables `SAMPLE_ALPHA_TO_COVERAGE` and WebGPU builds the pipeline with +`alphaToCoverageEnabled`; flutter_gpu has neither nor a sample mask, and the +software rasteriser draws one sample, so Impeller and the software set draw +the hard cutoff and `FrameResult.alphaToCoverageDeclined` says so. The +cutoff goes to the shader as itself plus one when coverage is on, and +`ReadSurface` then keeps the fragment and sharpens its alpha over a pixel +with `fwidth` instead of cutting. The scene pass switches coverage off +before its contributors. On the way, a masked surface now writes an opaque +alpha once it has survived the cut: it wrote the texture's, and a capture +reading the frame as premultiplied lit every leaf towards its rim. Tests: +`alpha_to_coverage_test.dart` (on, then off before the contributors; the +encoding only when the call was made; the refusal; nothing emitted for a +scene that does not ask; mutation-checked twice) and `alpha-to-coverage` +in all four sets, the browsers at 0.6% from Impeller on the discs' rims. +The shadow cascades under an orthographic camera: the near ones are slabs +along the view axis, as wide as the frame's half-diagonal and spanning the +depth the casters take up inside it (five rays down the axis, clipped by +the casters' box), and `shadow.glsl` and its CPU mirror pick one by depth +along the axis. Tests: `cascade_test.dart` (slabs one after another and +narrower than the level; the same shadow as one cascade; stepping the +camera back forty metres moves at most four pixels — mutation-checked: +picked by distance, 1188 move) and `orthographic-shadows` in all four sets, +WebGPU at 0 pixels from Impeller, WebGL at 0.262% and the software set at +0.975%, on the edges; with the perspective path's spheres it fails. +Particles' and splats' fog and facing: the six stages that declared a +shortened `FogInfo` include `contributor_eye.glsl` — the whole block, +`FogDistance` and `TowardsEyeFrom` of a world position — and the three +contributors bind the axis and the lens (billboards already faced the +camera's plane and sorted along its axis). Tests: +`orthographic_particles_test.dart` (four stages' fog and the shards' +facing, a column off the axis against one on it; mutation-checked on both +helpers), `splat_fog_test.dart` (both composites; mutation-checked) and +`orthographic-particles` in all four sets, WebGPU at 0 pixels from +Impeller, WebGL at 0.006% and the software set at 0.078%; every perspective +particle and splat golden byte-identical on all four. +A near plane behind the eye: `CameraNode.readViewOrigin` is where the +rays begin — the camera, or its near plane where that is behind it — and +the twenty-one places the renderer and the particle contributors read the +camera's position for depths, fog, cascades, velocity and reflections read +that instead (LOD and the sky dome keep the position). Test: +`orthographic_test.dart` (a wall three metres behind a camera whose near +plane is ten behind draws as the same wall seen from that plane, fog and +all; mutation-checked). +Splats sort by depth along the axis through an orthographic lens and +re-sort when it turns rather than when it moves (`splat_sort_test.dart`, +`splat_quads_test.dart`; mutation-checked). `MeshOverlay.lookThrough` takes +the pixel from the projection's own matrix, which the example now uses +(`mesh_overlay_test.dart`; mutation-checked; `mesh-overlay` unchanged). And +`Brush.drawOrder` in the level document: batched by it, kept by a breach +and a duplicate, offered by the inspector, set on the editor's batches +(`brush_draw_order_test.dart`, `level_draw_order_test.dart`; every +assertion mutation-checked). **P7 is done.** + +Already ours and not theirs, to keep: volumetric fog, weighted blended OIT, +motion blur, local exposure and HDR out, EVSM, impostors, Hi-Z and software +occlusion, generated LOD, WebGPU, the CPU backend, retargeting. + +### 4.2 Materials + +**P8. User materials compiled for the GPU (L).** flutter_scene's answer is +`.fmat`: GLSL stages with parameters, varyings and, since 30 September, +lighting hooks, compiled by a build hook and hot-reloaded. Ours: +`formats/material_language/` already emits GLSL and evaluates on the CPU, but +`flutter3d_build` never compiles it into GPU bundles. Make the build hook +compile material-language sources into Impeller, WebGL and WebGPU bundles +(the last through the existing translation), load them through +`GraphicsDevice.loadShaders`, and let the CPU backend evaluate the same +source. Parameters, instance attributes and hot reload through HR1. Custom +lighting hooks (a BRDF or toon inside the light loop) follow in 0.9.0 once +surface outputs pass on all four backends (decision 21). +*Tests:* one material-language source, one golden per backend. + +**P8 progress.** *Done:* the build hook compiles `assets_src/**/*.f3dmat` +into `.f3dshaders` with an Impeller, a WebGL2 and a WebGPU section, and the +bundle carries each material's source in `ShaderBundle.materialSection`. +The software backend compiles that source (`CpuDevice(materialCompiler:)`; +`flutter3d_app` registers `materialLanguageCompiler`, moved there from +`flutter3d_testing` so a game can depend on it), and a reload points the +stage already handed out at the new source. `BundledMaterials` reads a +bundle's lighting models off the sources, so how a material binds is the +program's answer. The `material-language` golden draws one `.f3dmat` +(`example/shaders/rim_glow.f3dmat`, compiled by +`example/tool/build_material.dart`) on all four backends — WebGPU at 0 of +172800 pixels from Impeller, WebGL at 0.199% and the software set at +0.291%, on the silhouette. Its rim colour is a `uniform` set orange over the +source's blue, so the picture shows `Material.parameters` reaching every +backend: a `uniform` is a member of `MaterialParams`, not folded, read by +the CPU evaluator from the same block, refused in a variant; +`BundledMaterials.parameters` gives a material its defaults, and +`Material.parameters` is now a map of the material's own (it was a shared +constant, so a material made without parameters could not be given any). Drawing it on a GPU for the first time found the +emitted stage keeping a point-shadow block and the base colour map unbound: +black on Impeller and refused on WebGL2, fixed in the emitter and in +`describeMaterial`. +*Tests:* `bundled_material_test.dart` (the section round trip, the software +backend drawing a built material with nothing registered, the refusal +without a compiler, a reload reaching a draw already holding the stage, a +broken source leaving the library as it was, the lighting model off the +program; mutation-checked on the reload), the build test's section, the +example's `material_language_test.dart` holding the golden's constant model +to the source. +Hot reload: `HotSwap.loadMaterial('assets_src/x.f3dmat', device:)` reads +the bundle the hook wrote at `generatedMaterialPathFor`, loads it, reads its +`BundledMaterials` and registers the library, so an edit the hook compiles +again is drawn by the next frame on every backend, the software one +compiling the new source under the stage a draw holds (`hot_swap_test.dart`, +mutation-checked on the registration). +More than one bundle: `Renderer.addMaterials` and `removeMaterials` over a +`ShaderLibraryStack`, the latest first, forgetting what was resolved by +name (`bundled_material_test.dart`, two bundles drawing and a name changing +hands and back; mutation-checked on both). A hot edit that changes how a +material binds: `HotMaterials.bind` keeps a material and gives it the new +source's model and new uniforms' defaults after a reload +(`hot_swap_test.dart`, a uniform declared by the edit drawn; mutation-checked). +Lighting hooks (decision 21): a `light` block is the stage's `ShadeLight`, +run inside `AccumulateLights` and shadowed, reading `lightDir`, `halfDir`, +`nDotL`, `nDotH`, `vDotH`; the fragment body reads `lit`, the whole of what +`lambert.frag` adds up, so reading it keeps every map and atlas the lit +model binds. Refused: a light input outside the block, `lit` inside it or +without one, a block nothing reads. The software backend gathers through +`evaluateMaterialLight`. Drawing it on WebGPU found a loaded stage with no +`kept`, so the renderer bound by the model's flags and left the metal-rough +map the WGSL declares unbound; `WebGpuStage.declared` answers now. Tests: +`material_language_test.dart` (the block parsed, emitted as ShadeLight, the +bindings, five refusals; mutation-checked), `material_language_frame_test` +(a hook returning the albedo is the hand-written Lambert to the pixel under +a shadowed sun; mutation-checked on the shadow and the ambient), +`webgpu_loaded_shaders_test.dart` (mutation-checked), and +`material-light-hook` (`example/shaders/toon_hook.f3dmat`) in all four sets, +WebGPU at 0 pixels from Impeller, WebGL at 0.330% and the software set at +0.534%, on the silhouettes. +Instance attributes: `InstancedMeshNode.setInstanceData` writes four floats +after the colour (a record of twenty), the six mesh vertex stages pass +`v_instance` — slot 1 in the instanced one, nought elsewhere, after +`v_lightmap_uv` so the location agrees with every header's inputs — and the +language reads `instance`, declaring the varying only when read. Tests: +`instanced_mesh_node_test.dart` (the layout, a reused slot from nought, a +moved one keeping its numbers), `bundled_material_test.dart` (two copies +painted by their own numbers on the software backend; mutation-checked), +the emitter's declaration, and `material-instance-data` in all four sets, +WebGPU at 0 pixels from Impeller, WebGL at 0.229% and the software set at +0.418%; `instanced-field`, `impostor-forest`, `lightmapped-room`, +`shadow-teapot` and `particles-mesh` byte-identical on all four. +**P8 is done.** + +### 4.3 Physics + +**P9. Rotation, inertia, joints, CCD in pure Dart (L).** The M1 milestone of +`doc/engine-gap-analysis.md`: orientation and angular velocity on +`RigidBody`, inertia tensors per shape, contact manifolds with friction that +turn bodies, joints (fixed, hinge, ball, slider, distance), swept shapes for +fast bodies, a triangle-mesh shape and a 3D broadphase (today's grid is XZ +only). Snapshot and restore for rollback come free with determinism. +Ragdoll in §5 depends on it. +*Tests:* stacking, tipping and pendulum scenes compared by digest on two +operating systems; energy drift bounds. + +**P9 progress.** *Done:* `RigidBody` has an `orientation`, an +`angularVelocity`, principal moments from `inertiaFor` (box, sphere, capsule +exact; wedge and heightfield as their bounding box) and a world inverse +tensor kept in doubles; `applyImpulseAt` and `applyTorqueImpulse` spin it. +Only a body built with `canRotate: true` turns, so every shipped level steps +and saves exactly as before. The step keeps angular momentum rather than +angular velocity (the gyroscopic term, implicitly) and turns the body in the +convention `asRotationMatrix` draws. `save()` carries orientation and spin +for a turning body only. `src/portable_math.dart` gives the step `sin`, +`cos`, `atan`, `atan2`, `asin` and `acos` with the same bits as +`flutter3d_sim`'s `Portable`, for the joints. +*Tests:* `flutter3d_physics/test/rotation_test.dart` (inertia per shape, +r × J, a free tumble holding L to 1e-4 over ten seconds, sleep, damping, a +snapshot through JSON that resumes bit for bit) and `portable_math_test.dart` +(digests and ulp bounds against `dart:math`). +*Left:* contact manifolds with friction that turn bodies, and an oriented +collider (a turning box still collides axis-aligned); joints; swept shapes; +a triangle-mesh shape; a 3D broadphase; the stacking, tipping and pendulum +digests on a second operating system; ragdoll. + +**P9, the native core (decided 2026-10-03).** After reading Rapier 0.22: an +engine of our own in C11 with no dependencies (`flutter3d_physics_native`), +built by a hook through `native_toolchain_c`, compute through wgpu-native +(WGSL) and the browser through clang → wasm32 with no emscripten. Two modes: +deterministic (f32 as Rapier, own libm, no contraction, a bit-exact world +snapshot) and fast (SIMD, a thread pool). `flutter3d_physics` stays the +reference — compared with a tolerance, since it is in doubles — and the +fallback. TGS-soft with substeps for bodies, XPBD for cloth; on the GPU the +solver by graph colour, the broadphase, primitive narrowphase, cloth, fluid +and particles, a frame late for visual bodies. Joints fixed, revolute, +prismatic, spherical, motors, rope, spring, distance and multibody; shapes +convex hull, trimesh with a BVH, compound, cylinder, cone, rounded; soft CCD +by default and hard by flag; the native character controller; the existing +API extended; stepped from the fixed step; ragdoll as the closing check. +Phases: 0 infrastructure, 1 core, 2 contacts, 3 solver, 4 broadphase, +5 convex shapes, 6 trimesh, 7 joints, 8 CCD, 9 queries and the controller, +10 GPU, 11 fast mode, 12 web, 13 ragdoll and the remaining platforms. +*Phase 0 done:* bodies in an arena named by generational handles, gravity and +a semi-implicit Euler step, transforms read back in one buffer, an allocator +for the import-free WebAssembly module. Tests: the C tests under ASan and +UBSan (1050 checks: handles, a stale handle refused, a generation wrapping +past nought, growth, refusals, the step); `bindings_test.dart` (the header's +ABI, constants and every exported function bound); `native_world_test.dart` +(the API, the step's order, a disposed world, a free fall against +`flutter3d_physics` to 1e-4); `wasm_test.dart` (the module and the native +library step five bodies six hundred steps to the same bits; mutation-checked +with contraction turned on in the hook). +*Phase 1 done (ABI 3):* shapes (sphere, box, capsule) as inertia, surface +and drag; the orientation stepped as `RigidBody.integrateOrientation` steps +it, angular momentum kept; impulses at a point, forces and torques through +the bus, damping, a rotation lock, sleep. The air (temperature, density), +a uniform wind and a trilinear wind grid; drag ½ρCA|u|u with A = S/4, +implicit with |u| from the step's start, so the terminal speed is exact. +Materials (presets inert, wood, paper, rubber, steel, stone) and a lumped +temperature per body: Watson's convection with the wind past it, radiation +as an exact conductance, one implicit step that cannot overshoot; ignition +and extinction at the ignition point, burning at a rate per m² with a share +of the heat kept and the rest read as fire sources for the smoke grid, mass +and inertia lost; water landing at the air's temperature, held at boiling +until boiled off, putting fires out. An origin in doubles with +`f3d_world_shift_origin`; an event ring; byte-exact snapshots; `f3d_real` +with `F3D_REAL_DOUBLE`. *Tests:* the C tests in both precisions under the +sanitisers (world, motion, heat, snapshot); `native_world_test.dart` (a box +spun off-axis and an off-centre impulse against `flutter3d_physics`, sleep +events, the origin, a snapshot round trip); `native_heat_test.dart` (catch, +burn, smoke, burn out; water before and after; terminal speed; the wind +carries and cools); `wasm_test.dart` now compares the whole snapshot after +six hundred steps of the same burning, turning bodies in a wind grid. +Mutation-checked on contraction, the momentum-keeping turn, the flame's +share, the grid in snapshots, boiling, the water's temperature and the +implicit drag. *Moved to phase 2:* islands, which need contacts or joints to +join anything, and conduction between bodies that touch. +*Phase 2 done (ABI 4):* the narrow phase for every pair of sphere, box and +capsule, oriented: manifolds of up to four points with depths (negative +within the margin) and feature ids; box–box by fifteen axes with a bias to +faces, the incident face clipped to the reference face and reduced to the +four spanning the most, edge–edge at one point; capsules lying on a box or +another capsule keep both ends. A sort and sweep along x (phase 4 replaces +it), collision layers and masks, contact began/ended events naming both +bodies, manifolds kept for pairs that are both asleep or fixed, islands by +union–find under the lower root that sleep and wake as one (fixed bodies +join none). Conduction across touching contacts by Holm's constriction +G = 4a/(1/k₁ + 1/k₂) over the manifold's polygon or Hertz's circle, +implicit per pair; a fixed body of no thermal mass is a reservoir; materials +have a conductivity. Contacts are in snapshots. *Tests:* `test_collide.c` +in both precisions (each pair against its closed form, turned boxes with +whole faces, a box on its edge, crossed edges, an octagon reduced to four, +capsules, both orders of every pair, the sweep against every pair of two +hundred shapes, events, filters, islands, conduction against +e^(−(2G+hA)t/C), a hot plate, fire warming its neighbour, contacts through a +snapshot); `native_contact_test.dart` (unturned contacts against +`flutter3d_physics`' `contactBetween`, events, filters and the margin, heat +across a contact); `wasm_test.dart` now includes a floor the bodies fall +through. The sanitisers found a clipped edge ending on a plane counted twice; +fixed. Mutation-checked on the event merge, the kept manifolds, the islands, +the conductance, the sweep, the reduction and the capsule's second point. +*Found:* wood conducts too little for fire to cross a contact, which is +right; flames crossing between bodies wait for the smoke grid. +*Phase 3 done (ABI 5):* the soft step of Box2D v3 in 3D. Substeps (default +4) of: integrate velocities, warm start, solve with a soft bias, integrate +positions, relax without bias; restitution after the last, impulses kept on +the manifold points and carried to the next step's points of the same +feature id. Contact springs at min(30 Hz, ¼ substep rate), damping ratio 10, +push-out held to 3 m/s, 5 mm slop; friction in Coulomb's circle on two +tangents, μ the pair's geometric mean, e the larger, no bounce under 1 m/s; +anchors followed through the substeps by each body's turn. The step became +collide → solve → heat, and sleep is judged at the step's start; touching is +within the slop. *Tests:* `test_solve.c` in both precisions (rest and sleep +with the floor holding m g h a substep, a stack of ten that does not walk or +move once asleep, bounce at e, a slope at g(sin θ − μ cos θ) and holding at +μ > tan θ, rolling at 5/7 g sin θ without slip, momentum in elastic and +inelastic collisions, tipping flat, a push-out of at most 3 m/s); +`test_world.c` holds n substeps equal to n steps of dt/n to the bit; +`native_solve_test.dart` (a crate resting where `flutter3d_physics` rests +it, a stack sleeping and waking, friction, restitution and substeps through +the binding); `wasm_test.dart` byte-exact with bodies landing on the floor. +Mutation-checked on the warm start, the relax pass, restitution, friction, +the push limit and the anchors. *Measured:* each joint of a stack settles +7 mm (slop plus the spring's give); graph colouring for the GPU and fast +mode is phases 10–11. +*Phase 4 done (ABI 6):* a dynamic AABB tree after `b2DynamicTree` in 3D: +fat leaves (+0.1 m), insertion by surface-area cost, AVL rotations on the +way up, removal and refit; leaves brought up to date at the step's start. +Pairs come from tree queries by awake bodies only (an awake lower slot skips +what an awake higher one will find), plus last step's pairs between two +bodies that cannot move; a body placed, turned or reshaped by hand wakes the +bodies its kept contacts name. The tree is derived state, out of snapshots +and rebuilt after a restore. `f3d_world_query_box`, in slot order. +*Tests:* `test_tree.c` (invariants after inserts, removals and reuse; a row +of 1024 boxes ≤ 22 deep; 200 queries over 2000 boxes against brute force; a +leaf kept while its body moves inside it; a destroyed body's leaf gone; a +restored world rebuilding its tree and stepping to the same bytes; a crate +on a plank moved away waking and falling; 4000 bodies with no pairs); the +earlier sweep-versus-brute-force test now holds the tree; +`native_contact_test.dart` holds the query through the binding. +Mutation-checked on the balancing, the fat-box reuse, the wake on a moved +body, the awake-pair rule and the query's order. +*Phase 5 done (ABI 7):* cylinder, cone (origin at its centroid) and convex +hull shapes, and a rounding radius on any shape. Hulls are built +incrementally in doubles from the caller's points, weighed from tetrahedra +(Blow and Binstock), recentred on their centre of mass, and kept with their +triangles in the world and its snapshots. Inertia is a full symmetric tensor +(diagonal shapes keep the old arithmetic exactly). The general narrow phase: +support functions of the cores, GJK with Ericson's closest points and van +den Bergen's relative touching test, EPA from GJK's simplex blown up to a +tetrahedron, and manifolds from facing features (face within 5°, edge within +5°, else a corner) clipped polygon to polygon or edge to face, depth from +the reference face's Newell plane, reduced to four. Sphere, capsule and +unrounded box keep their closed forms. *Tests:* `test_convex.c` in both +precisions (gaps and depths against closed forms, a cylinder on end and on +side, a cone on base, slant and apex, a hull cube against the box, refusals, +a tetrahedron's centroid, a turned hull's products, a spun hull keeping its +momentum, cylinder and cone inertia, a rounded box resting on its face, +every new pair both ways round, a cylinder rolling at 2/3 g sin θ, shapes +resting and sleeping, hulls through a snapshot); `native_contact_test.dart` +(the shapes resting through the binding); `wasm_test.dart` now with a +cylinder. *Found on the way:* an absolute touching test that f32 never +reaches, and depth taken from a wide face's lowest corner, which floated a +cylinder five millimetres over a floor; both fixed. Mutation-checked on +both, the feature manifold, the hull's recentring and the tensor's turn. +*Phase 6 done (ABI 8):* indexed triangle meshes for fixed bodies, held by +the world with a byte of edge flags a triangle and a per-mesh tree of +triangles (derived, rebuilt after a restore). Ball and capsule against a +triangle by Ericson's closest point (a lying capsule's ends both kept), +other shapes by GJK/EPA against a triangle shape; one-sided by the body's +centre; the triangles found taken in index order and merged into one +manifold on the deepest normal (within 18°), reduced to four. Internal edges +— shared by exactly two triangles across a flat or hollow fold — snap an +edge or corner contact to the face normal. *Tests:* `test_mesh.c` in both +precisions (32 triangles and 40 internal edges of a grid, a tent's ridge +real and a dip's fold internal, refusals, a ball on a triangle and on a +vertex, a crate across seams on four points, a lying capsule, a cylinder and +a hull resting and sleeping, a crate sliding at 5 m/s over 20 000 triangles +with no vertical speed over 5 cm/s, a body behind the mesh left alone and +one from above stopped, a ball rolling down a sloped mesh at 5/7 g sin θ, a +mesh through a snapshot); `native_contact_test.dart` (a mesh floor through +the binding); `wasm_test.dart` now with a mesh hill. Mutation-checked on the +internal-edge snap, one-sidedness, the flat-fold rule and the merge's normal +test. +*Phase 7 done (ABI 9):* joints in a generational arena — fixed, spherical, +revolute, prismatic, distance — solved in the substeps before the contacts +(warm start, biased solve, relax), soft at min(60 Hz, ¼ substep rate) with +damping ratio 2. The locked directions of a joint (up to three point and +three turn rows) are one block solved by Cholesky; the free direction takes +spring, motor (force-bounded) and one-sided limits, speculative when apart. +Distance joints are rods, springs between least and most, or ropes (spring +of 0 Hz, least 0). Joined bodies skip collision unless asked; joints join +islands; a body's joints go with it; joints are in snapshots, the filter of +joined pairs is rebuilt. The core's own atan2 from a series. *Tests:* +`test_joint.c` in both precisions (atan2 against libm over the circle, a +physical pendulum's period to 1 %, the hinge's angle, a hinge holding its +plane where a ball joint swings out, a rod's length to 1 mm and its m g, a +slack rope falling free until taut, a spring's period, a welded cantilever, +a door's limits and motor and a motor too weak for its load, a slider on +its axis at the discrete free fall with limit and motor, joined bodies not +colliding and colliding when asked, a joint going with its body, a chain +asleep as one and woken by a tug, joints through a snapshot, refusals); +`native_solve_test.dart` (a hinge and a rod through the binding); +`wasm_test.dart` now with a limited hinge. *Found:* hinge rows solved one +after another let a light ball drift out of its plane; the block fixed it. +And a rod made a rope kept warm-starting the impulse it had held as a rod, +so the weight never fell; a change of mode now starts afresh. +Mutation-checked on the warm starts, the relax, the hinge's and slider's +rows, the weld's turn, limits' one-sidedness, the motor's bound, the +collision filter, the islands and the arctangent's length. *Not here:* +multibody (Featherstone) chains, cone and twist limits on the spherical +joint, breakable joints. +*Phase 8 done (ABI 10):* soft CCD on by default — leaves cover the +step's sweep, and a pair's contact margin grows by (|v| + |ω|·reach)·dt of +both bodies, at most 10 m — and hard CCD for bullets: after the solve, a +line sweep from the step's start against every non-bullet near it by +conservative advancement over the narrow phase, the bullet put back at the +first impact with its velocity kept; touching at the start or receding is +not an impact. `f3d_world_set_speculative`, `f3d_body_set_bullet`. +*Found:* the solver's separation carried a contact's anchor round with the +body, which a fast-rolling ball turned into a phantom gap (it sank 1.8 cm +at 10 m/s); the anchor's motion is first order now (Δφ × r). *Tests:* +`test_ccd.c` in both precisions (300 m/s through a 1 cm wall without soft +CCD and stopped with it, a bullet stopped by a box and by a one-triangle +mesh with soft CCD off and a plain ball not, a bullet leaving a wall not +called back, 100 m/s past a box unslowed, 200 m/s head on, a bullet rolling +on a floor, 100 m/s onto a mesh, an elastic bounce at 50 m/s); +`test_solve.c` holds a ball rolling at 10 m/s within the slop; +`native_solve_test.dart` holds both kinds through the binding. +Mutation-checked on the speculative reach, the swept leaves, the bullets' +sweep, the touching rule and the receding rule. *Then:* a bullet's turn +is swept as well — its pose recorded after every substep and each substep +swept as a line and a normalised blend of turns, the closing speed bounded +by 4 tan(β/2) times its reach — so a bar spun at 300 rad/s stops at a wall +it would have turned through (the step's two ends alone read a 5 rad turn +backwards); `test_ccd.c` holds it, mutation-checked on the per-substep path +and the turn's bound. *Not here:* a turn over half a revolution in one +substep. *Found:* a bar 2 m × 2 cm (long-axis inertia 5000 times smaller) +stopped from 300 rad/s by one contact at its end blows up in the contact +solver; *fixed after phase 9:* a body's turn is held to π/4 a substep +(Box2D v3's B2_MAX_ROTATION), before the turn and after it, since the turn +reads the spin back through the tiny inertia; `test_solve.c` holds the rod +thrown back sane, mutation-checked. +*Phase 9 done (ABI 11):* queries — rays nearest and all (tree walked by +slabs, nearest child first, cut at each hit; closed forms for balls and +boxes, Möller–Trumbore front faces for meshes, conservative advancement of +a point for the rest), shape overlaps (narrow phase at margin 0) and shape +casts (conservative advancement, overlapping start at 0), each with a mask +and an ignored body, answers in a fixed order. The character controller: +cast and slide up to four times, ground by slope, steep met as an upright +wall, steps by lift-move-set-down kept only on ground and further, ground +snap down to a step's height. *Tests:* `test_query.c` in both precisions +(each shape's ray against its closed form, a mesh front and back, rays from +inside, nearest and all with a capacity, mask and ignore, a thousand rays +against every ball, overlaps in slot order, casts against a box from the +side and above and overlapping at the start, and a character landing, +walking, sliding along a wall, up a step and not a ledge, up a gentle slope +and stopped by a steep one, keeping to a slope going down, and under a +ceiling); `native_contact_test.dart` (rays against `flutter3d_physics`' +`raycast`, a cast and an overlap, a character through the binding). +*Found:* a ball's ray by b² − c lost 2 mm at 30 m in f32; by the nearest +distance it agrees. Mutation-checked on the advancement step, the slide, +the steep-as-wall rule, steps, the ground snap, stepping onto steep ground +and rays from inside. +*Phase 10 begun (ABI 12, GPU ABI 1):* `f3d_gpu`, a library of its own in +`csrc/gpu/` linked statically with wgpu-native v29.0.1.1, which the hook +downloads per target (thirteen archives pinned by sha256, cached in the +hooks' shared output; without one the core builds alone and +`NativeGpu.open()` is null). A headless device on the best adapter; +callbacks turned over by `wgpuInstanceProcessEvents`, the queue waited on +by `wgpuDevicePoll`. Particles: `f3d_particles` in the core (gravity, an +implicit drift to the wind, a floor with restitution and friction, life, +a ring of slots) and the same step in WGSL, one dispatch a step. +*Tests:* `test_particles.c` in both precisions (a flight to semi-implicit +Euler's sums, drag to the wind, the bounce and the slide, the ring and the +dead staying put); `native_particles_test.dart` (the CPU through the +binding; on the GPU, a thousand particles for two seconds within 1e-4 m of +the CPU — 2.7e-5 measured on an M3 Pro — and the ring; skipped without an +adapter). Mutation-checked on the shader's friction and restitution, the +GPU slot packing, the CPU bounce, the drag and the ring. *Next in phase +10:* visual bodies a frame late, then cloth and fluid on the GPU. +*Phase 10, visual bodies (ABI 13, GPU ABI 2):* `f3d_debris` — balls with +statics (planes, unturned boxes) — and the same passes in WGSL, read a +frame late through two staging buffers mapped in turn. *Decided:* for +contacts, not graph colouring but Jacobi gathered per body with mass +splitting: no float atomics, a pair's impulse computed from its lower slot +so both get it to the bit, and the CPU's to the GPU's rounding (exact +until bodies meet). The grid is a hashed table of lists built by +`atomicExchange`, so no cell overflows. A contact's set is fixed for the +substep by distance alone (a quarter of the radii; a still shape's, the +radius) — counting by closing speed changed between impulse passes and +divided by a count of nought. Eight substeps of four passes, push 0.8. +*Tried and dropped:* fixed cells of sixteen (a compressed cluster +overflowed them and lost momentum); a split impulse (Jacobi leaves a deep +heap a little velocity downwards each substep, which only a push in the +velocity takes back — the heap sank by 190% of a radius). *Tests:* +`test_debris.c` in both precisions (rest on a floor and a box, rolling at +5/7 g sin θ and sliding at g sin θ, head on elastic and inelastic, a free +cluster's momentum, the groove without a hop, a centre inside a box out +through the nearest face, a column of ten giving under 12 mm, a heap of +five hundred without leak, overlap under a tenth of a radius, still); `native_debris_test.dart` (a rest through the +binding; on the GPU: the read a frame late, the groove and a stack of +three against the CPU every tenth step to 1e-4, and the heap — the first +five steps to 1e-4, then the mean height within 1%). Mutation-checked on +the mass splitting (both sides, CPU and WGSL), the pair's counts, the +sign, friction, restitution, the push, the passes repeated, the +quaternion, the box's nearest face and its side, and the plane-or-box +test in WGSL. *Not caught by any test:* the check that a body met in a +cell's list lives in that cell — no scene tried had two occupied +neighbouring cells share a hash, the table being at least twice the slots +— and which come-back copy a third request gives up, which depends on the +GPU's timing. The C tests are now built at -O1 under the sanitisers: at +-O0 the heap took minutes and the test runner's thirty seconds ran out. +*Phase 10, cloth (ABI 14, GPU ABI 3):* `f3d_cloth` — distance +constraints with a compliance each, XPBD in small steps (one pass a +substep, the multiplier from nought), balls, a floor, wind, damping, +pins that can be carried. Coloured greedily at creation (a 32² sheet with +bending edges takes 13 colours); `f3d_cloth_edges` hands the GPU the same +coloured order, so the colouring is in one place, and the GPU solves a +colour a dispatch through a uniform at a dynamic offset. *Tests:* +`test_cloth.c` in both precisions (α m g + g h² of sag, damped; a rigid +pendulum's length and its √(2gL); a hanging sheet, its stretch, its +pins, carried; a drape over a ball and the floor; the wind's speed by +drag; loose points on the floor at the thickness; the colours sharing no +point; bad input); `native_cloth_test.dart` (a hanging sheet through the +binding; on the GPU: a pendulum, a spring in a wind, a rod off a ball and +points blown along the floor against the CPU every tenth step to 5e-4 — +a swing's phase drifts by its rounding — a 32² sheet point for point for +half a second, then its stretch and lowest point; the read a frame late, +carried pins). A 32² sheet stretches about 4% at sixteen substeps, on +either. Mutation-checked on the compliance, the correction's sign, the +friction, the thickness at balls and the floor, pins, the colouring, +drift, damping, the colour's offset. `move_point` no longer writes the +previous position: the next substep's prediction overwrites it, so +nothing could tell. +*Phase 10, fluid (ABI 15, GPU ABI 4):* `f3d_fluid` — PBF with poly6 for +density and spiky for its gradient, the kernel twice the spacing, the +constraint only pushing (no tensile clumping, no surface tension), XSPH +viscosity, a tank; rest density summed over a lattice of the spacing. +*Found:* clamping at the walls alone left the bottom layer without +neighbours below, short of density, and pressed flat — 325 particles in a +layer of 200, the centre of mass at 0.10 m for 0.125. *Fixed:* each wall +counts as still layers of the lattice past it, under the particle, which +add density and push straight off as a neighbour of the particle's own +multiplier would; now 210, and 0.1254 at ten seconds. The tank still +sloshes at about 0.2 m/s ten seconds after a dam breaks, viscosity being +water's. *Tests:* `test_fluid.c` in both precisions (rest density to a +part in 10⁵, a lone particle's fall, a viscous pair's slide and none +without, the dam reaching the far wall, its centre of mass, inside the +tank, mean and greatest density, the floor layer, sloshing dying down, +the ring, bad input); `native_fluid_test.dart` (the dam through the +binding; on the GPU: the viscous pair against the CPU every tenth step to +1e-5, the dam particle for particle for five steps, then its centre of +mass and density; the read a frame late). Mutation-checked on the push's +clamp, the neighbours' multipliers, the walls' density and push, the +viscosity, the tank, the kernel's width, and the same in WGSL. +*Also:* kernels are built in a validation error scope, so WGSL that does +not compile (as `move`, `smooth` and an entry named like a buffer did +here) fails the constructor instead of the process; and three C tests had +given CHECK_NEAR a tolerance already scaled by the value it scales by. +*Phase 10 done*, but for what the plan named and this did not: the +broadphase and primitive narrow phase on the GPU for game bodies, and the +solver by graph colour for them — the GPU here steps only what nothing in +the game reads (particles, debris, cloth, water), and game bodies stay on +the CPU, where their bits agree everywhere. +*Phase 11 begun (ABI 16): threads.* Measured first: a heap of 4000 boxes +and balls in a pen, 21 ms a step, 13.3 ms of it collision and half of +that in tree queries (dense packing: ~450 nodes for ~20 leaves a query; +the tree itself is fine, height 15). `f3d_pool` — POSIX threads or +Windows', a static split of [0, n) by worker, the caller worker nought, +none in the WebAssembly build; `f3d_world_set_threads`, not in a +snapshot. The queries gather into per-worker lanes, sorted together +after; the narrow phase writes each pair's manifold to its own slot, +closed up after; `f3d_joined_ready` builds the joined table before the +queries instead of lazily inside one. *Same bits on any number of +threads*, so the deterministic mode keeps them: `test_threads.c` in both +precisions steps a world of every shape and a hinged chain on 1, 2, 3 and +8 threads to the same snapshot every step for four seconds, and back to +one; ThreadSanitizer finds nothing (run by hand: it is not in the suite). +`native_solve_test.dart` holds the binding. 13.3 → 3.8 ms on six threads +(the machine at load 49). Mutation-checked on the lanes' merge, the +split's ends and the pool's wait (each crashes the test); dropping the +sort is caught by `test_collide.c`, not by the threads, since body order +does not depend on the split. *Next:* the solver, 8 ms on one thread — +contacts coloured so a colour solves in parallel (the fast mode, its own +bits, the same on any number of threads), then SIMD. +*Phase 11, the fast mode (ABI 17, snapshot 7):* `F3dWorldState.fast`; +greedy colouring in contact order, 64 colours by a mask a moving body, +an overflow group solved by worker nought; one pool pass a step, the +stages met at a sense-reversing spin barrier (atomics as GCC/Clang and +MSVC spell them; a waiter yields after 4096 spins); joints on worker +nought between barriers; a contact no longer writes a body that does not +move (in both modes: bit-identical, checked by a motion hash of every +body against the previous commit). *Tests:* `test_fast.c` in both +precisions (the scene of every shape on 1, 2, 3 and 8 threads to the +same snapshot every step and unlike the deterministic mode's; a crate +resting, a stack of ten upright and asleep, the mode through a snapshot +and the threads not; a hundred balls on one dynamic plank — more contacts +than colours — the same on 1 and 4 threads and resting; a bounce equal to +the deterministic mode's to the bit; a hinge holding its bob); +`native_solve_test.dart` (the binding). ThreadSanitizer quiet, by hand. +Mutation-checked on the colour masks, the overflow on one worker, the +barrier after a colour, restitution, the warm start, the joints. The C +tests now link libm, which glibc's libc lacks. *Measured:* eight colours +on the heap of 4000; 8.4 → 7.0 ms on four threads, 4.9 on six — at load +29 to 49 from other work, where a thread waiting at a barrier waits on +threads descheduled; not yet measured on an idle machine. *Next:* SIMD +for the contacts of a colour. +*Phase 11, pairs kept (no ABI change):* the tree queries were half a +step; the world now keeps the pairs of overlapping leaves (`pairs`, +sorted keys), drops those whose leaves parted, and queries only the +leaves that `f3d_update_proxies` inserted or removed since (`moved`); a +restore or a moved list that cannot grow has them all found again. The +step's pairs are the kept pairs with one body awake, the layers, no +joint, and the swept boxes (now computed once a step, in parallel) +overlapping — the same set the per-body queries found, since a leaf holds +its body's swept box: a motion hash of all 310 bodies of the test scene +after 240 steps is unchanged. 13.3 → 2.2 ms collision on one thread, 1.2 +on six. Mutation-checked on finding the moved leaves' pairs and on noting +an insertion (the collision and solver tests fail); resetting the pairs +on a restore is not caught — a restore reinserts every leaf, and the +moved list then finds every pair anyway — nor is the swept-box test, +whose pairs the narrow phase drops in every scene tried. *Measured next:* +the fast mode's solver stops at 4.5 ms from two threads on; the profile +is mostly waiting — at barriers behind the slowest share of a colour, and +between passes — and the solver reads bodies out of the large slots. +*Phase 11, lanes (no ABI change):* a tight `Motion` array (velocity, +spin, moved, turned) gathered from the slots before each stage of +contacts and scattered after, in both modes — measured, it alone bought +nothing; and in the fast mode a colour's contacts in `WideBatch`es of +four lanes (GCC/Clang `vector_size`, a struct a lane at a time under MSVC +or `F3D_NO_SIMD`), each lane the scalar contact's operations in the same +order, branches as masks, impulses held in the batch for the step and +stored back after. Checked bit for bit: motion hashes of the test scene +unchanged in both modes, with and without vectors; `c_unit_test` builds +`test_fast.c` with and without `F3D_NO_SIMD` and compares the snapshot +hash it prints; `test_fast.c` holds a bounce, a box sunk into the floor +and a box dropped on it with no restitution to the deterministic mode's +bits (one contact, one lane). Batches sorted by point count within a +colour (5.3 → 4.5 ms). Mutation-checked on the soft bias's mass scale, +storing the impulses, the friction clamp, the most impulse, pushing the +second body, and restitution's zero test; a padded lane marked live is +not caught, its pushes being masked by bodies that do not move. +*Measured:* the fast mode's solver 7.7 → 4.5 ms on one thread; on several +from 3.3 to 20 ms as the machine went from load 8 to 49 — some 130 +barriers a step, each waiting on the slowest thread, which an +oversubscribed system parks. *Phase 11 done*, but for a number on an idle +machine. +*Phase 12 begun (decided 2026-10-04: one API, two backends; the core +and wasm threads):* the wrappers moved off `dart:ffi` onto `lib/src/core/` +— `calls_native.g.dart` (`@Native` behind functions of addresses) and +`calls_web.g.dart` (the module's exports through `dart:js_interop`, a +64-bit handle two 32-bit halves through a generated C shim), `layout.g.dart` +(the five settings structs' offsets), `memory_native`/`memory_web` +(alloc, typed reads and writes, copies), `buffers.dart` (`F32s` and kin, +`implements int`), and `constants.dart` (the header's numbers, plain +Dart). `tool/gen_core.dart` writes the generated three from the header, +formatted, with `--check`; `bindings_test.dart` runs the check and holds +the layouts against `offsetof` from cc. GPU passes in `gpu_native.dart`, +`gpu_web.dart` a stub (`open()` null). Finalizer for worlds and systems +in place of NativeFinalizer. All 101 native tests pass unchanged; the +package compiles with dart2js and dart2wasm. *Next:* the module served +and loaded in Chrome, a world stepped there against the native one; then +threads. +*Phase 12, the browser:* `module_web.dart` (fetch or bytes, +`WebAssembly.instantiate`, the exports, halves of 64 bits by arithmetic), +`memory_web.dart` (ByteData over the module's memory, made again when the +memory's buffer grows — a detached buffer's length cannot be asked, it +throws), `csrc/wasm/f3d_wasm_shim.c` generated (57 calls with `__w` +halves, `f3d_wasm_high`), the module shipped as `web/f3d_physics.wasm` +and declared a Flutter asset; `loadPhysicsCore` natively a no-op. +*Tests:* `web_core_test.dart` in Chrome under dart2js and dart2wasm (a +crate resting, events, a ray, transforms, a slot used again — its +generation through both halves and through memory —, a snapshot restored, +the four CPU systems, and the shared scene's snapshot hash equal to the +native one `shared_scene_test.dart` holds); `wasm_test.dart` now steps the +shipped module too against the native library, byte for byte, and lists +the shim's exports. *Found:* `NativeBody.generation` was `raw >>> 32`, +nought under dart2js, whose shifts are 32-bit; division now. Caught only +once a test used a slot again. Mutation-checked on joining the halves, +writing and reading a 64-bit word in memory, and reading a float's +bytes. *Next:* threads in the browser. +*Phase 12, threads:* `F3D_WASM_THREADS` — `-matomics -mbulk-memory`, +linked `--shared-memory --import-memory` with `__stack_pointer` exported — +built beside the single module as `web/f3d_physics_threads.wasm`. The pool +in the module takes workers its host started: each an instance on the +shared memory, a stack the host allocated, in `f3d_worker_main`, counted +by `f3d_wasm_workers_ready`; a pass is one job in memory, its `pass` +bumped and notified, workers waiting with `memory.atomic.wait32`, the +caller spinning (a page may not block). The allocator is spin-locked. +Dart: `loadPhysicsCore(threads:)` compiles the threads module, makes the +shared memory, starts Web Workers from `web/f3d_worker.js` and waits for +them; `physicsCoreThreads`; memory through a JS DataView. *Tests:* +`wasm_test.dart` steps the threads module on four node workers to the +native bytes; `web_threads_test.dart` steps the shared scene on four Web +Workers to the native hash, under dart2js and dart2wasm, on a Chrome +platform with `--enable-features=SharedArrayBuffer` (`dart_test.yaml`), +and skips where there is none. Mutation-checked on a worker's share, the +caller's wait for the pass, a worker's stack, and the wait for the +workers; the allocator's lock is not caught — the workers allocate too +seldom in these scenes for the race to show. *Phase 12 done.* +*Phase 13, ragdoll (ABI 18, snapshot 8):* the ball joint's cone +(`f3d_joint_set_cone`: the swing of B's axis from A's, `f3d_atan2` of the +cross and dot, held as a one-sided limit about their normalised cross), +its twist limited through `f3d_joint_set_limits`, with the impulse pushed +along (a1 + a2) / (1 + a1·a2), and friction (`f3d_joint_set_friction`: +three world axes, each impulse clamped to torque × h, warm-started). +`f3d_joint_get_swing`; `f3d_joint_get_value` reads the twist. Dart: +`setJointCone`, `setJointFriction`, `jointSwing`. *Found:* the twist limit +pushing about A's axis alone let a shoulder swung 1.5 rad go 0.46 rad +past it on the stairs; with the sum it goes 0.04. Without friction a +ragdoll never sleeps (a head rolls, a leg turns about its own length). At +four substeps an arm struck on a stair edge passes its cone by 0.25 rad; +a ragdoll wants eight. A point bob on a coned joint is barely held, since +its orientation hardly follows its position; a limb is held. *Tests:* +`test_joint.c` (a limb knocked past level held in a 30° cone, twist held +at ±0.5, refusals); `test_ragdoll.c`, eleven bodies on ten joints (to the +floor and asleep within two seconds, a 30 g bullet at 300 m/s knocks it +along and it sleeps again, down eight stairs; joints within 2.5 cm and +0.08 rad at the worst, a fraction of a millimetre at rest; the same bits +on one thread and four, deterministic and fast); `native_solve_test.dart` +holds the binding. Mutation-checked on the cone's sign, solve, warm start, +range and type, the swing angle, the twist solve and its axis, +friction's solve, warm start, push, cap, type and range. Survived: +the cone's impulse within a substep and the twist's warm start, each +covered by the other's next substep, and the twist axis's 1/(1 + cos) +scale, which changes the limit's stiffness only. +*Phase 13, platforms:* `csrc/tests/test_digest.c` hashes the transforms and +velocities of seven scenes (the shared world, fast on three threads, the +ragdoll down the stairs, debris, cloth, fluid, particles) against numbers +taken on macOS arm64, f32 and f64. They hold on Linux arm64, x86-64 and +armv7 (Debian in Docker, gcc and clang), on the iOS simulator and on +Android arm64 (NDK clang, emulator); `tool/digest_platforms.sh` repeats +them. Windows: `c_unit_test.dart` builds with `cl /fp:precise` when on +Windows, and CI's `physics-native-windows` runs the package under MSVC. +Its first run found the pool calling `InitializeSRWLockExclusive`, which +Win32 does not have: the core had never linked on Windows. Built with +mingw-w64 and run under wine, `test_threads.c` and the digests pass. *Found:* gcc on i386 uses the x87 and all seven +scenes differ; with `-msse2 -mfpmath=sse` gcc and clang both match. The +hook passes those for ia32 and `f3d_internal.h` refuses an x87 build +(Debian's clang for i386 defaults to the x87 too, and is refused). +Also found: `flutter test` had refused the package since phase 12, its +`dart_test.yaml` naming a browser platform; Chrome now gets +SharedArrayBuffer from `tool/chrome_sab.sh` through CHROME_EXECUTABLE, +and `tool/ci.sh` runs both browser tests under both compilers. +*Not run:* Android armv7, x86-64 and i686 (built, no device), a real iOS +device (only an app can run there), Windows on ARM. +*Under the API:* `RigidDynamics` in `flutter3d_physics` (world, gravity, +bodies, add, remove, bodyOf, step, push) is what `WorldStep`, the +platformer, shooter and racing simulations, the crate, the Flame bridge and +the platformer demo hold; `Dynamics` implements it unchanged and `Pusher` +is its push, shared. `NativeDynamics` in the native package: the core +holds the truth and each `RigidBody` mirrors it — what a game changed +since the last step (impulse, push, teleport, `restore`) found by +comparison and written in, everything written back after; colliders not +a body's and not triggers stand as fixed bodies (box, sphere, capsule; +wedge as a hull, offset by its centre of mass; height field as a mesh +split as it splits), moved with the velocity they moved at. +`snapshot`/`restore` for rollback. *Tests:* `native_dynamics_test.dart` +— six scenes through both backends (rest, a stack asleep, a push, an +impulse, a height field), and the core's own (a wedge, a teleport and a +restore, a lift, a wall removed, a crate tipped off an edge, a snapshot +resumed to the same bits). Mutation-checked on each write in, the sleep +written back, the lift's velocity, a standing body's removal, the mesh's +winding, the restore's write-back and the rotation lock. *Found:* a lift +moved by teleport alone left its crate 11 cm behind over a metre. +*A game on it:* the platformer's `stage` takes `dynamicsFor`; the game +still builds `Dynamics` (the core would compile and fetch wgpu-native into +every build of it), and `native_physics_test.dart` plays the shipped level +with `NativeDynamics` as a dev dependency: 34 crates land where the +reference lands them, within 5 cm, and sleep; the runner's route is the +same to the bit; a walker's push moves a crate; two runs agree to the bit. +*Found:* nine of the level's crates start inside brushes — four 1.4 m wide +in metre gaps between pillars (`crate 06`–`09`), five half sunk in steps +at z = 31 and 160 — and the reference pushes them out without a word; +`crate 06` lands 6 cm apart on the two solvers. *The web:* +`web_dynamics_test.dart` steps a mirrored scene (crates turning and not, +a ball down a wedge, a lift, a push) on the module under dart2js and +dart2wasm to the hash `native_dynamics_test.dart` holds natively, its +numbers hashed by their float64 bytes with an FNV kept under 2⁵³. Also: +the hook dated wgpu-native's unpacked headers now, and a fresh checkout's +build said a file was modified during it; dated 2000 now. Windows' CI +job had passed 119 of 119 and still exited 1 beside that message; whether +it was the cause, the next run says. *Next:* whether the game ships on the +core. +*Shipped on the core (decided 2026-10-05):* the platformer builds +`NativeDynamics` (`platformerDynamics` in `staging.dart`; +`--dart-define=FLUTTER3D_PHYSICS=dart` for the reference) and awaits +`loadPhysicsCore` before staging; a closed level disposes its core. +`RigidDynamics.saveState`/`restoreState`, carried by all three games' +snapshots under `dynamics` (absent for `Dynamics`, so the reference's +digests stand). The native restore reconciles what came and went since +the snapshot (bodies made again, standing colliders made again, orphans +removed, hull and mesh caches dropped) and resumes each standing collider +at its own position. The shipped tape `ascent.f3drun` (and the site's +sample) re-recorded by `tool/record_sample.dart`, its two goldens +re-recorded (one pixel had moved). Built for the web (the wasm modules +are assets) and for macOS (the hook's `f3d_physics.framework`); the +macOS app ran. *Found:* a save restored while a barge was elsewhere read +as a jump, and the mirror gave the barge that speed; final states agreed +and only a per-step comparison showed it. *Tests:* the shipped level +restored from a save taken as a shoved crate comes to rest steps on to +the same save every step; mutation-checked on the simulation's restore +of the state and the standing colliders' resume. *Next:* the wasm assets +ship in native builds too; Android and iOS builds of the app. + +### 4.4 Declarative widget API + +**P10. Scene as widgets (L).** `Scene3D`, `SceneNode`, `SceneMesh`, +`SceneModel` (async, with a placeholder), lights and cameras as widgets over +the imperative graph, reconciled by key, with animation control as +properties. Games keep the imperative API; examples and the site move to +whichever reads better. +*Tests:* widget tests that a rebuild with the same keys keeps node identity, +and goldens. + +**P10 progress.** *Done:* `flutter3d_app`'s `Scene3D` with `Node3D`, +`Mesh3D`, `Model3D`, `Light3D` and `Camera3D` (suffixed because `SceneNode` +is the engine's own class, exported beside them). Each owns one node of an +ordinary `Scene`, made in `didChangeDependencies`, given the widget's +properties on every update, moved with a keyed reparent and removed on +dispose; a null transform property leaves the node as a game put it. +`Scene3D` opens a device through `openDevice` unless handed one, draws +through `SceneSurface` with the children built offstage first, ticks while +`continuous`, and hands its device, renderer, scene and camera to +`onCreated`. `Model3D` loads from `assets_src/` or a loader, stands a +placeholder in until it arrives, and plays, pauses and changes its clip as +properties. A shape made fresh in `build` is built again and uploaded only +when its vertices or indices change. *Tests:* `scene_widgets_test.dart` +(the widgets as nodes, keyed reorder keeping each node and an imperative +change on it, properties applied and a removed widget's node gone, the +active camera, the placeholder until the model arrives, the animation +playing and holding; and in place of a golden, a frame drawn from widgets +equal to the byte to the same scene built by hand on the same backend; +mutation-checked on the pause). +Then the rest: `Material3D`, a material as a widget that the meshes below +it share and that a rebuild changes in place; `ReflectionProbe3D`, +`Decal3D`, `Mirror3D` (its child meshes are its surfaces), `Particles3D` +and `Contributor3D`; `SceneWidgets.mount`, the same widgets built into a +scene somebody else draws with no screen, which is how the runner draws +`widget-scene` (floor, mirror, shared-material boxes, a decal, a plume) in +all four sets — WebGPU and WebGL at 0 pixels from Impeller, the software +set at 0.021%; `example/lib/widgets_main.dart` and the quickstart's widget +section. Tests: `scene_widgets_test.dart` (one material shared and changed +in place, a mesh with none refused, a mirror's surfaces following its +children, a probe and a decal, particles emitted through the scene's loop +and their contributor gone with the widget, a mount keeping a keyed node +and taking it all back, a mount's frame equal to the imperative one; +mutation-checked on the material, the mirror and the particles) and the +example's `widgets_smoke_test.dart`. **P10 is done.** + +### 4.5 Editor and tools + +**P11. Editor (L).** Play (HR5); Windows and Linux; docking shell, outliner, +component inspector, console, command palette, render-graph inspector; +published like the modeler: a web build, which cannot Play another project, +and desktop downloads for macOS, Windows and Linux with full Play (decision +24). flutter_scene's editor is not published, which is the chance to be +first. The web build's Play is ready in `flutter3d_editor_play/attach.dart` +(HR5); the build itself is not. + +**P12. Agent tooling (M).** In a running game, not only the 320×200 CPU +render: `list_draws`, `get_draw`, `list_passes`, `pass_output`, `read_pixel`, +`scan_nan`, `render_stats`. `build`, `run`, `stop`, `hot_reload`, +`reload_level`, console and event subscription from MCP. A `skills` command +installing per-package skills (idioms, performance, looks, verification). +Desync bisect as MCP tools on top of N4. +*Game side done:* `registerRenderExtensions` in `flutter3d_app` puts +`ext.flutter3d.render.{passes, passOutput, draws, draw, readPixel, scanNan, +stats}` on the VM service, registered by the three demos beside their +timeline extensions. Each request is `Renderer.captureNextFrame(draws: +true)`, so the answer is the GPU frame the game drew; `passOutput`, `draw` +and `readPixel` read the capture already held so an index means the same +frame. The draws come from a `DrawJournal` that the mesh encoder and the +shadow casters write through `state.journal?.add(...)`, null outside the +captured frame; full-screen draws are counted per pass as `undetailed`. The +answers are plain functions of a `FrameCapture` in `render_inspection.dart`, +tested without a VM. *Left:* `scanNan` reads floats only on the software +backend (`CpuDevice.readHdrPixels`); on the hardware ones a float target is +reported unread, and a real answer there needs a float readback or a pass +that writes a NaN mask into an eight-bit target. The MCP tools that call +these extensions, and everything else in this item, are still to come. + +## 5. New features flutter_scene does not have + +Checked absent in a clone of flutter_scene by grep, and in its open issues. + +| Id | Feature | Builds on here | Effort | +|---|---|---|---| +| N1 | **Animation graph:** state machines with typed parameters and crossfades, 1D/2D blend spaces, layers with masks, clip events as `GameEvent`s, IK nodes (foot plant, look-at, hand target), root motion into `CharacterController`; stored in `.f3d`; modeler panel and MCP tools | `engine/animation/{animation_layer,inverse_kinematics,skin_blend}.dart`, `model_core/{ik_constraint,root_motion_commands}.dart` — "called from tests only" | L | +| N2 | **Navmesh and crowds:** a Recast-like bake in Dart at build time (voxelise, regions, contours, polygons), tile rebake at runtime, A* with a funnel, off-mesh links from `JumpReach`, RVO avoidance; flow fields stay for hordes | `sim/nav/{nav_grid,flow_field,jump_links}.dart` | L | +| N3 | **Behaviour trees and utility AI** as data, leaves registered by kind, blackboard in the ECS store so it is in snapshots and rewind; a debug overlay of the active path | `sim/actors/{brain,actor_system}.dart` | M | +| N4 | **Time-travel debugger:** per-entity tracks, scrubber, branch from here, divergence bisect between two tapes to the first differing step and component | `game/run/run_timeline.dart`, `sim/save/{rewind,state_digest,tape}.dart`, `net/snapshot_divergence.dart` | M | +| N5 | **Replay tests for game developers:** `testReplay('level.tape', digestAt: …, goldensAt: …)` on the CPU backend, so a game's CI needs no GPU | `flutter3d_testing/{golden,replay_golden}.dart`, `sim/headless_run.dart` | S | +| N6 | **Saves:** autosave on pause and at checkpoints, schema version and migrations, cloud sync adapters (HTTP, Play Games, iCloud) resolving conflicts by step and digest | `app/storage/atomic_write.dart`, `sim/save/snapshot.dart`, `game/screens/save_file.dart` | M | +| N7 | **Telemetry and heatmaps from tapes:** opt-in upload of input, metrics by re-simulation on a server, a heatmap in the editor | `sim_mcp/playtest.dart` (`Playtest.heatmap`) | M | +| N8 | **Photo mode:** pause, free camera held inside the level, filters, tiled capture at any resolution, share | `sim/loop/pause_gate.dart`, `camera_rig.dart`, `cpu_png.dart` | S–M | +| N9 | **Colour accessibility:** daltonise and simulate passes, semantic colour roles a player can remap, high contrast with outlines, a lint for hue-only cues | `game/config/accommodations.dart`, `app/surface/scene_semantics.dart` | S–M | +| N10 | **Sharing and ghosts:** content-addressed {level, version, tape} bundles, short codes, a web viewer, ghosts for any genre from pose tracks | `game_racing/ghost.dart`, `sim/save/{tape,replay,demo}.dart`, `cloud/` | M | +| N11 | **Procedural generation:** WFC over brush-kit tiles, hydraulic and thermal erosion on `Heightfield`, `(seed, rules) → Level` in an isolate, a reachability rule, an MCP `generate_level` | `sim/level/{heightfield,level_recipes}.dart`, `editor_core/tool/levels/` | M | +| N12 | **Cinematic sequencer:** camera on a spline, clips and graph parameters, audio, signals, subtitles, fades; in the fixed step, so it replays and rewinds; skippable to the same state | `game/run/demo_timeline.dart`, `model_core/key_table.dart`, `sim/math/spline.dart` | M–L | + +**N4. Time-travel debugger.** *Done:* `RunTimeline.scrubTo` moves the +live state to any held step while paused, keeping the tape and the present +(written down on the first scrub); a drag to the right plays on from the +scrubbed step instead of the keyframe. `returnToPresent` restores the +present exactly, `branchHere` cuts the future at the scrubbed step and stays +paused, `stepOnce` from a scrub walks the tape, and `resume` goes on from +the present. `EntityTracks` is one lane per component of each entity, +holding only the steps it changed, read through an `EntityLayout` +(`ecs` for an `EcsWorld.save()`, `rows` for one row per entity); +`RunTimeline.tracks` fills it by replaying the buffer and puts the live +state back. `bisectTapes` takes two `ReplaySide`s (start, tape, step, +restore, capture) and finds the step after which the two runs first differ +with the step before agreeing: digests at each probe, the full snapshots +once, so a thousand-step bracket is about a dozen comparisons and at most +twice its length in steps. It names the entity and component, and says +whether that step's input differed (another recording) or not (the code or +the platform). `bracketFromTraces` narrows it to one checkpoint interval of +two `DigestTrace`s. Over the wire: `ext.flutter3d.timeline.scrubTo`, +`returnToPresent`, `branchHere`, `tracks`, and `status` with the window. The +editor's timeline panel has the scrubber, "Back to present", "Branch here" +and the lanes, whose marks scrub to their step. +*Tests:* a scrub back and forth lands on the state recorded before each +step; bisection finds a planted defect at its step and component in at most +twelve probes; the extensions end to end over a real VM service; the panel +against a fake client. +*Left:* bisection is a library call; the two `.f3drun` files side by side in +the editor and as MCP tools are P12. A scrub through a *loaded* `.f3drun`'s +whole length, beyond what the rewind buffer holds. `stepOnce` at the present +still steps without recording an entry or a keyframe (older than N4), so +the tape is short of the state by the steps taken that way, and a rewind or +branch afterwards replays without them. The cross-OS digest test §7 asks of the step does not apply yet: N4 +adds nothing that runs in the step. +**N5.** *Done:* `testReplay(path, start:, digestAt:, goldensAt:)` in +`flutter3d_testing` registers a test that reads a `.f3drun`, builds the game +on a software device and replays the tape into it. At each step of +`digestAt` (by default every checkpoint the tape holds) the digest of the +game's snapshot must equal the recorded one, and a divergence names its step +and the last checkpoint that agreed; at each step of `goldensAt` the frame is +compared with `test/goldens/-.png`. A game implements +`ReplaySubject` (`levelHash`, `restore`, `step`, `save`, `frame`), which is +four calls and the hash. Refused before the first step: a step past the end +of the tape, a digest where no checkpoint was taken, a test checking nothing, +and a level whose hash changed since the recording. `expectReplayMatches` +takes a `Demo` already in memory, `readTape` reads one with a sentence when +it cannot. The example is `apps/flutter3d_demo_platformer/test/replay_test.dart`: +600 steps of the Ascent, 24 checkpoints, goldens at 120 and 600, about five +seconds. Its hash check found the site's `platformer.f3drun` recorded before +the level's last edit; `tool/record_sample.dart` now writes that sample and +the test's tape from one run. +*Left:* a digest-only replay under plain `dart test` through +`HeadlessGame`/`HeadlessRun`, for a game whose simulation has no Flutter in +it and a server that verifies runs (`HeadlessRun` would need `restore`); the +dungeon's `replay_golden_test.dart` and the racing demo moved onto +`testReplay`; the strategy demo's `MatchDemo`, which is not a `Demo`; and a +way to drop a tape the running game recorded into `test/tapes/` from the +editor or the MCP server instead of a script per game. +**N7 status.** *Done:* `flutter3d_sim` has the client side and the format. +`TelemetryConsent` keeps the player's answer with the wording it was given +to; a grant to an older wording and a damaged settings file both count as no. +`TelemetryUpload.prepare` is the only way to build an upload and refuses +without consent. It drops `recordedBy`, and the consent travels with the run. +`TelemetryUploader` checks on every send and calls the sink only with a +grant. `HttpTelemetrySink` posts through a function the caller hands in. +`resimulate` replays a `Demo` against its level hash, starting state and +checkpoints, and returns the outcome and a trail, or where it parted. +`Heatmap` bins trails in `Playtest.heatmap`'s JSON, and `Playtest.heatmap` +and the sim server's `verify` now go through both. The reference server in +`cloud/server` has `POST /api/telemetry/runs` (refused without consent, +replayed in an isolate, a diverging or unknown run refused with nothing kept, +the input dropped and the trail and outcome kept with the consent record), +`DELETE runs/?key=` with only the key's hash stored, and +`GET heatmap?level=`. Postgres has migration 004. The editor's +report screen fetches that heatmap for the open level's digest and draws it +through `ai-02`'s view, where a lost run is a run id on the server. +*Tests:* consent, upload, replay and heatmap in `telemetry_test.dart`. The +routes run in memory, isolate included, in `telemetry_routes_test.dart`. +`telemetry_db_test.dart` covers the repository against Postgres and was not +run here, since no database was up. The fetch path is in the editor's +report screen test. +*Left:* the question in the games. No settings panel asks it yet, and no +demo sends a finished run, so `TelemetryUploader` needs a `JsonPost` over a +real client in `flutter3d_game`. A genre the server can step under +`dart run` comes next: every genre needs Flutter, so `main.server.dart` +registers no game and answers 503 until one is split out the way +`flutter3d_sim` was. Our instance is not deployed and not configured (decision +23). The heatmap is drawn on its own screen, not over the level in the +viewport. There is no upload rate limit and no retention period, and the +privacy page says nothing about telemetry yet. +**N8 status.** *Done:* `shouldPause(photoMode:)` stops the world whatever the +pointer and the pad say. `PhotoCamera` in `flutter3d_sim` flies with look, +tilt and zoom, and is held three ways: a tether round where the player stood, +the level's box when there is one, and the walls, by sweeping each move and +sliding along what it meets; it starts by sweeping out from the player, so a +chase camera left behind a wall does not start the photo there. `PhotoFilter` +in `flutter3d_core` is eight looks (none, vivid, warm, cool, mono, sepia, +noir, faded) composed onto the game's `LookSettings`, so all four backends +draw them through the composite they already have. `capturePhoto` draws a +picture of any size in tiles on the game's own renderer, each with a margin +through the new `CropProjection`, and hands it out a row of tiles at a time; +`PhotoFinish` puts the vignette and the grain back over the whole frame. +Exposure is held at the screen's, and what cannot be carried over (temporal +anti-aliasing, motion blur, render scale, chromatic aberration) is listed in +the report. `PngStripWriter` in `flutter3d_cpu` writes the PNG a strip at a +time. `takePhoto` and `PhotoShelf` in `flutter3d_app` save it: the Pictures +folder on a desktop, the game's folder on a phone, the share sheet or a +download in a browser. The platformer has it on P: WASD, Space and C fly, +`[` `]` pick a filter, `,` `.` tilt, `-` `=` zoom, Enter saves at twice the +window and Shift+Enter at four times. +*Tests:* `photo_camera_test.dart` (walls, slide, tether, box, 500 random +moves in a furnished room) and `pause_gate_test.dart` in `flutter3d_sim`; +`photo_capture_test.dart` in `flutter3d_cpu` holds a ragged 3×3 grid to the +whole frame within one step, the margin to bloom seams of at most 6 steps +against 53 without, the vignette to the whole frame's within two, and the +strips to a PNG that zlib reads with its checksum; `photo_shelf_test.dart` in +`flutter3d_app`; `photo_mode_test.dart` in the platformer. +*Left:* the native share sheet on Android and iOS, which needs a plugin this +package does not take (a game passes its own `PhotoShelf`); chromatic +aberration over the whole frame; touch and pad controls for photo mode, and +the other demos; bloom's widest levels reach past any margin, so a capture +with bloom is close to the screen rather than equal; the browser share sheet +is untested in a browser, and the platformer's photo mode has not been +driven in a running window yet. + +Lifted exclusions (decision 12), each its own item: + +- **N13. `flutter3d_voxel` plus a demo (M–L):** chunks, greedy meshing, + collision against chunks, edits saved as deltas, a small sandbox demo. +- **N14. Networking past two (L):** rollback for 4–8 players in + `flutter3d_net` and `flame_multiplayer`, rooms, matchmaking and a relay, and + spectators fed by the tape; plus an authoritative-server model with state + replication and client prediction for larger matches (decision 15), built + in `flame_multiplayer` over `PeerWire` and our snapshots rather than on + someone else's replication (decision 22). +- Behaviour on objects and level generators are N3 and N11. + +**N3 progress.** *Done:* `BehaviourTree.read` takes a JSON tree +(`sequence` that resumes, `selector` that asks again from the top, `utility` +scoring options as weight times the product of considerations with inertia +for the running one, `invert`, `alwaysSucceed`, `cooldown`) and refuses a +document with every problem and where it is. Leaves and considerations are +registered by kind in `BehaviourKinds`; the standard ones are what `Mind` +already does. `BehaviourBrain` keeps nothing itself: path, per-node memory, +cooldowns, a clock and what it heard or felt are a `Blackboard` component in +the ECS save, and a board under a changed tree (by digest) starts again. +`BehaviourOverlay` in `flutter3d_game` draws the running path and the goal +through `Renderer.debugLines` without touching the boards. +*Tests:* `flutter3d_sim/test/behaviour_tree_test.dart` (reading, every node, +the board, a 600-step digest trace and a restore at step 300 that agrees to +the end while one without boards does not); +`flutter3d_game/test/behaviour_overlay_test.dart`. +*Left:* walking to a point goes straight (`Mind.steerTowards`) until N2's +navmesh can route there, and then `goTo` is replaced in `BehaviourKinds`; no +`parallel` node (one body steers one way); trees authored in the editor and +over MCP; the overlay's names shown in a demo rather than only returned by +`describe()`; the digest trace measured on a second operating system. +**N6, saves.** *Done:* `SaveSchema` in `flutter3d_sim` is a game's list of +migrations, and its version is their count, so the version cannot move +without one; an older run is brought up on the way in and a newer one is +refused as newer rather than misread. The document is a `SaveRecord`: +level, snapshot, schema, step (`RunSession.stepOf`, the run's steps across +its levels) and a digest of the level and run. `resolveSaves` settles two +copies by digest and step against the digest both last agreed on, and a tie +between two different runs goes to the player. `Autosave` in +`flutter3d_game` writes on the way into a pause, at a checkpoint and when +the application goes to the background; `SaveFile` skips writing the run it +last wrote. `SaveSync` sends nothing until the player has agreed (kept in +`cloud_saves.json`, off on a fresh install, and checked before any store is +called), settles by `resolveSaves`, retries once when another device wrote +in between, and leaves a cloud save from a newer build alone. Stores: +`HttpCloudSaves` (`GET`/`PUT` on `saves//` with `ETag` +preconditions) and `PlatformCloudSaves` for Play Games and iCloud over the +`flutter3d/cloud_saves` channel. The platformer autosaves on pause, at +checkpoints and in the background, and counts its steps from its elapsed +time. +*Left:* the native sides of `flutter3d/cloud_saves` (Play Games Services +snapshots in Kotlin, CloudKit or the iCloud key-value store in Swift) and a +plugin package to carry them; the `saves/` routes in the reference server +in `cloud/`, with accounts (decision 23); a consent switch and a "which run +do you want" dialog in the settings panel, and the platformer calling +`SaveSync` before `begin`; the dungeon and racing on `Autosave`. + +## 6. Platforms (decision 16) + +- **Windows and Linux:** runners for the demos and the editor; native sides of + `pad_input` and `pointer_lock`; CI desktop jobs already exist and become + required. +- **Android:** the frame numbers on the Galaxy A55 that 0.8 left out + (`doc/pacing/`), committed and gated. +- **WebGPU by default in the browser**, with WebGL2 as the fallback when no + adapter is offered. The 376 KB of JavaScript this costs (measured on the + strategy demo) is accepted; it brings compute to the web, which H11 (GPU + splat sort) and GPU particles can then use. + +## 7. Quality rules (decision 18) + +Four backends agree; every effect has a CPU mirror; a new effect is off by +default until its golden exists in all four sets. Everything in §5 that runs +in the step is tested by digest across operating systems. Each new test moves +the counts `tool/structure.dart` holds README, ARCHITECTURE.md and the site +to. + +## 8. Carried over from 0.8 + +- **Upstream-triggered:** H9 push constants, H10 indirect draw and GPU + culling, H12 subgroups and bindless, H13 Slang, C10 neural texture + compression. **H11, GPU splat sort on WebGPU compute, has its trigger met** + (H6 landed) and moves into 0.9.0, helped by WebGPU becoming the default. +- **Partly done:** H2 GPU timings on WebGL (`EXT_disjoint_timer_query`); N2's + quality table measured per device class instead of on the CPU rasteriser; + N3 pacing on Android (§6). +- **From `plan-status.json`:** `fmt-27` USDZ writer, `gfx-05n` lights by + importance, `gfx-08n` SSAO on by default, `gfx-30n` lens flare (now P2), + `gfx-25n` one switch for the post chain. +- **From the gap analysis still open:** M2 reversed-Z, sea and rivers, terrain + layers and geomorphing, LOD cross-fade, clip planes, outline/matcap/toon + ramp; M3 ribbons, stretched billboards, particle collision, sub-emitters, an + effect format, Doppler; M4 streaming with a VRAM budget, prefab assets with + sockets, a quaternion on `EntityDef`; M5 sockets, more than 64 joints and 8 + morphs, world-space text. These are not all promised for 0.9.0; each is + pulled in when an item above needs it (ragdoll needs P9, the sequencer needs + sockets, and so on). +- **Lab (phase 9):** unchanged, not in 0.9.0. + +## 9. Order + +``` +HR1 ── HR2 ── HR5 ── HR3 ── HR4 (first: decision 14) +P9 ─── N1 (ragdoll, root motion into bodies) +P8 ─── HR1 (materials reload through the coordinator) +P10 independent, touches every example: early enough to write the rest in it +P1 P2 P3 P4 P5 P6 P7 independent, parallel +N2 ── N3 ── N12 (NPCs that walk, decide and act in cutscenes) +N4 ── P12 (desync bisect for agents), N5, N7 +N6 ── N10 +N8 N9 N11 N13 N14 H11 independent +§6 platforms run alongside from the start +``` + +## 10. What one release of all of this costs + +This is larger than 0.8, which was the largest release so far. The risk 0.8 +named is sharper here: packages on pub.dev stay at 0.8.x for months while +flutter_scene ships 0.24 and whatever follows. Three things keep it honest. +Every item lands behind a setting that is off by default, so nothing half +done reaches a user of the branch. Items are merged into `0.9.0` as they +finish, so the branch is always releasable minus what is not in yet. And +§9 puts the visible parity first, so if the owner revisits decision 13, a +parity-only 0.9.0 is the natural cut. + +## 11. Stale now, fixed before the release + +- `site/content/reference/comparison.md` was read against 0.8.1/0.8.2; fix + versions, the bridge's 0.8.4, `flame_multiplayer`, and flutter_scene's new + input package now; revise the whole table for 0.9.0 (decision 19). +- The README and ARCHITECTURE §8.4/§15 say `convert` has no texture encoder; + `formats/ktx2/encode/` has BC1, BC3, ETC2 and ASTC. +- The README package table omits `flame_flutter3d`, `flame_flutter3d_audio`, + `flame_multiplayer*` and `flutter3d_lti`. +- ARCHITECTURE says the dungeon demo has no Windows or Linux runner; it has + both directories. + +## 12. Services + +N7 and N10 need somewhere to send tapes and bundles to. The packages define +the client side and the formats; `cloud/` gets a reference server anyone can +run, and the demos on the site use our instance (decision 23). Moderation of +shared levels and consent for telemetry are that server's job, and the client +never uploads without an explicit opt-in. + +*Done, as code and tests, deployed nowhere:* the format and the client are +in `flutter3d_sim`. `ShareBundle` holds a level document, its hash and +optionally a `.f3drun`, and a run recorded in another version of the level +is refused. `RunService` speaks the `v1/shares` routes over a transport the +game hands in. The server half is in `cloud/server`, beside N7's telemetry +and in the same shape: a pure `ShareService`, a store in memory and one in +Postgres (migration 005), routes under `/api/v1/shares` and +`/api/v1/moderation`. A bundle is filed by the SHA-256 of the bytes the +server writes and gets a short Crockford code derived from it. Moderation +starts in `review` (nothing opens until a moderator publishes it) or +`open` (enough reports send it back to review); a removal needs a reason and +the code answers with it. In `review` with no moderator token the server +refuses shares rather than keeping what nobody could publish. Telemetry is +N7's and is not duplicated here. +*Tests:* the format and the client in `flutter3d_sim`; the routes with +`RunService` against the real handler in `cloud/server`, and the Postgres +store under the `db` tag. +*Left:* deploying our instance; the web viewer for a code and ghosts from +pose tracks (N10); a game that actually shares, which is where the client +is first used outside tests. diff --git a/test/structure_test.dart b/test/structure_test.dart new file mode 100644 index 000000000..310034eb4 --- /dev/null +++ b/test/structure_test.dart @@ -0,0 +1,34 @@ +/// The repository's own arrangement, held to the rules `tool/structure.dart` +/// checks, under `dart test` from the root. +/// +/// dart test +/// +/// **The same rules, not a second copy of them.** The script is the first step +/// of `tool/ci.sh` and prints a report a person reads; this runs the list it +/// runs, `allRules`, so a check that only knows to call `dart test` at the root +/// asks the same questions. Before this file the root held only reference +/// pictures, and `dart test` there found nothing and exited 79. +library; + +import 'package:test/test.dart'; + +import '../tool/structure/detectors.dart'; +import '../tool/structure/rules.dart'; + +void main() { + // Mutation: a detector that can no longer fire passes every rule built on + // it; this is the test that says so before the rules are believed. + test('every detector fires on what it is meant to find', () { + final broken = proveDetectorsWork(); + expect(broken, isEmpty, reason: broken.join('\n')); + }); + + for (final rule in allRules) { + // Mutation: change a test count in README.md, or drop a golden from one + // set, and the rule that holds it fails here as it does in the script. + test(rule.name, () { + final found = rule.run(); + expect(found, isEmpty, reason: found.join('\n')); + }); + } +} diff --git a/tool/ci.sh b/tool/ci.sh index de3f886d2..487e09680 100755 --- a/tool/ci.sh +++ b/tool/ci.sh @@ -9,7 +9,7 @@ # # What it does NOT do, stated so the gap is not mistaken for coverage: # -# * The Impeller half of the golden set. Those seventy-eight scenes render through +# * The Impeller half of the golden set. Those ninety-five scenes render through # flutter_gpu and need a real device, so they run from # packages/flutter3d/tool/golden.sh on a machine with a GPU. The software # half runs here, and cross_backend_test.dart compares the two committed @@ -250,6 +250,15 @@ for package in packages/*/; do fi done +# **The teaching examples, one directory down.** `packages/education` groups +# them rather than being a package, so the loop above walks past it; each +# example is a Flutter app with a suite of its own. +for example in packages/education/*/; do + [ -f "$example/pubspec.yaml" ] || continue + [ -n "$(find "$example/test" -name '*_test.dart' -print -quit 2>/dev/null)" ] || continue + step "test $(basename "$example")" in_dir "$example" flutter test +done + # **The models service, which the loop above cannot see.** `cloud/server` is a # service rather than a package, so it sits outside `packages/` and outside the # pub workspace, resolves on its own, and would otherwise have no step at all. @@ -360,6 +369,17 @@ step "test webgpu_spike (browser)" in_dir tool/webgpu_spike flutter test --platf # the slower place to do that. step "test flutter3d_physics (browser)" in_dir packages/flutter3d_physics dart test -p chrome step "test flutter3d_sim (browser)" in_dir packages/flutter3d_sim dart test -p chrome +# The native physics core as WebAssembly, under both web compilers: the +# shared scene to the native library's hash, on one thread and on four Web +# Workers, and flutter3d_physics' bodies on it through NativeDynamics. The second needs SharedArrayBuffer, which tool/chrome_sab.sh asks +# Chrome for; the test runner's pages are not cross-origin isolated. +for compiler in dart2js dart2wasm; do + step "test flutter3d_physics_native (browser, $compiler)" in_dir packages/flutter3d_physics_native \ + dart test -p chrome -c "$compiler" test/web_core_test.dart test/web_dynamics_test.dart + step "test flutter3d_physics_native (browser threads, $compiler)" in_dir packages/flutter3d_physics_native \ + env CHROME_EXECUTABLE="$PWD/packages/flutter3d_physics_native/tool/chrome_sab.sh" \ + dart test -p chrome -c "$compiler" test/web_threads_test.dart +done # The game layer's input files, named for the reason the application layer's # are above: the saves, the settings document and the timeline's service # extensions beside them reach `dart:io`. diff --git a/tool/flat_dart_check.sh b/tool/flat_dart_check.sh index 222d04f20..840a9817b 100755 --- a/tool/flat_dart_check.sh +++ b/tool/flat_dart_check.sh @@ -37,6 +37,9 @@ FAILED=() # which is not there is a different failure with the same colour. for package in packages/*/; do name="$(basename "$package")" + # A directory that groups packages rather than being one — see + # `publish_check.sh`. + [ -f "$package/pubspec.yaml" ] || continue mkdir -p "$WORK/packages/$name" # `lib`, `bin` and the pubspec are what a resolve and a `--help` need. Tests # and examples are not copied: they carry `flutter_test` on purpose in the diff --git a/tool/publish_check.sh b/tool/publish_check.sh index fe143f3c9..38b60363d 100755 --- a/tool/publish_check.sh +++ b/tool/publish_check.sh @@ -69,6 +69,10 @@ ALLOWED='modified in git|The previous version is' for dir in packages/*/; do name="$(basename "$dir")" + # A directory that groups packages rather than being one — + # `packages/education` holds the teaching examples, each its own app that + # is never published — has no pubspec of its own. + [ -f "$dir/pubspec.yaml" ] || continue cp "$dir/pubspec.yaml" "$BACKUPS/$name.yaml" python3 - "$dir/pubspec.yaml" <<'PY' import sys diff --git a/tool/structure/detectors.dart b/tool/structure/detectors.dart index 244431aa5..e7222ad76 100644 --- a/tool/structure/detectors.dart +++ b/tool/structure/detectors.dart @@ -73,6 +73,15 @@ const List kGenreWords = [ 'lap', 'nitro', 'chicane', 'pit stop', ]; +/// Names another program chose, which this repository calls by them and +/// cannot rename, each exempt from [kGenreWords] whole and only whole. +/// +/// * `reloadSources` is the service the flutter tool registers on a game's +/// VM service for a hot reload (`flutter3d_editor_play`'s `AttachedRun` +/// calls it, as DevTools does). Spelling it any other way is a call that +/// finds nothing; `reloadAll` of our own still fires. +const Set kProtocolNames = {'reloadSources'}; + final RegExp _camel = RegExp(r'[A-Z]+(?![a-z])|[A-Z][a-z0-9]*|[a-z0-9]+'); final RegExp _identifier = RegExp(r'[A-Za-z_][A-Za-z0-9_]*'); @@ -286,6 +295,7 @@ bool _sameWords(List a, List b) { List genreWordsIn(String source, {List words = kGenreWords}) { final found = []; for (final match in _identifier.allMatches(codeOf(source))) { + if (kProtocolNames.contains(match.group(0))) continue; final said = _words(match.group(0)!); for (final forbidden in words) { final wanted = forbidden.split(' '); @@ -479,6 +489,17 @@ List proveDetectorsWork() { 'a line comment', 'prose explaining the rule must not break it', ); + quiet( + 'genre words', + genreWordsIn("const swapService = 'reloadSources';").isEmpty, + 'a protocol method name', + 'a name another program chose is called by its name', + ); + fires( + 'genre words', + genreWordsIn('Future reloadAll() async {}').isNotEmpty, + 'reloadAll (one of ours)', + ); quiet( 'genre words', genreWordsIn('/* a boss with a gun */ int x = 0;').isEmpty, diff --git a/tool/structure/repository.dart b/tool/structure/repository.dart index e26785b72..438247870 100644 --- a/tool/structure/repository.dart +++ b/tool/structure/repository.dart @@ -117,6 +117,10 @@ const Map flatDartPackages = { 'which cannot resolve a package that depends on the Flutter SDK — so a ' 'single Flutter import here is not a heavier process, it is a server ' 'that will not start on any machine that has not got the Flutter tool', + 'flutter3d_editor_play': + 'the editor\'s MCP server runs Play through it, and that server is ' + 'started with `dart run`: a Flutter import here is `play` taking the ' + 'whole server down with it', 'flutter3d_hardware': 'the vocabulary a backend implements named no graphics API already; ' '`GraphicsDevice.present` was its one Flutter import, returning the ' @@ -214,6 +218,12 @@ const Map> genreBridgeHalf = >{ /// it does instead: a camera with no subject to follow has no rig to turn, and /// that is a reason; "it was easier" is not. const Map notARigCamera = { + 'flutter3d_sim/lib/src/camera/photo_camera.dart': + 'photo mode\'s camera, flown by the player with the world paused: it ' + 'follows nothing, so there is no smoothing to share, and a shake or a ' + 'kick on a camera lining up a still picture is the one thing it must ' + 'not have. It keeps out of walls by sweeping each move, which the rig\'s ' + 'ray back from a subject cannot do for a camera with no subject', 'flutter3d_core/lib/src/engine/scene/camera_node.dart': 'the scene-graph camera the rig steers, not a camera that follows ' 'anything: it holds the projection and the view, sits below ' @@ -631,6 +641,13 @@ String relative(File file, Directory dir) => file.path /// the next one has to be argued for rather than typed. const Map> boundaryEnumExempt = >{ + 'flutter3d_sim/lib/src/actors/blackboard.dart': { + 'BehaviourStatus': + 'success, failure and running are the whole algebra a behaviour ' + "tree's composites are defined over. A fourth is not an outcome a " + "leaf is missing; it is a different kind of tree, and every " + 'composite would have to be rewritten for it anyway', + }, 'flame_flutter3d/lib/src/world/grid_mover.dart': { 'GridHeading': 'the four ways out of a square cell and standing still. A fifth ' @@ -977,6 +994,12 @@ boundaryEnumExempt = >{ 'playing, won, lost. The vocabulary all three games and every ' 'screen are built on, and the same set RunStatus is sealed around', }, + 'flutter3d_sim/lib/src/save/save_record.dart': { + 'SaveResolution': + 'what can come of comparing two copies of one thing: they are the ' + 'same, keep this one, keep that one, or nobody but the player can ' + 'say. A fifth would not be an answer this comparison is missing', + }, 'flutter3d_editor_core/lib/src/gizmos.dart': { 'Piece': 'the three things a level document is made of, seen from an editor: ' @@ -1173,6 +1196,9 @@ const Map> portableStepExempt = 'flutter3d_sim': { 'lib/src/camera/camera_rig.dart': 'a camera is where the picture is taken from; no run depends on it', + 'lib/src/camera/photo_camera.dart': + 'flown only while the run is paused for a photo, and nothing it ' + 'does reaches a snapshot or a tape', 'lib/src/world/light_fixture.dart': 'how bright a lamp looks on a frame, which nothing steps on', 'lib/src/level/lightmap_baker.dart': diff --git a/tool/structure/rules.dart b/tool/structure/rules.dart index 2b5964f07..f401d6372 100644 --- a/tool/structure/rules.dart +++ b/tool/structure/rules.dart @@ -93,6 +93,10 @@ List get allRules => [ ), (name: 'a step asks no machine for an answer', run: _portableStepArithmetic), (name: 'every skill is named for its package', run: _skillNames), + ( + name: 'a package that says it runs on the web reaches no dart:io there', + run: _webPackagesReachNoIo, + ), ]; // ------------------------------------------------------------------- genre @@ -913,6 +917,11 @@ List _ruleCount() { 'thirty-three', 'thirty-four', 'thirty-five', + 'thirty-six', + 'thirty-seven', + 'thirty-eight', + 'thirty-nine', + 'forty', ]; final actual = allRules.length; final found = []; @@ -2265,6 +2274,10 @@ List _goldenSceneCount() { 'tool/ci.sh', 'packages/flutter3d_webgl/tool/golden_web.sh', 'ARCHITECTURE.md', + // The README's own count of the scenes was left at seventy-eight when + // the seventy-ninth landed: it was the one page that says it and that + // nothing here read. + 'README.md', ]) File('${repositoryRoot.path}/$where'), ..._prosePages(), @@ -2623,6 +2636,10 @@ const List _countedInWords = [ 'sixty-seven', 'sixty-eight', 'sixty-nine', 'seventy', 'seventy-one', 'seventy-two', 'seventy-three', 'seventy-four', 'seventy-five', 'seventy-six', 'seventy-seven', 'seventy-eight', 'seventy-nine', 'eighty', + 'eighty-one', 'eighty-two', 'eighty-three', 'eighty-four', 'eighty-five', + 'eighty-six', 'eighty-seven', 'eighty-eight', 'eighty-nine', 'ninety', + 'ninety-one', 'ninety-two', 'ninety-three', 'ninety-four', 'ninety-five', + 'ninety-six', 'ninety-seven', 'ninety-eight', 'ninety-nine', 'one hundred', ]; /// A count said both ways, so a finding can be read and searched for. @@ -3153,3 +3170,100 @@ List _surfaceDepthStays() { } return found; } + +// --------------------------------------------------------------------- web + +/// The libraries pub.dev counts against the web: an import of one, from +/// anything a package's libraries reach in the browser's build, and pub.dev +/// lists the package without the web — stub or not, run there or not. +const Set _notOnTheWeb = { + 'dart:io', + 'dart:isolate', + 'dart:ffi', + 'dart:mirrors', + 'dart:cli', +}; + +/// A package whose pubspec lists `web:` under `platforms:` reaches none of +/// [_notOnTheWeb] through the imports and exports the browser's build +/// follows: into this repository's packages, and down the web branch of a +/// conditional one. `flutter3d` was listed without the web while it ran +/// there, for one `dart:io` it imported to name an exception. +List _webPackagesReachNoIo() { + final found = []; + for (final entry in packages.entries) { + final pubspec = File('${entry.value.path}/pubspec.yaml'); + if (!pubspec.existsSync()) continue; + if (!RegExp( + r'^platforms:\n(?: \w+:\n)* web:', + multiLine: true, + ).hasMatch(pubspec.readAsStringSync())) { + continue; + } + final seen = {}; + // From its public libraries, as pub.dev reads it: what `lib/src` holds + // counts only where one of them reaches it. + final queue = [ + ...Directory( + '${entry.value.path}/lib', + ).listSync().whereType().where((f) => f.path.endsWith('.dart')), + ]; + while (queue.isNotEmpty) { + final file = queue.removeLast(); + if (!seen.add(file.absolute.path) || !file.existsSync()) continue; + for (final uri in _webDirectives(file.readAsStringSync())) { + if (_notOnTheWeb.contains(uri)) { + found.add( + Finding( + file.path.substring(file.path.indexOf('packages/')), + 'reaches $uri in the browser, though ${entry.key} says it runs ' + 'there: choose it by `if (dart.library.js_interop)`', + ), + ); + continue; + } + final next = _resolveInRepository(uri, file); + if (next != null) queue.add(next); + } + } + } + return found; +} + +/// Every import and export in [source], each as the browser's build takes +/// it: a conditional one by its web branch when it names one, by its +/// default when it asks for `dart.library.io` the browser has not. +Iterable _webDirectives(String source) sync* { + final directive = RegExp( + r"^(?:import|export)\s+'([^']+)'((?:\s+if\s*\([^)]*\)\s*'[^']+')*)", + multiLine: true, + ); + final branch = RegExp(r"if\s*\(([^)]*)\)\s*'([^']+)'"); + for (final match in directive.allMatches(source)) { + var chosen = match.group(1)!; + for (final condition in branch.allMatches(match.group(2) ?? '')) { + final asks = condition.group(1)!.trim(); + if (asks.contains('js_interop') || + asks.contains('dart.library.html') || + asks.contains('dart.library.js')) { + chosen = condition.group(2)!; + break; + } + } + yield chosen; + } +} + +/// [uri], imported from [from], as a file in this repository; null for the +/// SDK's libraries and packages from elsewhere. +File? _resolveInRepository(String uri, File from) { + if (uri.startsWith('dart:')) return null; + if (uri.startsWith('package:')) { + final rest = uri.substring('package:'.length); + final slash = rest.indexOf('/'); + final home = packages[rest.substring(0, slash)]; + if (home == null) return null; + return File('${home.path}/lib/${rest.substring(slash + 1)}'); + } + return File.fromUri(from.absolute.uri.resolve(uri)); +} diff --git a/tool/webgpu_spike/lib/src/webgpu_interop.dart b/tool/webgpu_spike/lib/src/webgpu_interop.dart index bfe8df228..3883fccdd 100644 --- a/tool/webgpu_spike/lib/src/webgpu_interop.dart +++ b/tool/webgpu_spike/lib/src/webgpu_interop.dart @@ -105,7 +105,11 @@ extension type GPURenderPassEncoder._(JSObject _) implements JSObject { external void setScissorRect(int x, int y, int width, int height); external void setBlendConstant(GPUColorDict color); external void setStencilReference(int reference); - external void drawIndexed(int indexCount, int instanceCount); + external void drawIndexed( + int indexCount, + int instanceCount, [ + int firstIndex, + ]); external void end(); } diff --git a/tool/webgpu_spike/lib/src/webgpu_spike_device.dart b/tool/webgpu_spike/lib/src/webgpu_spike_device.dart index 360465c5f..f8bf0396b 100644 --- a/tool/webgpu_spike/lib/src/webgpu_spike_device.dart +++ b/tool/webgpu_spike/lib/src/webgpu_spike_device.dart @@ -274,6 +274,10 @@ final class WebGpuSpikeDevice implements GraphicsDevice { @override bool get supportsWireframe => false; + /// False — `P7`: the spike predates it. + @override + bool get supportsAlphaToCoverage => false; + @override bool get supportsStencil => true; diff --git a/tool/webgpu_spike/lib/src/webgpu_spike_encoder.dart b/tool/webgpu_spike/lib/src/webgpu_spike_encoder.dart index 444f9e879..67a9013a9 100644 --- a/tool/webgpu_spike/lib/src/webgpu_spike_encoder.dart +++ b/tool/webgpu_spike/lib/src/webgpu_spike_encoder.dart @@ -160,6 +160,10 @@ final class WebGpuSpikeEncoder implements CommandEncoder { @override void setDepthWrite(bool enabled) => _depthWrite = enabled; + /// Nothing — `P7`: the spike's device answers false to `supportsAlphaToCoverage`. + @override + void setAlphaToCoverage(bool enabled) {} + @override void setDepthCompare(CompareFunction compare) => _depthCompare = compare; @@ -291,7 +295,12 @@ final class WebGpuSpikeEncoder implements CommandEncoder { // ------------------------------------------------------------- the draw @override - void draw({int instanceCount = 1}) { + void draw({int instanceCount = 1, int firstIndex = 0, int? indexCount}) { + final window = indexWindow( + _indexCount, + firstIndex: firstIndex, + indexCount: indexCount, + ); if (instanceCount == 0) return; final pipeline = _pipeline; if (pipeline == null) { @@ -309,7 +318,7 @@ final class WebGpuSpikeEncoder implements CommandEncoder { } _pass ..setIndexBuffer(_indexBuffer!, gpuIndexFormat(_indexType)) - ..drawIndexed(_indexCount, instanceCount); + ..drawIndexed(window.count, instanceCount, window.first); } /// The key this draw's state makes, which is also what the pipeline cache is