diff --git a/src/gltf/backends/opengl/renderer.nim b/src/gltf/backends/opengl/renderer.nim index 80e90b9..e8bbeb7 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,41 @@ type jointMatrices: seq[Mat4] blended: seq[BlendEntry] deferred: seq[BlendEntry] + passValues: PbrPassValues + glState: PbrGlState + passDepth: int + 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 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 + 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 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. @@ -262,6 +339,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.invalidateGlState() ctx.skyboxShader = compileShaderFiles( SkyboxVertexShader, SkyboxFragmentShader @@ -361,6 +449,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 +722,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 +785,83 @@ proc ensureData(primitive: Primitive): PrimitiveData = primitive.data = PrimitiveData() primitive.data +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: + 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() @@ -709,6 +876,7 @@ proc uploadTextureToGpu( ## Uploads a texture to OpenGL. if ktx2Data.len > 0: textureId = loadKtx2Texture(ktx2Data, sampler) + inc textureBindEpoch return if image == nil: @@ -716,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(): @@ -816,6 +987,7 @@ proc clearMaterialFromGpu(material: Material) = glDeleteTextures(1, data.emissiveId.addr) data.emissiveId = 0 material.data = nil + inc textureBindEpoch proc clearFromGpu*(primitive: Primitive) @@ -1027,6 +1199,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 +1413,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 +1460,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 +1477,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 +1521,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,76 +1608,129 @@ 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 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 + ## 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. + ## 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 - node.updateTransforms(ctx.transform, ctx.useTrs) + let implicitPass = ctx.passDepth == 0 + if implicitPass: + ctx.beginPass() - ctx.blended.setLen(0) - - 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 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 + 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 ) - 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) + 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. @@ -1669,108 +1909,88 @@ proc drawPbrWithShadow( if not node.visible: return - node.updateTransforms(ctx.transform, ctx.useTrs) + let implicitPass = ctx.passDepth == 0 + if implicitPass: + ctx.beginPass() + var completed = false + try: - let (lightView, lightProj, lightSpace, _) = - getShadowMatrices(node, ctx.transform, ctx.sunLightDirection) + node.updateTransforms(ctx.transform, ctx.useTrs) - # Save viewport and framebuffer. - var - oldViewport: array[4, GLint] - oldFramebuffer: GLint - glGetIntegerv(GL_VIEWPORT, oldViewport[0].addr) - glGetIntegerv(GL_FRAMEBUFFER_BINDING, oldFramebuffer.addr) + let (lightView, lightProj, lightSpace, _) = + getShadowMatrices(node, ctx.transform, ctx.sunLightDirection) - # 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) + # Save viewport and framebuffer. + var + oldViewport: array[4, GLint] + oldFramebuffer: GLint + glGetIntegerv(GL_VIEWPORT, oldViewport[0].addr) + glGetIntegerv(GL_FRAMEBUFFER_BINDING, oldFramebuffer.addr) - renderShadowNode( - node, - node, - ctx.transform, - lightView, - lightProj, - ctx.tint, - ctx, - applyTrs=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) - glBindFramebuffer(GL_FRAMEBUFFER, oldFramebuffer.GLuint) + renderShadowNode( + node, + node, + ctx.transform, + lightView, + lightProj, + ctx.tint, + ctx, + applyTrs=true + ) - # Restore viewport. - glViewport(oldViewport[0], oldViewport[1], oldViewport[2], oldViewport[3]) + glBindFramebuffer(GL_FRAMEBUFFER, oldFramebuffer.GLuint) - # Main pass with shadow sampling. - ctx.blended.setLen(0) + # Restore viewport. + glViewport(oldViewport[0], oldViewport[1], oldViewport[2], oldViewport[3]) - 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 - ) + # The depth pass bound its own program and toggled enables. + ctx.invalidateGlState() - 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 + # 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 ) - 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) + + 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. @@ -1785,6 +2005,7 @@ proc draw*(ctx: PbrContext, node: Node) = ctx.environmentMap, ctx.skyboxLod ) + ctx.invalidateGlState() if ctx.useShadows and ctx.debugView == dvLit: drawPbrWithShadow(node, ctx) else: 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"