Wave Racer | Dev Log

Short update:

I sunk some time over the weekend into refactoring my code. Along with some cruft and a too-long file, the refactor wasn't just for cleanliness:

  • Multiple CPU racers I had knowingly baked in a single "cpu" opponent in a lot of places, knowing I could abstract to multiple, later. Well, now was that time. So... now I can add multiple cpu racers and have them tied into things like collisions and the "AI" for racing the course. I was pleasantly surprised that I didn't see much of a CPU performance bump even when increasing the number of collidable objects. I was penciling in some optimization time there.
  • Ability to restart a race. This might sound simple but I had a lot of state and global vars strewn about. I organized my code into a handful of namespaces and was able to take better account of each piece of state such that I could reset the world. I ran into a good amount of memory and CPU creep on each reset for a while, but now things look stable.

I added in a menu item to 'restart race' so I can more easily play dev builds on the device (previously the race just started immediately on launch).

Seeing the 2 CPU racers mostly "just work" is great, starting to have fun racing and making passes at this point. Always more to tune...

multiple racers and a restart

I'll be circling back to my background writeup and experimenting with memory saving approaches there soon.

2 Likes

Infinitely scrollable, 360 degree, tiled background.

The end goal is for a player to drive a jetski in any direction and the background never runs out.

Here's an example animation just to set the stage, with other pieces of the game disabled so we can focus just on the background tiles:

ahh, bliss

Constraints

In theory, I don't need infinite scrolling for my game. I could find a way to keep a player within the confines of a course, and prevent travelling in all directions. I might even still do this.

However, I did not want to be limitted to a specific course size, I wanted to be able to flex the course size up and not worry about scaling the background.

Using a single, large background image with a confined course might work. However, this runs into a major issue in regards to image & memory size: since I am always rotating the view, I would need to have a 360 degree spritesheet for a large background image, which would almost certainly not fit into memory.

Another constraint is the number of tiles. I know upfront I can't just add 100's of background tile sprites/images, since the overhead in moving and rotating all of them would eat up my perf budget.

tl;dr try to use a reasonable number of tiles, to balance memory and performance.

Approach

Overview of the algorithm:

  • Move & rotate all background tiles in response to the user input (covered in my post above).
  • Detect any tiles that have moved out of view. Add these tiles to a list, let's call it freeTiles.
  • For every visible tile:
    • Check if each neighboring, visible, tile slot has a tile.
    • For any missing tile: move a tile from the free_tiles list to that slot.
    • If the free_tiles list is empty, create a new tile, and place it in that slot.

The algorithm works as long as there is 1 tile placed anywhere that is visible to start.

Showing an animation, starting from a single tile placement:

  • For that tile, check each of it's neighbors (north, east, south, west) to see if we need a tile placed.
  • In this case we need 3 tiles, so we create a tile and place in each slot.
  • On the next tick, we check the neighbors of all 4 tiles (including the 3 that were just placed), and perform the same operation, filling in any gaps.

fill animation

Now, let's look at the case where we are rotating and moving, and tiles are going out of view.

Let's assume we've reached this state where a tile has gone out of view.

nb This is for illustration only, this much of a gap would not be possible, as the missing tiles would have been detected immediately.

  • This tile has gone entirely out of view, so we add it to our freeTiles.
  • We run our algorithm for each tile, and find the missing slot.
  • Remove the tile from the freeTiles list, and move to the missing slot.

free tile move

And that's it!

No matter what happens, the background always fills itself in.

The re-use via the freeTiles list means that we only create enough tiles to cover the worst-case scenario - which is rotated such that just a tiny bit of corners of tiles are visible on the screen.

Perf tuning

Notice in the screenshots above that there is a + sign shaped check for the neighbors. The 4 offsets can be computed for the current rotation a single time per tick, then applied to each tile, making the neighbor computation quite cheap.

Currently, I landed on a tile size such that the algorithm maxes out at 13 tiles in the worst-case. This means there are 13 * 4 neighbor checks, and only 13 tiles are part of the rotating & moving logic (outlined in my previous big post). This number is just a place I landed considering tradeoffs, and can tune a bit. A larger tile size - I might only need, say, 9 tiles in the worst case, but I would need more memory for the image (or a smaller image, using more tiles, and more CPU for moving).

As mentioned previously, I am using a sprite sheet/image table of 360 degrees. For other game objects I can get away with 180 degrees, however, with the tiles being relatively large, the steps at 180 degrees are noticable, and you can see the shear at the edges of the tiles (tbh you can still see some if you look for it). This does mean that the actual images are large when loaded into memory - this is an area I hope to try a few things in soon and there are already some comments above!

Bonus

I wanted the water to feel more "real", so there's two more things I layered in here.

First, I can have the water move in a direction, for example, always move a little "East". This can be useful for giving a bit of a moving wave effect.

Second, I want the water to "shimmer" and not feel so static. To accomplish this, I actually have several image tables, each of which represent a frame in an animation. For example, right now I am using a 3 frame animation. I set a timer to increment the frame so it's not locked to the FPS. I can even start each tile at a different frame to give even more shimmer effect.

Showing the result of those two effects with zero movement on the player - to show both effects without the distraction of movement/rotation, but work when moving/rotating as well.

bg_move

And the final result:

riding

Future

I plan on making a distinct background for each level, but at this point that should "just" be on the graphics side and can plug them into this same algo.

I have some thoughts on how to keep positional information for tiles as well. For example, I want to have a rainy pond level, with rain pattering on the water and maybe some simple lilly pads or reeds. I'd want those lilly pads to not teleport as you race around. If I can reduce the memory footprint, I can run more frames for smoother animations as well.

5 Likes

Quick update on:

Finally getting into the flip options provided by the SDK.

My background images aren't horizontally or vertically symmetrical, so a mirrored single axis flip is not the same as the equivalent rotation, which means I can not leverage "flipX" or "flipY" flip options.

However, I can use flipXY, since that accurately represents a 180 degree rotation.

Rather than cutting down to 1/4 of the image table size, I can go down 1/2 - only compute the first 180 degree rotation and then use "flipXY" to get through 360 - which is still a very nice win!

I did briefly try using sprite:setRotation in increments of 90 degrees, as I saw a little chatter for that being optimized, but that quickly pegged CPU and I dropped a ton of frames.

Using setImage(image, flip) appears to be performant, I am seeing maybe a few extra % CPU doing a quick side by side, though that might be normal variation, but the drop is memory is a nice win:


(nb I have GC disabled)

3 Likes

Progress Update

tl;dr reworking handling and going deep into race balancing

First, a few smaller progress updates before going deeper into a topic:

  • Buoys now can be passed/failed. I haven't worked out the penalty for a buoy miss just yet but I've gone with a "simple" line segment collision with a line segment extending out from either side of the buoy. This is working well but I don't have a great visual representation of either the hit or miss of this just yet. Very likely a solid speed penalty for a miss, and max number of misses allowed.
  • Adding a finish line and basic lap times. I am using the same line segment collision detection on the finish line to reset the lap timer. While tuning (more below) I found I needed some real times to compare against, so built just enough to have lap times.

Deeper dive into handling and racer balancing

What I've spent the most time on recently is creating and tuning the different racing profiles, which lead into re-working some of the handling basics.

To set the stage, I'd like to have 3 riders with different handling profiles:

  • Profile 1 - Average across the board: fairly responsive handling, decent accelerationg and top speed.
  • Profile 2 - Very high acceleration and tight/responsive handling, trading off a lower top speed.
  • Profile 3 - High top speed, but loose/sluggish handling. Should be tough to navigate most courses.

I had previously (and, honestly, always) put some work/thought into the handling of the jetskis, outlined in my post above. However I found while pursuing these profiles, that I was still missing something.

I wound up re-working my "grip" physics, which is how much slippage there is compared to the desired direction. A zero grip would be a jetski sliding in one direction on ice, while spinning like a top - aka 100% slippage, the desired direction is never applied.

I previously had a version of this that "sort" of worked, but as I teased apart the profiles I realized it wasn't correct. Not going to go into details here but previously I was approximating the loss of grip by pushing along the X axis. At small numbers this felt okay but when I decreased grip further the result was way too much horizontal momentum applied, which doesn't make sense when the thrust is always from the back of the jetski. The new approach looks and feels more accurate and can easily be dialed up or down while still making sense (though at some point, very low grip doesn't make sense in the real world).

While tuning Profile 3 - my thought was I could just drop the (new & improved) grip until the handling felt sluggish enough. But, the result of the low-grip profile wasn't sluggish handling, but more felt like I was handling something on ice, instead of in water (though - ice level?). It just felt slippery and not "sluggish". It was hard to handle but not in a fun or realistic way. (It didn't feel like the handling of the original wave race).

Introducing "roll"

After lots of trial n error, (and replaying the original wave race 64), I realized I was missing an entire aspect of handling, something I am going to call - roll.

Explaining roll by way of example.

Let's say a jetski is holding a solid right hand turn, and the player quickly pulls the crank solidly to the left:

  • Tight roll: the jetski rolls rapidly to the left and almost immediately changes direction in response to the crank.
  • Loose roll: the jetski takes some time to start turning left. Even though the crank is pulled left, the jetski will still continue right for a moment, gradually straightening out, and finally getting to the desired left direction.

A loose roll introduces a lag in the controls, and requires some future planning by the player.

Roll is not a substitute for grip, in fact being able to tune both independently can yield some really interesting results. For profile 3 - low grip and a loose roll is starting to feel very right.

Visualizing these concepts across the 3 profiles using some lines. Explaining from the bottom to top:

  1. The bottom line is showing the exact rotation of the crank.
  2. The next line from the bottom is visualizing the amount of slippage (ie low grip). Having this line be vertical would be zero slippage.
  3. The next line (second from the top) is showing the desired direction to apply to the jetski per tick. This line is just a linear relationship to the raw crank value, they move at the same exact time. This is how all my handling was working, previously.
  4. The top line shows the final change in direction per tick applied to the jetski. When there is a loose roll, this line will follow/lag the line just below it.

Here's what I have for Profile 3:

Profile 3

Note how the top line follows & lags behind the line just below it. There are moments where this line stays on the opposite side of the crank, but then smoothly catches up to match the desired direction. This is the "loose roll" in action, and really creates that sluggish feeling. Being late to turn for a buoy is punished at this tuning.

In sharp contrast, here is Profile 2:

Profile 2

Notice how the lines all pretty much stay locked in. There is virtually no lag or roll, and there's only a little slippage. A little tougher to see but also a high acceleration, this profile pretty much follows the crank directly (but has a lower top speed).

Finally, Profile 1, a balance between the others:

Profile 1

Can see a little slippage and a little roll. It still feels like a jetski, takes some planning to handle, but isn't punishing.

For fun, showing an even more exaggerated/silly Profile 3, even less grip and looser roll, just to show off the concepts and what is possible, not meant to be a final playable profile:

silly

CPU/AI Handling

With a new grip, and a new "roll" concept, I then started to apply these to my CPU racers.

Selecting one of the racers for youself, you will then race against the other 2 profiles. The AI for the CPU racers do not use the exact same physics, though there are overlapping concepts. In short, the AI looks for the "next" buoy and applies some smoothing/maths for how to turn towards that buoy. This works great since the AI can get knocked around, either by you or by each other, and they'll always stay on course, and even compete for similar race lines. I will likely write more on this some day.

Balance

Now, the big challenge ahead for me is: balance.

I'd like playing with each of racing profiles to be somewhat competitive, with profile 3 ultimately rewarding high-skill, though with maybe a tight course to give an edge to other profiles. I'd also like the AI to be competitive with each other.

To measure the balance, I am running lots of practice laps and comparing times, and observing the CPU racers race with each other, tuning, and trying again. It's a very manual process thus far.

Getting the profiles to balance for the human player is tricky, but I feel like I am already sort-of close.

Having the CPU balanced is proving to be conceptually much harder. Having Profile 2 vs Profile 3 for example - the AI's handling being very reliable & perfect, profile 3 just rips. I am starting to introduce more counter-weighting specific to the CPU flavor of the profiles, but, it is time consuming. I have a handful of more ideas in this space, and will likely be focusing on it for a while, since it's a very, very core part of making this an actually playable game.

On the upside - the game is actually fun to play, even when not perfectly balanced! I've been very pleased with the AI - seeing one AI pass another then pass back is satisfying. So, hopefully I can find something that "good enough".

3 Likes

CPU Race Balancing

Primer on my CPU racing approach

Outlining the initial algorithm I am using for controlling how the CPU jet skis navigate the course:

  • Each CPU knows what the "next" buoy is.
  • For the next buoy, find an offset a short distance away on the "success" side of the buoy as the point to aim towards. The reason for this offset is because we want the CPU to go around the buoy and not directly through it.
  • Game-istic physics - on each tick:
    • Depending on: distance to the buoy, current speed and current heading make minor turning adjustments. If the CPU is far away and moving quickly, it is should turn less. If slowed and close to the buoy, it is allowed to turn more. This prevents the CPU from taking sharp angles to each buoy and kinda/sorta simulates a decent racing line with curves.
    • Each CPU is subject to the same physics around acceleration and loss of momentum, based on angle of the turn being made. This means the different racing profiles perform differently when used by the CPU. The caveat here is that handling/roll and grip for the profiles are less impactful (or ignored entirely) - there's a little bit of "cheating" here to allow the CPU to actually race the course, not get stuck, etc.

Aside from some of the squishy math for computing the turn angles, this approach felt pretty simple & reliable to me, and was able to get a modestly decent prototype CPUs to race against a couple months ago.

I initially played with Bézier curves but got a little hung up on the "tick" nature of the game loop - I would need to compute a point very close on the curve, re-find the angle to that point to turn towards. There's not much value in pre-computing a full curve, since the CPU might get bumped, so each tick there would have to be a full computation based on the current location relative to a buoy. I think there's some fruit on this tree but the computation was getting heavy for me, and I wasn't able to get a prototype quickly.

I also briefly toyed with the idea of pre-computed paths or rails through a course, but that would put a lot of pressure on needing to rotate complex paths, which would blow my perf budget. This might also lead to an unnatural feeling CPU. Hypothetically, if the CPU gets knocked off it's pre-computed rail, it would need to semi-awkwardly get back to the rail to continue forward, which might not feel like a natural movement (and then I'd also need to implement per-tick logic to get back on the rail).

A nice effect of this algorithm is that the CPU can start anywhere, get bumped around - either colliding with each other, a buoy, or the player - and it will correct itself and keep driving the course on the next tick. I've had probably 1000's of test laps and I haven't really seen the CPU go too erratic or lose the course, even when doing some spiteful collision testing. The paths it take feel realistic enough to me, but admit they aren't perfect.

Back to Balancing

I'd like each racing profile to be competitive, which includes when 2 CPU profiles are racing each other.

A fundamental issue I was facing was that profile 3 was OP/too good. The slightly-cheated algo for the CPU would allow profile 3 (high top end, poor handling) to be tough to beat by a player and would crush profile 2 (slower but good at turning). The issue is the CPU would take a "perfect" path through with the fastest jetski, which a human would struggle to do.

I knew I would have to introduce balances when the profile was controlled by the CPU. For example, I could limit the top end of the profile 3 (or increase for 2), or introduce more lost momentum when turning. Ie, find ways to make the CPU less perfect.

Balancing the race line

After much trial n error, I settled on a new idea that solved a handful of issues at once: balancing the racing line the CPU takes through the course.

The crux of the idea:

  • Each buoy has a "success" side and and "fail" side, from which I extend a line. These can be at any angle from the center of the buoy (useful to help with tight areas where fail/success lines might overlap).
  • Along the success line, I created 7 offset points at increasing distances from the center of the buoy.
  • Each CPU starts by following a race line by hitting a middle offset from the buoy.
  • At each buoy checkpoint (crossing the "success" line) - for each CPU, I determine if they are ahead or behind the player.
    • If the CPU is ahead of the player - increase the target buoy offset by one. ie, start following a path that travels through an offset that is further from the buoy.
    • If the CPU is behind the player - decrease the buoy offset by one. ie, start following a tighter race line.
  • To maintain the spirit of my racing profiles, I have a min/max offset for each profile, but otherwise the math/approach here is really simple, and I can lean on all my existing CPU turn mechanics.

Visualizing

Using a 90 deg rotated screen grab, with some viz debugging enabled.

For a refresher: the dark buoys should be passed on the right, white on the left. The solid line represents the success line, the dotted line represents the fail line.

Each of the 7 dots extending out from the success line are the positions of the 7 offsets.

image

The 7 offsets might not appear impactful, but, showing (an approximation!) of the worst path in blue:

image

And now overlaying the most optimal offset path in green:

image

If the CPU is ahead, I am pushing its race line out closer to the blue line, giving the player opportunity to catch up. If the CPU is behind, it will take a path closer to the green line to try to gain ground if the player slips up at all.

This was sort of an "ah ha" moment, I really like this approach for a few reasons:

  • I already had "physics" for turning the CPU: more turning == decreased speed and more distance to travel. By just adjusting the racing line the CPU takes, I can keep the existing CPU algorithm in tact while still having a big lever to control the overall time it takes the CPU to complete a lap.
  • It looks & feels like more natural racing than some straw man alternatives. For example, I spent a solid amount of time by playing with the core algorithm to try to balance: either more lost momentum when turning, or just increasing/decreasing the max speed. The end result didn't feel natural when you watched the CPU racing. There would be awkward slowdowns around turns for no reason, or having slower profiles overtake faster profiles on straights - things just didn't make sense. Having the CPU take slightly increasingly suboptimal paths feels natural to me - the hard part of racing IRL is keeping the line and speed, sometimes the CPU makes little mistakes that allow you to catch up if you can nail the correct line. It feels more like racing against humans.
  • The implementation wound up being very simple for how complex of an impact it has. I admit I was getting a little lost/frustrated in tweaking math, thresholds, hard-coding weights, trial n error, etc on other approaches. Since this whole project is just a hobby / not tied to revenue, sometimes it's just nice to feel good about a technical approach.
