Make lighting view projection and frustum calculation consistent
- Dominant language
- Rust
- Stars
- 48.2k
- Forks
- 4.8k
- Avg merge
- 3d 16h
- Merged PRs (30d)
- 171
Description
## The Problem
While trying to implement debug meshes for bounding volumes, including `Frustum`, I noticed inconsistencies in how the view projection matrices are calculated for the purpose of creating/updating `Frustum` for directional lights verses how the view projection is calculated for shadow mapping. Along the path of investigation, some other discrepancies came up so I thought I would document them first and then decide what to do about them.
## Frusta
Frusta for different projections and for different purposes seem to be calculated in different ways. This could imply bugs, or specific deviations that are necessary for specific algorithms. But, as the Frusta are used for culling, they must match the frusta defined by view projection matrices used for lighting and shadow mapping, otherwise there will be bugs in certain cases.
Foundations of Game Engine Development, Volume 2: Rendering says that the frustum planes can be extracted directly from the projection matrix. But we extract from the view projection (i.e. projection * inverse view) matrix. Is this still correct?
## Camera View Frusta
Both perspective and orthographic projection camera view frusta are updated from the view projection matrix after transform propagation for the entities with the `OrthographicProjection` or `PerspectiveProjection` components, which is only their corresponding bundles, by default.
## Light Frusta
### Directional Light Frusta
Directional light frusta are also updated based on the view projection matrix, after transform propagation. This could possibly differ from the view and projection used for the actual shadow mapping! This means that culling against this frustum could be wrong. It is only correct if the directional light entity's translation and scale are identity.
Directional light lighting view projection is calculated as:
```rust
// NOTE: A directional light seems to have to have an eye position on the line along the direction of the light
// through the world origin. I (Rob Swain) do not yet understand why it cannot be translated away from this.
let view = Mat4::look_at_rh(Vec3::ZERO, light.direction, Vec3::Y);
// NOTE: This orthographic projection defines the volume within which shadows from a directional light can be cast
let projection = light.projection;
...
// NOTE: * view is correct, it should not be view.inverse() here
view_projection: projection * view,
```
In previous investigations it has seemed that Mat4::look_at_* have produced inverse matrices as compared to GlobalTransform. This is odd. Why are they inconsistent? Which is wrong? But, this is why projection * view is used for the view projection for lighting.
For shadow mapping the view projection is calculated as:
```rust
transform: GlobalTransform::from_matrix(view.inverse()),
projection,
...
let projection = camera.projection;
let view = camera.transform.compute_matrix();
let inverse_view = view.inverse();
let view_uniforms = ViewUniformOffset {
offset: view_uniforms.uniforms.push(ViewUniform {
view_proj: projection * inverse_view,
view,
inverse_view,
projection,
```
Again, as Mat4::look_at_* seem to produce inverse matrices compared to GlobalTransform, the inverse of the Mat4::look_at_rh view matrix is used to construct a GlobalTransform forward transform. This is later inversed again and the shadow mapping view projection is projection * inverse view as usual.
Only the rotation is used from the light's GlobalTransform is used. The `light.direction` above is from `global_transform.forward()` during extraction. Translation is not used, nor is scale. This too looks broken, even if I did add a note that moving it away from the origin breaks stuff.
### Point Light Frusta
Point light frusta are created from the global transform translation, and pre-defined cubemap face rotations for each of the 6 cube faces, then view projection as projection * inverse (view translation * view rotation).
For lighting, only the global transform translation, and lower-right 2x2 part of the 4x4 cube face projection matrices are used. The rotation is not needed as the lighting is the same in all directions from the light. Only one cube face projection is needed as the projection is the same regardless of which cube face direction is being considered.
For shadow mapping, only the translation is used from the light entity's global transform, then view translation * cube face view rotation is passed as the view matrix, the cube face projection matrix is passed, and the view projection matrix is the usual projection * inverse view.
This all looks consistent and correct.
## Outstanding Questions
- [ ] Is extracting the frustum planes from the view projection matrix correct or must it be done from only the projection matrix?
- Implement tests to verify?
- [ ] Why are GlobalTransform look at and glam Mat4 look at matrices inverses of each other? Are they? Which is correct?
- PR to fix one or the other or add comments explaining why?
- [ ] Directional frusta use the full light global transform, whereas lighting and shadow mapping use only the global transform rotation. As long as there is no translation and no scaling, these should match up.
- [ ] Use only GlobalTransform or Mat4 to be consistent. Basically make the code for calculating the view projection matrix consistent.
- [ ] Why do directional lights need to have the projection relative to the world origin?
Contributor guide
Research direction
Start by tracing the frustum extraction and directional-light view-projection paths described in the issue, then compare them with the shadow-mapping camera path and point-light handling. Add tests for frustum-plane extraction and directional-light consistency where appropriate. Done means the relevant calculations are consistent or their intentional differences are documented and verified.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- rust
- Domain
- computer-graphics, game-dev
- Issue type
- Refactor
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Stale
- Clarity
- Needs clarification
- Newbie friendliness
- 35/100