Skip to content

Conversation

riccardobl
Copy link
Member

JME lights only affect children of the nodes to which they are attached.
This makes sense from a performance/optimization standpoint, but it does not reflect the behavior of blender and other renderers.

This PR adds support for global lights ie. lights that affect all the spatials up to the rootNode.

Lights can be marked as global by setting to true the "global" boolean flag on the lights' constructors . The flag is false by by default, so existing scene won't be affected.

This implementation exploits the jme scenegraph update logic to make global lights possible without any change to the renderer, this is an overview of how it works:

  • A new refresh flag RF_GLOBAL_LIGHTS is added.
  • This flag is always propagated upstream up to the rootNode
  • The rootNode seeing this flag will do a deep traversal of its children to find all the global lights to add to its own world light list.
  • As a side effect, when the children are be updated, immediately after, they will pull the world lights list of their parent (first one being the rootNode with the global lights) propagating the global lights down the scene graph.

* @param global if true, the light affects the entire tree from the root node,
* otherwise it only affects the children of the node in which it is attached.
*/
public DirectionalLight(boolean global) {
Copy link
Member Author

Choose a reason for hiding this comment

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

i didn't make a setter for the global param, to make it defacto immutable.
This makes implementation much easier and performant, since we never need to check if the flag changed.

* @param filter an optional filter to apply to the lights
* (null means no filtering)
*/
public void update(LightList local, LightList parent, Predicate<Light> filter) {
Copy link
Member Author

Choose a reason for hiding this comment

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

added a filter here so that we can exclude global lights from being added the children's internal world lights list.

That's because the rootNode propagates down all the global lights, so when a global light is owned by a children and it is propagated to the same children later, we would end up with the children having two references to the same light, one from its internal local light list and one from the rootNode global light list.
To fix that we would need to deduplicate the lights in the list, that is not great for performances, so instead i've decided to use this filter based approach

}
sb.append("]");
return sb.toString();
}
Copy link
Member Author

Choose a reason for hiding this comment

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

this is unrelated, but helped with the debug, so i've just left it here