4 Likes

Simulating waves, proof of concept

I kept coming back to this idea in the back of my mind, I finally had a nugget of an idea on how this might be possible, maybe, and decided to give it a go.

The approach is fairly simple, it was just challenging to actually get working:

  1. Sample a handful of pixels of the background image underneath the jetski. If any pixels are black, then we have overlapped with a wave.
  2. Once we overlap with the wave - do some stuff: visually show that we hit a wave (bounce up a bit, etc) and jostle the jetski (push in a direction or simulate airtime with low grip, etc).

The first step is, by far, the hardest.

My background image tiles are constantly being both rotated and also moved in a rotation around the player. I finally figured out a way to measure the correct offset and get to the pixel information such that I can call image:sample(x, y) underneath the jetski, and have it work in all manner of movement/rotations, etc, and (importantly) in a performant manner.

I take this one step further and measure a 5 pixel line across the center of the jetski, just to have more surface area so that I don't pass through dithered pixels undetected (still not 100% perfect but working OK for now).

I've only spent a very brief amount of time on the second part - responding to the wave. For now I am crudely bumping the scale of the player to simulate a bounce and dropping the grip so it feels a little like getting thrown by the wave, just to start getting some feedback cycles in.

Finally, a GIF:

waves

This is still proof of concept across the board, but very cool to seeing the full idea come together end to end.

Some issues/ideas, seeing this live for the first time:

  • Adding lots of visual noise to the waves can be messy/distracting, but, the sparse outlines of waves in this specific graphic results in a sort of choppy experience. When comparing to other GIFs in my thread you'll notice I actually increased the density of the wave line dithering to try to have the pixels detect slip by less.
  • I could sample pixels in a small grid (maybe 3x3) and gather directional info based on how the grid is full up, to better simulate pushing over a wave. But, again, with this being tied directly to the background image tuned for visuals - the pixels are too stark/rigid to make this feel natural. I'd want basically a full gradient throughout (much like perlin noise).

My next idea:

Generate an image that is a gradient-only version of the visual waves. This image will never be drawn to the screen, but it will be rotated the same as the background. I'll then sample this gradient image (in a grid) in order to measure the pixels that impact the jetski. By having this image not be visible, I can fill it out with lots of pixels as input to the wave.

Bonus gif of my attempt at using perlin noise to generate a water effect (all pre-baked images made in GIMP)

perlin_water

I could see using this approach to create the pairing: both a visual representation of water and the wave gradient image, just by adjusting the dithering threshold of the visual layer.

5 Likes

A few more attempts at wave simulation

Following up from my last post, I wanted to show a few more attempts in this space.

Image height map

The idea here is to create an image that contains the height information for my background water graphic tile, and sample the number of black pixels at the front & back of the jetski to get its tilt. This image is will not be seen, it's only used as a height map.

Recapping from the prior post, the approach is to translate the world coordinates of my jetski back onto the tile (un-rotating and modulo to stay in the tile) and sample a 3x3 grid to get a "height".

For the first approach, I created a gradient version of my water tile, to try to give a height map that matches the actual visible pattern.

Here is this approach, visualized:

wave_gradient

I am showing the gradient tile I created, and showing the mapping of the current position of my jetski onto that tile.

In theory - this "works", but had some issues:

  • The pattern is "tight", especially at speed, which meant really rapid/choppy animation, it doesn't feel smooth at all.
  • I am sampling a 3x3 grid at the front & back of the jetski to get a height, 0-9. This often works, but, dithering isn't perfectly uniform, so there are pockets where there are unexpected tilts, depending on just how the pixels happen to line up. I tried moving to a 5x5 grid to have a larger capture, and it helped, but still wasn't great.

For the second approach, I tried using a tile with perlin noise (generated in GIMP).

wave_perlin

Same idea as the other approach, but instead using perlin noise to generate the height map.

This was smoother, but, a couple issues/concerns:

  • Same pixel sampling/dithering issues as the gradient image - there are pockets of unexpected results just due to sampling.
  • The waves are now decoupled, visually, from the game. I am not sure how I feel about this. On one hand, it's a fairly small game, having this be approximate might be fine, and trying to marry this to visuals might be an uphill battle. I think treating my graphics as a "texture" is fair game, but I sort of want a visual indication of the wave pattern.

Compound Sine Waves

