Skip to content

Renderer: full-scene render binds a stale texture (benign GL_INVALID_OPERATION) after cross-test render-graph/asset churn #505

Closed

Description

@drsnuggles8

Summary

The #485 GL-error-state listener surfaced this: after a run of GPU render tests churns Renderer3D's process-wide render-graph / asset state, a subsequent full-scene render emits GL_INVALID_OPERATION — "<texture> is not a valid texture name" — via the GL debug callback, every frame. The render output is correct (all victim tests pass their pixel assertions); only the GL error queue is dirtied. So it is benign today but indicates a stale texture handle being bound somewhere in the full render path.

Evidence (from #485)

  • Victim tests (SceneRenders3D, SphereAreaLightShadowEvidenceTest, MetallicSphereUnderNebula, EASUVisualEvidenceTest.*, AutoExposureBrightScene, OccludedInstanceFieldScene) all pass in isolation, and fail only in the full single-process suite → the bad state is inherited from earlier tests, not self-generated.
  • It is diffuse / accumulative with multiple sources: fixing one confirmed source (GPUOcclusionCullGPUTest, which left an HZB bound to a texture unit then deleted it) removed it for some victims, but others still tripped and the failing set shifted → more than one upstream test leaves shared renderer state that makes a later render bind an invalid texture name.
  • The debug message is id 1282 (GL_INVALID_OPERATION) "<texture> is not a valid texture name", i.e. glBindTextureUnit(unit, name) with a non-zero name that is no longer a valid texture.

Current containment (not a fix)

RendererAttachedTest::RunFrames / RunEditorFrames now drain the GL error queue after each guarded render tick (mirroring the #485 production fix 69aa935 that drains stray errors in the readback helpers), so a render self-contains its error queue and cannot poison a later GPU test. The #485 listener stays strict everywhere else. This masks the symptom in tests — the underlying stale-handle bind is unfixed.

What to do

Root-cause which shared Renderer3D resource (render-graph target / material / IBL / HZB / bindless handle) is left referencing a deleted texture across a render-graph resize / path switch / asset churn, and fix the lifetime so the full pipeline binds no stale texture name. Then the per-tick drain in RendererAttachedTest can be removed and the listener will guard render-attached tests too.

Likely approach: run the suite with a synchronous GL debug context + a break/stack on id 1282 to capture the exact bind site, then trace the handle back to the resource whose owner was freed.

Score

capability: 1
craft: 1
stability: 8
decay: 8
effort: 5
confidence: 0.5
learning: 3
fun: 2
kano: table-stakes
blocked_by: []
blocks: []

Benign today (correct pixels) but a real stale-handle bug in a core render path; effort is a diffuse multi-source hunt, confidence low. Surfaced by #485.

Activity

  1. added
    bugSomething isn't working
    robustnessProduction hardening / shipping robustness
    on Jul 1, 2026
  2. coderabbitai commented on Jul 1, 2026

    @coderabbitai
    Contributor
    🔗 Related PRs

    #249 - fixing state leak via policy change [merged]
    #250 - Fix viewport-resize rendering bugs (GTAO ghost halo + transient pool … [merged]
    #263 - fix(tests): untangle Renderer3D test coupling for full-pipeline render tests (#258) [merged]
    #465 - Fix/flaky fog visual evidence test [merged]
    #487 - Feature/renderer hzb occlusion cull [merged]


    🧪 Issue enrichment is currently in open beta.

    To disable automatic issue enrichment, add the following to your .coderabbit.yaml:

    issue_enrichment:
      auto_enrich:
        enabled: false

    💬 Have feedback or questions? Drop into our discord!

  3. drsnuggles8 commented on Jul 3, 2026

    @drsnuggles8
    OwnerAuthor

    Root-caused and fixed in #528 (auto-closes this on merge).

    It was not diffuse — a std::stacktrace capture wired into the synchronous GL debug callback attributed all 248 in-render id 1282 errors from a full-suite run to one bind site: ToneMapRenderPass::Execute binding Renderer3D::GetWaterSurfaceDepthTextureID() for the underwater-fog stage.

    The lifecycle bug: WaterRenderPass publishes its depth FB's raw GL name and cleared it only at the top of its own Execute() — but the render graph culls the water pass on no-water frames, so the publication from the last water-rendering test survived while graph churn deleted the owning FB. ToneMap (never culled) re-bound the dead name every frame. PlanarReflectionTextureID had the identical lifecycle — that's why fixing individual upstream tests only shifted the victim set: the actual variable was which water/reflection test ran last.

    Fix: both publications are reset every frame in RenderPipeline::PrepareFrame; the executing pass re-publishes. Also fixed en route: GLStateGuard::ApplyCore now validates FBO/program/VAO names before restoring (the restore path itself emitted one id-1282 when a tick deleted an entry-bound VAO).

    The per-tick GL_INVALID_OPERATION carve-out in RendererAttachedTest is removed — the tick check is now strict (any GL error fails the tick that produced it). Verified: 3 consecutive full-suite runs with 0 id-1282 messages (baseline: 230). New deterministic regression test WaterStaleTexturePublicationTest (L4) fails with the fix disabled.

    The stack-capture diagnostics stay in OpenGLDebug.cpp permanently — any future ERROR-type GL debug message now logs the offending call site with file:line.

  4. added a commit that references this issue on Jul 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingrobustnessProduction hardening / shipping robustness

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions