Skip to content

Set up an initial framework to allow megastructures to spawn in the world #454

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 4 commits into
base: master
Choose a base branch
from

Conversation

patowen
Copy link
Collaborator

@patowen patowen commented Apr 14, 2025

This PR adds all the fundamental additions required to support the random spawning of large structures. To demonstrate this framework, a temporary option is available to spawn a random assortment of horospheres throughout the world.

While I tried to document the code well to explain the reasoning behind the algorithm, a summary would be worthwhile, so I'll repeat the summary I gave in Discord a while back (with a few changes):

One nice thing about the dodecahedral tiling, which isn't true of every tiling but works in our case, is that every time you get to a neighbor of a node, you are crossing a plane that divides the space into two halves. You can never cross that plane again without crossing a descender. Here is a 2D representation:
image
A horosphere in any given position can only be generated by one unique node. The criteria for a node to be able to generate a horosphere are as follows:

  • The horosphere must be entirely contained within all of the half-spaces bound by the descenders of the node.
  • The horosphere must not be able to be generated by any child node following the rule above.

Following these two rules and carefully choosing a probability distribution within a given node allows you to generate a random distribution of horospheres in a completely isotropic way.

Implementation-wise, each node containing part of a horosphere needs to have a reference to said horosphere. To avoid unbounded memory usage, we can "forget" about a horosphere the moment we cross a plane that leaves that horosphere behind.

The problem is that some of these horospheres may intersect with each other. To prevent this, one naive approach would be to just skip generating a horosphere if it would overlap with one of the horospheres it (or a parent) already generated. The issue is that it doesn't capture all possible overlaps because two nodes that aren't ancestors of each other can generate intersecting horospheres. The picture below shows a 2D example, where dots with arrows show which node owns which horocycle.
image

The problem arises because although these two nodes initially appear unrelated, they are related after all because it is possible to reach the same node from both of them. Nodes of the same depth in the graph with this property need to be somewhat aware of each other to avoid causing conflicts when they generate any kind of megastructure. In this PR, I call them "peer nodes", and a fair amount of logic is needed to determine which nodes are peer nodes. See the PR's diff for details.

To get the full list of potential horospheres a node needs to know about, there are three steps:

  • Step 1: The node needs to inherit all horospheres from its parents. This requires the parents to be done generating horospheres (as in, done with step 3).
  • Step 2: The node needs to generate candidate horospheres it owns. This should be done after step 1 to avoid generating candidate horospheres that obviously won't work.
  • Step 3: The node needs to determine which candidate horospheres should actually become horospheres. This has to be done after step 1 and step 2, and it also requires all peer nodes to be done with step 2 so that it knows what other candidate horospheres might interfere.

For more detail on step 3, a candidate can only become a horosphere if it doesn't intersect with any other inherited horospheres or higher-priority candidate horospheres known by the node we're focused on or any of its peers. Candidate horosphere priority can be chosen pretty much arbitrarily. This PR just uses their node-local w-coordinate.

The interaction of steps 1, 2, and 3 means that the order in which various properties are initialized in various nodes interact in intricate ways, so it is no longer feasible to populate NodeState for each node in the order it's added to the graph. Instead, we split it into PartialNodeState (for nodes that completed step 2), and NodeState (for nodes that completed step 3).

One consequence of the importance of peer nodes is that it effectively widens node paths in the graph. In practice, this should be fine, as we already tend to want to form a large sphere of nodes around each player, but it may increase the cost of some things. I don't expect it to be an important bottleneck, though.

For a visual of this, see the below image. For the circled node to have a fully set up NodeState, all red nodes need a NodeState, and all blue nodes need a PartialNodeState.
image

Fortunately, this approach should be reusable for any kind of megastructure, as the only property this relies on is the fact that it can be bounded by a plane.

One concern I have had in the past is that this intersection test is somewhat asymmetric, so isotropy may no longer be preserved. I don't think this is a major concern, as things look correct. Perhaps there is a way that someone could use this to find the origin if they're lost using some tricky analysis on the distribution of megastructures, but I don't think that's possible, and if it is, it probably isn't something we need to address.

For simplicity, this PR only allows a node to reference at most one horosphere, which effectively means that all horospheres are separated so that the convex hulls of the nodes containing them will never intersect.

@patowen patowen requested a review from Ralith April 14, 2025 02:50
@patowen patowen force-pushed the initial-megastructure-framework branch from feb3991 to 3624275 Compare April 17, 2025 00:57
@Ralith
Copy link
Owner

Ralith commented Apr 17, 2025

Please rebase for clarity.

@patowen patowen force-pushed the initial-megastructure-framework branch from 3624275 to 345639b Compare April 17, 2025 04:00
@patowen
Copy link
Collaborator Author

patowen commented Apr 17, 2025

Please rebase for clarity.

Done! Thanks for reviewing the other PRs so quickly!


