Skip to content

Cut the lit shaders' compile time on ANGLE by 60 % (one cascade sampler, explicit LOD in loops) - #502

Open
scalz wants to merge 3 commits into
bdero:masterfrom
scalz:lit-shader-compile-time
Open

scalz wants to merge 3 commits into
bdero:masterfrom
scalz:lit-shader-compile-time

Conversation

@scalz

@scalz scalz commented Oct 12, 2026 •

Copy link
Copy Markdown

Summary

On Windows (Impeller on ANGLE's Direct3D backend), the first use of a lit pipeline freezes the application for seconds, and nothing keeps the compiled program between launches, so every launch pays it again (flutter/flutter#191860). StandardFragment alone takes 14 s to link on a Ryzen 5 2600 with a Radeon RX 580.

This change takes the four standard lit entries from 23.7 s to 9.5 s in total, with pixel-identical output on ANGLE, by removing two patterns from the generated OpenGL ES source:

  1. The directional cascade sampler was inlined four times. _TRY_CASCADE(0..3) expanded SampleCascade (three filters, the blocker search, the probes) once per cascade. SampleShadow now walks the cascades in a loop bounded by the cascade count, so the sampler is compiled once. The matrix and box size are still selected with literal indices, and the loop has a single exit (no break), as the existing Direct3D note asks. The arithmetic and the order of the cascades are unchanged.
  2. Implicit-LOD texture() reads sat inside loops. ANGLE marks a loop that contains one [loop] and every branch around it [flatten], and reads the texture at LOD 0 anyway. Two places were left after Sample the shadow atlas and punctual light tables with an explicit LOD #497: the band atlas in SamplePrefilteredRadiance (inside the one-iteration do { } while (false) that SPIRV-Cross emits for the function's early return) and the LTC table in the rect area light branch (inside the punctual light loop). Both textures have a single level (createPrefilterAtlasTexture(mipLayout: false), buildBrdfLutTexture), so textureLod(..., 0.0) returns the same texel on every backend.

This fixes #501 .

Measurements

Flutter stable 3.47.6 (engine 692136cb65), Ryzen 5 2600, Radeon RX 580 (driver 31.0.21923.11000), Windows 11 25H2. Base is 1f3fc8a9f2.

Compile and link of one program on ANGLE (D3D11), each in a fresh process, with the generated OpenGL ES source of each entry and UnskinnedVertex. Flutter links ANGLE statically, so this uses the ANGLE DLLs that ship with VS Code; the times agree with the application's within 5 %. One measurement each:

Entry Base This PR Generated source Shadow atlas reads
StandardFragment 13,993 ms 5,176 ms 219 KB to 134 KB 103 to 34
StandardLeanFragment 6,181 ms 2,134 ms 161 KB to 76 KB 101 to 32
StandardNoShadowFragment 2,770 ms 1,692 ms 82 KB (unchanged) none
StandardLeanNoShadowFragment 786 ms 513 ms 33 KB (unchanged) none

In an application (ten primitives with PhysicallyBasedMaterial, Scene.warmUp called with one more feature each time, release build, three launches each, median):

Step Pipelines Base This PR
1. lit materials 3 2290 ms 1928 ms
2. + directional shadows 2 6365 ms 2053 ms
3. + ambient occlusion 4 14952 ms 5552 ms
Total 9 23.6 s 9.5 s

The smoke suite on the same machine ran in 4 min 06 s instead of 5 min 56 s (one run each).

Where the time went

fxc /T ps_5_0 /O2 on the HLSL that ANGLE generates for StandardFragment at base, which reproduces the link time:

Input Time
as ANGLE compiles it (ANGLE_ENABLE_LOOP_FLATTEN=1) 13.8 s
same HLSL without the [loop] and [flatten] attributes 8.5 s
as the first row, with /Od instead of /O2 2.2 s

So about 5 s came from the attributes, and most of the rest from the size of main (5,000 lines once everything is inlined), of which the four cascade copies were the largest part: once the attributes are gone, keeping a single _TRY_CASCADE takes the entry from 9.0 s to 4.8 s.

Testing

  • examples/smoke_render on Windows (ANGLE, hardware D3D11), before and after: all 50 tests pass and the 45 frames are identical pixel for pixel.
  • New test/lit_shader_compile_cost_test.dart compiles the four entries the way the build hook does and checks, on the generated OpenGL ES source, that no implicit-LOD texture read is inside a loop and that the shadow atlas is read from one copy of the cascade sampler. It fails on base (103 reads, and the reads in the two places above) and passes here. It needs only impellerc, not a GPU.
  • test/physical_material_sources_test.dart: two assertions on the source text of SampleShadow follow the new loop.
  • flutter test in packages/flutter_scene: the same 8 tests fail on this Windows checkout with and without the change (bloom_pass_test, engine_stamp_test, source_scene_loader_test, generated_target_isolation_test, shader_precision_test, and three in physical_material_sources_test); none is related.

Not verified

  • Metal, Vulkan, Mesa and WebGL2: I could only run ANGLE. The cascade loop is the same arithmetic in the same order and the two reads are of single-level textures, so I expect identical frames, and the smoke lanes will tell.
  • Frame time on a mobile GPU, which matters given 17296f49da. The loop replaces four guarded blocks with one loop of at most four iterations, with the same texture reads. On the RX 580 I rendered a shadowed scene (a ground plane filling the view, 48 casters) off screen at 2048 x 2048 with a read-back per frame, 80 frames per configuration, for 8 configurations (no shadows, each of the four filters with four cascades, one cascade, a 0.3 cascade overlap, and the full shader), base and this PR in alternation, three runs each, then again in the opposite order. On frames of 6.5 to 13 ms, this PR is 0.2 to 0.6 ms slower in the first order and within 0.3 ms either way in the second, and the configuration without shadows moves as much as the shadowed ones. I read that as noise of about 5 %: it rules out a large change on this desktop GPU and nothing finer.
  • The .fmat physical materials include the same files and should gain as much; I did not time them.

Left out on purpose

Three [loop] or [flatten] sites remain in the lit entries on ANGLE: SpecularAARoughness and TangentFrame return early before screen-space derivatives. Giving them a single return removes the sites and saves a further 0.1 to 0.2 s per entry, but it changes 8 of the 45 smoke frames on ANGLE. In the two I looked at closely, dielectric_specular differs by a one-pixel rim at the silhouettes, and physical_layered shows a pinch near a pole of the sphere at base that the single-return version does not. That looks like a fix, but it is a visible change on one backend and I have no frame from another backend to compare with, so it belongs in its own pull request.

Also untouched: ResolveFragment took 0.27 s in 0.24.3 and takes 0.85 s since bfb788dca7, which calls Display() from three places.

🤖 Generated with Claude Code

scalz and others added 2 commits October 12, 2026 22:45
…cit LOD

On ANGLE's Direct3D backend (Impeller on Windows) a lit pipeline's first
use runs the HLSL compiler, and nothing keeps the result between launches.
StandardFragment took 14 s to link on a Ryzen 5 2600 with a Radeon RX 580,
and the application is frozen meanwhile.

SampleShadow expanded SampleCascade once per cascade through
_TRY_CASCADE(0..3), so its three filters and the blocker search were
compiled four times: the generated OpenGL ES source read the shadow atlas
103 times. It now walks the cascades in a loop bounded by the cascade
count. The matrix and box size are still selected with literal indices,
the loop has a single exit, and the arithmetic and the cascade order are
unchanged.

Two texture() reads were still inside loops: the band atlas in
SamplePrefilteredRadiance, behind an early return that SPIRV-Cross emits
as a one-iteration loop, and the LTC table in the punctual light loop.
ANGLE marks such a loop [loop] and the branches around it [flatten], and
reads the texture at LOD 0 anyway. Both textures have a single level, so
textureLod(..., 0.0) returns the same texel on every backend.

Compile and link on ANGLE, one program per process: StandardFragment
14.0 s to 5.2 s, StandardLeanFragment 6.2 s to 2.1 s,
StandardNoShadowFragment 2.8 s to 1.7 s, StandardLeanNoShadowFragment
0.79 s to 0.51 s. Warming up a ten-primitive scene with shadows and
ambient occlusion went from 23.6 s to 9.5 s.

The 45 smoke frames are identical pixel for pixel on Windows before and
after. Frame time at 2048 x 2048 on the same machine is unchanged within
the 4 % noise of the measurement, for every directional filter; it was not
measured on a mobile GPU.

lit_shader_compile_cost_test.dart checks both patterns on the source the
build hook's compile produces.
The test now takes its impellerc arguments from shaderBundleImpellercArguments and the GLES version from the engine hook's constant, so it cannot drift from the build. Adds the CHANGELOG entry.
@argos-ci

argos-ci Bot commented Oct 13, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Argos notifications ↗︎

Build Status Details Updated (UTC)
ios (Inspect) ✅ No changes detected - Oct 13, 2026, 12:29 PM
macos (Inspect) ✅ No changes detected - Oct 13, 2026, 12:22 PM

@scalz
scalz marked this pull request as ready for review October 13, 2026 07:13
Picking the cascade's matrix through an if-chain made Mali flatten it and load all four matrices on every iteration, so shadowed frames on a Galaxy A16 drew 4 to 5% slower than with the unrolled cascades. Vulkan and Metal now index the array with the loop counter. GLES keeps literal indices, since some GLES drivers misread a dynamically indexed uniform array, and transforms inside each branch so a flattened chain still reads one matrix.
@bdero
bdero force-pushed the lit-shader-compile-time branch from 08c7eff to 55d62a6 Compare October 13, 2026 12:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Windows] The lit shaders take 2 to 14 s each to compile on ANGLE: nine pipelines freeze a ten-primitive scene for 24 s

2 participants