diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 2627251b2..30e74d293 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -2691,7 +2691,7 @@ storage. |---|---| | Style | `dart format` | | Analysis | `flutter analyze` clean across the workspace, no warnings | -| Unit tests | **11340 tests** across 43 packages and 10 applications | +| Unit tests | **11343 tests** across 43 packages and 10 applications | | Structure rules | 35, `dart run tool/structure.dart`, the first CI step | | CI | GitHub Actions over `tool/ci.sh`, on `ubuntu-latest`, with no graphics card | @@ -2731,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.** 90 scenes against **four complete independent +**Golden render tests.** 91 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. @@ -3143,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 ninety golden scenes the way Impeller does**, +**The web backend draws all ninety-one 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 @@ -3259,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 ninety scenes and both browser backends. +is one dart2js run serving ninety-one 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 diff --git a/README.md b/README.md index d468a8dd9..57b33150f 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 11340 tests across forty-three packages and nine applications. The +There are 11343 tests across forty-three 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 90 scenes +half is rendered by the software backend, which is what makes 91 scenes checkable in a headless run. Several steps run in a browser. `flutter test --platform chrome` covers the two diff --git a/packages/flutter3d/CHANGELOG.md b/packages/flutter3d/CHANGELOG.md index 95f48b367..b18d6476e 100644 --- a/packages/flutter3d/CHANGELOG.md +++ b/packages/flutter3d/CHANGELOG.md @@ -1,5 +1,9 @@ ## Unreleased +- `orthographic-shadow` in the reference set: a strategy board through an + isometric orthographic camera, its posts' shadows as sharp at the top of + the frame as at the bottom. + - **`generatedMaterialPathFor`**: where the build hook writes a `.f3dmat`'s compiled bundle, `flutter3d_generated/.f3dshaders`. diff --git a/packages/flutter3d/example/lib/src/spike/backend_web.dart b/packages/flutter3d/example/lib/src/spike/backend_web.dart index 983e7134d..6e347face 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 ninety scenes because the scene is a query parameter, and one +/// serves ninety-one 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_runner.dart b/packages/flutter3d/example/lib/src/spike/golden_runner.dart index a04f2a839..caa5896ef 100644 --- a/packages/flutter3d/example/lib/src/spike/golden_runner.dart +++ b/packages/flutter3d/example/lib/src/spike/golden_runner.dart @@ -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 ninety scenes in both + // same reason on both: one build has to serve ninety-one scenes in both // directions, and anything the compiler sees is another build. update: updateOverride ?? diff --git a/packages/flutter3d/example/lib/src/spike/golden_scenes.dart b/packages/flutter3d/example/lib/src/spike/golden_scenes.dart index 72e6f9e9f..734ead07a 100644 --- a/packages/flutter3d/example/lib/src/spike/golden_scenes.dart +++ b/packages/flutter3d/example/lib/src/spike/golden_scenes.dart @@ -1264,6 +1264,16 @@ final List kGoldenScenes = [ configure: GoldenStages.orthographicMetalSettings, ), + // P7. A strategy board through an isometric orthographic camera, its + // shadows from cascades split through the box rather than from the eye. + const GoldenScene( + name: 'orthographic-shadow', + source: 'Cube', + bloom: false, + ground: false, + stage: GoldenStages.orthographicShadow, + ), + // P6. `shadow-teapot` wiped at the middle: lit on the left, the shading // normal on the right. const GoldenScene( diff --git a/packages/flutter3d/example/lib/src/spike/golden_stages.dart b/packages/flutter3d/example/lib/src/spike/golden_stages.dart index 05a408b82..f0339e530 100644 --- a/packages/flutter3d/example/lib/src/spike/golden_stages.dart +++ b/packages/flutter3d/example/lib/src/spike/golden_stages.dart @@ -1269,6 +1269,68 @@ abstract final class GoldenStages { fog: FogSettings(color: Vector3(0.7, 0.75, 0.8), density: 0.03), ); + /// `orthographic-shadow`: a strategy board seen isometrically — a floor a + /// hundred and twenty metres on a side, a level far larger than the box + /// the camera sees, with blocks for buildings and thin posts for units in + /// rows from the near edge of the view to the far one, under the sun, + /// through an orthographic camera standing sixty metres back along its + /// axis. + /// + /// What it holds is `P7`'s cascade split: the near cascades fitted to the + /// box and shared evenly through its depth, so the posts at the top of the + /// frame cast as sharp a shadow as the ones at the bottom. Split from the + /// eye as a perspective camera's are, every shadow here fell to the last + /// cascade, the whole floor, and the posts cast none that could be seen. + static Future orthographicShadow(GoldenStage stage) async { + final device = stage.device; + stage.sun.setLocalForward(Vector3(-1.0, -1.1, -0.45).normalized()); + final stone = Material(baseColor: Vector4(0.78, 0.74, 0.68, 1.0)); + final roof = Material(baseColor: Vector4(0.62, 0.34, 0.26, 1.0)); + final unit = Material(baseColor: Vector4(0.25, 0.4, 0.7, 1.0)); + return GoldenStaged( + nodes: [ + _slab( + device, + Vector3(120.0, 0.2, 120.0), + Vector3(0.0, -0.1, 0.0), + Material(baseColor: Vector4(0.5, 0.6, 0.42, 1.0), roughness: 0.95), + ), + for (final (x, z, height) in <(double, double, double)>[ + (-4.0, -4.0, 2.5), + (3.0, -5.0, 1.5), + (-5.0, 3.0, 1.8), + (4.5, 4.0, 3.0), + (0.0, 0.0, 1.2), + ]) ...[ + _slab( + device, + Vector3(1.8, height, 1.8), + Vector3(x, height * 0.5, z), + stone, + ), + _slab( + device, + Vector3(2.0, 0.3, 2.0), + Vector3(x, height + 0.15, z), + roof, + ), + ], + for (var row = -3; row <= 3; row++) + for (var column = -1; column <= 1; column++) + _slab( + device, + Vector3(0.12, 1.0, 0.12), + Vector3(column * 2.4 + row * 0.6, 0.5, row * 2.6), + unit, + ), + ], + everyFrame: (_, _) => stage.camera + ..projection = const OrthographicProjection(height: 14.0, far: 200.0) + ..setPosition(34.6, 34.6, 34.6) + ..lookAt(Vector3.zero()), + ); + } + // ------------------------------------------------------------------ P6 /// `debug-view-split`: the lit teapot left of the middle and its shading @@ -1276,10 +1338,7 @@ abstract final class GoldenStages { /// 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, - ), + debugView: const DebugViewSettings(view: DebugView.normal, split: 0.5), ); // ------------------------------------------------------------------ R6 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 689c97aa2..863de3987 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 ninety scenes, and rebuilding +/// The reason is arithmetic: the suite is ninety-one 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 -/// ninety scenes and rebuilding for each would be seventy-eight dart2js +/// ninety-one 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/test/ambient_test.dart b/packages/flutter3d/test/ambient_test.dart index 1ee55c99b..8d45cea89 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. - // Ninety goldens move at once. + // Ninety-one 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 1a195eb38..717bb8d90 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 ninety golden scenes are recorded on. +/// backend ninety-one 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 ninety golden scenes are + // What makes this safe to add to the backend ninety-one 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/auto_batch_test.dart b/packages/flutter3d/test/auto_batch_test.dart index 1671e90c8..b35cdde90 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 ninety goldens where they are. +/// default — which is also what keeps the ninety-one 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 ninety goldens where they are: the default + // Which is what keeps the ninety-one 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/fog_test.dart b/packages/flutter3d/test/fog_test.dart index 3544d7373..9f0338c1e 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 - // — ninety scenes, zero-pixel threshold, three backends — are all + // — ninety-one 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/orthographic-shadow.png b/packages/flutter3d/test/goldens/orthographic-shadow.png new file mode 100644 index 000000000..b95f9ff87 Binary files /dev/null and b/packages/flutter3d/test/goldens/orthographic-shadow.png differ diff --git a/packages/flutter3d/test/grade_test.dart b/packages/flutter3d/test/grade_test.dart index c1b9e67a2..3a323c538 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 ninety goldens rest on. Neutral for these three is + // The promise ninety-one 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 f23c355d5..4a9d5d2ab 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 ninety goldens need. + // byte for byte as it did — which is what ninety-one 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/irradiance_bounce_test.dart b/packages/flutter3d/test/irradiance_bounce_test.dart index b86199775..6f3a64733 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 ninety goldens take. + // so this is the path ninety-one 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 a5044f2fe..f6620e262 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 ninety goldens + // pipeline and no texture bound per draw, so the ninety-one 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 406816c2d..fe2788cc4 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 ninety goldens cannot move. + // why the ninety-one goldens cannot move. final withSeam = await _draw(lighting: _plain); final device = CpuDevice( diff --git a/packages/flutter3d/test/per_view_exposure_test.dart b/packages/flutter3d/test/per_view_exposure_test.dart index 2d53c9f2c..17e9426cc 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 ninety goldens stay where they are. + // path, and ninety-one 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 4ecb4c381..d2f0416ad 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', () { - // Ninety goldens are recorded at the size they asked for. A default + // Ninety-one 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/sharpen_test.dart b/packages/flutter3d/test/sharpen_test.dart index 7cd991571..411c076b1 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 { - // Ninety goldens go through this pass. The shader returns the centre + // Ninety-one 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_procedural_test.dart b/packages/flutter3d/test/sky_procedural_test.dart index db1101498..8f75da215 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 ninety goldens rest on: a frame with no sky in it is + // The property ninety-one 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 a7735c48b..236d719ff 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. Ninety goldens depend on the frame being what it + // are switched on. Ninety-one 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_core/CHANGELOG.md b/packages/flutter3d_core/CHANGELOG.md index a99f05f5c..147167cd9 100644 --- a/packages/flutter3d_core/CHANGELOG.md +++ b/packages/flutter3d_core/CHANGELOG.md @@ -1,5 +1,17 @@ ## Unreleased +- **Shadow cascades under an orthographic camera.** They were split by + distance from the eye, mostly logarithmically, as a perspective camera + wants; through an orthographic lens the eye is only where the camera was + put along its axis, so the near cascades covered the air in front of the + board and every shadow fell to the last, the whole level. Now the near + cascades share the depth the camera's box and the casters have in common, + evenly, since a pixel needs the same texel at every depth, and each is a + sphere fitted to the slab of the box its threshold can send to it. The + last stays the whole scene, for mirrors and probes. Perspective cameras + split as before. `Renderer.debugCascadeSplits` reads where the near + cascades end. `orthographic-shadow` is in all four golden sets. + - **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 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 002421bff..f93e22c43 100644 --- a/packages/flutter3d_core/lib/src/engine/render/render_settings.dart +++ b/packages/flutter3d_core/lib/src/engine/render/render_settings.dart @@ -1462,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 ninety goldens keep the frame + /// nothing headless can run them. So the ninety-one 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 @@ -2246,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. Ninety goldens depend on that being exact, and the composite +/// existed. Ninety-one 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 diff --git a/packages/flutter3d_core/lib/src/engine/render/renderer.dart b/packages/flutter3d_core/lib/src/engine/render/renderer.dart index 68a6e4cff..4b1f269fc 100644 --- a/packages/flutter3d_core/lib/src/engine/render/renderer.dart +++ b/packages/flutter3d_core/lib/src/engine/render/renderer.dart @@ -1256,6 +1256,18 @@ final class Renderer implements RenderServices { /// snapping's whole job is that this value *quantises* as the camera creeps, /// and a picture at any single moment cannot show the difference between a /// number that jumps and one that slides. + /// Where the near cascades end, in metres from the camera, as of the last + /// shadow pass: the distances the shading picks a cascade by. Nought past + /// the last cascade there is. + /// + /// For tests: under an orthographic camera the question worth asking is + /// whether these land inside the box the camera sees or in the air in front + /// of it, and the picture cannot say which cascade drew a shadow. + List get debugCascadeSplits => List.unmodifiable([ + _shadowCascades[0], + _shadowCascades[1], + ]); + List get debugCascadeCentres => List.unmodifiable(_shadowCascadeCentres); final List _shadowCascadeCentres = []; @@ -1595,7 +1607,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: ninety goldens go through this + /// strength has, and for the same reason: ninety-one 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; @@ -4282,6 +4294,16 @@ final class Renderer implements RenderServices { // splits and one map cannot serve both; the primary view wins, which // is the same answer reflections give. camera: ordered.isEmpty ? null : ordered.first.camera, + aspect: switch (ordered) { + [final first, ...] => switch (_viewportPixels( + first.viewportFraction, + width, + height, + )) { + final rect => rect.width / rect.height, + }, + _ => 1.0, + }, ); // One node per probe the scene holds, in scene order, so the name the // scene reads for the i-th probe is the name the i-th node provides. A 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 039dd0177..0b61a518c 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 @@ -301,12 +301,17 @@ final class _ShadowMapNode extends RenderNode { required this.settings, required this.casterIndex, this.camera, + this.aspect = 1.0, }); /// Where the player is looking, for cascade splits. Null for a scene with no /// views, which is a scene with nothing to split by. final CameraNode? camera; + /// [camera]'s view's width over its height: what an orthographic camera's + /// box is fitted with — `P7`. + final double aspect; + final Renderer _renderer; final Scene scene; final ShadowSettings settings; @@ -332,6 +337,7 @@ final class _ShadowMapNode extends RenderNode { settings: settings, casterIndex: casterIndex, camera: camera, + aspect: aspect, ); if (!drew) return; frame.resources.provide( 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 0e8ce83f5..ff39a705f 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 ninety goldens depend on that being the same bytes. + // nothing, and ninety-one 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 @@ -1678,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 ninety goldens hold. + // has always been and the bytes are the bytes ninety-one goldens hold. final perView = settings.autoExposure.enabled && settings.autoExposure.perView && 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 fa4061e20..a213739e9 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 @@ -650,6 +650,7 @@ extension _ShadowPasses on Renderer { required ShadowSettings settings, required int casterIndex, CameraNode? camera, + double aspect = 1.0, }) { if (!settings.enabled || settings.strength <= 0.0) return false; if (casterIndex < 0) return false; @@ -749,7 +750,31 @@ extension _ShadowPasses on Renderer { final radii = []; final splits = [0.0, 0.0]; - if (count > 1 && camera != null) { + final cameraViewProjection = camera?.viewProjection(aspect); + final orthographic = + count > 1 && + cameraViewProjection != null && + isOrthographic(cameraViewProjection) + ? _orthographicCascades( + viewProjection: cameraViewProjection, + eye: camera!.readWorldPosition(), + bounds: bounds, + count: count, + viewDistance: math.max(settings.viewDistance, 1.0), + ) + : null; + if (orthographic != null) { + splits + ..[0] = orthographic.splits[0] + ..[1] = orthographic.splits[1]; + for (var i = 0; i < orthographic.radii.length; i++) { + // Never wider than the whole scene: a small board in a big box is + // better served by the map that fits the board. + final fits = orthographic.radii[i] < sceneRadius; + centres.add(fits ? orthographic.centres[i] : sceneCentre.clone()); + radii.add(fits ? orthographic.radii[i] : sceneRadius); + } + } else if (count > 1 && camera != null) { final eyeAt = camera.readWorldPosition(); final forward = camera.readForward(); final near = 1.0; @@ -1251,7 +1276,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 ninety + // the masked half costs the common path nothing and why ninety-one // goldens recorded against the plain stage cannot move. final masked = maskedShadowShader != shadowShader && _castsMasked(node); final kind = @@ -1995,6 +2020,210 @@ extension _ShadowPasses on Renderer { } } +/// The near cascades under an orthographic camera — `P7`: where they end and +/// the spheres they cover, or null when the camera's box holds no caster. +/// +/// [viewProjection] is the camera's own, in the engine's `[0, 1]` depth range +/// and before any backend adjustment; [eye] is the camera's position, which is +/// what the shading measures a fragment's distance from; [bounds] are the +/// casters'. [count] cascades in all, of which the last stays the whole scene +/// and is not returned. +/// +/// **Why perspective's split is wrong here.** It divides a distance from the +/// eye between the cascades, mostly logarithmically, because through a +/// perspective lens a texel covers more world the further away it is. Through +/// an orthographic lens it does not — every pixel is the same size at every +/// depth — and the eye is only where the camera was put along its axis, often +/// tens of metres short of anything. Split from there, the near cascades +/// covered air in front of the board and every fragment fell through to the +/// last, which is the whole level and the softest map there is. +/// +/// So the box is split instead, evenly, and only the part of it the casters +/// occupy: the depth range of the box clipped to their bounds, which on an +/// isometric board is where the box's four long edges meet the ground rather +/// than wherever its near and far planes were left. The near cascades share +/// that range between them and the last is left as a net for what a mirror +/// or a probe sees outside the box. +/// +/// The shading still picks a cascade by distance from [eye], which through an +/// orthographic lens is depth plus a sideways offset. So the thresholds are +/// distances and each sphere is fitted to every point of the box that its +/// threshold can send to it: the last near one ends at the farthest corner's +/// distance, and a cascade past the first starts no nearer than the depth at +/// which a point at the box's rim reaches the previous threshold. +({List splits, List centres, List radii})? +_orthographicCascades({ + required vm.Matrix4 viewProjection, + required vm.Vector3 eye, + required vm.Aabb3 bounds, + required int count, + required double viewDistance, +}) { + final near = count - 1; + if (near < 1) return null; + final inverse = vm.Matrix4.copy(viewProjection); + if (inverse.invert() == 0.0) return null; + final forward = viewAxisOf(viewProjection); + + vm.Vector3 unproject(double x, double y, double z) { + final p = inverse * vm.Vector4(x, y, z, 1.0) as vm.Vector4; + return vm.Vector3(p.x / p.w, p.y / p.w, p.z / p.w); + } + + // The box's corners: index bit 0 is x, bit 1 is y, bit 2 is depth. + final corners = [ + for (var i = 0; i < 8; i++) + unproject( + i & 1 == 0 ? -1.0 : 1.0, + i & 2 == 0 ? -1.0 : 1.0, + i & 4 == 0 ? 0.0 : 1.0, + ), + ]; + double depthOf(vm.Vector3 p) => (p - eye).dot(forward); + final boxNear = depthOf(corners[0]); + final boxFar = depthOf(corners[4]); + if (!(boxFar > boxNear)) return null; + + // The box and the bounds as half-spaces, each a function that is not + // negative inside. The box's are linear in the point because an + // orthographic matrix leaves w alone. + final m = viewProjection.storage; + double row(int r, vm.Vector3 p) => + (m[r] * p.x + m[4 + r] * p.y + m[8 + r] * p.z + m[12 + r]) / m[15]; + final inBox = [ + (p) => 1.0 - row(0, p), + (p) => 1.0 + row(0, p), + (p) => 1.0 - row(1, p), + (p) => 1.0 + row(1, p), + (p) => row(2, p), + (p) => 1.0 - row(2, p), + ]; + final inBounds = [ + (p) => p.x - bounds.min.x, + (p) => bounds.max.x - p.x, + (p) => p.y - bounds.min.y, + (p) => bounds.max.y - p.y, + (p) => p.z - bounds.min.z, + (p) => bounds.max.z - p.z, + ]; + final boundsCorners = [ + for (var i = 0; i < 8; i++) + vm.Vector3( + i & 1 == 0 ? bounds.min.x : bounds.max.x, + i & 2 == 0 ? bounds.min.y : bounds.max.y, + i & 4 == 0 ? bounds.min.z : bounds.max.z, + ), + ]; + const edges = <(int, int)>[ + (0, 1), (2, 3), (4, 5), (6, 7), // + (0, 2), (1, 3), (4, 6), (5, 7), // + (0, 4), (1, 5), (2, 6), (3, 7), + ]; + + // The depths the box and the bounds share. A vertex of their intersection + // is a corner of one inside the other or where an edge of one crosses a + // face of the other, and clipping every edge of each by the other's + // half-spaces finds all of those as the clipped segments' ends. + var from = double.infinity; + var to = double.negativeInfinity; + void clip(List points, List planes) { + for (final (i, j) in edges) { + final a = points[i]; + final b = points[j]; + var t0 = 0.0; + var t1 = 1.0; + for (final plane in planes) { + final fa = plane(a); + final fb = plane(b); + if (fa < 0.0 && fb < 0.0) { + t0 = 1.0; + t1 = 0.0; + break; + } + if (fa < 0.0) t0 = math.max(t0, fa / (fa - fb)); + if (fb < 0.0) t1 = math.min(t1, fa / (fa - fb)); + } + if (t0 > t1) continue; + final da = depthOf(a); + final db = depthOf(b); + final d0 = da + (db - da) * t0; + final d1 = da + (db - da) * t1; + from = math.min(from, math.min(d0, d1)); + to = math.max(to, math.max(d0, d1)); + } + } + + clip(boundsCorners, inBox); + clip(corners, inBounds); + if (!(to >= from)) return null; + from = math.max(from, boxNear); + to = math.max(from + 1e-3, math.min(to, from + viewDistance)); + + // How far a point at the box's rim lies off the axis through the eye: what + // the distance the shading measures adds to the depth. + var offAxis = 0.0; + for (var i = 0; i < 4; i++) { + final across = corners[i] - eye; + across.sub(forward.scaled(across.dot(forward))); + offAxis = math.max(offAxis, across.length); + } + + // Even in depth, because the texel a pixel needs is the same at every depth. + final depthSplits = [ + for (var k = 1; k <= near; k++) from + (to - from) * k / near, + ]; + final thresholds = [ + for (var k = 0; k < near; k++) + k == near - 1 + ? math.sqrt(math.max(to * to, from * from) + offAxis * offAxis) + : depthSplits[k], + ]; + + vm.Vector3 cornerAt(int i, double depth) { + final t = (depth - boxNear) / (boxFar - boxNear); + return corners[i & 3] + (corners[(i & 3) + 4] - corners[i & 3]).scaled(t); + } + + final centres = []; + final radii = []; + for (var k = 0; k < near; k++) { + final reach = thresholds[k]; + final zHigh = math.min(to, reach); + final lowest = math.max(from, -reach); + final previous = k > 0 ? thresholds[k - 1] : 0.0; + final square = previous * previous - offAxis * offAxis; + final zLow = math.min( + zHigh, + lowest >= 0.0 && square > 0.0 + ? math.max(lowest, math.sqrt(square)) + : lowest, + ); + final slab = [ + for (var i = 0; i < 8; i++) cornerAt(i, i & 4 == 0 ? zLow : zHigh), + ]; + final centre = slab.fold(vm.Vector3.zero(), (sum, p) => sum..add(p)) + ..scale(1.0 / 8.0); + final fitted = slab.fold( + 1e-3, + (widest, p) => math.max(widest, (p - centre).length), + ); + // Rounded up an eighth of an octave at a time, so a camera panning over + // uneven ground does not change a texel's size, and with it every shadow + // edge, a little each frame. + centres.add(centre); + radii.add( + math + .pow(2.0, (math.log(fitted) / math.ln2 * 8.0).ceil() / 8.0) + .toDouble(), + ); + } + return ( + splits: [thresholds[0], near > 1 ? thresholds[1] : 0.0], + centres: centres, + radii: radii, + ); +} + /// What a static cascade tile does in a frame — `S1`. enum _StaticTile { keep, scroll, redraw } 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 9dc72ca40..93c71dec3 100644 --- a/packages/flutter3d_core/lib/src/engine/render/shadow_settings.dart +++ b/packages/flutter3d_core/lib/src/engine/render/shadow_settings.dart @@ -247,6 +247,10 @@ final class ShadowSettings { /// which is what perspective actually wants, since a texel's world size grows /// with distance. The usual practical answer is most of the way towards /// logarithmic, and that is the default. + /// + /// **Not read under an orthographic camera** — `P7`. There a texel covers + /// the same world at every depth, so the near cascades split the camera's + /// box evenly whatever this says. final double cascadeSplit; /// How far from the camera the cascades are fitted for, in metres. @@ -265,6 +269,11 @@ final class ShadowSettings { /// unshadowed past it** — the last cascade is still fitted to the whole scene /// and every fragment falls through to it, so this trades sharpness near the /// camera against sharpness far from it, and never against coverage. + /// + /// **Under an orthographic camera it is measured from the nearest point of + /// the casters' bounds inside the box** rather than from the eye, which + /// there is only where the camera was put along its axis — `P7`. The near cascades share the depth + /// the box and the casters have in common, up to this much of it. final double viewDistance; /// Distance bias for a point light's cube map, in **metres**. diff --git a/packages/flutter3d_core/test/formats/ktx2_supercompression_test.dart b/packages/flutter3d_core/test/formats/ktx2_supercompression_test.dart index 76c4d2c0c..19adb5d83 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 ninety goldens and every existing asset take. A level with + // The path ninety-one 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_cpu/CHANGELOG.md b/packages/flutter3d_cpu/CHANGELOG.md index dbef8bead..b02ba72a1 100644 --- a/packages/flutter3d_cpu/CHANGELOG.md +++ b/packages/flutter3d_cpu/CHANGELOG.md @@ -1,5 +1,8 @@ ## Unreleased +- `orthographic-shadow` in the software reference set, at 1.117% from + Impeller on the edges. + - 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 diff --git a/packages/flutter3d_cpu/lib/src/cpu_shaders_post.dart b/packages/flutter3d_cpu/lib/src/cpu_shaders_post.dart index 4d972f26f..fe084ac3e 100644 --- a/packages/flutter3d_cpu/lib/src/cpu_shaders_post.dart +++ b/packages/flutter3d_cpu/lib/src/cpu_shaders_post.dart @@ -180,7 +180,7 @@ final class CompositeShader implements CpuFragmentShader { // 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 ninety goldens hold untouched by arithmetic + // and it keeps the frames ninety-one goldens hold untouched by arithmetic // they never used to go through. if (shade != 1.0) colour.scale(shade); diff --git a/packages/flutter3d_cpu/lib/src/cpu_texture.dart b/packages/flutter3d_cpu/lib/src/cpu_texture.dart index 96dd763b4..aaf962999 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 ninety golden scenes are recorded on: with + // safe to add to a backend ninety-one 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 1693eceea..f4380711a 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 -/// ninety scenes differed at all. +/// ninety-one 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/cross_backend_test.dart b/packages/flutter3d_cpu/test/cross_backend_test.dart index c8b998526..81810f6e2 100644 --- a/packages/flutter3d_cpu/test/cross_backend_test.dart +++ b/packages/flutter3d_cpu/test/cross_backend_test.dart @@ -63,6 +63,10 @@ const Map _budgets = { // 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, + // 1.117% measured, on the edges and nowhere in a shadow: the posts are a + // tenth of a metre wide and almost all edge, which Impeller multisamples + // and this set does not. The shadows, near and far, agree. + 'orthographic-shadow': 1.2, // 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, diff --git a/packages/flutter3d_cpu/test/goldens/orthographic-shadow.png b/packages/flutter3d_cpu/test/goldens/orthographic-shadow.png new file mode 100644 index 000000000..33d4356c5 Binary files /dev/null and b/packages/flutter3d_cpu/test/goldens/orthographic-shadow.png differ diff --git a/packages/flutter3d_cpu/test/orthographic_test.dart b/packages/flutter3d_cpu/test/orthographic_test.dart index 74d564973..15785c57b 100644 --- a/packages/flutter3d_cpu/test/orthographic_test.dart +++ b/packages/flutter3d_cpu/test/orthographic_test.dart @@ -10,6 +10,7 @@ /// parallel rays instead. library; +import 'dart:math' as math; import 'dart:typed_data'; import 'package:flutter3d_core/flutter3d_core.dart'; @@ -93,6 +94,77 @@ double _spread(Float32List frame) { reds.reduce((a, b) => a < b ? a : b); } +/// An isometric board, the way a strategy game looks at one: a floor sixty +/// metres on a side with a post at each of [posts], the sun low across it, +/// and the camera [back] metres up its axis from the board's middle, looking +/// down at thirty-five degrees through a box [height] metres tall. +/// +/// Returns the renderer after one frame with three cascades, and the camera. +({Renderer renderer, CameraNode camera, Scene scene}) _board({ + List posts = const [], + double back = 80.0, + double height = 20.0, +}) { + final device = CpuDevice( + width: _width, + height: _height, + shaders: CpuShaderLibrary(builtinCpuShaders()), + ); + final scene = Scene(); + MeshNode block(Vector3 size, Vector3 at) => MeshNode( + DeviceMesh.upload(device, CuboidShape(size: size).build()), + Material(baseColor: Vector4(0.7, 0.7, 0.7, 1.0)), + )..setPosition(at.x, at.y, at.z); + scene.add(block(Vector3(60.0, 1.0, 60.0), Vector3(0.0, -0.5, 0.0))); + for (final post in posts) { + scene.add(block(Vector3(0.6, 3.0, 0.6), post + Vector3(0.0, 1.5, 0.0))); + } + scene.add( + LightNode(type: LightType.directional, intensity: 2.0, castsShadow: true) + ..setLocalForward(Vector3(-0.4, -0.8, 0.3)), + ); + final pitch = 35.0 * math.pi / 180.0; + final axis = Vector3(0.0, -math.sin(pitch), -math.cos(pitch)); + final camera = CameraNode(projection: OrthographicProjection(height: height)) + ..setPosition(-axis.x * back, -axis.y * back, -axis.z * back) + ..lookAt(Vector3.zero()); + scene.add(camera); + final renderer = Renderer.create(device: device) + ..render( + width: _width, + height: _height, + scene: scene, + views: [RenderView(camera: camera)], + settings: const RenderSettings( + bloom: BloomSettings(enabled: false), + shadows: ShadowSettings(cascades: 3), + ), + ); + return (renderer: renderer, camera: camera, scene: scene); +} + +/// Which cascade the shading reads at [point] and how wide it is: the first +/// whose split [point]'s distance from the eye has not passed and whose +/// sphere holds it, which is the fall-through `shadow.glsl` makes. +({int index, double radius}) _cascadeAt( + Renderer renderer, + CameraNode camera, + Vector3 point, +) { + final radii = renderer.debugCascadeRadii; + final centres = renderer.debugCascadeCentres; + final splits = renderer.debugCascadeSplits; + final distance = (point - camera.readWorldPosition()).length; + var index = 0; + if (radii.length > 1 && distance > splits[0]) index = 1; + if (radii.length > 2 && distance > splits[1]) index = 2; + while (index < radii.length - 1 && + (point - centres[index]).length > radii[index]) { + index++; + } + return (index: index, radius: radii[index]); +} + const OrthographicProjection _ortho = OrthographicProjection(height: 6.0); const PerspectiveProjection _perspective = PerspectiveProjection( fovYRadians: 1.2, @@ -176,4 +248,79 @@ void main() { final bottom = _at(sky, _width ~/ 2, _height - 2); expect((top - bottom).length, greaterThan(0.05)); }); + + test('the first cascade ends inside the box, not in the air before it', () { + // Mutation: drop the orthographic branch in `_renderShadowMap`, and the + // split is perspective's — about nine metres from an eye eighty metres + // short of the board, in front of everything the box holds. + final board = _board(); + final eye = board.camera.readWorldPosition(); + final forward = board.camera.readForward(); + // The nearest and farthest of the floor the camera sees, along its axis. + final bounds = board.scene.computeBounds(castersOnly: true); + final depths = [ + for (var i = 0; i < 8; i++) + (Vector3( + i & 1 == 0 ? bounds.min.x : bounds.max.x, + i & 2 == 0 ? bounds.min.y : bounds.max.y, + i & 4 == 0 ? bounds.min.z : bounds.max.z, + ) - + eye) + .dot(forward), + ]; + final nearest = depths.reduce(math.min); + final split = board.renderer.debugCascadeSplits.first; + expect(split, greaterThan(nearest)); + expect(split, lessThan(depths.reduce(math.max))); + // And the box, not the whole floor: the floor reaches forty metres + // either side of the board's middle along the axis, and the box sees + // about twenty of them. + expect(split, lessThan(80.0 + 5.0)); + }); + + test('near and far casters get the same texel', () { + // Mutation: as above, and both posts fall through to the last cascade, + // the whole sixty-metre floor; or put the first split well short of the + // box's middle, and both land in the second cascade. + const nearPost = [0.0, 0.0, 8.0]; + const farPost = [0.0, 0.0, -8.0]; + final board = _board( + posts: [Vector3.array(nearPost), Vector3.array(farPost)], + ); + final near = _cascadeAt( + board.renderer, + board.camera, + Vector3.array(nearPost), + ); + final far = _cascadeAt( + board.renderer, + board.camera, + Vector3.array(farPost), + ); + final whole = board.renderer.debugCascadeRadii.last; + expect(near.index, lessThan(2), reason: 'the near post in the last'); + expect(far.index, lessThan(2), reason: 'the far post in the last'); + expect(near.index, lessThan(far.index)); + expect(near.radius, lessThan(whole * 0.7)); + expect(far.radius, lessThan(whole * 0.7)); + final ratio = + math.max(near.radius, far.radius) / math.min(near.radius, far.radius); + expect(ratio, lessThan(1.5)); + }); + + test('where the camera stands along its axis barely moves a cascade', () { + // The eye's place along the axis changes nothing in the picture, so it + // should not change which world a texel covers either. It does a little: + // the shading picks a cascade by distance from the eye, which adds the + // box's sideways reach to the depth, and an eye close to the board has + // to fit the second cascade a little wider for it. One rounding step of + // the radius, an eighth of an octave, and no more. + final close = _board(back: 40.0).renderer; + final distant = _board(back: 120.0).renderer; + for (var i = 0; i < 2; i++) { + final a = close.debugCascadeRadii[i]; + final b = distant.debugCascadeRadii[i]; + expect(math.max(a, b) / math.min(a, b), lessThan(1.1), reason: '$i'); + } + }); } diff --git a/packages/flutter3d_impeller/test/gpu_formats_test.dart b/packages/flutter3d_impeller/test/gpu_formats_test.dart index 7c8b524c6..f0ef20b6b 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 ninety scenes exercise and nothing else — `BlendFactor` has +/// values the ninety-one 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_webgl/CHANGELOG.md b/packages/flutter3d_webgl/CHANGELOG.md index 228918340..4a2f61a20 100644 --- a/packages/flutter3d_webgl/CHANGELOG.md +++ b/packages/flutter3d_webgl/CHANGELOG.md @@ -1,5 +1,8 @@ ## Unreleased +- `orthographic-shadow` in the browser reference set, at 0.433% from + Impeller on the edges. + - 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 diff --git a/packages/flutter3d_webgl/test/cross_backend_test.dart b/packages/flutter3d_webgl/test/cross_backend_test.dart index 3251d594c..431ebff1a 100644 --- a/packages/flutter3d_webgl/test/cross_backend_test.dart +++ b/packages/flutter3d_webgl/test/cross_backend_test.dart @@ -108,6 +108,9 @@ const Map _budgets = { // does not multisample as Impeller does; the highlights, the fog and the // sky agree. 'orthographic-metal': 0.5, + // 0.433% measured, on the posts' and the blocks' edges, which WebGL2 + // does not multisample as Impeller does; the shadows agree. + 'orthographic-shadow': 0.5, // 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. diff --git a/packages/flutter3d_webgl/test/goldens/orthographic-shadow.png b/packages/flutter3d_webgl/test/goldens/orthographic-shadow.png new file mode 100644 index 000000000..11e7f4568 Binary files /dev/null and b/packages/flutter3d_webgl/test/goldens/orthographic-shadow.png differ diff --git a/packages/flutter3d_webgl/test/lighting_models_test.dart b/packages/flutter3d_webgl/test/lighting_models_test.dart index 5d2dcf3d7..5f2aa6485 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 ninety scenes, and says it per model rather than per +/// browser run over ninety-one 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/golden_web.sh b/packages/flutter3d_webgl/tool/golden_web.sh index 3ba053c28..957469600 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 ninety scenes +# a query parameter rather than a compile-time define, so the ninety-one 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_webgpu/CHANGELOG.md b/packages/flutter3d_webgpu/CHANGELOG.md index 5a4d7060a..2b37808b9 100644 --- a/packages/flutter3d_webgpu/CHANGELOG.md +++ b/packages/flutter3d_webgpu/CHANGELOG.md @@ -1,5 +1,8 @@ ## Unreleased +- `orthographic-shadow` in the WebGPU reference set, at 0 pixels from + Impeller. + - 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 diff --git a/packages/flutter3d_webgpu/test/cross_backend_test.dart b/packages/flutter3d_webgpu/test/cross_backend_test.dart index efbf68e98..848e99654 100644 --- a/packages/flutter3d_webgpu/test/cross_backend_test.dart +++ b/packages/flutter3d_webgpu/test/cross_backend_test.dart @@ -132,6 +132,7 @@ const Map _budgets = { // the hard cutoff on Impeller — `Material.alphaToCoverage`, two of four. 'alpha-to-coverage': 0.65, 'orthographic-metal': 0.01, + 'orthographic-shadow': 0.01, 'debug-view-split': 0.01, 'teapot-generated-normals': 0.01, 'shadow-teapot': 0.01, diff --git a/packages/flutter3d_webgpu/test/goldens/orthographic-shadow.png b/packages/flutter3d_webgpu/test/goldens/orthographic-shadow.png new file mode 100644 index 000000000..98e403363 Binary files /dev/null and b/packages/flutter3d_webgpu/test/goldens/orthographic-shadow.png differ diff --git a/site/content/core/architecture.md b/site/content/core/architecture.md index 4e8601c29..6ef25b3f2 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 90 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 91 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. diff --git a/site/content/core/index.md b/site/content/core/index.md index cfee5c2f3..9dd7e27b0 100644 --- a/site/content/core/index.md +++ b/site/content/core/index.md @@ -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 90 golden scenes stay checkable with no GPU in the room | +| `flutter3d_cpu` | Nothing: it rasterises in Dart, so 91 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/rendering.md b/site/content/core/rendering.md index 72227a656..ab338a571 100644 --- a/site/content/core/rendering.md +++ b/site/content/core/rendering.md @@ -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: ninety goldens are recorded against none + enabled: true, // off by default: ninety-one 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 diff --git a/site/content/quickstart.md b/site/content/quickstart.md index 9dac63652..94d3fa0e5 100644 --- a/site/content/quickstart.md +++ b/site/content/quickstart.md @@ -91,7 +91,7 @@ tool/ci.sh # shaders, analyze, every test (cd packages/flutter3d_physics && dart test) # plain Dart, no Flutter needed ``` -There are 11340 tests across 43 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 scenes stay checkable in a headless run. +There are 11343 tests across 43 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-one scenes stay checkable in a headless run. ## Your own application diff --git a/site/content/reference/packages.md b/site/content/reference/packages.md index 985442fb5..e65f1c6ee 100644 --- a/site/content/reference/packages.md +++ b/site/content/reference/packages.md @@ -60,7 +60,7 @@ 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 ninety 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-one 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. diff --git a/site/content/reference/testing.md b/site/content/reference/testing.md index d89d101c1..81912c748 100644 --- a/site/content/reference/testing.md +++ b/site/content/reference/testing.md @@ -1,10 +1,10 @@ --- -description: Four independent golden sets, mutation-checking every new test, determinism and snapshots, and why only about thirty of 11340 tests need a GPU. +description: Four independent golden sets, mutation-checking every new test, determinism and snapshots, and why only about thirty of 11343 tests need a GPU. --- # Testing -11340 tests across 43 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. +11343 tests across 43 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 | |---|---|---|---|---| @@ -20,7 +20,7 @@ description: Four independent golden sets, mutation-checking every new test, det | `flutter3d_game_racing` | 223 | | `flutter3d_webgl` | 62 | | `flutter3d_game_platformer` | 221 | | `flutter3d_hardware` | 70 | | `apps/flutter3d_demo_platformer` | 208 | | `flutter3d_impeller` | 64 | -| `flutter3d_cpu` | 380 | | `apps/flutter3d_demo_strategy` | 50 | +| `flutter3d_cpu` | 383 | | `apps/flutter3d_demo_strategy` | 50 | | `apps/flutter3d_editor` | 234 | | `apps/flutter3d_demo_arcade` | 20 | | `apps/flutter3d_demo_racing` | 162 | | `pointer_lock` | 28 | | `flutter3d_physics` | 269 | | `flutter3d_webgpu` | 193 | @@ -40,7 +40,7 @@ description: Four independent golden sets, mutation-checking every new test, det | `flame_flutter3d` | 140 | | `apps/flutter3d_showcase` | 88 | | `flame_multiplayer` | 9 | | `flame_multiplayer_dashwire` | 3 | -The rows sum to 11313 rather than 11340: 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 11316 rather than 11343: 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 +52,7 @@ tool/ci.sh # shaders, analyze, every test ## Four independent golden sets, not one -Ninety 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-one 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 +65,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 ninety 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-one 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. diff --git a/tasks/0.9-engine-roadmap.md b/tasks/0.9-engine-roadmap.md index 3dc054372..896246098 100644 --- a/tasks/0.9-engine-roadmap.md +++ b/tasks/0.9-engine-roadmap.md @@ -551,10 +551,29 @@ reading the frame as premultiplied lit every leaf towards its rim. Tests: 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: split by distance from +the eye, the near ones covered the air in front of the board and every +shadow fell to the last, the whole level. When `isOrthographic` reads the +view, the near cascades share evenly the depth the camera's box and the +casters' bounds have in common (their intersection, found by clipping each +one's edges by the other), since a pixel needs the same texel at every +depth; each is a sphere fitted to the slab of the box its distance +threshold can send to it, radius rounded up an eighth of an octave at a +time; the last stays the whole scene, and the caster reach-back is kept. +Tests: `orthographic_test.dart` (the first split inside the casters' depth +in the box, not in front of it; a near and a far post in near cascades of +radii within 1.5 of each other; the eye's place along the axis moving a +radius by one rounding step at most; mutation-checked by removing the +branch and by an uneven split) and `orthographic-shadow` in all four sets, +WebGPU at 0 pixels from Impeller, WebGL at 0.433% and the software set at +1.117%, on the edges. `orthographic-metal` draws no shadow and did not move. *Left:* the order in the level format and the editor; -under an orthographic -camera, the shadow cascades (split by distance from the eye, so the near -ones cover air and everything falls to the last and softest), particles' +under an orthographic camera, the shading still picks a cascade by distance +from the eye rather than by depth along the axis, so the second cascade is +fitted a little wider to hold the box's rim and an eye close to the board +costs up to one rounding step of its radius (picking by `ViewDepth` in +`shadow.glsl`, the volumetric fog, the shafts and the reflections, with +their mirrors, would remove it); particles' and splats' fog and facing (their stages declare a shortened `FogInfo` without the axis), a near plane at or behind the eye (the surface buffer reads depth nought or less as sky), splat sorting by distance rather than diff --git a/tool/ci.sh b/tool/ci.sh index 1b9a7b7dd..3224d2641 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 ninety scenes render through +# * The Impeller half of the golden set. Those ninety-one 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