Enabling cast shadow on meshes makes frustum culling less efficient

I have this scene with about 480k triangles total:

Each city block is a separate object so frustum culling works pretty well when zoomed in (as the camera will be most of the time - the entire city will never be in view).

When I zoom in to a single city block, most of the city is correctly frustum culled. In this view only 67k/480k triangles are rendered, around 13% of all triangles.

However, if I take the exact same view but now set castShadow=true on all meshes, with no other changes, in the same view I get 258k/480k triangles rendered, or ~53% of triangles.

This is a big difference, and it’s especially noticeable on slower mobile devices - I’m seeing about 15fps difference on my phone.

I assume that the meshes are being considered still in frustum because they are in the shadow camera frustum. Is that correct? Does anyone know of a way to mitigate this so that I can still have shadows while getting better frustum culling?

In this case I think it would work fine to ignore the shadow frustum entirely for frustum culling, as each city block is quite separate. Only the tall buildings in the top cast shadow onto the next block.

1 Like

Yes. Shadow map rendering also uses view frustum culling but based on the shadow camera’s projection matrix.

First thing that comes in my mind is to update the shadows camera frustum dynamically (e.g. based on the zoom level). But it really depends on the application design how easy it is to implement something like that.

Yeah, I had thought of doing that. There’s a lot of camera animation happening though so I’d need to do it every frame.

I guess it would be possible to create a projection matrix out of the intersection of two other projection matrices, although the shadow camera is orthographic and the main camera is perspective so that makes it a bit trickier.

But if I can get that to work, I can store the original shadow frustum and intersect it with the main camera frustum each frame. Will try that out tomorrow.

Alternatively, do you think there is some way to make the shadow camera use the main camera’s frustum culling results? I expect that would be harder to set up than just doing the intersection :sweat_smile:

I noticed the same thing and was told this was normal behavior. Was I mislead?

Do your objects move at all? If your objects are static, you might be able to render the shadowmap only once with LightShadow.autoUpdate = false

2 Likes

Ha, this is a fun topic. In my game engine, I use a whole lot of tricks to work with that. I recommend that you use CSM instead of the standard shadow map. Why? Because the whole point of the CSM is to build the shadow camera frustum relative to the view frustum.

As for me, I use something very similar to a CSM, technically it’s a CSM, but it has only 1 cascade, which makes it hard to call it CMS. It does exactly what @Mugen87 mentioned, and what CSM does - it auto-magically updates shadow camera. I also found that three.js’s frustum culling is painfully slow for scenes with 1000s of objects, but that’s a whole different story not really applicable here.

3 Likes

Related: three.js webgl - cascaded shadow maps

1 Like

I did test this, and it does make frustum culling work much better. Unfortunately there are lots of moving objects so I didn’t look into it further.

However, I found a kind of solution by creating a clone of the shadow casting light:

const dymanicShadowLight = staticShadowLight.clone();

Next, set each light intensity to half the original value so overall brightness is the same.

Before rendering the first frame:

  1. Hide the canvas
  2. Enable castShadow on all static meshes
  3. Disable castShadow on all dynamic meshes.
  4. dymanicShadowLight.shadow.enabled = false
  5. staticShadowLight.shadow.enabled = true

Render the first frame, then:

  1. Disable castShadow on all static meshes (they can still receive shadow)
  2. Enable castShadow on all dynamic meshes.
  3. dymanicShadowLight.shadow.enabled = true
  4. staticShadowLight.shadow.enabled = false
  5. Render another frame now with all the shadows
  6. Unhide the canvas

I was kind of surprised this works but it does, and frustum culling results are the same level as they are with shadows disabled. The dynamic objects even receive the shadow cast by static objects when they move into the shadow of a building.

However, the shadows look different because they are still modulated by the brightness of the light that is casting them. So they are half as intense as they were previously.

In this case I’m fortunate that it’s a cartoony scene and I’m not using an environment map, so I can probably tweak the lighting to make this look ok. I don’t think it’s a general solution, however, and it does require adding an additional light to the scene.

2 Likes

It would be amazing if there was some way to bake the results of the initial staticShadowLight shadow so that I could then completely disable this light and still keep the shadows.

Then I could render the initial shadows with this light at full brightness, and also have the dymanicShadowLight at full brightness. After the first frame I could then simply set staticShadowLight.visible=false (or even remove it from the scene entirely).

1 Like

That is fantastic! I’ve always wondered if there’s a way to “save” the shadowmap for static objects and use it as a template on each frame so it only needs to re-draw the moving shadows. Looks like you found a pretty good solution with two shadowmaps!

I was thinking of cloning the shadowmap WebGLRenderTarget to store it for future frames, and feed it to the renderer at the beginning of each frame. But the renderer has so many private methods and properties, that it might not be possible to access them. I like your solution better, it’s simpler.

PS: dy-ma-nic typo cracked me up. Looks like you’re committed to it! :laughing:

1 Like

:laughing: It’s like dynamic, but crazy!

Yeah it is simple. The only issue is the shadows becoming lighter. I was thinking it might be possible to add a light.shadow.intensity setting and feed it into the shader, probably scaling this value:

So it would become:

return shadow * intensity;

Hopefully it can be scaled linearly so intensity = 2 would compensate for the lights at half brightness.

Anyway, I’ve reached the budget limit for this project so I’ll test that out next time :slight_smile:

2 Likes