Following the lead from Matt here: Wave Racer | Dev Log - #9 by matt

I started using compound sine waves to represent wave information.

Initial results with this felt smoother right off the bat, and in theory the "height map" is purely computational, I don't need to create tiles or 2d arrays.

I still need to map coordinates back to world view, so that I don't need to worry about rotating the sine wave function (I think not directly possible).

Anyways, finally pulled together a visualization so that I could "see" the waves in the game, and help me tune (ie tough to tell if I have a really choppy function or too smooth of a function).

Here's a first version of of getting a visualization:

sinewave

There is an element of time included in the function, so that there is some natural "wave" movement. Seeing this live, I could see it was a little too tight and too much on a grid.

After some tuning, I arrived at a smoother & more organic looking approach. I also took a moment to re-introduce more water graphics:

sin

This is likely the coolest GIF I'll post :sweat_smile: It does not run on the device (< 1 FPS) but, to me, really starts to feel like water. I'd love to run this type of viz for the full game, but rotating this does not work (the waves are all fixed on the world view) and generating the graphic is very slow.

I am basically creating a series of 5px squares and filling the entire screen (so 400 / 5 x 240 / 5) = 4,000 tiles. For each tile's z/y, I am running the compound sine function in order to get the height. I then normalize the height between 0-1, and use that as the dither pattern. I then draw a 5x5 square via fillRect. With a time element, the animation just runs as you see here (the visualization is not tied to any movement or rotation).

Showing the ghost of the jetski getting mapped back into the sine waves. This is sort of trippy - the 3 circles are showing the location of the jetski if the camera were fixed and never rotated:

trippy

While visualizing this is prohibitively expensive (4000 compound sine function innovations, 4000 rectangles drawn...)j, I only need to compute the height at ~4 coordinates to get my "tilt", which is pretty efficient.

The result is smooth - no more sampling dither patterns, instead relying on sin functions. Keeping this visualization debugging in my back pocket - it's sort of like magic in terms of tuning, but at least I can "see" it now.

Here's a GIF back to game mode, where I am applying some directional impulse to the jetsk, but using the sine function from the above GIF for height:

sin_no_debug

Can see a little bobbing at the beginning, harder to quickly show off the impulse at speed but I push the jetski around little bit, which can, somewhat, impact the ability to navigate.

Bonus: playing with perlin noise in the SDK

I spent a long while trying to create a grid of perlin noise via the SDK.

I found this post & source code to be very helpful after struggling for a while: Perlin Noise explorer

However, I never got to the point where I got a smooth, water like pattern, showing one of the variations:

perlin

(the diagonal movement is, I believe, just me getting one of the x/y tick values wrong)

At some point I just figured the compound sine wave was going to be better for a water effect that scales, just at looking at examples/tutorials/images of both on the internet.

Next thoughts

At this point, the effect is okay (ish) - I like how smooth it is, tho I need to work on my actual pixel animations, and I like the impact it has back on the jetski...if only I could "see" it while playing.

So...the big downside here is that the height is still "invisible". If you are trying to make a buoy and get pushed by an invisible force and miss or collide etc - is that frustrating? Maybe if the waves are sort of ambient and don't cause much impact to the player, they won't be frustrating. But at that point, why bother with all this math and simulation?

I have lots of spiraling thoughts on next steps on still using sine waves, but I don't think any of them will come under my CPU budget if I have to rotate and/or visualize.

Definitely open to feedback, if anyone is reading this: do you think the waves need to be visualized? Is there another approach?

2 Likes

Nice work, keep it up!

some other ideas:

  • represent grayscale data in memory using numbers rather than an image (eg. PGM format)
  • instead of rotating images or data for collision/height do the opposite: rotate your point back onto it (similar to how it's done for non-AABB)

In my last iteration - I am now doing a flavor of these approaches - translating/unrotating positions back into world coordinates, then using a compound sin wav formula for those translated coordinates (I also had also tried perlin functions).

I think the crux of my issue is - that works well and is performant for the actual height/tilt for the jetski, since I can just measure a few points under the jetski - the cost of measuring a few points is not so bad.

But! Actually visualizing the waves in the water - I need to deal with a lot more points - translating 100's of points to get the height value, so that I can then display something for the wave (aka lots of shaded rects or circles or something - the viz above had 4000 rects for example). Dealing with lots of points starts to eat up my CPU budget, so I either need less points or much more clever math (which, I actually haven't ruled out).

I've been starting to think about ways to group areas of similar heights, and draw shades polygons to cover those areas - sort of like an elevation contour map. That might help me reduce the number of points I need to translate & measure, but I haven't sunk time yet into how to actually do this in terms of an algorithm.

