Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions jme3-core/src/main/java/com/jme3/renderer/RenderContext.java
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,22 @@ private static <T> WeakReference<T>[] newWeakReferenceArray(int size) {
* Stencil test function for back-facing polygons.
*/
public RenderState.TestFunction backStencilFunction;
/**
* Stencil reference value for front-facing polygons.
*/
public int frontStencilReference;
/**
* Stencil reference value for back-facing polygons.
*/
public int backStencilReference;
/**
* Stencil comparison mask for front-facing polygons.
*/
public int frontStencilMask;
/**
* Stencil comparison mask for back-facing polygons.
*/
public int backStencilMask;

/**
* Vertex attribs currently bound and enabled. If a slot is null, then
Expand Down Expand Up @@ -417,6 +433,10 @@ private void init() {
backStencilDepthPassOperation = RenderState.StencilOperation.Keep;
frontStencilFunction = RenderState.TestFunction.Always;
backStencilFunction = RenderState.TestFunction.Always;
frontStencilReference = 0;
backStencilReference = 0;
frontStencilMask = -1;
backStencilMask = -1;

depthFunc = RenderState.TestFunction.Less;
alphaFunc = RenderState.TestFunction.Greater;
Expand Down
15 changes: 12 additions & 3 deletions jme3-core/src/main/java/com/jme3/renderer/opengl/GLRenderer.java
Original file line number Diff line number Diff line change
Expand Up @@ -1154,16 +1154,25 @@ public void applyRenderState(RenderState state) {
|| context.backStencilDepthFailOperation != state.getBackStencilDepthFailOperation()

Copy link
Copy Markdown
Contributor

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 with state.isStencilTest() ||? If so, every applyRenderState with an enabled stencil state re-enters this block and re-issues glStencilFuncSeparate / glStencilOp / glStencilMask each 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 raw state.isStencilTest() term entirely) would cover both directions — enable and disable — and make the "unchanged state" path meaningful.

if (state.isStencilTest() != context.stencilTest
  • One thought: since the cached flag is now maintained, the bare state.isStencilTest() || term is redundant and it defeats the unchanged-state optimization. Could you confirm whether it was replaced by a != comparison?

Copy link
Copy Markdown
Contributor Author

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 raw state.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.

Copy link
Copy Markdown
Contributor

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 with context.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.

|| context.backStencilDepthPassOperation != state.getBackStencilDepthPassOperation()
|| context.frontStencilFunction != state.getFrontStencilFunction()
|| context.backStencilFunction != state.getBackStencilFunction()) {

context.frontStencilStencilFailOperation = state.getFrontStencilStencilFailOperation(); //terrible looking, I know
|| context.backStencilFunction != state.getBackStencilFunction()
|| context.frontStencilReference != state.getFrontStencilReference()
|| context.backStencilReference != state.getBackStencilReference()
|| context.frontStencilMask != state.getFrontStencilMask()
|| context.backStencilMask != state.getBackStencilMask()) {

context.stencilTest = state.isStencilTest();
context.frontStencilStencilFailOperation = state.getFrontStencilStencilFailOperation();
context.frontStencilDepthFailOperation = state.getFrontStencilDepthFailOperation();
context.frontStencilDepthPassOperation = state.getFrontStencilDepthPassOperation();
context.backStencilStencilFailOperation = state.getBackStencilStencilFailOperation();
context.backStencilDepthFailOperation = state.getBackStencilDepthFailOperation();
context.backStencilDepthPassOperation = state.getBackStencilDepthPassOperation();
context.frontStencilFunction = state.getFrontStencilFunction();
context.backStencilFunction = state.getBackStencilFunction();
context.frontStencilReference = state.getFrontStencilReference();
context.backStencilReference = state.getBackStencilReference();
context.frontStencilMask = state.getFrontStencilMask();
context.backStencilMask = state.getBackStencilMask();

if (state.isStencilTest()) {
gl.glEnable(GL.GL_STENCIL_TEST);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
/*

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice and focused — mocking glEnable / glDisable / glStencilFuncSeparate / glStencilOp / glStencilMask is the right level for this.

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 glDisable happened.

  • Also: the native JNA/GLES offscreen test described under Validation isn't part of this diff. Will it land in a follow-up, or should the PR description point to where it lives?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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 verifyNoInteractions(gl) after the second. That requires zero additional GL calls of any kind, including every stencil function/operation call: test lines 70–78.

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both points check out now:

  • clearInvocations(gl) + verifyNoInteractions(gl) on the unchanged-enabled test is exactly the call-count assertion I was after — stricter, actually, since it catches any redundant GL call, not just the stencil ones.
  • Good call on making the native harness public on the validation-only branch. That makes the supplemental evidence checkable without relying on private artifacts.

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;
}
}