refreshFlags &= ~RF_GLOBAL_LIGHTS;
// if root node, we collect the global lights
if (getParent() == null){
depthFirstTraversal(sx->{
Copy link
Member Author

@riccardobl riccardobl Aug 10, 2025

Choose a reason for hiding this comment

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

this is the traversal path, it is heavy, but only needs to happen if

  1. you have global lights in the scene
  2. light lists need a refresh

Copy link
Member

Choose a reason for hiding this comment

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

And from my reading only happens if a global light is literally removed or added (not moved, as I was initially worried might be the case)

Copy link
Member Author

Choose a reason for hiding this comment

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

yes, only if added or removed

while (p != null) {
if ((p.refreshFlags & RF_CHILD_LIGHTLIST) != 0) {
// The parent already has this flag,
// so must all ancestors.
return;
}
p.refreshFlags |= RF_CHILD_LIGHTLIST;
if (hasGlobalLights) {
p.refreshFlags |= RF_GLOBAL_LIGHTS;
}
Copy link
Member Author

Choose a reason for hiding this comment

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

this propagates the flag upstream

@richardTingle
Copy link
Member

Very cool! I have often wanted lights to behave exactly like this. This avoids a lot of messing about and booking keeping with lights attached to the root node but positioned elsewhere

LightList childLights = sx.getLocalLightList();
for (Light l : childLights) {
if (l.isGlobal()) {
worldLights.add(l);
Copy link
Member

Choose a reason for hiding this comment

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

I'm trying to think about ways to avoid this full traversal. Adding new lights is probably easy; only track down paths with RF_GLOBAL_LIGHTS looking for new lights. The getting rid of old lights is harder, I can't see anything in Light that records where a light came from (which would make removing old ones much easier)

Copy link
Member Author

@riccardobl riccardobl Aug 10, 2025

Choose a reason for hiding this comment

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

Good idea!

Check the last commit. The refresh flag was set after the light was removed, so it wasn't even refreshing the global lights properly, i've changed the order now.

This means also that we have a path with RF_GLOBAL_LIGHTS for removed lights, this should make what you've proposed possible, right?

The rootNode will just see a RF_GLOBAL_LIGHTS and rebuild the list and the removed global light won't be there anymore because it isn't attached anywhere anymore.

@codex128
Copy link
Contributor

codex128 commented Aug 10, 2025

Thanks for this! This is definitely something I wish I had.

JME lights only affect children of the nodes to which they are attached. This makes sense from a performance/optimization standpoint.

Come to think of it, I think this may actually be a performance problem. JME currently uploads a new light buffer for each geometry, so if there are 5 global lights and 100 geometries, roughly 8kb of light data is being sent to the gpu every frame. It would probably be much faster to upload all lights once using a UBO or SSBO (maybe using a byte/short buffer for selecting local lights in-shader, or forget local lights altogether). Plus, we're also recompiling all shaders whenever a light is added or removed, so SSBOs would fix that, too.

@riccardobl
Copy link
Member Author

riccardobl commented Aug 10, 2025

It depends.
If you have a lot of lights affecting everything in the scene (or that are global with this pr) with modern OpenGL, having a big light buffer helps a lot.

But if you have lights affecting small parts of the scene you will end up processing a lot more lights than what you need in every fragment.

In lowend gpus or old opengl with no UBO support, the current jme approach is much better.

So I'd say it depends on how you build the scene and what your target gl version and platform is. The current approach allows for more optimization, but is probably outdated with modern expectations and necessities.

Plus, we're also recompiling all shaders whenever a light is added or removed, so SSBOs would fix that, too.

I think this is the case only if you change the light batch size in every frame in single pass lighting, and never the case in multipass lighting.
So, if you set the light batch, to like 32, and you never change it, it will compile only once. And if you have 33 lights it will render it in two passes.

@riccardobl
Copy link
Member Author

riccardobl commented Aug 11, 2025

@richardTingle check the last commit, i think i've implemented your suggestion.
It is not fully tested yet, but It should work™

@richardTingle
Copy link
Member

@riccardobl Nice! I'm going to write some screenshot tests for global lights and that will double as some testing

@yaRnMcDonuts
Copy link
Member

yaRnMcDonuts commented Aug 11, 2025

I also agree this would be a great addition.

I've previously had to use a hacky workaround for global lights in my custom editor, where I flag lights as "global" with userData and then move them to the rootNode if they have that userData. But then they also need moved back and forth anytime the scene is saved/loaded to make sure the light is actually attached to the scene thats being saved (and not lost in the rootNode).

So I'm looking forward to this PR for sure! It will definitely be a useful feature.

@richardTingle
Copy link
Member

richardTingle commented Aug 12, 2025

@riccardobl I'm struggling to get this working. I've created the beginnings of a test at https://github.com/richardTingle/jmonkeyengine/blob/globalights-tests/jme3-screenshot-tests/src/test/java/org/jmonkeyengine/screenshottests/light/GlobalLights.java

I create two nodes with a red local light and a green global light. Two white cubes are added, one a sibling of the lights, the other on a different branch. I expect the sibling to be illuminated by both and the non sibling to only be illuminated by the global light. But the global light doesn't seem to affect either cube.

image

I've experimented and I think that's because it is attached from the beginning. I get different behaviour if I add it later

image

But it still doesn't illuminate the other cube

@riccardobl
Copy link
Member Author

Thanks for the test case, i'll look into it.
There might be some overlook on my side in the flag propagation

@riccardobl
Copy link
Member Author

riccardobl commented Aug 24, 2025

I think it is fixed now, basically the problem was that the previous behavior had the the refresh flag being propagated up the branch, but it didn't take into consideration that if the scene has multiple branches the flag must be propagated down to all the other branches too

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants