Skip to content

fix(core): compile only the color grading stages a grade enables - #4884

Open
miga-heygen wants to merge 3 commits into
mainfrom
fix/color-grading-check-hang
Open

miga-heygen wants to merge 3 commits into
mainfrom
fix/color-grading-check-hang

Conversation

@miga-heygen

Copy link
Copy Markdown
Contributor

Fixes #4881.

What was wrong

Every data-color-grading element drew through one fragment shader with every effect compiled in. A stage set to 0 returns early, which costs nothing on a GPU. Software GL (SwiftShader, which check uses when WebGL is unavailable or with --no-browser-gpu) and some ANGLE backends still pay for every stage on every pixel. So an adjust-only grade paid for ASCII, tape damage, crosshatch, glitch and the rest on each draw.

Measured on 0.8.106 with software GL, one 3s 1080p clip, adjust-only grade:

hyperframes check
No grading 15s
Adjust-only grade 231s
Same grade, main shader swapped for a passthrough 21s

check draws the grade about 60 times per run, at 3 to 4s each. On the reporter's Iris Xe with ANGLE/D3D11, that reads as a hang.

The fix

  • Each optional stage of the main fragment shader sits behind #ifdef HF_STAGE_*, and a guarded function falls back to returning its input.
  • FRAGMENT_SHADER_STAGES is one table mapping each define to the grade condition that makes the stage change a pixel. That condition mirrors the shader's own amount > 0 test.
  • useMainShader compiles the variant for the grade's active stages, caches it per WebGL context in least-recently-used order, sized to one full Studio look-preview batch (32). It is the only way either draw site (drawEntry, renderPreviewCandidate) binds the main program. Preview, render and Studio look previews therefore share one path.
  • The stage-free variant is still compiled when the context is created, so a context that cannot run the shader still fails at creation, as before.
  • If a driver rejects one variant, that stage set is remembered and draws through the all-stages variant, which is the previous shader. It is not recompiled every frame.

Equivalence and speed

  • Old shader vs new shader with the derived defines, rendered in chrome-headless-shell on SwiftShader at 320x180: byte-identical readPixels output in 28 of 28 cases. Each stage was tested alone with its sub-parameters, plus RGB curves, hue curves, secondaries, LUT, compare, a combination, and tape sub-parameters with tape damage at 0.
  • The CRT warp stays ungated. Returning uv directly differs from (uv * 2 - 1) * 0.5 + 0.5 by one ulp, and that flips digital-glitch block boundaries.
  • Per 1080p draw on SwiftShader: about 6s before, about 0.1s after.

Tests

packages/core/src/runtime/colorGrading.test.ts, through the real runtime:

  • An adjust-only grade compiles no stage.
  • A grade compiles exactly the stages it enables.
  • Each of the 24 stages is keyed to its own grade value: enabling only that value defines only that stage.
  • A grade with everything enabled defines exactly the set of stages the shader guards. This pins the table and the GLSL against drift in both directions.
  • A variant compiles once per stage set and is reused when the grade comes back.
  • The least recently used variant is evicted at the cap, not one still in use.
  • The variant comes from the animated grade (CSS custom properties), not the authored one.
  • Look previews compile each candidate's own stages.
  • A variant the driver rejects falls back to every stage, and is not retried.

Mutation-checked: all of these fail against the previous runtime. Deleting one table row fails the drift test. Swapping two predicates fails the per-stage test.

Verification

  • vitest run src/runtime/colorGrading.test.ts: 87 passed.
  • tsc --noEmit for packages/core and tsconfig.runtime.json: clean.
  • End-to-end, hyperframes check on the 1080p adjust-only repro with software GL: 231s on 0.8.106, 23s with this branch. The ungraded baseline is 15s.

Independent review

Ranked findings from an independent adversarial review of this branch:

  • Blocking: none.
  • Should fix, done: fall back when a variant fails to compile instead of retrying every frame. Cover the animated-grade and look-preview selection paths with tests.
  • Notes, left as they are:
    • The LUT stage is enabled whenever a LUT is configured, including while it loads. That is the same cost as before this change.
    • A new context compiles the stage-free variant plus the grade's variant.
    • The first look-preview batch can compile up to 32 variants.

Miga

miga-heygen and others added 3 commits October 1, 2026 19:22
Every data-color-grading element drew through one fragment shader with
every effect compiled in. An effect at 0 returns early, which is free on
a GPU, but software GL and some ANGLE backends still pay for every stage
on every pixel. A 1080p adjust-only grade took about 6s per draw on
SwiftShader, so `hyperframes check` on one graded 3s clip went from 15s
to 231s and read as a hang.

Each optional stage now sits behind an #ifdef HF_STAGE_* guard.
FRAGMENT_SHADER_STAGES maps each define to the grade condition that
makes the stage change a pixel, and useMainShader compiles and caches
the matching variant per WebGL context (LRU, sized to one Studio preview
batch). Both draw sites bind the main program only through it.

