From 5f4932624d86c76dd99c07ac4dfd37ae37cdc78c Mon Sep 17 00:00:00 2001 From: Richard Higgins Date: Thu, 2 Jul 2026 22:10:56 -0700 Subject: [PATCH 1/2] Cache PBR uniform values and GL binds within a draw 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. --- src/gltf/backends/opengl/renderer.nim | 741 ++++++++++++++++---------- 1 file changed, 452 insertions(+), 289 deletions(-) diff --git a/src/gltf/backends/opengl/renderer.nim b/src/gltf/backends/opengl/renderer.nim index 80e90b9..b31dfc6 100644 --- a/src/gltf/backends/opengl/renderer.nim +++ b/src/gltf/backends/opengl/renderer.nim @@ -113,6 +113,48 @@ type node: Node primitive: Primitive transform: Mat4 + tint: Color + root: Node + + PbrPassValues = object + ## Shadow copy of PBR uniform values already uploaded to pbrShader. + ## Uniform values are per-program GL state, so this cache stays valid + ## across foreign program binds and frames until the values change. + valid: bool + view: Mat4 + proj: Mat4 + lightSpace: Mat4 + tint: Color + ambientLightColor: Color + sunLightDirection: Vec3 + sunLightColor: Color + rimLightDirection: Vec3 + rimLightColor: Color + debugView: DebugView + cameraPosition: Vec3 + fogColor: Color + fogStart: float32 + fogEnd: float32 + fogDensity: float32 + fogStrength: float32 + environmentMapStrength: float32 + environmentMipCount: float32 + useShadow: bool + useSkinning: bool + useNormalTexture: bool + alphaCutoff: float32 + + PbrGlState = object + ## Shadow copy of the global GL bindings and enables the PBR pass owns + ## while a pass is active. Unknown values force a real GL call. + programBound: bool + activeUnit: int + boundTexture: array[7, GLuint] + textureEpoch: uint64 + blend: int8 + depthMask: int8 + cullFace: int8 + frontFaceCw: int8 PbrContext* = ref object ## Reusable state for PBR rendering. @@ -157,6 +199,34 @@ type jointMatrices: seq[Mat4] blended: seq[BlendEntry] deferred: seq[BlendEntry] + passValues: PbrPassValues + glState: PbrGlState + lastMaterial: Material + lastMaterialVersion: uint64 + +const TextureUnknown = high(GLuint) + +var textureBindEpoch: uint64 = 1 + ## Bumped whenever GL texture ids are deleted anywhere, so per-unit bind + ## caches never trust an id that may have been recycled by glGenTextures. + +proc resetGlStateCache(ctx: PbrContext) = + ## Marks the GL program, texture-unit, and enable caches unknown so the + ## next draw re-issues them. Called at the start of each draw (foreign GL + ## between draws may have changed this state) and after the library's own + ## shadow/skybox passes. Uniform values are per-program GL state and stay + ## cached across this reset. + if ctx == nil: + return + ctx.glState.programBound = false + ctx.glState.activeUnit = -1 + for unit in 0 ..< ctx.glState.boundTexture.len: + ctx.glState.boundTexture[unit] = TextureUnknown + ctx.glState.blend = -1 + ctx.glState.depthMask = -1 + ctx.glState.cullFace = -1 + ctx.glState.frontFaceCw = -1 + ctx.lastMaterial = nil proc uniformLocation(shader: GLuint, name: cstring): GLint = ## Returns one shader uniform location. @@ -262,6 +332,17 @@ proc setupPbr(ctx: PbrContext) = PbrFragmentShader ) ctx.pbrUniforms = loadPbrUniforms(ctx.pbrShader) + # Sampler-unit assignments never change; set them once at link time. + glUseProgram(ctx.pbrShader) + glUniform1i(ctx.pbrUniforms.baseColorTexture, 0) + glUniform1i(ctx.pbrUniforms.metallicRoughnessTexture, 1) + glUniform1i(ctx.pbrUniforms.normalTexture, 2) + glUniform1i(ctx.pbrUniforms.occlusionTexture, 3) + glUniform1i(ctx.pbrUniforms.emissiveTexture, 4) + glUniform1i(ctx.pbrUniforms.environmentMap, 5) + glUniform1i(ctx.pbrUniforms.shadowMap, 6) + ctx.passValues = PbrPassValues() + ctx.resetGlStateCache() ctx.skyboxShader = compileShaderFiles( SkyboxVertexShader, SkyboxFragmentShader @@ -361,6 +442,7 @@ proc destroy*(environmentMap: var EnvironmentMap) = ## Deletes the OpenGL texture owned by an environment map. if environmentMap.textureId != 0: glDeleteTextures(1, environmentMap.textureId.addr) + inc textureBindEpoch environmentMap = EnvironmentMap() proc loadCubeTexture(path: string): EnvironmentMap = @@ -633,6 +715,7 @@ proc destroy*(ctx: PbrContext) = ctx.ownsEnvironmentMap = false if ctx.shadowMapTex != 0: glDeleteTextures(1, ctx.shadowMapTex.addr) + inc textureBindEpoch if ctx.shadowMapFbo != 0: glDeleteFramebuffers(1, ctx.shadowMapFbo.addr) if ctx.skyboxVbo != 0: @@ -695,6 +778,77 @@ proc ensureData(primitive: Primitive): PrimitiveData = primitive.data = PrimitiveData() primitive.data +proc beginDrawScope(ctx: PbrContext) = + ## Opens one draw's scope: resets the GL binding/enable caches (foreign GL + ## between draws may have changed them) and clears the deferred-blend list. + ctx.resetGlStateCache() + ctx.blended.setLen(0) + +proc ensurePbrProgram(ctx: PbrContext) = + if not ctx.glState.programBound: + glUseProgram(ctx.pbrShader) + ctx.glState.programBound = true + +proc bindTextureCached( + ctx: PbrContext, + unit: int, + target: GLenum, + id: GLuint +) = + if ctx.glState.textureEpoch != textureBindEpoch: + for cachedUnit in 0 ..< ctx.glState.boundTexture.len: + ctx.glState.boundTexture[cachedUnit] = TextureUnknown + ctx.glState.textureEpoch = textureBindEpoch + if ctx.glState.boundTexture[unit] == id: + return + if ctx.glState.activeUnit != unit: + glActiveTexture(GLenum(GL_TEXTURE0.int + unit)) + ctx.glState.activeUnit = unit + glBindTexture(target, id) + ctx.glState.boundTexture[unit] = id + +proc setBlendCached(ctx: PbrContext, on: bool) = + let want = int8(ord(on)) + if ctx.glState.blend == want: + return + if on: + glEnable(GL_BLEND) + glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA) + else: + glDisable(GL_BLEND) + ctx.glState.blend = want + +proc setDepthMaskCached(ctx: PbrContext, on: bool) = + let want = int8(ord(on)) + if ctx.glState.depthMask == want: + return + glDepthMask(if on: GL_TRUE else: GL_FALSE) + ctx.glState.depthMask = want + +proc setCullFaceCached(ctx: PbrContext, on: bool) = + let want = int8(ord(on)) + if ctx.glState.cullFace == want: + return + if on: + glEnable(GL_CULL_FACE) + else: + glDisable(GL_CULL_FACE) + ctx.glState.cullFace = want + +proc setFrontFaceCached( + ctx: PbrContext, + transform: Mat4, + mode: PrimitiveMode +) = + let cw = int8(ord( + mode in {TrianglesMode, TriangleStripMode, TriangleFanMode} and + transform.determinant < 0.0'f + )) + if ctx.glState.frontFaceCw == cw: + return + glFrontFace(if cw == 1: GL_CW else: GL_CCW) + ctx.glState.frontFaceCw = cw + proc ensureData(material: Material): MaterialData = if material.data == nil: material.data = MaterialData() @@ -816,6 +970,7 @@ proc clearMaterialFromGpu(material: Material) = glDeleteTextures(1, data.emissiveId.addr) data.emissiveId = 0 material.data = nil + inc textureBindEpoch proc clearFromGpu*(primitive: Primitive) @@ -1027,6 +1182,198 @@ proc setTextureTransformUniform( ) glUniform1f(uniforms.rotation, transform.rotation) +template syncPassValue(ctx: PbrContext, field, value, uploadStmt: untyped) = + ## Uploads one shadowed pbrShader uniform only when its value changed. + if not ctx.passValues.valid or ctx.passValues.field != value: + ctx.passValues.field = value + uploadStmt + +proc applyPassUniforms( + ctx: PbrContext, + view, proj, lightSpace: Mat4, + tint: Color, + ambientLightColor: Color, + sunLightDirection: Vec3, + sunLightColor: Color, + rimLightDirection: Vec3, + rimLightColor: Color, + debugView: DebugView, + cameraPosition: Vec3, + useShadow: bool +) = + ## Uploads the frame-level uniform block through the value shadow, so a + ## run of primitives sharing the same frame state costs zero GL calls. + let u = ctx.pbrUniforms + ctx.syncPassValue(view, view): + var arr = view + glUniformMatrix4fv(u.view, 1, GL_FALSE, cast[ptr float32](arr.addr)) + ctx.syncPassValue(proj, proj): + var arr = proj + glUniformMatrix4fv(u.proj, 1, GL_FALSE, cast[ptr float32](arr.addr)) + ctx.syncPassValue(lightSpace, lightSpace): + var arr = lightSpace + glUniformMatrix4fv(u.lightSpace, 1, GL_FALSE, cast[ptr float32](arr.addr)) + ctx.syncPassValue(tint, tint): + glUniform4f(u.tint, tint.r, tint.g, tint.b, tint.a) + ctx.syncPassValue(ambientLightColor, ambientLightColor): + glUniform4f( + u.ambientLightColor, + ambientLightColor.r, + ambientLightColor.g, + ambientLightColor.b, + ambientLightColor.a + ) + ctx.syncPassValue(sunLightDirection, sunLightDirection): + glUniform3f( + u.sunLightDirection, + sunLightDirection.x, + sunLightDirection.y, + sunLightDirection.z + ) + ctx.syncPassValue(sunLightColor, sunLightColor): + glUniform4f( + u.sunLightColor, + sunLightColor.r, + sunLightColor.g, + sunLightColor.b, + sunLightColor.a + ) + ctx.syncPassValue(rimLightDirection, rimLightDirection): + glUniform3f( + u.rimLightDirection, + rimLightDirection.x, + rimLightDirection.y, + rimLightDirection.z + ) + ctx.syncPassValue(rimLightColor, rimLightColor): + glUniform4f( + u.rimLightColor, + rimLightColor.r, + rimLightColor.g, + rimLightColor.b, + rimLightColor.a + ) + ctx.syncPassValue(debugView, debugView): + glUniform1i(u.debugViewMode, debugView.int.GLint) + ctx.syncPassValue(cameraPosition, cameraPosition): + glUniform3f( + u.cameraPosition, + cameraPosition.x, + cameraPosition.y, + cameraPosition.z + ) + ctx.syncPassValue(fogColor, ctx.fogColor): + glUniform4f( + u.fogColor, + ctx.fogColor.r, + ctx.fogColor.g, + ctx.fogColor.b, + ctx.fogColor.a + ) + ctx.syncPassValue(fogStart, ctx.fogStart): + glUniform1f(u.fogStart, ctx.fogStart) + ctx.syncPassValue(fogEnd, ctx.fogEnd): + glUniform1f(u.fogEnd, ctx.fogEnd) + ctx.syncPassValue(fogDensity, ctx.fogDensity): + glUniform1f(u.fogDensity, ctx.fogDensity) + ctx.syncPassValue(fogStrength, ctx.fogStrength): + glUniform1f(u.fogStrength, ctx.fogStrength) + ctx.syncPassValue(environmentMapStrength, ctx.environmentMapStrength): + glUniform1f(u.environmentMapStrength, ctx.environmentMapStrength) + ctx.syncPassValue(environmentMipCount, ctx.environmentMap.mipCount): + glUniform1f(u.environmentMipCount, ctx.environmentMap.mipCount) + ctx.syncPassValue(useShadow, useShadow): + glUniform1i(u.useShadow, useShadow.GLint) + ctx.passValues.valid = true + +proc applyMaterial( + ctx: PbrContext, + primitive: Primitive, + shadowTex: GLuint +) = + ## Binds one primitive's material: texture binds are keyed on the actual + ## GL ids (so late texture uploads are caught), material uniforms upload + ## only when the material or its version changed, and blend/depth/cull + ## enables go through the GL-state shadow. + let material = primitive.material + let u = ctx.pbrUniforms + if material == nil: + ctx.bindTextureCached(0, GL_TEXTURE_2D, 0) + ctx.lastMaterial = nil + return + let materialData = material.ensureData() + + ctx.bindTextureCached(0, GL_TEXTURE_2D, materialData.baseColorId) + ctx.bindTextureCached(1, GL_TEXTURE_2D, materialData.metallicRoughnessId) + ctx.bindTextureCached(2, GL_TEXTURE_2D, materialData.normalId) + ctx.bindTextureCached(3, GL_TEXTURE_2D, materialData.occlusionId) + ctx.bindTextureCached(4, GL_TEXTURE_2D, materialData.emissiveId) + let activeShadowTex = + if shadowTex != 0.GLuint: + shadowTex + else: + ctx.shadowMapTex + ctx.bindTextureCached(6, GL_TEXTURE_2D, activeShadowTex) + + # Depends on the primitive, not just the material. + let useNormalTexture = + material.hasNormalTexture and + primitive.normals.len > 0 and + primitive.tangents.len > 0 + ctx.syncPassValue(useNormalTexture, useNormalTexture): + glUniform1i(u.useNormalTexture, useNormalTexture.ord.GLint) + + var cutoff = material.alphaCutoff + case material.alphaMode + of MaskAlphaMode: + ctx.setBlendCached(false) + ctx.setDepthMaskCached(true) + cutoff = material.alphaCutoff + of BlendAlphaMode: + ctx.setBlendCached(true) + ctx.setDepthMaskCached(false) + cutoff = -1.0 + else: + ctx.setBlendCached(false) + ctx.setDepthMaskCached(true) + cutoff = -1.0 + ctx.syncPassValue(alphaCutoff, cutoff): + glUniform1f(u.alphaCutoff, cutoff) + ctx.setCullFaceCached(not material.doubleSided) + + if ctx.lastMaterial == material and + ctx.lastMaterialVersion == material.materialVersion: + return + + glUniform4f( + u.baseColorFactor, + material.baseColorFactor.r, + material.baseColorFactor.g, + material.baseColorFactor.b, + material.baseColorFactor.a + ) + setTextureTransformUniform(u.baseColorTransform, material.baseColorTransform) + glUniform1f(u.metallicFactor, material.metallicFactor) + glUniform1f(u.roughnessFactor, material.roughnessFactor) + glUniform1f(u.transmissionFactor, material.transmissionFactor) + setTextureTransformUniform( + u.metallicRoughnessTransform, + material.metallicRoughnessTransform + ) + glUniform1f(u.normalScale, material.normalScale) + setTextureTransformUniform(u.normalTransform, material.normalTransform) + glUniform1f(u.occlusionStrength, material.occlusionStrength) + setTextureTransformUniform(u.occlusionTransform, material.occlusionTransform) + glUniform3f( + u.emissiveFactor, + material.emissiveFactor.r, + material.emissiveFactor.g, + material.emissiveFactor.b + ) + setTextureTransformUniform(u.emissiveTransform, material.emissiveTransform) + ctx.lastMaterial = material + ctx.lastMaterialVersion = material.materialVersion + proc renderPbrPrimitive( primitive: Primitive, transform, view, proj: Mat4, @@ -1049,30 +1396,41 @@ proc renderPbrPrimitive( ) = if primitive == nil: return - let - pbrShader = ctx.pbrShader - pbrUniforms = ctx.pbrUniforms + let pbrUniforms = ctx.pbrUniforms let isBlend = (primitive.material != nil and primitive.material.alphaMode == BlendAlphaMode) or - ctx.tint.a < 1 + tint.a < 1 if deferBlend and isBlend: blended.add(BlendEntry( node: owner, primitive: primitive, - transform: transform + transform: transform, + tint: tint, + root: root )) return - glUseProgram(pbrShader) + ctx.ensurePbrProgram() + ctx.applyPassUniforms( + view, + proj, + lightSpace, + tint, + ambientLightColor, + sunLightDirection, + sunLightColor, + rimLightDirection, + rimLightColor, + debugView, + cameraPosition, + useShadow + ) var modelArray = transform normalArray = transform.normalMatrix - viewArray = view - projArray = proj - lightSpaceArray = lightSpace glUniformMatrix4fv( pbrUniforms.model, 1, @@ -1085,28 +1443,11 @@ proc renderPbrPrimitive( GL_FALSE, cast[ptr float32](normalArray.addr) ) - glUniformMatrix4fv( - pbrUniforms.view, - 1, - GL_FALSE, - cast[ptr float32](viewArray.addr) - ) - glUniformMatrix4fv( - pbrUniforms.proj, - 1, - GL_FALSE, - cast[ptr float32](projArray.addr) - ) - glUniformMatrix4fv( - pbrUniforms.lightSpace, - 1, - GL_FALSE, - cast[ptr float32](lightSpaceArray.addr) - ) root.skinMatricesInto(owner, ctx.jointMatrices) let useSkinning = ctx.jointMatrices.len > 0 - glUniform1i(pbrUniforms.useSkinning, useSkinning.ord.GLint) + ctx.syncPassValue(useSkinning, useSkinning): + glUniform1i(pbrUniforms.useSkinning, useSkinning.ord.GLint) if useSkinning: glUniformMatrix4fv( pbrUniforms.jointMatrices, @@ -1119,191 +1460,25 @@ proc renderPbrPrimitive( let primitiveData = primitive.data glBindVertexArray(primitiveData.vertexArrayId) - glActiveTexture(GL_TEXTURE5) - glUniform1i(pbrUniforms.environmentMap, 5) - glUniform1f( - pbrUniforms.environmentMipCount, - ctx.environmentMap.mipCount - ) - glBindTexture(GL_TEXTURE_CUBE_MAP, ctx.environmentMap.textureId) - - if primitive.material != nil: - let materialData = primitive.material.ensureData() - let useNormalTexture = - primitive.material.hasNormalTexture and - primitive.normals.len > 0 and - primitive.tangents.len > 0 - - glActiveTexture(GL_TEXTURE0) - glUniform1i(pbrUniforms.baseColorTexture, 0) - glBindTexture(GL_TEXTURE_2D, materialData.baseColorId) - - glUniform4f( - pbrUniforms.baseColorFactor, - primitive.material.baseColorFactor.r, - primitive.material.baseColorFactor.g, - primitive.material.baseColorFactor.b, - primitive.material.baseColorFactor.a - ) - setTextureTransformUniform( - pbrUniforms.baseColorTransform, - primitive.material.baseColorTransform - ) - - glActiveTexture(GL_TEXTURE1) - glUniform1i(pbrUniforms.metallicRoughnessTexture, 1) - glBindTexture(GL_TEXTURE_2D, materialData.metallicRoughnessId) - glUniform1f(pbrUniforms.metallicFactor, primitive.material.metallicFactor) - glUniform1f(pbrUniforms.roughnessFactor, primitive.material.roughnessFactor) - glUniform1f( - pbrUniforms.transmissionFactor, - primitive.material.transmissionFactor - ) - setTextureTransformUniform( - pbrUniforms.metallicRoughnessTransform, - primitive.material.metallicRoughnessTransform - ) - - glActiveTexture(GL_TEXTURE2) - glUniform1i(pbrUniforms.normalTexture, 2) - glBindTexture(GL_TEXTURE_2D, materialData.normalId) - glUniform1f(pbrUniforms.normalScale, primitive.material.normalScale) - setTextureTransformUniform( - pbrUniforms.normalTransform, - primitive.material.normalTransform - ) - glUniform1i(pbrUniforms.useNormalTexture, useNormalTexture.ord.GLint) - - glActiveTexture(GL_TEXTURE3) - glUniform1i(pbrUniforms.occlusionTexture, 3) - glBindTexture(GL_TEXTURE_2D, materialData.occlusionId) - glUniform1f( - pbrUniforms.occlusionStrength, - primitive.material.occlusionStrength - ) - setTextureTransformUniform( - pbrUniforms.occlusionTransform, - primitive.material.occlusionTransform - ) - - glActiveTexture(GL_TEXTURE4) - glUniform1i(pbrUniforms.emissiveTexture, 4) - glBindTexture(GL_TEXTURE_2D, materialData.emissiveId) - glUniform3f( - pbrUniforms.emissiveFactor, - primitive.material.emissiveFactor.r, - primitive.material.emissiveFactor.g, - primitive.material.emissiveFactor.b - ) - setTextureTransformUniform( - pbrUniforms.emissiveTransform, - primitive.material.emissiveTransform - ) - - let activeShadowTex = - if shadowTex != 0.GLuint: - shadowTex - else: - ctx.shadowMapTex - glActiveTexture(GL_TEXTURE6) - glUniform1i(pbrUniforms.shadowMap, 6) - glBindTexture(GL_TEXTURE_2D, activeShadowTex) - - var cutoff = primitive.material.alphaCutoff - case primitive.material.alphaMode - of MaskAlphaMode: - glDisable(GL_BLEND) - glDepthMask(GL_TRUE) - cutoff = primitive.material.alphaCutoff - of BlendAlphaMode: - glEnable(GL_BLEND) - glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA) - glDepthMask(GL_FALSE) - cutoff = -1.0 - else: - glDisable(GL_BLEND) - glDepthMask(GL_TRUE) - cutoff = -1.0 - if ctx.tint.a < 1 and - primitive.material.alphaMode != BlendAlphaMode: - # The tint uniform's alpha already multiplies the fragment alpha in - # the shader; blending it here lets engines fade whole draws (for - # example distance fades) without cloning materials. The depth write - # stays on so mostly-solid objects keep occluding while they fade. - glEnable(GL_BLEND) - glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA) - glUniform1f(pbrUniforms.alphaCutoff, cutoff) - - if primitive.material.doubleSided: - glDisable(GL_CULL_FACE) - else: - glEnable(GL_CULL_FACE) - else: - glBindTexture(GL_TEXTURE_2D, 0) - if ctx.tint.a < 1: - glEnable(GL_BLEND) - glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA) - - glUniform4f( - pbrUniforms.ambientLightColor, - ambientLightColor.r, - ambientLightColor.g, - ambientLightColor.b, - ambientLightColor.a - ) - glUniform3f( - pbrUniforms.sunLightDirection, - sunLightDirection.x, - sunLightDirection.y, - sunLightDirection.z - ) - glUniform4f( - pbrUniforms.sunLightColor, - sunLightColor.r, - sunLightColor.g, - sunLightColor.b, - sunLightColor.a - ) - glUniform3f( - pbrUniforms.rimLightDirection, - rimLightDirection.x, - rimLightDirection.y, - rimLightDirection.z - ) - glUniform4f( - pbrUniforms.rimLightColor, - rimLightColor.r, - rimLightColor.g, - rimLightColor.b, - rimLightColor.a - ) - glUniform1i(pbrUniforms.debugViewMode, debugView.int.GLint) - glUniform3f( - pbrUniforms.cameraPosition, - cameraPosition.x, - cameraPosition.y, - cameraPosition.z - ) - glUniform4f(pbrUniforms.tint, tint.r, tint.g, tint.b, tint.a) - glUniform4f( - pbrUniforms.fogColor, - ctx.fogColor.r, - ctx.fogColor.g, - ctx.fogColor.b, - ctx.fogColor.a - ) - glUniform1f(pbrUniforms.fogStart, ctx.fogStart) - glUniform1f(pbrUniforms.fogEnd, ctx.fogEnd) - glUniform1f(pbrUniforms.fogDensity, ctx.fogDensity) - glUniform1f(pbrUniforms.fogStrength, ctx.fogStrength) - glUniform1f( - pbrUniforms.environmentMapStrength, - ctx.environmentMapStrength + ctx.bindTextureCached( + 5, + GL_TEXTURE_CUBE_MAP, + ctx.environmentMap.textureId ) - glUniform1i(pbrUniforms.useShadow, useShadow.Glint) + ctx.applyMaterial(primitive, shadowTex) + + if tint.a < 1 and ( + primitive.material == nil or + primitive.material.alphaMode != BlendAlphaMode + ): + # The tint uniform's alpha already multiplies the fragment alpha in the + # shader; blending it here lets engines fade whole draws (for example + # distance fades) without cloning materials. The depth write stays on so + # mostly-solid objects keep occluding while they fade. + ctx.setBlendCached(true) let glMode = primitive.mode.glValue - setFrontFace(transform, primitive.mode) + ctx.setFrontFaceCached(transform, primitive.mode) when not defined(emscripten): if primitive.mode == PointsMode: glPointSize(1.0) @@ -1329,11 +1504,6 @@ proc renderPbrPrimitive( else: raise newException(GltfError, "Invalid indices") - glDisable(GL_BLEND) - glDepthMask(GL_TRUE) - glFrontFace(GL_CCW) - glEnable(GL_CULL_FACE) - proc renderPbrNode( node: Node, transform, view, proj: Mat4, @@ -1421,18 +1591,74 @@ proc renderPbrNode( root=rootNode ) +proc flushBlended(ctx: PbrContext) = + ## Renders deferred blended primitives back-to-front. Entries replay with + ## the tint captured at defer time; shadows are not sampled in the blended + ## pass, matching the pre-pass behavior. + if ctx.blended.len == 0: + return + ctx.blended.sort(proc(a, b: BlendEntry): int = + let + pa = (a.transform * vec4(0, 0, 0, 1)).xyz + pb = (b.transform * vec4(0, 0, 0, 1)).xyz + da = (ctx.cameraPosition - pa).lengthSq + db = (ctx.cameraPosition - pb).lengthSq + if da > db: -1 elif da < db: 1 else: 0 + ) + for entry in ctx.blended: + ctx.deferred.setLen(0) + renderPbrPrimitive( + entry.primitive, + entry.transform, + ctx.view, + ctx.proj, + entry.tint, + ctx.ambientLightColor, + ctx.sunLightDirection, + ctx.sunLightColor, + ctx.rimLightDirection, + ctx.rimLightColor, + ctx.debugView, + ctx.cameraPosition, + useShadow=false, + lightSpace=mat4(), + shadowTex=0, + deferBlend=false, + blended=ctx.deferred, + ctx=ctx, + owner=entry.node, + root=entry.root + ) + ctx.blended.setLen(0) + +proc endDrawScope(ctx: PbrContext) = + ## Closes one draw's scope: flushes this draw's deferred blended primitives + ## (sorted back-to-front) and restores canonical GL state (blend off, depth + ## writes on, CCW front faces, culling on). + ctx.flushBlended() + glDisable(GL_BLEND) + glDepthMask(GL_TRUE) + glFrontFace(GL_CCW) + glEnable(GL_CULL_FACE) + ctx.glState.blend = 0 + ctx.glState.depthMask = 1 + ctx.glState.frontFaceCw = 0 + ctx.glState.cullFace = 1 + proc drawPbr( node: Node, ctx: PbrContext ) = - ## Draws a node tree with PBR shading. + ## Draws a node tree with PBR shading. Frame-level uniforms upload only + ## when they change, material/texture binds dedup across the tree, and + ## deferred blended primitives flush back-to-front at the end of the draw. doAssert ctx != nil, "PBR context must not be nil." if not node.visible: return - node.updateTransforms(ctx.transform, ctx.useTrs) + ctx.beginDrawScope() - ctx.blended.setLen(0) + node.updateTransforms(ctx.transform, ctx.useTrs) renderPbrNode( node, @@ -1456,41 +1682,7 @@ proc drawPbr( root=node ) - if ctx.blended.len > 0: - glDepthMask(GL_FALSE) - ctx.blended.sort(proc(a, b: BlendEntry): int = - let - pa = (a.transform * vec4(0, 0, 0, 1)).xyz - pb = (b.transform * vec4(0, 0, 0, 1)).xyz - da = (ctx.cameraPosition - pa).lengthSq - db = (ctx.cameraPosition - pb).lengthSq - if da > db: -1 elif da < db: 1 else: 0 - ) - for entry in ctx.blended: - ctx.deferred.setLen(0) - renderPbrPrimitive( - entry.primitive, - entry.transform, - ctx.view, - ctx.proj, - ctx.tint, - ctx.ambientLightColor, - ctx.sunLightDirection, - ctx.sunLightColor, - ctx.rimLightDirection, - ctx.rimLightColor, - ctx.debugView, - ctx.cameraPosition, - useShadow=false, - lightSpace=mat4(), - shadowTex=0, - deferBlend=false, - blended=ctx.deferred, - ctx=ctx, - owner=entry.node, - root=node - ) - glDepthMask(GL_TRUE) + ctx.endDrawScope() proc shadowLookAt(eye, center, up: Vec3): Mat4 = ## Standard OpenGL lookAt (z-backward) for shadow mapping. @@ -1669,6 +1861,8 @@ proc drawPbrWithShadow( if not node.visible: return + ctx.beginDrawScope() + node.updateTransforms(ctx.transform, ctx.useTrs) let (lightView, lightProj, lightSpace, _) = @@ -1712,9 +1906,10 @@ proc drawPbrWithShadow( # Restore viewport. glViewport(oldViewport[0], oldViewport[1], oldViewport[2], oldViewport[3]) - # Main pass with shadow sampling. - ctx.blended.setLen(0) + # The depth pass bound its own program and toggled enables. + ctx.resetGlStateCache() + # Main pass with shadow sampling. renderPbrNode( node, ctx.transform, @@ -1737,40 +1932,7 @@ proc drawPbrWithShadow( root=node ) - if ctx.blended.len > 0: - glDepthMask(GL_FALSE) - ctx.blended.sort(proc(a, b: BlendEntry): int = - let pa = (a.transform * vec4(0, 0, 0, 1)).xyz - let pb = (b.transform * vec4(0, 0, 0, 1)).xyz - let da = (ctx.cameraPosition - pa).lengthSq - let db = (ctx.cameraPosition - pb).lengthSq - if da > db: -1 elif da < db: 1 else: 0 - ) - for entry in ctx.blended: - ctx.deferred.setLen(0) - renderPbrPrimitive( - entry.primitive, - entry.transform, - ctx.view, - ctx.proj, - ctx.tint, - ctx.ambientLightColor, - ctx.sunLightDirection, - ctx.sunLightColor, - ctx.rimLightDirection, - ctx.rimLightColor, - ctx.debugView, - ctx.cameraPosition, - useShadow=false, - lightSpace=mat4(), - shadowTex=0, - deferBlend=false, - blended=ctx.deferred, - ctx=ctx, - owner=entry.node, - root=node - ) - glDepthMask(GL_TRUE) + ctx.endDrawScope() proc draw*(ctx: PbrContext, node: Node) = ## Draws a node tree using PBR context state. @@ -1785,6 +1947,7 @@ proc draw*(ctx: PbrContext, node: Node) = ctx.environmentMap, ctx.skyboxLod ) + ctx.resetGlStateCache() if ctx.useShadows and ctx.debugView == dvLit: drawPbrWithShadow(node, ctx) else: From 0b383310005e064e57e1f96486403883fb914f42 Mon Sep 17 00:00:00 2001 From: Richard Higgins Date: Thu, 2 Jul 2026 22:11:37 -0700 Subject: [PATCH 2/2] Add opt-in beginPass/endPass to batch state across draws 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. --- src/gltf/backends/opengl/renderer.nim | 92 +++++++++++++++++---------- 1 file changed, 58 insertions(+), 34 deletions(-) diff --git a/src/gltf/backends/opengl/renderer.nim b/src/gltf/backends/opengl/renderer.nim index b31dfc6..37012ac 100644 --- a/src/gltf/backends/opengl/renderer.nim +++ b/src/gltf/backends/opengl/renderer.nim @@ -201,6 +201,7 @@ type deferred: seq[BlendEntry] passValues: PbrPassValues glState: PbrGlState + passDepth: int lastMaterial: Material lastMaterialVersion: uint64 @@ -210,12 +211,11 @@ var textureBindEpoch: uint64 = 1 ## Bumped whenever GL texture ids are deleted anywhere, so per-unit bind ## caches never trust an id that may have been recycled by glGenTextures. -proc resetGlStateCache(ctx: PbrContext) = - ## Marks the GL program, texture-unit, and enable caches unknown so the - ## next draw re-issues them. Called at the start of each draw (foreign GL - ## between draws may have changed this state) and after the library's own - ## shadow/skybox passes. Uniform values are per-program GL state and stay - ## cached across this reset. +proc invalidateGlState*(ctx: PbrContext) = + ## Marks GL program, texture-unit, and enable state unknown. Call after + ## running your own GL commands (binding other programs or textures, + ## toggling blend/depth/cull) between draws inside a pass. Uniform values + ## are per-program state and stay cached. if ctx == nil: return ctx.glState.programBound = false @@ -228,6 +228,13 @@ proc resetGlStateCache(ctx: PbrContext) = ctx.glState.frontFaceCw = -1 ctx.lastMaterial = nil +proc invalidateUniformCache*(ctx: PbrContext) = + ## Escape hatch for consumers that upload uniforms to pbrShader directly. + if ctx == nil: + return + ctx.passValues.valid = false + ctx.lastMaterial = nil + proc uniformLocation(shader: GLuint, name: cstring): GLint = ## Returns one shader uniform location. glGetUniformLocation(shader, name) @@ -342,7 +349,7 @@ proc setupPbr(ctx: PbrContext) = glUniform1i(ctx.pbrUniforms.environmentMap, 5) glUniform1i(ctx.pbrUniforms.shadowMap, 6) ctx.passValues = PbrPassValues() - ctx.resetGlStateCache() + ctx.invalidateGlState() ctx.skyboxShader = compileShaderFiles( SkyboxVertexShader, SkyboxFragmentShader @@ -778,11 +785,17 @@ proc ensureData(primitive: Primitive): PrimitiveData = primitive.data = PrimitiveData() primitive.data -proc beginDrawScope(ctx: PbrContext) = - ## Opens one draw's scope: resets the GL binding/enable caches (foreign GL - ## between draws may have changed them) and clears the deferred-blend list. - ctx.resetGlStateCache() - ctx.blended.setLen(0) +proc beginPass*(ctx: PbrContext) = + ## Starts a batched PBR pass. Until the matching endPass, the context + ## assumes it owns the GL program binding, texture units 0-6, and the + ## blend/depth/cull/front-face enables across draw calls, and defers + ## blended primitives from all draws into one globally sorted flush at + ## endPass. Nesting is allowed; only the outermost pair has effect. + doAssert ctx != nil, "PBR context must not be nil." + inc ctx.passDepth + if ctx.passDepth == 1: + ctx.invalidateGlState() + ctx.blended.setLen(0) proc ensurePbrProgram(ctx: PbrContext) = if not ctx.glState.programBound: @@ -1631,32 +1644,39 @@ proc flushBlended(ctx: PbrContext) = ) ctx.blended.setLen(0) -proc endDrawScope(ctx: PbrContext) = - ## Closes one draw's scope: flushes this draw's deferred blended primitives - ## (sorted back-to-front) and restores canonical GL state (blend off, depth - ## writes on, CCW front faces, culling on). - ctx.flushBlended() - glDisable(GL_BLEND) - glDepthMask(GL_TRUE) - glFrontFace(GL_CCW) - glEnable(GL_CULL_FACE) - ctx.glState.blend = 0 - ctx.glState.depthMask = 1 - ctx.glState.frontFaceCw = 0 - ctx.glState.cullFace = 1 +proc endPass*(ctx: PbrContext) = + ## Ends a batched PBR pass: flushes deferred blended primitives, sorted + ## back-to-front across every draw in the pass, and restores the canonical + ## GL state (blend off, depth writes on, CCW front faces, culling on). + doAssert ctx != nil, "PBR context must not be nil." + doAssert ctx.passDepth > 0, "endPass without matching beginPass." + if ctx.passDepth == 1: + ctx.flushBlended() + glDisable(GL_BLEND) + glDepthMask(GL_TRUE) + glFrontFace(GL_CCW) + glEnable(GL_CULL_FACE) + ctx.glState.blend = 0 + ctx.glState.depthMask = 1 + ctx.glState.frontFaceCw = 0 + ctx.glState.cullFace = 1 + dec ctx.passDepth proc drawPbr( node: Node, ctx: PbrContext ) = - ## Draws a node tree with PBR shading. Frame-level uniforms upload only - ## when they change, material/texture binds dedup across the tree, and - ## deferred blended primitives flush back-to-front at the end of the draw. + ## Draws a node tree with PBR shading. Outside an explicit pass this runs + ## as its own implicit pass (blended primitives flush at the end of this + ## draw, exactly as before); inside beginPass/endPass the blended flush + ## and state restore defer to endPass. doAssert ctx != nil, "PBR context must not be nil." if not node.visible: return - ctx.beginDrawScope() + let implicitPass = ctx.passDepth == 0 + if implicitPass: + ctx.beginPass() node.updateTransforms(ctx.transform, ctx.useTrs) @@ -1682,7 +1702,8 @@ proc drawPbr( root=node ) - ctx.endDrawScope() + if implicitPass: + ctx.endPass() proc shadowLookAt(eye, center, up: Vec3): Mat4 = ## Standard OpenGL lookAt (z-backward) for shadow mapping. @@ -1861,7 +1882,9 @@ proc drawPbrWithShadow( if not node.visible: return - ctx.beginDrawScope() + let implicitPass = ctx.passDepth == 0 + if implicitPass: + ctx.beginPass() node.updateTransforms(ctx.transform, ctx.useTrs) @@ -1907,7 +1930,7 @@ proc drawPbrWithShadow( glViewport(oldViewport[0], oldViewport[1], oldViewport[2], oldViewport[3]) # The depth pass bound its own program and toggled enables. - ctx.resetGlStateCache() + ctx.invalidateGlState() # Main pass with shadow sampling. renderPbrNode( @@ -1932,7 +1955,8 @@ proc drawPbrWithShadow( root=node ) - ctx.endDrawScope() + if implicitPass: + ctx.endPass() proc draw*(ctx: PbrContext, node: Node) = ## Draws a node tree using PBR context state. @@ -1947,7 +1971,7 @@ proc draw*(ctx: PbrContext, node: Node) = ctx.environmentMap, ctx.skyboxLod ) - ctx.resetGlStateCache() + ctx.invalidateGlState() if ctx.useShadows and ctx.debugView == dvLit: drawPbrWithShadow(node, ctx) else: