this post was submitted on 17 Aug 2024
7 points (70.6% liked)

Rust

6005 readers
3 users here now

Welcome to the Rust community! This is a place to discuss about the Rust programming language.

Wormhole

!performance@programming.dev

Credits

  • The icon is a modified version of the official rust logo (changing the colors to a gradient and black background)

founded 1 year ago
MODERATORS
 

Another crazy idea I share with this website.

I was developing a game and an engine in Rust, so I was reading many articles, most of which criticize the 'borrow checker'.

I know that Rust is a big agenda language, and the extreme 'borrow checker' shows that, but if it weren't for the checker, Rust would be a straight-up better C++ for Game development, so I thought: "Why not just use unsafe?", but the truth is: unsafe is not ergonomic, and so is Refcell<T> so after thinking for a bit, I came up with this pattern:

let mut enemies = if cfg!(debug_assertions) {
    // We use `expect()` in debug mode as a layer of safety in order
    // to detect any possibility of undefined bahavior.
    enemies.expect("*message*");
    } else {
    // SAFETY: The `if` statement (if self.body.overlaps...) must
    // run only once, and it is the only thing that can make
    // `self.enemies == None`.
    unsafe { enemies.unwrap_unchecked() }
};

You can also use the same pattern to create a RefCell<T> clone that only does its checks in 'debug' mode, but I didn't test that; it's too much of an investment until I get feedback for the idea.

This has several benefits:

1 - No performance drawbacks, the compiler optimizes away the if statement if opt-level is 1 or more. (source: Compiler Explorer)

2 - It's as safe as expect() for all practical use cases, since you'll run the game in debug mode 1000s of times, and you'll know it doesn't produce Undefined Behavior If it doesn't crash.

You can also wrap it in a "safe" API for convenience:

// The 'U' stands for 'unsafe'.
pub trait UnwrapUExt {
    type Target;

    fn unwrap_u(self) -> Self::Target;
}

impl<T> UnwrapUExt for Option<T> {
    type Target = T;

    fn unwrap_u(self) -> Self::Target {
        if cfg!(debug_assertions) {
            self.unwrap()
        } else {
            unsafe { self.unwrap_unchecked() }
        }
    }
}

I imagine you can do many cool things with these probably-safe APIs, an example of which is macroquad's possibly unsound usage of get_context() to acquire a static mut variable.

Game development is a risky business, and while borrow-checking by default is nice, just like immutability-by-default, we shouldn't feel bad about disabling it, as forcing it upon ourselves is like forcing immutability, just like Haskell does, and while it has 100% side-effect safety, you don't use much software that's written in Haskell, do you?

Conclusion: we shouldn't fear unsafe even when it's probably unsafe, and we must remember that we're programming a computer, a machine built upon chaotic mutable state, and that our languages are but an abstraction around assembly.

you are viewing a single comment's thread
view the rest of the comments
[–] Jenztsch@discuss.tchncs.de 3 points 3 months ago (1 children)

Do you have any sources about this "unfitness" of Rust for gamedev? From my experience many people have problems with the borrow checker due to habits they obtained coding in other languages. Due to the strict enforcement of ownership rules some things can't be done exactly like in e.g. C++, but the language offers the tools to design and implement the software in a way to work with (not around!) the borrow checker (an example would be the discussion in another comment thread).

So I'd be interested what special behavior occurs in gamedev that makes coding in Rust more difficult than for other software.

Also, I really like that you're considering users with lower spec machines for your game. However, have you run a profiler over your software to see where there is optimization potential? In cases like this, I often use the (maybe over-used) quote from Donald Knuth:

We should forget about small efficiencies, say about 97% of the time: premature optimization is the root of all evil. Yet we should not pass up our opportunities in that critical 3%.

Many people think that with this quote, no optimization whatsoever should be considered. However, humans are often bad predictors on where the real bottlenecks are.

So yes, have some things in mind like e.g. algorithm performance for the expected input sizes. But in many cases, "optimization" done doesn't impact the runtime at all but makes the code much more difficult to understand and often more "unsafe" due to potentially increasing UB or other issues.

For this, a profiler helps to detect where your "critical 3%" are. And when you know them, then you can start optimization work to get big improvements. This also helps to direct your most likely limited effort to spend to the important parts of your game.

[–] Doods@infosec.pub 3 points 3 months ago* (last edited 3 months ago)

Do you have any sources about this “unfitness” of Rust for gamedev? From my experience many people have problems with the borrow checker due to habits they obtained coding in other languages.

I can't say anything for sure, but I read this article, in conjunction with this article, before I made this post, so you might consider looking at it, and how it influenced me.

Edit: wait, I'll extend this reply even more.

Edit 2: Done:

So I’d be interested what special behavior occurs in gamedev that makes coding in Rust more difficult than for other software.

Maybe it's because the gaming industry is historically among the slowest industries, they stuck with DOS until there was literally no drivers on it for the latest GPUs, only then did they upgrade. There's a video explaining how a recent AAA game could run on the steam deck, but not on Linux, it turns out the game was using a Windows XP library that's too old for wine to support, so how did it work on the deck? they effectively added this to their code:

if platform_name == "steamdeck" { use_modern_library() }

, which explains why it only ran on the deck, but notice how they still stuck to the ~2003 library as the default even though the modern one works, that's how much they hate change.

Considering the above, suggesting they change the particular way of their forefathers wouldn't be fruitful, unless extremely obvious B I G gains are to be found. Notice how Jonathan Blow's game development language is literally 'C++ but better', and how it mimics C++ in everything but the universally hated parts, and adds none but the universally wanted features. (as universal as an industry can agree on anything, that is)

That may be because games are a dangerous business, you pool all your resources in one project, and you get basically no income for up to four years, then you release and possibly succeed.

I also speculate that games aren't really maintained, most of the best games I know only received 3 patches at most (version 1.3). I think the priority isn't: "How am I gonna remember how this works in 3 months from now and deal with technical dept", it's more like: "How can I implement this in a way that the only thing faster than the implementation time is the feature itself?", so there is no fear of possibly breaking something that the checker can save you from down the road.

The last sentence kinda doesn't make sense since the first 3 years are more that enough technical dept for Rust to start doing its thing, but IDK how they think.

Bonus: look for Jonathan Blow's opinions on Rust on Youtube, he is an example of a studio owner fearing the risk of the possible "friction" that the Borrow checker could possibly cause.