Didn't Work, Try AgainUnfortunately my super clever layered lightmap system didn't work out that well. Implementation-wise everything's fine and on a small level with a few lights/receivers it works great. But the realities of the Obra Dinn brought it to its knees. First 60 crew and now a boss lightmapping system. That ship is plain bad news.
The ship has ~30 lanterns spread around the decks, ~15 of them swing back and forth with the motion of ocean, and all 30 can be switched on/off by the player. Each of these generates their own personal set of lightmaps which receivers composite together in real time.
The problem is that because of the density of the ship's geometry it's not unusual for one receiver mesh to get lit by many lanterns. Even when the lanterns are contained in light-proof rooms, the meshes making up the walls can easily be lit from both sides by different lanterns. Combine that with the need to separate receiver groups based on material and things got way out of hand. The end result was that lighting the Obra Dinn with dynamic lightmap layers generated over 600 new materials, with a max of 8 compositing layers.
This breaks Unity's batching system badly, the sheer number of new material assets basically destroys the editor, and it exceeds my sensible limit of 4 layers per receiver. Conclusion, I had to scrap that system and work out an alternative.
Dynamic LightsI briefly experimented with just using dynamic shadow-casting lights - switching them on and off based on proximity/doors/etc. This works ok and looks good but even the slightest performance hit here is painful, since I know it can be done statically. One possibility is to generate each lantern's shadow depth map once (the slow part) and just reuse it every frame afterwards. This would allow rotating the light cookie and adjusting the intensity in realtime, which is 90% of what I need. There are performance penalties with this due to the added passes so I'd still want some way of occluding lights that were far away or not visible.
Still, this is objectively a good solution and probably the most reasonable way to handle this. So instead I went back to the lightmapper and tried something else.
Global Lightmapping, Dynamic CompositingUnder the old system, each lit receiver was responsible for maintaining unique lightmap uv coordinates for each light layer, and for compositing these layers together in the shader. Memory-wise this is great and with just a few light layers to worry about for each receiver it's also fast. On the downside there's a lot of extra complexity due to how multiple lightmap uvs are stored, and having to muck with the shader complicates the asset pipeline.
Instead of doing it per-receiver like this, an alternative is to use only a single global lightmap that gets dynamically updated. So each lit receiver just samples one lightmap with one set of uvs and the compositing task gets moved to the entire global lightmap.
The complexity now comes from how to A) build a global lightmap that incorporates all the light layers dynamically and B) how to composite the dynamic light layers into the global lightmap in realtime. Fortunately neither of these is too bad.
To start, all the dynamic layers are generated individually as before. For each dynamic light you get a packed lightmap and a set of atlased uvs for each receiver. Once all of these dynamic layers are built, you then re-atlas every receiver's uv into one huge atlas that represents the global lightmap.
Mapping each light's local lightmaps to the global lightmap atlas
At this point for each receiver you know where it is in any light's local lightmap, and where it is in the global lightmap. That position in the global lightmap becomes the receiver's lightmap uv. Then, the local lightmap data has to be copied into the global lightmap (per frame).
You could just have a version of each local lightmap that's already been atlased to the global lightmap, but that would waste a fantastic amount of memory. Instead, it's possible to create a 2D mesh with the local lightmap as a texture that remaps all the rectangles to their new positions in the global lightmap. Compositing the global lightmap then just requires rendering each local lightmap mesh into it, adjusting alpha as necessary.
Compositing local lightmap meshes into the global lightmap
CostsBecause this system uses both the global lightmap and the local lightmaps at runtime it does require a lot more memory than the old technique. As a small compensation though, the local lightmaps don't need to be power-of-two any more since their mips are never accessed. Also, the final global lightmap is often comprised of many pages. To save time it's possible to composite just one page per frame - light fades/transitions are a little less smooth but it's not that noticeable.
Beyond actually working properly, this system is also way, way simpler on the runtime/shader/asset side. That probably makes me the happiest. Performance is important but I'm strongly biased towards less complexity in the actual implementation.
Bonus GlitchWhile testing the fades without a proper 1-bit conversion:
Perfect