edit: I don't expect replies, mostly just rubber ducking!

More Wave Sim

I've had a little less time the last couple weeks, but at least reached a moment where I wanted to write up an update.

Recapping where I was at:

I was really struggling to visualize dynamic waves in the game. How can I visualize the wave while being able to rotate and pan the camera? How can this be performant? The the number of points to viz and deal with is way beyond anything I've done so far.

Initially I was going to try to use C to do try to scale up how many points I could rotate, for the sake of vetting what my max potential is. Can I rotate 1000's of points in C while only being able to handle 100+ in lua?

After talking in discord (thanks @freds72) decided to rethink my approach.

Latest approach

Recapping the main goals:

  • Render a grid of points
  • Be able to calculate the "wave height" for each point
  • Visualize each point, based on the height, to give the feeling of wave.
  • Have this work for ~infinite map ( in practice I could constrain this but the idea is to have waves everywhere the jetski can travel)

I shifted to operating on the grid in AABB "world" coordinates, and thinking about mapping those back to the rotated & translated game coordinates. The idea would be to calculate which points are in view in AABB, and then viz only on those points, translating back to game coordinates.

I started in on a grouping/tile system in world coordinates - rather than computing if every point is in view, compute if a section is in view and render just that section.

I quickly realized I (sort of) already solved this problem with my background tiling approach - I could compute a grid of points for each background tile, and just keep track of the world coordinates along the way.

The end result:

  • On each tick, compute a grid of offsets that is aligned on the current rotation that covers a single background tile.
  • For each background tile that I render - visualize each of the offset points (calculate the height & render something).

This meant zero (0) new rotations! The offset math is all addition and subtraction. The offsets grid itself can be computed once each tick and applied to each background tile. My background algo already ensures I have a ~min set of tiles to render. I am technically rendering a little extra off camera but, marginal.

Pulling that all together... for the first time I have a wave viz that actually operates correctly in the world:

wavegrid

I am using slightly overlapping circles at scaled dither pattern to represent height. My compound sine wave pieces have a time element so the waves "move" while remaining still. The viz is stable as I rotate and move the world.

The bad news - this runs at about 7 fps on the device. That's a whole lot better than 0.

Honestly... I am not 100% sure I can get this within budget.

If I run the entire simulation maths but do not render any circles, I only get back to 11 fps.

I think that's sort of a good news/bad news situation. The good news is that rendering is not actually my bottle neck, so if I can optimize the math, there's hope. The bad news is - I gotta find a way to tune the math.

Two trains of thought for next steps irt tuning the maths:

  1. Try to precompute the wave heights

I am calculating a "height" for every x, y, and time value - injecting those into a little compound sine wave formula.

I could probably modulus the x, y, and precompute, and make those lookups quite fast. The big issue here is time - the height for any given x, y is constantly changing. I think I'd have to try to settle on looping time interval - eg there's 10 time values that I loop through. Now I can precompute every x, y for each of the the 10 ticks. Getting all that to look smooth will be tricky and finicky - it also introduces another tiling problem which might actually be hard to solve. And finally, I image it being hard to change - if I want a level with less/more waves.

  1. Squeeze out as much as I can in C

Return to trying out C interop and at least push the compound sine function into C, and see where that gets me. I could also try to push the entire viz into C as well - rendering the grid per background tile is pretty well isolated from all other happenings in the game, I might be able to carve it all out - but lots to learn for me on the C interop so might take a little while. The upside is keeping the smooth experience.

Bonus GIF - decided to try to make the viz good, everything the same as a above just some small tuning:

better_waves

1 Like

Journey to the C

I've been busy learning lots of new things the last few weeks, but wanted to give an update.

Before doing that...I want to give a shoutout to the wonderful discord community for all the wonderful help. My little game here pales in comparison to others but I couldn't have made progress without the community, and made this super fun to work on.

Before abandoning wave simulation due to performance, I felt I needed to give C a shot.

Quick timeline:

The idea was to get to parity with where I was at with lua, which was running at 7fps, and see how close I got to 30fps.

With a ~verbatim port, I got to about...24 fps, which was tantalizingly close.

I then decided to reduce the number of circles & height points I was using to trade off a more chunky looking visualization for performance, as well as tweaking & tuning, and was able to get something right on the cusp of 30fps.

A few notes/thoughts from that journey:

