Skip to content

fix(core): harden render pipeline resource lifecycle - #3065

Closed
sun-nocat wants to merge 3 commits into
dev/2.0from
xingyi/fix-instance-buffer-gc
Closed

fix(core): harden render pipeline resource lifecycle#3065
sun-nocat wants to merge 3 commits into
dev/2.0from
xingyi/fix-instance-buffer-gc

Conversation

@sun-nocat

@sun-nocat sun-nocat commented Jul 12, 2026

Copy link
Copy Markdown

Summary

  • keep the render pipeline's engine-owned instance buffer alive during ordinary resource-manager garbage collection
  • release camera-owned render passes, pooled render targets, pooled textures, and internal materials when BasicRenderPipeline is destroyed
  • return the previous cascaded-shadow render target to the pool when shadow settings change
  • reject duplicate render-target and texture returns so the pool cannot lease one resource to two consumers
  • add focused Chromium regression coverage for GC retention, camera teardown, and pool deduplication

Root causes

Instance buffer lifetime

InstanceBuffer retained its GPU Buffer through a plain engine-owned field, so it had no component reference count. Because the buffer was not excluded from ordinary resource GC, ResourceManager.gc() could destroy it while the render pipeline still held and reused the wrapper. The next upload then reached a destroyed GLBuffer, causing repeated bindBuffer failures and WebGL context loss.

Camera pipeline teardown

BasicRenderPipeline.destroy() previously released only its culling results and two top-level pooled resources. Camera-owned shadow, depth, SAO, opaque-texture, and final passes could therefore retain render targets or internal materials after camera destruction. In addition, the shadow pass discarded its current render-target reference before PipelineUtils.recreateRenderTargetIfNeeded() could return an incompatible target to the pool, and the pool accepted duplicate returns of the same object.

Ownership boundaries

  • ResourceManager.gc() must not reclaim engine-owned buffers that remain active in the render pipeline.
  • Camera owns its BasicRenderPipeline; destroying the camera releases every resource owned by that pipeline exactly once.
  • RenderTargetPool owns reuse after a resource is returned and ignores duplicate returns; it remains responsible for destroying pooled resources during pool GC or engine teardown.
  • Explicit BatcherManager.destroy() and engine teardown still destroy the instance buffer.

Validation

  • pnpm run b:module
  • HEADLESS=true pnpm exec vitest run tests/src/core/RenderPipeline/InstanceBuffer.test.ts tests/src/core/RenderPipeline/RenderTargetPool.test.ts tests/src/core/Camera.test.ts (3 files, 32 tests)
  • pnpm run b:types
  • git diff --check origin/dev/2.0...HEAD
  • git merge-tree --write-tree origin/dev/2.0 HEAD (clean virtual merge)

Fixes #3064

Summary by CodeRabbit

  • Bug Fixes
    • Prevented actively used rendering instance buffers from being reclaimed during cleanup.
    • Improved render-pipeline pass teardown to be idempotent and ensure render targets and materials are properly released/destroyed.
    • Updated render-target pool to avoid duplicate entries when resources are freed multiple times.
  • Tests
    • Added and expanded test coverage to confirm instance buffers remain alive after garbage collection and are destroyed during pipeline teardown.
    • Added tests for render-target pool behavior when freeing the same items repeatedly.

@sun-nocat
sun-nocat requested a review from GuoLei1990 July 12, 2026 15:55
@coderabbitai

coderabbitai Bot commented Jul 12, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

Render pipeline resource lifetime handling is expanded: instance buffers are protected from ordinary GC, pipeline passes now release and destroy owned resources idempotently, and render-target pools reject duplicate frees. Tests cover buffer lifetime, camera teardown, and pool reuse.

Changes

Render pipeline resource lifetime

Layer / File(s) Summary
Instance buffer GC lifetime
packages/core/src/RenderPipeline/InstanceBuffer.ts, tests/src/core/RenderPipeline/InstanceBuffer.test.ts
Allocated instance buffers are excluded from ordinary GC, while tests verify survival through GC and destruction during batcher teardown.
Pipeline pass teardown
packages/core/src/RenderPipeline/BasicRenderPipeline.ts, packages/core/src/RenderPipeline/OpaqueTexturePass.ts, packages/core/src/lighting/ambientOcclusion/ScalableAmbientObscurancePass.ts, packages/core/src/postProcess/FinalPass.ts, packages/core/src/shadow/CascadedShadowCasterPass.ts, tests/src/core/Camera.test.ts
Pipeline destruction becomes idempotent and explicitly releases render targets, clears shadow bindings, and destroys pass materials; camera tests verify the cleanup calls and material destruction.
Pool free-list integrity
packages/core/src/RenderPipeline/RenderTargetPool.ts, tests/src/core/RenderPipeline/RenderTargetPool.test.ts
Render targets and textures are added to free lists only once, with tests covering duplicate frees and subsequent reuse.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Possibly related PRs

  • galacean/engine#3015: Both changes modify render-target pool free-list management and reuse behavior.

Suggested labels: bug, resource, enhancement

Suggested reviewers: cptbtptpbcptdtptp, guolei1990, zhuxudong

Poem

A rabbit guards buffers through GC’s little sweep,
Passes release what render loops keep.
Free lists stay tidy, no twins in a row,
Materials rest when teardown says go.
Hop, hop—resources flow!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The PR fixes InstanceBuffer GC retention and adds regression coverage matching issue #3064's required behavior.
Out of Scope Changes check ✅ Passed The added lifecycle cleanup changes support the same render pipeline resource-lifecycle objective and do not appear unrelated.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately reflects the main theme: hardening the render pipeline’s resource lifecycle.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch xingyi/fix-instance-buffer-gc

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@codecov

codecov Bot commented Jul 12, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 76.92308% with 12 lines in your changes missing coverage. Please review.
✅ Project coverage is 79.83%. Comparing base (75af66f) to head (39a191d).
⚠️ Report is 11 commits behind head on dev/2.0.

Files with missing lines Patch % Lines
...ckages/core/src/shadow/CascadedShadowCasterPass.ts 30.76% 9 Missing ⚠️
...kages/core/src/RenderPipeline/OpaqueTexturePass.ts 62.50% 3 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff             @@
##           dev/2.0    #3065      +/-   ##
===========================================
+ Coverage    79.52%   79.83%   +0.30%     
===========================================
  Files          904      906       +2     
  Lines       101168   101342     +174     
  Branches     11377    11621     +244     
===========================================
+ Hits         80456    80906     +450     
+ Misses       20528    20251     -277     
- Partials       184      185       +1     
Flag Coverage Δ
unittests 79.83% <76.92%> (+0.30%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

GuoLei1990

This comment was marked as outdated.

@GuoLei1990 GuoLei1990 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

二轮审查 @ 882db26c — 唯一 P3 已闭环,LGTM

自首轮 207d03584c 起线性 +1 commitcompare ahead_by:1/behind_by:0 无 force-push):882db26c chore(core): remove instance buffer comments,仅 InstanceBuffer.ts 删 2 行注释、isGCIgnored=true 逻辑逐字不变。

