Skip to content

Cache PBR uniform values and GL binds; add beginPass/endPass - #46

Closed
relh wants to merge 2 commits into
treeform:masterfrom
relh:perf/pbr-state-caching
Closed

Cache PBR uniform values and GL binds; add beginPass/endPass#46
relh wants to merge 2 commits into
treeform:masterfrom
relh:perf/pbr-state-caching

Conversation

@relh

@relh relh commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

Problem

renderPbrPrimitive re-submits the full GL state for every primitive, every frame: an unconditional glUseProgram, view/proj/lightSpace uploads, ~15 lighting/fog/camera uniforms, the env-cubemap bind, 6–7 texture-unit binds plus ~20 material uniforms, and blend/depth/cull toggles — then a full state reset after each draw. That's ~55 GL calls per primitive; in a scene with 1000+ primitives it dominates CPU submit time.

Change (one commit, rebased on current master)

  • Uniform value shadow: uniform values are per-program GL state, so the frame-level block is dirty-checked against a PbrPassValues shadow and uploaded only on change. Always on — no API, no behavior change.
  • Sampler units set once in setupPbr (they never change).
  • Material uniforms keyed on (material, materialVersion); texture binds keyed on the actual GL ids (so a texture that streams in later is still caught), with an epoch guard against ids recycled after glDeleteTextures.
  • Enable-state shadow for blend/depth-mask/cull/front-face; the per-primitive tail reset moves to the end of the draw.
  • Opt-in beginPass()/endPass()/invalidateGlState() for engines that call ctx.draw(node) many times per frame: scopes the bind caches across draws and defers blended primitives from all draws into one globally sorted back-to-front flush (per-draw flushing can't sort blends across trees). invalidateGlState() covers callers that run their own GL mid-pass. Consumers that never call beginPass get an implicit per-draw pass — identical submission order, per-draw blended flush, and post-draw GL state as today.

#44's tint-alpha fade semantics are preserved: tint.a < 1 still defers draws to the blend pass and blends opaque materials with depth writes on; in pass mode each deferred entry replays with the tint captured at defer time, so cross-draw fades sort and blend correctly.

Measured (macOS GL, ~1300 primitives/frame)

Native sampling: the per-primitive uniform+bind block drops from the bulk of renderPbrPrimitive's self time to noise (7 + 16 samples over 10 s); submit-path CPU −19%. Hot path for an opaque same-material primitive: ~5 GL calls vs ~55.

Notes for review

  • tests/tests.nim fails at master with a decodeImage PixieError before this change too (test asset issue); backend_shaders.nim compiles clean.
  • Happy to split the opt-in pass API into a follow-up PR if you'd rather take just the always-on caching — the implicit per-draw pass stands alone.

@relh
relh force-pushed the perf/pbr-state-caching branch from 71e27af to 71bd94b Compare July 3, 2026 01:25
relh added 2 commits July 2, 2026 22:10
renderPbrPrimitive re-uploaded every uniform, rebound all texture units,
and re-toggled blend/depth/cull for every primitive, then reset that
state after each draw - ~55 GL calls per primitive. A glTF model with
many primitives (the common case) pays this per primitive even when the
frame constants and material are unchanged.

This caches within one ctx.draw:
- Uniform values are per-program GL state, so a PbrPassValues shadow
  dirty-checks the frame-level block (view/proj/lightSpace, lighting,
  fog, camera, tint, debugView, env) and uploads only on change.
- The seven sampler-unit uniforms move to setupPbr (set once).
- Material uniforms upload only when the material or its version changes;
  texture binds key on the actual GL id (so a late texture upload is
  still caught) with an epoch guard against ids recycled by
  glDeleteTextures.
- Blend/depth-mask/cull/front-face go through an enable-state shadow.

The binding/enable caches reset at the start of each ctx.draw (foreign
GL between draws may have changed them) and after the library's own
shadow/skybox passes; the uniform value cache is per-program and
persists. Submission order, per-draw back-to-front blended flush, and
post-draw GL state are unchanged, so output is identical. No API change.

Measured on a dense scene (~1300 primitives, macOS GL): per-primitive
uniform+bind cost drops from the bulk of renderPbrPrimitive's self time
to noise; ~5 GL calls for an opaque same-material primitive vs ~55.
Builds on the within-draw caching. Engines that call ctx.draw(node)
many times per frame (one draw per scene object) reset the binding and
enable caches at every draw boundary, so the caching only helps inside
a single model. beginPass/endPass let such an engine declare that it
owns the GL program, texture units, and enables across a run of draws:

- The binding/enable caches persist across draws within the pass, so a
  field of same-material objects switches material state once, not once
  per object.
- Deferred blended primitives from every draw in the pass flush together
  at endPass, globally sorted back-to-front (per-draw flushing can only
  sort within one draw - a latent transparency-ordering fix).
- invalidateGlState lets the engine interleave its own GL (other shader
  programs) mid-pass; invalidateUniformCache covers direct pbrShader
  uniform writes.

Consumers that never call beginPass are unchanged: ctx.draw wraps itself
in an implicit per-draw scope with identical behavior to the caching
commit. Nesting is refcounted; only the outer pair has effect.

Measured on a dense scene (~1300 draws/frame, macOS GL): the doodad
submit path drops ~19% CPU with the pass held across the object loop.
@relh
relh force-pushed the perf/pbr-state-caching branch from 71bd94b to 0b38331 Compare July 3, 2026 05:12
@relh

relh commented Jul 3, 2026

Copy link
Copy Markdown
Contributor Author

Splitting this into a reviewable general win (within-draw uniform/bind caching, no API change) and a separate opt-in cross-draw pass API. Reopening the caching half as a fresh single-commit PR; the pass API stacks on it.

@relh relh closed this Jul 3, 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.

1 participant