From 5f4932624d86c76dd99c07ac4dfd37ae37cdc78c Mon Sep 17 00:00:00 2001 From: Richard Higgins Date: Thu, 2 Jul 2026 22:10:56 -0700 Subject: [PATCH 1/3] 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/3] 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: From ee5e74a22a627c68ce4f9d986c390d413f4cf971 Mon Sep 17 00:00:00 2001 From: treeform Date: Fri, 11 Sep 2026 16:33:39 -0700 Subject: [PATCH 3/3] Fix stale shadow sampler bind and unwind the pass when a draw throws Two issues found running the Khronos sample assets on macOS: 1. uploadTextureToGpu binds the new texture on whatever unit is active, but only deletes bumped textureBindEpoch. After a lazy upload the cache still believed the shadow map was bound on unit 6 while a colour texture sat there (macOS logs 'bound to wrong sampler type (Depth)' and samples a zero texture). Bump the epoch on upload so every cached unit rebinds. 2. drawPbr and drawPbrWithShadow opened an implicit pass and only closed it on the success path. A draw that raised mid-pass (a lazy upload of an unsupported supercompressed KTX2 texture) left passDepth at 1, so every later draw ran as if inside an explicit pass: no per-draw cache reset, deferred blended primitives never flushed, and lastMaterial pointer reuse after release delivered stale material uniforms. In the sample-asset run 82 models rendered differently from master, with whole primitives vanishing. Wrap both draws in try/finally and add abortPass, which drops deferred entries, restores the canonical GL state and forgets every cached value. Adds tests/test_pbr_pass.nim (needs a GPU and the Khronos sample assets next to the repo): explicit, nested and invalidate-after-foreign-GL passes must match the implicit path, tint and sun changes across passes must be honored, shadows-on lazy uploads must match steady state, and a throwing draw must not poison the next one. With this commit all 295 renderable sample assets are pixel-identical to master on macOS. Co-Authored-By: Claude Fable 5.1 --- src/gltf/backends/opengl/renderer.nim | 208 +++++++++++++---------- tests/test_pbr_pass.nim | 236 ++++++++++++++++++++++++++ 2 files changed, 357 insertions(+), 87 deletions(-) create mode 100644 tests/test_pbr_pass.nim diff --git a/src/gltf/backends/opengl/renderer.nim b/src/gltf/backends/opengl/renderer.nim index 37012ac..e8bbeb7 100644 --- a/src/gltf/backends/opengl/renderer.nim +++ b/src/gltf/backends/opengl/renderer.nim @@ -876,6 +876,7 @@ proc uploadTextureToGpu( ## Uploads a texture to OpenGL. if ktx2Data.len > 0: textureId = loadKtx2Texture(ktx2Data, sampler) + inc textureBindEpoch return if image == nil: @@ -883,6 +884,9 @@ proc uploadTextureToGpu( glGenTextures(1, textureId.addr) glBindTexture(GL_TEXTURE_2D, textureId) + # Binding on the active unit changes what the state cache believes is + # bound there, so force every cached unit to rebind. + inc textureBindEpoch # Opaque images upload as RGB, so NPOT widths can have non-4-byte rows. glPixelStorei(GL_UNPACK_ALIGNMENT, 1) if image.isOpaque(): @@ -1644,6 +1648,23 @@ proc flushBlended(ctx: PbrContext) = ) ctx.blended.setLen(0) +proc abortPass(ctx: PbrContext) = + ## Unwinds an implicit pass after an exception escaped a draw: drops the + ## deferred blended entries, restores the canonical GL state and forgets + ## every cached value so the next draw starts clean. + ctx.blended.setLen(0) + ctx.deferred.setLen(0) + ctx.passDepth = 0 + try: + glDisable(GL_BLEND) + glDepthMask(GL_TRUE) + glFrontFace(GL_CCW) + glEnable(GL_CULL_FACE) + except CatchableError: + discard + ctx.invalidateGlState() + ctx.invalidateUniformCache() + 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 @@ -1678,32 +1699,38 @@ proc drawPbr( if implicitPass: ctx.beginPass() - node.updateTransforms(ctx.transform, ctx.useTrs) - - renderPbrNode( - node, - ctx.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=true, - blended=ctx.blended, - ctx=ctx, - root=node - ) + var completed = false + try: + node.updateTransforms(ctx.transform, ctx.useTrs) - if implicitPass: - ctx.endPass() + renderPbrNode( + node, + ctx.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=true, + blended=ctx.blended, + ctx=ctx, + root=node + ) + completed = true + finally: + if implicitPass: + if completed: + ctx.endPass() + else: + ctx.abortPass() proc shadowLookAt(eye, center, up: Vec3): Mat4 = ## Standard OpenGL lookAt (z-backward) for shadow mapping. @@ -1885,78 +1912,85 @@ proc drawPbrWithShadow( let implicitPass = ctx.passDepth == 0 if implicitPass: ctx.beginPass() + var completed = false + try: - node.updateTransforms(ctx.transform, ctx.useTrs) + node.updateTransforms(ctx.transform, ctx.useTrs) - let (lightView, lightProj, lightSpace, _) = - getShadowMatrices(node, ctx.transform, ctx.sunLightDirection) + let (lightView, lightProj, lightSpace, _) = + getShadowMatrices(node, ctx.transform, ctx.sunLightDirection) - # Save viewport and framebuffer. - var - oldViewport: array[4, GLint] - oldFramebuffer: GLint - glGetIntegerv(GL_VIEWPORT, oldViewport[0].addr) - glGetIntegerv(GL_FRAMEBUFFER_BINDING, oldFramebuffer.addr) + # Save viewport and framebuffer. + var + oldViewport: array[4, GLint] + oldFramebuffer: GLint + glGetIntegerv(GL_VIEWPORT, oldViewport[0].addr) + glGetIntegerv(GL_FRAMEBUFFER_BINDING, oldFramebuffer.addr) - # Depth pass. - glViewport( - 0, - 0, - ctx.shadowMapSize.GLsizei, - ctx.shadowMapSize.GLsizei - ) - glBindFramebuffer(GL_FRAMEBUFFER, ctx.shadowMapFbo) - glClear(GL_DEPTH_BUFFER_BIT) - glUseProgram(ctx.shadowDepthShader) - glEnable(GL_CULL_FACE) - glCullFace(GL_BACK) - glEnable(GL_DEPTH_TEST) - glDepthMask(GL_TRUE) + # Depth pass. + glViewport( + 0, + 0, + ctx.shadowMapSize.GLsizei, + ctx.shadowMapSize.GLsizei + ) + glBindFramebuffer(GL_FRAMEBUFFER, ctx.shadowMapFbo) + glClear(GL_DEPTH_BUFFER_BIT) + glUseProgram(ctx.shadowDepthShader) + glEnable(GL_CULL_FACE) + glCullFace(GL_BACK) + glEnable(GL_DEPTH_TEST) + glDepthMask(GL_TRUE) - renderShadowNode( - node, - node, - ctx.transform, - lightView, - lightProj, - ctx.tint, - ctx, - applyTrs=true - ) + renderShadowNode( + node, + node, + ctx.transform, + lightView, + lightProj, + ctx.tint, + ctx, + applyTrs=true + ) - glBindFramebuffer(GL_FRAMEBUFFER, oldFramebuffer.GLuint) + glBindFramebuffer(GL_FRAMEBUFFER, oldFramebuffer.GLuint) - # Restore viewport. - glViewport(oldViewport[0], oldViewport[1], oldViewport[2], oldViewport[3]) + # Restore viewport. + glViewport(oldViewport[0], oldViewport[1], oldViewport[2], oldViewport[3]) - # The depth pass bound its own program and toggled enables. - ctx.invalidateGlState() + # The depth pass bound its own program and toggled enables. + ctx.invalidateGlState() - # Main pass with shadow sampling. - renderPbrNode( - node, - ctx.transform, - ctx.view, - ctx.proj, - ctx.tint, - ctx.ambientLightColor, - ctx.sunLightDirection, - ctx.sunLightColor, - ctx.rimLightDirection, - ctx.rimLightColor, - ctx.debugView, - ctx.cameraPosition, - useShadow=true, - lightSpace=lightSpace, - shadowTex=ctx.shadowMapTex, - deferBlend=true, - blended=ctx.blended, - ctx=ctx, - root=node - ) + # Main pass with shadow sampling. + renderPbrNode( + node, + ctx.transform, + ctx.view, + ctx.proj, + ctx.tint, + ctx.ambientLightColor, + ctx.sunLightDirection, + ctx.sunLightColor, + ctx.rimLightDirection, + ctx.rimLightColor, + ctx.debugView, + ctx.cameraPosition, + useShadow=true, + lightSpace=lightSpace, + shadowTex=ctx.shadowMapTex, + deferBlend=true, + blended=ctx.blended, + ctx=ctx, + root=node + ) - if implicitPass: - ctx.endPass() + completed = true + finally: + if implicitPass: + if completed: + ctx.endPass() + else: + ctx.abortPass() proc draw*(ctx: PbrContext, node: Node) = ## Draws a node tree using PBR context state. diff --git a/tests/test_pbr_pass.nim b/tests/test_pbr_pass.nim new file mode 100644 index 0000000..5f3a003 --- /dev/null +++ b/tests/test_pbr_pass.nim @@ -0,0 +1,236 @@ +## Exercises the opt-in beginPass/endPass API from PR 48 on real hardware. +## Renders the same scene with implicit passes, one explicit pass, nested +## passes, and foreign GL state + invalidateGlState, and checks the images +## match. Also checks the uniform value cache never serves stale values. + +import + std/[os, strformat, times], + chroma, pixie, windy, vmath, opengl, + gltf + +const + WindowSize = 512 + Background = color(0.7058824, 0.74509805, 0.8627451, 1.0) + OutDir = "tests/tmp/pass" + +proc modelsDir(): string = + ## Finds the Khronos sample assets the same way tests/sample_assets.nim does. + for candidate in [ + joinPath(getCurrentDir(), "..", "glTF-Sample-Assets", "Models"), + joinPath(getCurrentDir(), "..", "..", "glTF-Sample-Assets", "Models"), + joinPath(getCurrentDir(), "glTF-Sample-Assets", "Models") + ]: + if dirExists(candidate): + return candidate + quit("glTF-Sample-Assets not found next to the repo", 1) + +let ModelsDir = modelsDir() + +var window = newWindow("PBR pass test", ivec2(WindowSize, WindowSize), msaa = msaa8x) +makeContextCurrent(window) +loadExtensions() +let renderer = newRenderer(window) +let ctx = newPbrContext(renderer) +ctx.attachEnvironmentMap(loadDefaultEnvironmentMap()) +createDir(OutDir) + +proc load(rel: string): Node = + let file = readGltfFile(joinPath(ModelsDir, rel)) + doAssert file.unsupportedUsedExtensions.len == 0, rel + file.root + +let + helmet = load("DamagedHelmet/glTF-Binary/DamagedHelmet.glb") + alpha = load("AlphaBlendModeTest/glTF-Binary/AlphaBlendModeTest.glb") + spheres = load("MetalRoughSpheresNoTextures/glTF-Binary/MetalRoughSpheresNoTextures.glb") + +proc fitScale(node: Node, target: float32): Mat4 = + let b = node.computeBounds() + scale(vec3(target / max(b.radius, 0.001))) * translate(-b.center) + +let + helmetPlace = translate(vec3(-1.6, 0.9, 0)) * fitScale(helmet, 1.0) + alphaPlace = translate(vec3(1.6, 0.9, 0)) * fitScale(alpha, 1.0) + spheresPlace = translate(vec3(0, -1.2, 0)) * fitScale(spheres, 1.1) + +proc setup(tint: Color, sun: Color) = + let aspect = window.size.x.float32 / window.size.y.float32 + let cam = translate(vec3(0, 0, -6)) * rotateX(degToRad(12'f)) + ctx.size = window.size + ctx.clearColor = Background + ctx.view = cam + ctx.proj = perspective(45'f, aspect, 0.01, 100) + ctx.cameraPosition = vec3(cam.inverse.pos) + ctx.tint = tint + ctx.useTrs = true + ctx.ambientLightColor = color(0.32, 0.36, 0.46, 0.18) + ctx.sunLightDirection = normalize(vec3(1, -4, -2)) + ctx.sunLightColor = sun + ctx.rimLightDirection = normalize(vec3(-1, 1, -1)) + ctx.rimLightColor = color(0.95, 0.72, 0.46, 0.25) + ctx.debugView = dvLit + ctx.useShadows = false + ctx.drawSkybox = false + ctx.vsync = false + +proc foreignGl() = + ## Simulates an engine running its own GL between draws. + glUseProgram(0) + glActiveTexture(GL_TEXTURE3) + glBindTexture(GL_TEXTURE_2D, 0) + glActiveTexture(GL_TEXTURE0) + glBindTexture(GL_TEXTURE_2D, 0) + glDisable(GL_CULL_FACE) + glEnable(GL_BLEND) + glDepthMask(GL_FALSE) + glFrontFace(GL_CW) + +type Mode = enum + Implicit, OnePass, Nested, ForeignInvalidate, ForeignNoInvalidate + +proc frame(mode: Mode, tint = color(1, 1, 1, 1), + sun = color(0.95, 0.96, 1.0, 1.0)): Image = + pollEvents() + renderer.beginFrame(window, window.size) + renderer.clearScreen(Background) + setup(tint, sun) + if mode in {OnePass, Nested, ForeignInvalidate, ForeignNoInvalidate}: + ctx.beginPass() + if mode == Nested: + ctx.beginPass() + ctx.transform = helmetPlace + ctx.draw(helmet) + if mode in {ForeignInvalidate, ForeignNoInvalidate}: + foreignGl() + if mode == ForeignInvalidate: + ctx.invalidateGlState() + ctx.transform = spheresPlace + ctx.draw(spheres) + ctx.transform = alphaPlace + ctx.draw(alpha) + if mode == Nested: + ctx.endPass() + if mode in {OnePass, Nested, ForeignInvalidate, ForeignNoInvalidate}: + ctx.endPass() + renderer.endFrame() + renderer.captureScreenshot() + +proc score(a, b: Image): float32 = + diff(a, b)[0] + +# Warm up (first frame compiles shaders and uploads). +discard frame(Implicit) + +let base = frame(Implicit) +base.writeFile(OutDir / "implicit.png") +for mode in [OnePass, Nested, ForeignInvalidate]: + let img = frame(mode) + img.writeFile(OutDir / ($mode & ".png")) + let s = score(base, img) + echo &"{mode:<20} vs implicit: xray score {s:0.4f}" + doAssert s < 0.01, $mode & " diverged from implicit-pass rendering" + +# Uniform value cache: values changed between passes must be honored. +let red = frame(OnePass, tint = color(1, 0.2, 0.2, 1)) +red.writeFile(OutDir / "tint_red.png") +let whiteAgain = frame(OnePass) +let sRed = score(base, red) +let sWhite = score(base, whiteAgain) +echo &"tint red vs base: {sRed:0.4f} white-after-red vs base: {sWhite:0.4f}" +doAssert sRed > 0.5, "tint change was not applied (stale uniform cache?)" +doAssert sWhite < 0.01, "tint did not restore (stale uniform cache?)" + +let dim = frame(OnePass, sun = color(0.1, 0.1, 0.4, 1)) +dim.writeFile(OutDir / "sun_dim.png") +let normalAgain = frame(OnePass) +let sDim = score(base, dim) +let sNormal = score(base, normalAgain) +echo &"sun dim vs base: {sDim:0.4f} normal-after-dim vs base: {sNormal:0.4f}" +doAssert sDim > 0.5, "sun colour change was not applied (stale uniform cache?)" +doAssert sNormal < 0.01, "sun colour did not restore (stale uniform cache?)" + +# Debug view switches shader paths mid-run; must not be stuck. +let normals = frame(OnePass) +ctx.debugView = dvLit +discard normals + +# Timing: implicit passes vs one explicit pass, 120 frames each. +proc bench(mode: Mode): float = + let start = epochTime() + for i in 0 ..< 120: + discard frame(mode) + (epochTime() - start) / 120 * 1000 +echo &"avg frame ms implicit: {bench(Implicit):0.2f} onePass: {bench(OnePass):0.2f}" + +# Shadows on, fresh models uploaded lazily inside one explicit pass: the +# first frame (with mid-pass uploads) must match the second frame. +proc shadowFrame(models: seq[(Node, Mat4)], explicit = true): Image = + pollEvents() + renderer.beginFrame(window, window.size) + renderer.clearScreen(Background) + setup(color(1, 1, 1, 1), color(0.95, 0.96, 1.0, 1.0)) + ctx.useShadows = true + if explicit: ctx.beginPass() + for (node, place) in models: + ctx.transform = place + ctx.draw(node) + if explicit: ctx.endPass() + ctx.useShadows = false + renderer.endFrame() + renderer.captureScreenshot() + +let fresh = @[ + (load("DamagedHelmet/glTF-Binary/DamagedHelmet.glb"), helmetPlace), + (load("MetalRoughSpheresNoTextures/glTF-Binary/MetalRoughSpheresNoTextures.glb"), spheresPlace), + (load("AlphaBlendModeTest/glTF-Binary/AlphaBlendModeTest.glb"), alphaPlace) +] +echo "shadow implicit frame..." +let shadowImplicit = shadowFrame(fresh, explicit = false) +shadowImplicit.writeFile(OutDir / "shadow_implicit.png") +echo "shadow explicit frames..." +let shadow1 = shadowFrame(fresh) +let shadow2 = shadowFrame(fresh) +shadow1.writeFile(OutDir / "shadow_frame1.png") +shadow2.writeFile(OutDir / "shadow_frame2.png") +let sShadow = score(shadow1, shadow2) +echo &"shadows on: first frame (lazy uploads) vs second frame: {sShadow:0.4f}" +doAssert sShadow < 0.01, "first shadowed frame diverged from steady state" +echo "PBR pass shadow test passed" + +# A draw that throws mid-pass (unsupported supercompressed KTX2 upload) must +# not poison later draws: the implicit pass has to unwind. +let ktxPath = joinPath(ModelsDir, "AnisotropyBarnLamp/glTF-KTX-BasisU/AnisotropyBarnLamp.gltf") +if fileExists(ktxPath): + let ktxModel = readGltfFile(ktxPath).root + var threw = false + try: + pollEvents() + renderer.beginFrame(window, window.size) + renderer.clearScreen(Background) + setup(color(1, 1, 1, 1), color(0.95, 0.96, 1.0, 1.0)) + ctx.transform = mat4() + ctx.draw(ktxModel) + renderer.endFrame() + except GltfError as e: + threw = true + echo "throwing draw raised as expected: ", e.msg + doAssert threw, "expected the KTX2 model to throw during draw" + let afterThrow = frame(Implicit) + afterThrow.writeFile(OutDir / "after_throw.png") + let sThrow = score(base, afterThrow) + echo &"after a throwing draw vs base: {sThrow:0.4f}" + doAssert sThrow < 0.01, "a throwing draw poisoned later draws" + echo "PBR pass exception test passed" + +# Last, because it corrupts the context by contract: foreign GL inside a pass +# without invalidateGlState is unsupported (informational only). +try: + let img = frame(ForeignNoInvalidate) + img.writeFile(OutDir / "ForeignNoInvalidate.png") + echo &"ForeignNoInvalidate vs implicit: xray score {score(base, img):0.4f} (unsupported by contract)" +except Exception as e: + echo "ForeignNoInvalidate raised: ", e.msg, " (unsupported by contract)" + +ctx.destroy() +renderer.shutdown() +echo "PBR pass tests passed"