Repository navigation
Fix stencil testing remaining enabled after it is disabled #2987
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,223 @@ | ||
| /* | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nice and focused — mocking One suggestion for hardening: in the "reapply an unchanged enabled state" test, also assert the count of stencil calls on the mock GL (or that no further stencil calls were recorded after the first apply). Right now a regression that re-issues every stencil call each frame could still pass if the test only checks that no
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The unchanged-enabled test already clears the mock's history after the first apply, then uses The supplemental native harness is now public on the fork's validation-only branch: runner, readable Java, pinned hashes and native logs. It was rerun from those public files against the exact baseline and PR heads. The README explains the failing-baseline expectations, installed dependencies and software-driver limits. It is separate from the Gradle regression suite in this focused PR.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Both points check out now:
No further concerns from me. Approving. 👍 |
||
| * Copyright (c) 2009-2026 jMonkeyEngine | ||
| * All rights reserved. | ||
| * | ||
| * Redistribution and use in source and binary forms, with or without | ||
| * modification, are permitted provided that the following conditions are | ||
| * met: | ||
| * | ||
| * * Redistributions of source code must retain the above copyright | ||
| * notice, this list of conditions and the following disclaimer. | ||
| * | ||
| * * Redistributions in binary form must reproduce the above copyright | ||
| * notice, this list of conditions and the following disclaimer in the | ||
| * documentation and/or other materials provided with the distribution. | ||
| * | ||
| * * Neither the name of 'jMonkeyEngine' nor the names of its contributors | ||
| * may be used to endorse or promote products derived from this software | ||
| * without specific prior written permission. | ||
| * | ||
| * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS | ||
| * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED | ||
| * TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR | ||
| * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR | ||
| * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, | ||
| * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, | ||
| * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR | ||
| * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF | ||
| * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING | ||
| * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS | ||
| * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. | ||
| */ | ||
| package com.jme3.renderer.opengl; | ||
|
|
||
| import static org.mockito.Mockito.clearInvocations; | ||
| import static org.mockito.Mockito.mock; | ||
| import static org.mockito.Mockito.verify; | ||
| import static org.mockito.Mockito.verifyNoInteractions; | ||
|
|
||
| import com.jme3.material.RenderState; | ||
| import com.jme3.material.RenderState.StencilOperation; | ||
| import com.jme3.material.RenderState.TestFunction; | ||
| import org.junit.jupiter.api.BeforeEach; | ||
| import org.junit.jupiter.api.Test; | ||
|
|
||
| /** | ||
| * Verifies that cached stencil state matches the calls made to OpenGL. | ||
| */ | ||
| public class GlRendererStencilStateTest { | ||
|
|
||
| private GL gl; | ||
| private GLRenderer renderer; | ||
|
|
||
| @BeforeEach | ||
| public void initializeRenderer() { | ||
| gl = mock(GL.class); | ||
| renderer = new GLRenderer(gl, mock(GLExt.class), mock(GLFbo.class)); | ||
| } | ||
|
|
||
| @Test | ||
| public void applyingUnchangedEnabledStateDoesNotRepeatGlCalls() { | ||
| RenderState state = stencil(true, TestFunction.Equal); | ||
| renderer.applyRenderState(state); | ||
| clearInvocations(gl); | ||
|
|
||
| renderer.applyRenderState(state); | ||
|
|
||
| verifyNoInteractions(gl); | ||
| } | ||
|
|
||
| @Test | ||
| public void stencilCanBeDisabledAndReenabledWithTheSameParameters() { | ||
| RenderState enabled = stencil(true, TestFunction.Never); | ||
| RenderState disabled = stencil(false, TestFunction.Never); | ||
| renderer.applyRenderState(enabled); | ||
| clearInvocations(gl); | ||
| renderer.applyRenderState(disabled); | ||
| verify(gl).glDisable(GL.GL_STENCIL_TEST); | ||
| clearInvocations(gl); | ||
|
|
||
| renderer.applyRenderState(enabled); | ||
|
|
||
| verify(gl).glEnable(GL.GL_STENCIL_TEST); | ||
| verify(gl).glStencilFuncSeparate(GL.GL_FRONT, GL.GL_NEVER, 0, Integer.MAX_VALUE); | ||
| verify(gl).glStencilFuncSeparate(GL.GL_BACK, GL.GL_NEVER, 0, Integer.MAX_VALUE); | ||
| } | ||
|
|
||
| @Test | ||
| public void changingOnlyTheFrontReferenceUpdatesTheFunction() { | ||
| RenderState state = stencil(true, TestFunction.Equal); | ||
| renderer.applyRenderState(state); | ||
| clearInvocations(gl); | ||
|
|
||
| state.setFrontStencilReference(2); | ||
| renderer.applyRenderState(state); | ||
|
|
||
| verify(gl).glStencilFuncSeparate(GL.GL_FRONT, GL.GL_EQUAL, 2, Integer.MAX_VALUE); | ||
| } | ||
|
|
||
| @Test | ||
| public void changingOnlyTheBackReferenceUpdatesTheFunction() { | ||
| RenderState state = stencil(true, TestFunction.Equal); | ||
| renderer.applyRenderState(state); | ||
| clearInvocations(gl); | ||
|
|
||
| state.setBackStencilReference(3); | ||
| renderer.applyRenderState(state); | ||
|
|
||
| verify(gl).glStencilFuncSeparate(GL.GL_BACK, GL.GL_EQUAL, 3, Integer.MAX_VALUE); | ||
| } | ||
|
|
||
| @Test | ||
| public void changingOnlyTheFrontMaskUpdatesTheFunction() { | ||
| RenderState state = stencil(true, TestFunction.Equal); | ||
| renderer.applyRenderState(state); | ||
| clearInvocations(gl); | ||
|
|
||
| state.setFrontStencilMask(0x0f); | ||
| renderer.applyRenderState(state); | ||
|
|
||
| verify(gl).glStencilFuncSeparate(GL.GL_FRONT, GL.GL_EQUAL, 0, 0x0f); | ||
| } | ||
|
|
||
| @Test | ||
| public void changingOnlyTheBackMaskUpdatesTheFunction() { | ||
| RenderState state = stencil(true, TestFunction.Equal); | ||
| renderer.applyRenderState(state); | ||
| clearInvocations(gl); | ||
|
|
||
| state.setBackStencilMask(0xf0); | ||
| renderer.applyRenderState(state); | ||
|
|
||
| verify(gl).glStencilFuncSeparate(GL.GL_BACK, GL.GL_EQUAL, 0, 0xf0); | ||
| } | ||
|
|
||
| @Test | ||
| public void changingOnlyTheOperationsUpdatesBothFaces() { | ||
| RenderState state = stencil(true, TestFunction.Equal); | ||
| renderer.applyRenderState(state); | ||
| clearInvocations(gl); | ||
|
|
||
| state.setStencil(true, | ||
| StencilOperation.Replace, StencilOperation.Keep, StencilOperation.Increment, | ||
| StencilOperation.Zero, StencilOperation.Decrement, StencilOperation.Invert, | ||
| TestFunction.Equal, TestFunction.Equal); | ||
| renderer.applyRenderState(state); | ||
|
|
||
| verify(gl).glStencilOpSeparate(GL.GL_FRONT, GL.GL_REPLACE, GL.GL_KEEP, GL.GL_INCR); | ||
| verify(gl).glStencilOpSeparate(GL.GL_BACK, GL.GL_ZERO, GL.GL_DECR, GL.GL_INVERT); | ||
| } | ||
|
|
||
| @Test | ||
| public void changingOnlyTheFunctionsUpdatesBothFaces() { | ||
| RenderState state = stencil(true, TestFunction.Equal); | ||
| renderer.applyRenderState(state); | ||
| clearInvocations(gl); | ||
|
|
||
| state.setStencil(true, | ||
| StencilOperation.Keep, StencilOperation.Keep, StencilOperation.Keep, | ||
| StencilOperation.Keep, StencilOperation.Keep, StencilOperation.Keep, | ||
| TestFunction.Never, TestFunction.Always); | ||
| renderer.applyRenderState(state); | ||
|
|
||
| verify(gl).glStencilFuncSeparate(GL.GL_FRONT, GL.GL_NEVER, 0, Integer.MAX_VALUE); | ||
| verify(gl).glStencilFuncSeparate(GL.GL_BACK, GL.GL_ALWAYS, 0, Integer.MAX_VALUE); | ||
| } | ||
|
|
||
| @Test | ||
| public void parametersChangedWhileDisabledAreAppliedWhenEnabled() { | ||
| RenderState state = stencil(false, TestFunction.Equal); | ||
| state.setFrontStencilReference(2); | ||
| state.setBackStencilReference(3); | ||
| state.setFrontStencilMask(0x0f); | ||
| state.setBackStencilMask(0xf0); | ||
| renderer.applyRenderState(state); | ||
| clearInvocations(gl); | ||
|
|
||
| state.setStencil(true, | ||
| StencilOperation.Keep, StencilOperation.Keep, StencilOperation.Keep, | ||
| StencilOperation.Keep, StencilOperation.Keep, StencilOperation.Keep, | ||
| TestFunction.Equal, TestFunction.Equal); | ||
| renderer.applyRenderState(state); | ||
|
|
||
| verify(gl).glEnable(GL.GL_STENCIL_TEST); | ||
| verify(gl).glStencilFuncSeparate(GL.GL_FRONT, GL.GL_EQUAL, 2, 0x0f); | ||
| verify(gl).glStencilFuncSeparate(GL.GL_BACK, GL.GL_EQUAL, 3, 0xf0); | ||
| } | ||
|
|
||
| @Test | ||
| public void invalidatingStateReappliesEnabledStencilParameters() { | ||
| RenderState state = stencil(true, TestFunction.Equal); | ||
| state.setFrontStencilReference(2); | ||
| state.setBackStencilMask(0xf0); | ||
| renderer.applyRenderState(state); | ||
| renderer.invalidateState(); | ||
| clearInvocations(gl); | ||
|
|
||
| renderer.applyRenderState(state); | ||
|
|
||
| verify(gl).glEnable(GL.GL_STENCIL_TEST); | ||
| verify(gl).glStencilFuncSeparate(GL.GL_FRONT, GL.GL_EQUAL, 2, Integer.MAX_VALUE); | ||
| verify(gl).glStencilFuncSeparate(GL.GL_BACK, GL.GL_EQUAL, 0, 0xf0); | ||
| } | ||
|
|
||
| @Test | ||
| public void enablingStencilWithDefaultParametersAppliesBothFaces() { | ||
| renderer.applyRenderState(stencil(true, TestFunction.Always)); | ||
|
|
||
| verify(gl).glEnable(GL.GL_STENCIL_TEST); | ||
| verify(gl).glStencilFuncSeparate(GL.GL_FRONT, GL.GL_ALWAYS, 0, Integer.MAX_VALUE); | ||
| verify(gl).glStencilFuncSeparate(GL.GL_BACK, GL.GL_ALWAYS, 0, Integer.MAX_VALUE); | ||
| verify(gl).glStencilOpSeparate(GL.GL_FRONT, GL.GL_KEEP, GL.GL_KEEP, GL.GL_KEEP); | ||
| verify(gl).glStencilOpSeparate(GL.GL_BACK, GL.GL_KEEP, GL.GL_KEEP, GL.GL_KEEP); | ||
| } | ||
|
|
||
| private static RenderState stencil(boolean enabled, TestFunction function) { | ||
| RenderState state = new RenderState(); | ||
| state.setStencil(enabled, | ||
| StencilOperation.Keep, StencilOperation.Keep, StencilOperation.Keep, | ||
| StencilOperation.Keep, StencilOperation.Keep, StencilOperation.Keep, | ||
| function, function); | ||
| return state; | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Quick check on the change-detection condition: after caching
stencilTest, does it still start withstate.isStencilTest() ||? If so, everyapplyRenderStatewith an enabled stencil state re-enters this block and re-issuesglStencilFuncSeparate/glStencilOp/glStencilMaskeach frame, so the "repeats all stencil calls" half of the bug described in the PR would still be there.Using the cached flag as a
!=comparison (or dropping the rawstate.isStencilTest()term entirely) would cover both directions — enable and disable — and make the "unchanged state" path meaningful.state.isStencilTest() ||term is redundant and it defeats the unchanged-state optimization. Could you confirm whether it was replaced by a!=comparison?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The published condition already starts with
context.stencilTest != state.isStencilTest(); there is no rawstate.isStencilTest() ||term. The cache is then assigned the requested flag inside the block. See the condition and assignment at fa5dad4. Both enable and disable transitions are therefore detected, while an unchanged enabled state can skip the block.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You're right, and thanks for the correction — my earlier comment was based on a truncated hunk that started mid-condition, so I inferred a leading
state.isStencilTest() ||term that isn't there. The diff shows the condition opening withcontext.stencilTest != state.isStencilTest(), refs/masks comparisons appended, and the cached flag assigned inside the block, which matches what you describe. Both transitions are detected and an unchanged enabled state skips the block.With that dropped, the only things still open from my side are the two smaller notes: asserting stencil call counts in the unchanged-state test, and a pointer to where the native JNA/GLES validation lives. Neither blocks this fix.