Output is byte-identical to the previous shader across every stage. The
same check now takes 23s.

Fixes #4881.

Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
…ompile

A variant the driver rejects is remembered and draws through the
all-stages variant, which is the previous shader, instead of being
recompiled every frame. If that fails too, the context stops compiling
and reports the draw error.

Adds tests for the animated-grade and look-preview selection paths.

Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
@github-actions

github-actions Bot commented Oct 2, 2026

Copy link
Copy Markdown

Edit accuracy: 852 passing here, 852 on the base branch

The gate passes.
Smoothness is reported in the artifact, not gated. A case fails only if it fails 2 of 3 runs.

Quarantined, measured but not gated (2)

Unstable (2)

  • seqresize-none-pct-r0-nested-z100: tracking 0.26, pressJump 0, drop 0, reload 0, render 0.03, undo false, teleport true / tracking 0.26, pressJump 0, drop 0, reload 0, render 0.03, undo true, teleport true / tracking 0.26, pressJump 0, drop 0, reload 0, render 0.03, undo true, teleport true
  • seqrotatenudge-none-pct-r0-nested-z100: tracking 0.02, pressJump 0, drop 0, reload 0, render 0.06, undo false, teleport true / tracking 0.02, pressJump 0, drop 0, reload 0, render 0.06, undo true, teleport true / tracking 0.02, pressJump 0, drop 0, reload 0, render 0.06, undo true, teleport true

@miga-heygen miga-heygen left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed at e70c202a83875b2ba03d88647499e941f9fd4c50. No blockers. The parity between the stage predicates and the GLSL guards holds, and the cache lifecycle is correct. The only gaps are in test coverage.

Strengths

  • Removing the #ifdef guards from the new FRAGMENT_SHADER leaves text byte-identical to the old shader, and the vertex shader is unchanged. The fallback variant really is the previous shader, so a rejected variant cannot render differently.
  • useMainShader (colorGrading.ts:3333, :3516) receives the same grading and compare objects that applyUniforms gets. The variant cannot be keyed to a different grade from the uniforms it is drawn with.
  • mainShaderFor (colorGrading.ts:1893) terminates in every case. If a variant fails, it falls back to all stages. If all stages fail, they are marked failed and the next lookup throws at :1902, which drawEntry turns into drawError.

Verification, run on the head

  • Compiling the variants: 83 variants compiled and linked in headless Chromium on SwiftShader (ANGLE/Vulkan), with no failures. The set was: no stages, all stages, each stage alone, and all stages but one. All-but-one catches a variable declared inside one guard and used under another.
  • Old vs new pixels:
    • The old shader and the new shader each drew 400 grades with random uniforms, at 96x54. 0 of 400 cases differed in readPixels.
    • Each case defined the set of stages from my own reading of the GLSL comparisons (clamp, then > 0; flags >= 0.5; secondary count), not from FRAGMENT_SHADER_STAGES.
    • Amounts included 1e-6, 5e-5 (CRT under the step(0.0001) threshold), values above 1, and negatives.
  • Harness control: dropping one active stage from the derived set gave 52 of 60 mismatches, so the harness can see a missing stage.
  • Predicate to uniform keys: every predicate reads the key whose u_${key} uniform the GLSL compares. LUT (grading.lut !== null) is a superset of u_lutEnabled, which is correct, as the PR notes.
  • Unit tests: vitest run src/runtime/colorGrading.test.ts passed 88 of 88. tsc --noEmit is clean for tsconfig.json and tsconfig.runtime.json.
  • Cache mutation tests:
    • Dropping the LRU re-insert on a hit fails the eviction test.
    • Dropping failedStageDefines.add fails both rejection tests.

Nits

  • The tests do not pin the GL resource cleanup or the cap bound. Each of these mutations still leaves 88 of 88 green:

    • Deleting gl.deleteProgram(evicted.program) at colorGrading.ts:1918, which leaks a program per eviction.
    • Deleting the deleteProgram loop in destroyMainProgramResources at :2131.
    • Changing >= to > at :1916, which lets the cache hold 33.

    One deleteProgram spy assertion in the eviction test, and one on runtime teardown, would close all three.

  • Note, no change asked: on a hardware GPU, a stage set first seen mid-playback compiles synchronously inside drawEntry. An example is an animated effect rising above 0 for the first time. That is one hitch per stage set per context, where before there was none. This trade is fine for #4881, but worth knowing if Studio playback reports a stutter on the first glitch or bloom keyframe.

Verdict: COMMENT (approve-level; this account authored the PR, so it cannot approve it)
Reasoning: The real GLSL compiles in every guard combination, and the derived variants render pixel-identical to the old shader across 400 random grades. The cache evicts least-recently-used, memoizes failures and always terminates. The open items are test coverage only.

— Miga

This branch has not been deployed

No deployments
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.

[feedback] data-color-grading on a <video> element hangs page navigation indefinitely on t…

1 participant