Through a decent amount of ad-hoc, one-off benchmarking:

  • If there is non-trivial math in a hot spot, C can be a nice win.
  • There is overhead is calling from lua to C, and part of that overhead is ~linear with the amount of arguments & data marshaling done. Try to pass as few args as possible when in a loop. A pattern would be to compute values a single time per tick instead of passing the state of the world. For me, this did lead to duplicating a lot of global "game state".
  • For simple shapes, like a circle, fillEllipse with a pattern is faster than the exact same drawBitmap . With complex shapes/images I am sure there is a rapid break-even point where bitmaps render faster, but I am rendering a single dither pattern in a circle - fillEllipse was about 33% faster.

Overall - I would say the process of using C for a few functions, was not nearly as painful as I thought, and would actually encourage folks to use it with a lua app, as long as you can isolate a piece of well fenced-off code.

In my case, I wound up moving a solid chunk of code, so it took me some wall-clock time to get back to running again.

So close, yet, so far?

I has mixed emotions about where I had landed after porting my background and wave sim to C.

I was tantalizingly close to making this work, after all this effort: I was mostly at 30fps, but I still had dips where I dropped to ~27/28fps. I had made some tradeoffs around a less-good-looking viz. In the grand scheme of things: I know no one cares or will notice, the game isn't likely to be graphically stunning, but, still, sacrificing such that I wasn't thrilled with the final look & feel, after a bunch of effort, is a little disappointing.

The dropped frame rate was directly related to how many circles I was rendering.

Here's a GIF of what that solution looked like at that time (ignore the poor rendering at the edges, that was addressed but I couldn't find a better gif laying around):

circles_version

I am only rendering a circle when the wave height is above a certain threshold. This means I am not always filling up the entire screen with circles: the number of circles is tied to the sine wave formula being run at that time.

Sort of fun symmetry: this means my CPU usage ALSO became a sine wave!

I don't have a screen grab from then but something like:

When there were too many circles: max CPU and dropping a few frames. When there were less circles: CPU goes down, no dropped frames, etc.

Direct frame buffer rendering

I posted a vague desire on discord that I wished there was another way to render patterns on the playdate, with less performance overhead than my "draw lots of circles" approach. I have seen and played 3d games, which I knew were pushing the display harder than I was, yet they were able to run.

Big shout out & thank you to @2DArray for picking up this one-off comment and completely teaching me about a concept I did not know existed.

I won't be able to do it justice, caveating I am still a noob, but recapping my understanding:

  • We can directly write to the playdate's frame buffer, to render on the screen blazingly fast.
  • The approach is to write 1x8 strips of bits, starting from the top left, traversing left to right, top to bottom, row by row.
  • We can manipulate the 1x8 strips of bits to do ~anything but in my case, I can easily represent rows from an 8x8 dither pattern (using an bayer 8x8 pattern).
    • I am using the amazing bayer pattern code from: Discord

So, as long as I can get my height data, per coordinate, aligned such that I can loop through each strip of the frame buffer in order, I should be able to render to the screen, basically (or at least without further wizardy), as fast as possible.

We can render anything this way, but you'd need to be able to render per-pixel instead of per-strip - for example, the edges of any shape that doesn't perfectly align on 1x8 strips. For my case & experience, I decided to just stick with a rendering an 8x8 grid pattern - this would be a huge improvement in viz quality for me, and it kept the math simpler in an area I was just learning.

Perf results

Before: Rendering up to ~120 circles, 32px in diameter, running at 27-30fps. This meant 120 calls to my sine height calculation, and 120 calls to fillEllipse.
After: Rendering 1500, 8x8 patterned squares, running smoothly at 30fps at about 85% CPU (total, with the full game running). This means 1500 calls to my sine wave height calculation, but the rendering is almost for free at this point, which is wild.

The visuals are also much, much smoother. I can also fill the full screen with a pattern, or not, without any real concern for rendering.

smooth

Next steps

I have a little headroom now, since I am around %85 CPU. The new-slowest thing is the 1500 sine wave height calculations. I got another great suggestion to look at incremental sin/cos angle addition instead of calling a compound sin wave function for every coordinate, and I am going to at least give that a go while I am in context.

When I am done & get a chance to tidy up the code, I'll try to post most of it here, in case someone else wants to run a similar full-screen pattern like I am. Swapping my height formula with a different formula could yield radically different results, so it's sort of a little toy pattern engine that dips its toes into some of the lower level render concepts.

I've started on the interactivity of the jetski irt waves, but my next big step here is going to be the animation - to show the jetski bouncing on waves. I might need to come up with a graphics pipeline here - voxels? sprite stacking?


I hadn't written C since grad school, but every piece of this problem has been fascinating, and going on this journey has been a blast.

6 Likes