The BlipJoy newsletter is a monthly indie gamedev publication that provides company updates and news about the game I am currently making. I'll keep with the theme introduced in the first issue with in a "technical postmortem" layout that goes over what went well, what went poorly, and what to do in the future.
Updates
To start off, I have an observation to share from my time spent with godot-rust. I have been using it extensively for a little over 6 weeks and have been able to begin familiarizing myself with how it handles the safety tradeoff between C++ and Rust. I won't be going over details in this newsletter, but I did provide some feedback on the project's GitHub issue tracker. I recommend reading the entire thread if you have any interest in the specific tradeoffs and how the interaction between the two languages may be improved in the future.
The linked comment contains a scary caveat that can easily lead to crashes and undefined behavior in developers are not careful about lifetimes of references that can be obtained unsafely. This is the kind of issue you might see referred to as "unsoundness" in Rust circles. The code is already unsafe, but it can be used in a way that violates Rust's memory safety invariants. Namely extending the lifetime of a temporary reference to an infinitely long duration (called 'static). It is possible to use this code soundly, though. Just be careful about how you store or return TRef<T> types.
Contributions
A quick aside here, I've been working in tech for about 15 years, and one of the things that drew me to the industry (apart from the obvious, I'm a computer and gaming nerd) is that the entire industry benefits directly from open source software and sometimes also contributes back. It was also frustrating when I wanted to open source a proprietary project, but the business disagreed or just didn't put forth enough effort to make it happen. When I launched BlipJoy, I made open source contributions a tenet of the business. Everything I'm using to build my game is licensed under MIT (or equivalent) rights and restrictions.
So, I'm excited to give back to the community, and proud to show off my first contribution to godot-rust. As I was working on the GUI for my terrain generator (more on that later!) I noticed that godot-rust offers a Rect2 type that doesn't export any methods, not even a constructor! The Aabb type also had the same issue. I opened a feature request ticket for it, and a few days later I decided to sit down and wrote all of the missing code.
The next godot-rust release (0.10.0) appears to be imminent, and the feature request is scheduled for 0.10.1. I don't expect the PR to be merged for quite a while, still. It was about a day and half of work. That's a fair donation, I think.
Gamedev News
On to the good stuff! One of the newsworthy updates since last month is that I have fully redesigned and built out the GUI for my terrain generator. In fact, having a terrain generator at all is new since January! It was something I quickly put together with the OpenSimplexNoise class. I got sick of recompiling the code to test minor changes like parameter adjustments, so I made this little GUI with property exports:
The only thing I kind of like about this GUI is that I was able to get property groups working with an incomplete hack. Otherwise, the GUI itself is impossible to understand. What do these numbers mean? What should I expect to happen when I change one? How might one affect another? These questions make the UX particular bad for this GUI. To remedy the issue, I scrapped it and rewrote it entirely using GraphEdit:
The nodes in the screenshot are still incomplete. Those large black rectangles are empty images where I want to put noise previews to aid the design of terrain features. But given the same value inputs, the connections between nodes make it much more obvious how these properties will interact with one another. And those image previews should help level set expectations. Even if it's still hard to predict exactly what will happen when changing something like the noise frequency, at least the result of such a change will be immediately apparent.
This new GUI is greatly inspired by NoiseTool and Blender. There are several other similar GUIs to point out as well, including Unreal Engine Blueprints and Substance Designer.
In terms of what to do next, I still haven't hooked up any of those outputs on the right-most node. (It's still just for show.) And I probably want to do some more optimizations like removing the "Constant" node and just allowing the various math nodes to take an optional constant value in place of a connection input. The underlying noise function that these nodes produce should be fairly straightforward when I get around to serializing the graph. At least I hope so.
Cycle Detection
The other issue I had, and the more interesting one, IMHO, is that all of the inputs and outputs in my graph are the same type. This made it easy to create cycles, even obviously dubious cycles by connecting a node's output directly to one of its own inputs. The solution I came up with is elegant because it is a cycle detection algorithm that runs in linear time with linear space.
A little background is in order. I know there is a way to detect cycles in linear time on a linked list with two pointers that walk the list in different speeds, apparently called Floyd's tortoise and hare, an apt name I admit. I don't believe my approach is novel, but I will share it here. The primary observation is that if the graph is already acyclic, it can remain acyclic by checking for potential cycles when adding a new edge connecting nodes.
To do this, I needed a hash table to lookup connections by node name. GraphEdit only gives you an array, and that is not very useful in this case. My connect and disconnect signal handlers just duplicate this information into a new HashMap, and I am careful about cleaning up the data structure when removing edges, so it doesn't leave any empty vectors.
#[derive(Debug, NativeClass)]
#[inherit(Panel)]
pub struct Gui {
/// Maps node names to its input and output connections
connections: HashMap<String, Connections>,
}
The code begins simple enough. The connections map is what I want to focus on:
/// A list of all input and output connections for the owning node.
#[derive(Debug, Default)]
pub struct Connections {
/// Maps our input ports to `(Node ID, output port)` combos.
pub inputs: HashMap<i64, (String, i64)>,
/// Maps our output ports to a list of `(Node ID, input port)` combos.
pub outputs: HashMap<i64, Vec<(String, i64)>>,
}
This could be structured a bit differently. But in this form, it allows only one edge to connect to each input port but allows multiple edges from each output port. To handle the former case, connection requests to an input which is already connected to something else will be rejected. This check is trivial with the mapping:
/// Handler for connection request events.
#[export]
fn connection(&mut self, base: &Panel, from: String, from_port: i64, to: String, to_port: i64) {
// Only allow one connection per input
if let Some(conn) = self.connections.get(&to) {
if conn.inputs.contains_key(&to_port) {
return;
}
}
// Avoid creating cycles
if self.is_connected(&to, &from) {
return;
}
// etc...
}
I'm not including the rest of the function here, because it isn't relevant. It just calls GraphEdit::connnect_node() and then adds the connection to the mapping. At this point, all of the background info you need is in place, and we can look at the is_connected() method, which is what I feel is the best part.
/// Check if there is at least one direct path from one node to another.
fn is_connected(&self, from: &str, to: &str) -> bool {
let mut names = VecDeque::from(vec![from]);
let mut dupes = BTreeSet::from_iter(names.iter().copied());
loop {
let from = match names.pop_front() {
Some(name) => name,
None => return false,
};
if from == to {
return true;
}
// Collect names for all nodes connected to the outputs
if let Some(conns) = self.connections.get(from) {
let outputs = conns
.outputs
.iter()
.flat_map(|(_, outputs)| outputs)
.filter_map(|(name, _)| dupes.insert(name).then(|| name.as_str()));
names.extend(outputs);
}
}
}
There are a few properties of this method that I admire. I think the algorithm itself is self-explanatory, so I won't provide an in-depth analysis. The use of VecDeque that is mutated while walking the tree is interesting. As well as the filter_map deduplication and safely modifying the search queue while iterating it. Deduplicating ensures that this function always completes in linear time; any cycles cannot lead to an infinite loop by definition.
What I don't particularly like about this method is that the search is brute force. Several graphs will exhibit non-optimal walking behavior. And the deduplication doubles the memory requirement (still linear, regardless). It also requires very careful coordination to keep the connection mapping in sync with the source of truth. Any deviation will cause all kinds of problems. This should still perform adequately until graphs become unreasonably large (hundreds of thousands of nodes I would guess, but I haven't tested).
Shadows and Head Kinematics
I was surprised by just how easy it was to give my FPS camera a shadow. Shadow rendering is still a huge pain with Godot but adding a model and making it cast shadows only was way less involved than I thought it would be. That includes animation blending and everything! I probably only put 15 minutes of effort into all of it, and I feel like it really improved the immersion.
A side effect that came out of adding a shadow was just how jarring it was to look straight down and not see a body or feet! What I came up with as a solution was limiting the view rotation just enough so the feet will always be out of frame. This was actually difficult to get right, because you still want to be able to look down at a steep enough angle in situations where you need to use a ranged weapon, for instance.
Simply clamping the angle means it will be too shallow to feel comfortable due to the shape of the camera frustum. I had the eyes placed basically right in the center of what would be the person's head, and the eyes would rotate in place, leading to this issue with the angle. I tried pushing the eyes out from the center and then simulating a head tilting, but that was a disaster. Instant motion sickness from this complex movement. Part of the problem was likely because the head simulation did not include a neck and it caused some very unfortunate deformations of things rendered in the periphery.
Instead of trying to work out the kinematics of a neck and head, I ended up with just pushing the eyes out from the center of the head and continuing to do eye rotations only. So it's like your character is wearing a neck brace and they can only move their eyes up and down and turn their whole body left and right. Weird thing to imagine, but it solved all problems I had with motion sickness and aspect deformations from rotating the camera. It also allowed me to increase the max angle while looking down while keeping the feed out of frame. Problem solved?
It all looks and feels good now, but there are still some moments that don't feel genuine. Especially when running while looking down. You can see the leg on the shadow apparently touch the ground, but there is no foot making contact with it. Maybe it is worth rendering a body (or least legs and feet). I'm not sure that I want to go that far, but I will probably try it out sometime.
Mistakes Were Made
The node lifecycle methods in Godot are pretty reasonable. They are very similar to what I used to do with melonJS. We had callback s for event handling when a pooled object was being recycled and added back to the scene tree. Very similar to Godot's _enter_tree() and _exit_tree() methods. But for some reason, it surprised me that adding children in _enter_tree and not removing them in _exit_tree (because I don't want to recreate them in _enter_tree after they have already been added) causes children to be added unbounded every time the node is removed and readded to the tree.
It is obvious in retrospect. But my brain thought it was doing everything correctly with the lifecycle events and then could not explain the unusual behavior of GUI state getting reset when switching tabs or being unable to get the scroll offset of the GraphNode (it always returned zeros). Of course, these are the kinds of things you should expect when you have duplicate children sharing the same name and the newest child sits on top (gets all of the user interactions) but the oldest child is the one you get a reference two when trying to look at its properties like scroll offset.
I was able to debug it with the help of Godot's source code and debug printing in the GUI node's constructor and destructor. I could then identify that I was creating a lot of duplicates without freeing any until the application was closed. Rather than changing how I was using the lifecycle events, I instead added a quick and dirty "ready flag" to my _enter_tree() method that will ensure children nodes are only created once. And then I had to go through the rest of the GUI and undo some workaround in various parts that I needed to save and restore state between removing and readding the GUI to the tree. None of that was necessary since the child nodes themselves are persistent and I didn't have fresh new children to restore the state to anymore. That was a dumb mistake. But it is good that I caught it and it wasn't a big deal to fix.
Looking Forward
The current plan is to wrap up work on the GUI in the next week or two and turn my focus on making this plugin easy to use. Did I mention this GUI is for a terrain generation plugin? I am already aware of both HTerrain and godot-voxel. Neither of these quite captures what I'm going for, so I decided to write my own plugin.
As it matures, I plan to release the source code (MIT license, of course!) so others can use it as well. And it should have some longevity because my game is going to rely on it heavily. I'll talk more about the plugin next month as it starts to take shape.
Concept Art
My brother has offered to do some concept art for the game, which has been so amazing. He's great at what he does, and I hope one day I can start showing it off here. We've had a lot of great conversations on the subject, and I'm always surprised by just how closely we think about these things. It's like he's in my head.
I slapped together some "concept art" of my own, too, but it looks bland in comparison. I have a really old Wacom Bamboo Pen 460 (the small one) which is just not great. The precision is awful (it tends to make a lot of perfectly straight lines when drawing slowly) and it's hard to use because my hand and pen are completely out of sight while drawing. I will be buying an iPad Pro to fix these issues, and then never touch this horrible Wacom tablet again. My plans for that are to wait until I start focusing on art in the game.
Gameplay Mechanics
I have most of the mechanics worked out and written down in my design doc. I was able to prototype the most important mechanic before I started on terrain stuff. It was a little wonky, but there is plenty of time to get it right. A lot of these things should start coming together quickly once I get out of abstract land with GUIs and noise generators. Building out the mechanics is a great segue into the next topic.
Screenshot Saturday
One of my favorite days ever! To contribute my own screenshots, I need to feel comfortable about my progress. I'm happy to share images with placeholder art and untextured cubes standing in for pretty much everything. I don't know how interesting those things are for people. But I do know it's really great watching the evolution of a game unfold over time. Even more interesting is to look back on its humble beginnings after many years in development. One day! :)
I'm going to make a commitment to myself to do a Screenshot Saturday post on Twitter sometime this month. And then try to stick to a weekly cadence posting a new image or video. This is part my self-improvement goal, trying to work on community building. I'm pretty nervous showing off something so early, knowing that none of it will make it to the final game. But it must be done!
Wrapping Up
February flew by faster than expected, and I didn't make as much progress as I had hoped. Realistically, an MVP for the game by the end of march is looking very unlikely. I mentioned earlier that I had prototyped some of the mechanics, and it should be clear that the FPS controller is practically complete at this point. I don't think terrain generation will be in a good enough state in 1 month to settle on it. And there is a boatload of work to be done on prototyping the remaining game mechanics, netcode, and absolutely all of the art and sound design which I haven't touched yet.
On the bright side, I am definitely chipping away at my task list and the project is slowly taking shape. I'll just keep doing that and report back at the end of March!
That's all for now! Thanks to my supporters on GitHub and Patreon, markusmoenig and Aeonoy! I appreciate that you started this journey with me. There is plenty more to come, so stay tuned.
No comments:
Post a Comment