首轮唯一 [P3](新增两行 // 注释末尾带句号、不合本目录 // 惯例)作者选择整段删除这两行 // 注释——闭环,本目录 // 无句号惯例恢复。核心修复(InstanceBuffer 裸 buffer 打 isGCIgnored=trueResourceManager.gc() 回收 active GPU-instancing buffer)与首轮逐字一致,本轮零逻辑改动无需复核。CI 全绿。

无 P0/P1/P2/P3。可合。

@sun-nocat sun-nocat changed the title fix(core): preserve instance buffer during resource gc fix(core): harden render pipeline resource lifecycle Jul 29, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@tests/src/core/Camera.test.ts`:
- Around line 138-152: Add spies for saoPass.release and finalPass.release to
the releaseSpies collection in the lifecycleCamera.destroy test, alongside the
existing pass release spies, so the existing toHaveBeenCalledOnce assertions
verify all delegated release calls.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 95a109c6-5f4d-4e6f-862f-1516f9ed3472

📥 Commits

Reviewing files that changed from the base of the PR and between 882db26 and 39a191d.

📒 Files selected for processing (8)
  • packages/core/src/RenderPipeline/BasicRenderPipeline.ts
  • packages/core/src/RenderPipeline/OpaqueTexturePass.ts
  • packages/core/src/RenderPipeline/RenderTargetPool.ts
  • packages/core/src/lighting/ambientOcclusion/ScalableAmbientObscurancePass.ts
  • packages/core/src/postProcess/FinalPass.ts
  • packages/core/src/shadow/CascadedShadowCasterPass.ts
  • tests/src/core/Camera.test.ts
  • tests/src/core/RenderPipeline/RenderTargetPool.test.ts

Comment on lines +138 to +152
const releaseSpies = [
vi.spyOn(cascadedShadowPass, "release"),
vi.spyOn(depthOnlyPass, "release"),
vi.spyOn(opaqueTexturePass, "release")
];
const saoMaterial = saoPass["_material"];
const finalSrgbMaterial = finalPass["_sRGBmaterial"];
const finalAntiAliasingMaterial = finalPass["_antiAliasingMaterial"];

lifecycleCamera.destroy();

for (const releaseSpy of releaseSpies) expect(releaseSpy).toHaveBeenCalledOnce();
expect(saoMaterial.destroyed).to.eq(true);
expect(finalSrgbMaterial.destroyed).to.eq(true);
expect(finalAntiAliasingMaterial.destroyed).to.eq(true);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Assert release for the SAO and final passes too.

BasicRenderPipeline.destroy() now calls saoPass.destroy() and finalPass.destroy(), both of which delegate to release(). Add spies for saoPass.release and finalPass.release; otherwise a regression that removes either release call could still pass while the material assertions remain green.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/src/core/Camera.test.ts` around lines 138 - 152, Add spies for
saoPass.release and finalPass.release to the releaseSpies collection in the
lifecycleCamera.destroy test, alongside the existing pass release spies, so the
existing toHaveBeenCalledOnce assertions verify all delegated release calls.

@GuoLei1990 GuoLei1990 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

🫧 尘小沫

结论

三轮增量审查覆盖 882db26c963b220636c4cbbba6a422b1df19d943 → 39a191d62ccb95e5767d62cab23f8670326523ba 的线性单 commit,以及 Camera → BasicRenderPipeline → 各 pass → RenderTargetPool 的直接上下游。目标 HEAD 为 39a191d62ccb95e5767d62cab23f8670326523ba。本轮发现 1 个 P1(阻塞)和 1 个 P2,实际 review 动作为 REQUEST_CHANGES;自动 CR 不替代人工 Reviewer 的合入门禁。

已关闭问题清单

  • 首轮 [P3]:InstanceBuffer.ts 两行单行注释末尾句号已由 882db26c chore(core): remove instance buffer comments 删除;isGCIgnored = true 的生命周期修复逐字保留,本轮不重复提出。

问题

  • [P1] packages/core/src/RenderPipeline/RenderTargetPool.ts:122-131includes 不是 lease ownership 校验,无法兑现“不会把同一资源租给两个消费者”的契约。 当前检查只挡连续两次 free(A);执行 free(A) → allocate()(B 得到 A) 旧 owner 再 free(A) 时,A 已不在 free-list,includes 返回 false,A 会在 B 仍使用期间重新入池,随后又可分配给 C。沿所有直接调用点核对后,真正的权威 owner 是各 pass/manager 的持有字段:release() 在归还后置 nullPipelineUtils.recreate* 负责把旧 lease 交还并让调用方覆盖 slot;pool 只应拥有已归还的 free-list。请删除这两个不完整的 includes 校验及 RenderTargetPool.test.ts:100-109/124-133 两个锁定无效兼容契约的测试,保留并验证各 owner 的 exact-once 归还与清空。若未来确实要在 pool 强制验证 stale owner,需要带 generation/token 的 lease 协议,而不是从 free-list 反推 ownership;这不必扩进本 PR。

  • [P2] tests/src/core/Camera.test.ts:129-154 — 新测试没有创建任何待释放的 render target,无法回归本次资源泄漏修复。 该 Camera 从未渲染,shadow/depth/SAO/opaque/final pass 的 target 均为空;因此测试只证明私有方法被调用和三个 Material 被销毁,即使删除 OpaqueTexturePass.release()、shadow target 归还或 shadow binding 清理,仍可通过。当前 codecov/patch 也对应失败:shadow 新逻辑缺 9 行、opaque 缺 3 行。请通过真实配置启用相关 pass 并至少渲染一帧,再销毁 Camera,断言实际 target 已归还/在 pool GC 时销毁、scene shadow texture binding 已按 owner 清除;另用 shadow resolution/cascade 变化覆盖旧 target 经 PipelineUtils.recreateRenderTargetIfNeeded() 归还的路径。现有私有 call-spy fixture 应按这些行为契约重写;仅再补 SAO/Final 的 release spy 仍锁实现且不能守住泄漏。

架构、熵增与测试治理

  • 上游唯一生命周期入口是 Camera._onDestroy()EngineObject.destroy() 已保证 Camera teardown 只执行一次。下游资源流应保持 Camera → BasicRenderPipeline → pass 持有字段 → freeRenderTarget/freeTexture → pool free-list → pool.gc(),各 pass 字段是 lease 的权威 owner,pool 从归还时才接管资源。
  • shadow 设置变化链路中,删除 _updateShadowSettings() 里提前把 _renderTarget 置空是正确的:旧 target 现在能继续传给 PipelineUtils.recreateRenderTargetIfNeeded(),由该唯一转换/归还 owner 判断复用或入池。
  • 增量净变化新增了 pass teardown 入口和 material 的显式销毁,方向正确;但同时新增的两条 free-list includes 校验把 caller ownership 下沉成不完整的第二份真相,且两个测试把该兼容分支固化。删除它们后,owner/状态/校验数量回到单一路径,不需要 legacy fallback、镜像 lease 状态或第二条校验协议。
  • InstanceBuffer 的 GC owner、显式 BatcherManager teardown 和原回归测试均未变化,既有结论继续成立。

@sun-nocat sun-nocat closed this Jul 29, 2026
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.

2 participants