this post was submitted on 17 Aug 2024
8 points (72.2% liked)

Rust

6005 readers
6 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
[–] TehPers@beehaw.org 3 points 3 months ago (5 children)

Two thoughts come to mind for me:

  1. I think people should feel free to use any language however they want for their own needs and projects, but it's also important to understand what exactly "unsound" and "undefined behavior" mean if you're going to dabble with them. If it's a risk you're willing to take, go for it, but don't be surprised if things break in ways that make no sense at all. Realistically a compiler won't delete your root directory if you trigger UB or anything, but subtle bugs can creep in and change behaviors in ways that still run but which make unrelated code break in difficult to debug ways.
  2. The borrow checker is one of Rust's biggest features, so looking for ways around it feels a bit counterproductive. Feature-wise, Rust has a lot of cool constructs around traits and enums and such, but the language and its libraries are built around the assumption that the guarantees the compiler enforces in safe code will always be true. These guarantees extend beyond the borrow checker to things like string representation and thread safety as well. As an alternative, some other languages (like C++, which you mentioned, or maybe even Zig) might be better suited for this approach to "dirty-but-works" development, and especially with C++, there are some excellent tools and libraries available for game development.
[–] Doods@infosec.pub 2 points 3 months ago (4 children)

Hello Pers,

I made a mistake when writing the post, it reads like I am against the borrow checker, which I am not, I love the checker, and didn't encounter any - major - problems with it.

I meant that even if we used unsafe everywhere it would still be a good language, which is an attempt at arguing with those saying that Rust isn't fit for gamedev because the of the checker. Which I failed at due to lack of experience, as this is my first time making a game, and Rust is my first language*.

Regarding: "If it doesn't panic in Debug, it won't act weird on Release", even if I got reported a really weird bug related to UB, I should (I am not experienced enough to make a claim) be able to know it's UB since the game's gonna crash when I try to recreate the bug in Debug.

Some would say that shipping the game with runtime checks won't have an effect on performance, which is probably true, since it's so simple the list of entities is an array (not a vector), and the game state is - effectively - global (everything is impl CombatContext { fn x(&mut self) {} })**, and some (most? too early in development to tell) of the game is locked at 5fps (maybe I'll bump it up a bit)***.

I am so concerned about performance because I had to daily drive a computer that most people on this website - and especially on Reddit - would consider garbage E-waste, for 4 years, and was trying hard to play games on it, which was further amplified by my GPU not supporting Vulkan (nor Dx9 for some time), which meant I couldn't use Proton, which taught me some hacks that are... let's not talk about them.

So I find huge pain in leaving any possible performance optimizations, especially that some people I know are stuck on - arguably - worse machines****; accessibility is a big priority.

It also makes me angry to see pixel games come with 70Mib binaries and require Vulkan because:

1 - internet costs money

2 - they claim in the system requirements that their game "Should run on anything".

Memes like: "Oh my game could run on a potato" infuriate me (good thing I don't use social media), NO, your game can't run a potato, DooM can, it was actually optimized properly, your 2D pixels can't even render on a machine a 100x more powerful, you should feel ashamed*(5).

*: I was messing around with C# + Godot not super long ago, nothing serious.

**: I have been refactoring my code lately to limit the scope of most functions, in a way inspired by ECSs, but significantly more primitive.

***: the game has both a 3D and a 2D part, the 2D part has locked FPS, the 3D part can run at any framerate.

****: Macroquad supporting OpenGL only down to 2.0ES would be a problem, if I wasn't intending on forking it anyway to reduce the binary size (grim is an extremely bloated dependency, I managed to shove off 10 Mib in a few hours), and unless using 1.x is as hard people on the internet claim it is, which is probably false, as these people are mostly weak and say the same things about using a custom engine.

*(5): this might sound toxic, but that's how people get better.

[–] sukhmel@programming.dev 1 points 3 months ago (1 children)

I see now, that you were misunderstood in some parts.

even if I got reported a really weird bug related to UB, I should (I am not experienced enough to make a claim) be able to know it's UB since the game's gonna crash when I try to recreate the bug in Debug.

This may be problematic for several reasons: it may be hard to reproduce, the more complicated the state, the harder; bug may rely on some race condition that may be much rarer in Debug because of speed difference; UB is notorious for causing things that should (seemingly) never happen, like returning from infinite loops, proving false statements true, and such, so it may be hard to understand what at all happened and why.

Regarding optimisations, it might still be better to try to profile the code (I will be honest, I don't do that until the moment when I can't go further without optimisation, and I haven't reached that with Rust) and see what are the real hot spots that require optimisations. I hope that someday you will be able to upgrade your machine, and hope that your game will be a good example of something that really runs anywhere

[–] Doods@infosec.pub 2 points 2 months ago

Oh no don't get me wrong, a year back I upgraded to an I5-7500 prebuilt, and it's a beast for all my tasks. (maybe compiling is quick because I split modules a little too much?)

Your advice is good for not knowing what I'm making. If I was making something multi threaded with much state I would fear UB more.

may be much rarer in Debug because of speed difference

Thanks, then I will remember to recreate bugs with opt-level = 3.

Wait no, this doesn't make sense if I don't have access to the user's machine, maybe I should send him a log-heavy version of some sort? How should I even what I am supposed to log? I should think about this some more before release.

load more comments (2 replies)
load more comments (2 replies)