// This implementation is rather complicated and can be difficult to follow. It is recommended to see the
// `alternative_implementation` test for an equivalent algorithm. Most of the logic here is just maintaining
// state to allow `PeerTraverser` to work much like an iterator instead of storing everything into an array or vec.
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Question for @Ralith: I would like to point out this comment. Based on the structure of the code and where the bottlenecks might be, it seems pretty viable to have a function that returns a Vec<PeerNode> instead of using this iterator-like approach. Although I went through the effort of writing this, and I believe I was careful to make it work correctly, I think this optimization may have ended up being premature, as I don't think the creation of a temporary Vec for each node being added to the graph would be a bottleneck, especially since it only needs to be called when a node (which as 20 chunks) is added to the graph, not every frame.

Basically, I'm considering restructuring this entire implementation to look a bit more like the alternative_implementation unit test, but I would like to check with you before updating this PR.

pub struct NodeState {
kind: NodeStateKind,
surface: Plane,
road_state: NodeStateRoad,
enviro: EnviroFactors,
horosphere: Option<HorosphereNode>,
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I haven't exactly decided how we would want to support multiple different kinds of megastructures. We essentially need NodeState to be aware of every megastructure there is, but it seems silly to have a field for every type of megastructure when only one would be populated (barring future work of supporting multiple megastructures in the same node, like something inside a horosphere).

My current idea is to punt this design question for a future PR when we would actually have multiple megastructure types to choose from, as trying to figure this out now seems like a premature abstraction.

Copy link
Owner

Choose a reason for hiding this comment

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

Punting seems like a good call. I've always figured we'd have something like Option<Box<dyn Megastructure>> in the end, but getting the interface for that right will be difficult before we actually have multiple types. A good follow-up might be to implement a handful of hyperrogue classics to fill things out.

/// Whether the horosphere will still be relevant after crossing the given side of the current node.
fn should_propagate(&self, side: Side) -> bool {
// TODO: Consider adding epsilon to ensure floating point precision
// doesn't cause `average_with` to fail
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

The scenario I'm afraid of here is that two megastructures are evaluated to not interfere, but when they're actually generated, due to slight floating point precision differences, they end up interfering. With the current implementation of average_with, that would cause a panic (a panic which allowed me to catch my earlier miscalculation of what could count as a peer node, where I thought all peer nodes were edge-adjacent to this node). Worse, it would be a rare panic that would be hard to reproduce.

It might be good to have some epsilon value to add some buffer here so that should_propagate is biased towards returning true when we're checking for possible interference. That would likely be paired with a unit test to see if this epsilon is doing its job.

for x in 0..chunk_size {
for y in 0..chunk_size {
for z in 0..chunk_size {
let pos = MVector::new(
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

This calculation could probably be a function in ChunkLayout. I hope to try to do that in a PR that also tries to rename some terms to be more intuitive and switches the matrix transformations x_to_y to y_from_x.

}

#[cfg(test)]
mod test {
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I probably need more tests here. I'm having trouble thinking of good test ideas. While the code has a fair amount of logic, a lot of it feels like specifications, where the tests would either repeat the code verbatim without testing anything useful, or the tests would give concrete examples that don't have much clarity and don't add much confidence (apart from possibly testing for regressions). I definitely welcome your suggestions here. If/when I have time to take another look at this, I'll also brainstorm test ideas.

Copy link
Owner

Choose a reason for hiding this comment

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

Drive-by thought before I dive too deep: property-based testing?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Possibly? I don't think this is necessarily complicated enough that we would need automated test cases. (I'm not that familiar with property-based testing, but initial googling suggests it's writing formal specifications and having an automated framework write tests around these specifications).

Thinking about this further, it might make sense to write tests similar to check_chunk_incident_max_elevations, where we set up NodeState for various nodes, manipulate properties we care about, and check that they propagate to a new NodeState as expected. That way, I would be able to deliberately and clearly write a test that, for instance, fails if I use the naive peer node implementation.

@@ -16,6 +16,7 @@ use crate::{
pub struct Graph {
nodes: FxHashMap<NodeId, NodeContainer>,
layout: ChunkLayout,
horospheres_enabled: bool,
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I realized while setting up this option that we don't actually have a framework for allowing worldgen to be configured in any way. The world is forced to be the same world every time. Since enabling horospheres is a worldgen option, I just had to put it somewhere that works. However, putting it in Graph feels wrong. Perhaps fixing this can be punted to another PR, although if we do that, I should probably write a TODO comment here.

Of course, this is meant to be temporary anyway, as scattering random horospheres about is really just meant for debugging megastructure code.

Copy link
Owner

Choose a reason for hiding this comment

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

Making this configurable at all seems premature, yeah. Might be nice once there's more to tinker with.

Copy link
Collaborator Author

@patowen patowen Apr 18, 2025

Choose a reason for hiding this comment

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

The main reason I'm making it configurable now is so that people playing on the master branch don't have to deal with horospheres being randomly placed everywhere.

EDIT: Although, an alternative option is to just use a constant so that people can turn it on or off by editing one part of the code, since I don't expect debug horospheres to be part of any compiled release.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I went ahead and made this a constant defined in the horosphere module to simplify things.

patowen added 2 commits April 28, 2025 22:21
To provide something to test with, an option has been added to scatter
a random assortment of horospheres throughout the world.
@patowen patowen force-pushed the initial-megastructure-framework branch from 345639b to 1a7c2bc Compare April 29, 2025 02:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants