|
| 1 | +--- |
| 2 | +title: Faking Buoyancy |
| 3 | +type: tech |
| 4 | +--- |
| 5 | + |
| 6 | +{{<video "demo.webm" >}} |
| 7 | + |
| 8 | +For the intro to a game I'm working on, I decided to put the player on a pirate |
| 9 | +ship. If (when) the player tries to jump off the ship, we could prevent them, |
| 10 | +have them reset/die, or swim. I decided on the simplest option, resetting them. |
| 11 | + |
| 12 | +Next, I moved on to making the boat rock with the waves. At first, I just made |
| 13 | +the X-axis rotation oscillate between two values, and this worked fine. Recently, |
| 14 | +I discovered [Acerola's](https://youtube.com/@Acerola_t) amazing channel. In |
| 15 | +[one of his videos](https://www.youtube.com/watch?v=ja8yCvXzw2c) he mentions |
| 16 | +using plane fitting algorithms as a method for faking buoyancy. This inspired |
| 17 | +me to add a bit more fidelity to the rocking. |
| 18 | + |
| 19 | +## Simple "Plane Fitting" |
| 20 | + |
| 21 | +Plane fitting is a 3D form of [line |
| 22 | +fitting](https://en.wikipedia.org/wiki/Line_fitting); a process of finding the |
| 23 | +straight line that can best fit some data points. Acerola's example used a |
| 24 | +bunch of samples from the bottom of an object as data points and fed them to |
| 25 | +what I assume was a "proper" plane fitting algorithm such as [Least |
| 26 | +Squares](https://en.wikipedia.org/wiki/Least_squares) or [Principal Component |
| 27 | +Analysis](https://en.wikipedia.org/wiki/Principal_component_analysis). |
| 28 | + |
| 29 | +Those seemed somewhat complicated to fully grasp. It's probably overkill for my |
| 30 | +stylized game. My requirements are minimal: |
| 31 | + |
| 32 | +* Orient the object to rest on the water's curve surface. |
| 33 | +* Put the object (near) the top of the water. |
| 34 | + |
| 35 | +### Orienting |
| 36 | + |
| 37 | +Starting with the orientation, we can look at this very similarly to looking |
| 38 | +for the normal of a heightmap. Our waves are just a vertex-displaced plane, |
| 39 | +where the y-displacement is defined by some function. There are a few options for |
| 40 | +computing the normals: |
| 41 | + |
| 42 | +#### Normal Calculation |
| 43 | + |
| 44 | +* Analytically differentiate the function and use the tangents to figure out |
| 45 | + the normal. |
| 46 | +* Use central differences, sampling different parts of the area we want to fit |
| 47 | + on our 3D curve. |
| 48 | +* Leverage the cross product to find our normals. |
| 49 | + |
| 50 | +The last one is the easiest, in my opinion. The first paragraph of [the |
| 51 | +Wikipedia article for the cross |
| 52 | +product](https://en.wikipedia.org/wiki/Cross_product) mentions using them to |
| 53 | +calculate normal vectors. |
| 54 | + |
| 55 | +We can start by defining a rectangle, centered at {{<katex>}}\\(P\\). Next, we |
| 56 | +take 4 samples of {{<katex>}}\\(f(x)\\) to get the corners |
| 57 | +[projected](https://www.desmos.com/3d/89a779a469) down onto the curve: |
| 58 | +{{<katex>}}\\(A\\) {{<katex>}}\\(B\\) {{<katex>}}\\(C\\) {{<katex>}}\\(D\\) and |
| 59 | +{{<katex>}}\\(P\\) projected onto the wave as {{<katex>}}\\(P_w\\). |
| 60 | + |
| 61 | + |
| 62 | + |
| 63 | +Next, we have to choose 3 points to form 2 vectors from. The cross of those two |
| 64 | +vectors will be the normal. We can get a pretty accurate normal by averaging |
| 65 | +the cross products of the sides of each of the triangles formed by |
| 66 | +{{<katex>}}\\(P_w\\) and the rectangle's corners. For gameplay purposes, we |
| 67 | +care much more about the tilt either forward or backward, so we'll use the |
| 68 | +average {{<katex>}}\\(\vec{AP_w} \times \vec{BP_w}\\) and |
| 69 | +{{<katex>}}\\(\vec{CP_w} \times \vec{DP_w}\\) as our normal. |
| 70 | + |
| 71 | +```gdscript |
| 72 | +func fit_plane(center: Vector3, size: Vector2) -> Transform3D: |
| 73 | + # form corners of the axis-aligned plane |
| 74 | + var front_r = center + Vector3(size.x, 0, size.y) |
| 75 | + var front_l = center + Vector3(-size.x, 0, size.y) |
| 76 | + var back_r = center + Vector3(size.x, 0, -size.y) |
| 77 | + var back_l = center + Vector3(-size.x, 0, -size.y) |
| 78 | +
|
| 79 | + # project the points onto the wave |
| 80 | + front_r.y = _wave(front_r) |
| 81 | + front_l.y = _wave(front_l) |
| 82 | + back_l.y = _wave(back_l) |
| 83 | + back_r.y = _wave(back_r) |
| 84 | + center.y = _wave(center) |
| 85 | +
|
| 86 | + # front normal |
| 87 | + var v1 = front_l - center; |
| 88 | + var v2 = front_r - center; |
| 89 | + var normal_f = v1.cross(v2).normalized(); |
| 90 | +
|
| 91 | + # back normal |
| 92 | + v1 = back_r - center; |
| 93 | + v2 = back_l - center; |
| 94 | + var normal_b = v1.cross(v2).normalized(); |
| 95 | +
|
| 96 | + # rotation based on average of cross products |
| 97 | + var normal = (normal_b + normal_f) / 2.0; |
| 98 | +``` |
| 99 | + |
| 100 | +{{< gallery >}} |
| 101 | + <img src="backbias.png" class="grid-w33"/> |
| 102 | + <img src="averaged.png" class="grid-w33"/> |
| 103 | + <img src="frontbias.png" class="grid-w33"/> |
| 104 | +{{< /gallery >}} |
| 105 | + |
| 106 | +The left side uses only `normal_b` and the right side uses `normal_f`. The center is the |
| 107 | +averaged normal. In my opinion, it looks much better. |
| 108 | + |
| 109 | +#### Allowing Rotated Objects |
| 110 | + |
| 111 | +Our calculation is still inaccurate, especially if the plane we're using |
| 112 | +isn't a square. This is easy enough to fix by taking the source object's |
| 113 | +rotation into account when finding the corner points: |
| 114 | + |
| 115 | +```gdscript |
| 116 | +func fit_plane(plane: Node3D, size: Vector2) -> Transform3D: |
| 117 | + var front_r = center + Vector3(size.x, 0, size.y).rotated(Vector3.UP, plane.global_rotation.y) |
| 118 | + var front_l = center + Vector3(-size.x, 0, size.y).rotated(Vector3.UP, plane.global_rotation.y) |
| 119 | + var back_r = center + Vector3(size.x, 0, -size.y).rotated(Vector3.UP, plane.global_rotation.y) |
| 120 | + var back_l = center + Vector3(-size.x, 0, -size.y).rotated(Vector3.UP, plane.global_rotation.y) |
| 121 | +
|
| 122 | + #... |
| 123 | +
|
| 124 | + # rotation based on average of cross products |
| 125 | + # we now need to convert back into local space by undoing the objects rotation |
| 126 | + var normal = ((normal_b + normal_f) / 2.0).rotated(Vector3.UP, -plane.global_rotation.y); |
| 127 | +``` |
| 128 | + |
| 129 | +{{< gallery >}} |
| 130 | + <img src="broken_rot.png" class="grid-w50"/> |
| 131 | + <img src="fixed_rot.png" class="grid-w50"/> |
| 132 | +{{< /gallery >}} |
| 133 | + |
| 134 | +### Positioning |
| 135 | + |
| 136 | +Now that our plane faces the right direction, we want to put it at the surface |
| 137 | +of the water. We could choose to use the point on the curve under the center of |
| 138 | +the plane, {{<katex>}}\\(P_w\\). |
| 139 | + |
| 140 | +```gdscript |
| 141 | +var surface_pos = center.y |
| 142 | +``` |
| 143 | + |
| 144 | +This works extremely well for how simple it is, but we can take it further. |
| 145 | +Waves usually move things, right? How can we capture the force created by a |
| 146 | +wave? The amplitude of the wave should affect the strength of the push we give. |
| 147 | +It sounds like we need to look at the slope... otherwise known as the gradient |
| 148 | +which we can find using the partial derivatives of the wave. |
| 149 | +The current wave is defined by: |
| 150 | + |
| 151 | +{{<katex>}}$$w\left(x,y\right)=H\cdot e^{\sin\left(\sqrt{x^{2}+y^{2}}\right)+\sin\left(y\right)}$$ |
| 152 | + |
| 153 | +Using this intermediate term: |
| 154 | + |
| 155 | +{{<katex>}}$$d = \sqrt{\left(x^{2}+y^{2}\right)}$$ |
| 156 | + |
| 157 | +So that our [partial |
| 158 | +derivatives](https://www.wolframalpha.com/input?i=derivative+of+e%5E%28sin%28sqrt%28x%5E2%2By%5E2%29%29+%2B+sin%28y%29%29+*+H) |
| 159 | +are a bit more readable: |
| 160 | + |
| 161 | +{{<katex>}}$$\frac{\partial f}{\partial x} w(x, y) = \frac{w\left(x,y\right)\cdot x\cos\left(d\right)}{d}$$ |
| 162 | + |
| 163 | +{{<katex>}}$$\frac{\partial f}{\partial y} w(x, y) = w\left(x,y\right)\cdot\left(\frac{y\cos\left(d\right)}{d}+\cos\left(y\right)\right)$$ |
| 164 | + |
| 165 | +Once we convert this to code, we can adjust our target position using this: |
| 166 | + |
| 167 | +```gdscript |
| 168 | +func _wave_gradient(p: Vector3) -> Vector3: |
| 169 | + # in reality x/y are further parameterized by time |
| 170 | + var time = total_time * wave_speed |
| 171 | + var uv = (Math.vec2(p) + time * Vector2(0.5, 0.5)) * wave_size |
| 172 | + var d = uv.length() |
| 173 | +
|
| 174 | + var w = pow(2.1231, sin(d) + sin((uv.y + 1.0))) * height |
| 175 | + var dx = (uv.x * w * cos(d)) / d |
| 176 | + var dy = w * ((uv.y * cos(d)) / d + cos(uv.y + 1)) |
| 177 | + return Vector3(dx, 0, dy) |
| 178 | +
|
| 179 | +func fit_plane(center: Vector3, size: Vector2): |
| 180 | + # ... |
| 181 | +
|
| 182 | + var surface_point = center - _wave_gradient(center) |
| 183 | +
|
| 184 | + # convert to transform |
| 185 | + var rotation_axis = Vector3.UP.cross(normal).normalized() |
| 186 | + var rotation_angle = Vector3.UP.angle_to(normal) |
| 187 | + if rotation_axis.length_squared() < .1: |
| 188 | + rotation_axis = Vector3.RIGHT |
| 189 | + return Transform3D(Basis(rotation_axis.normalized(), rotation_angle), surface_point) |
| 190 | +``` |
| 191 | + |
| 192 | +If we simply set our object's position to the `surface_point` it will slide |
| 193 | +towards a local minima of the wave. |
| 194 | + |
| 195 | +{{<video "basic_slide.webm" >}} |
| 196 | + |
| 197 | +That looks a bit too quick. Multiplying by delta time fixes this: |
| 198 | + |
| 199 | +```gdscript |
| 200 | +global_position += (surface_transform.origin - global_position) * Vector3(push_strength*delta, 1, push_strength*delta) |
| 201 | +``` |
| 202 | + |
| 203 | +We can add some artistic control by adding a `push_strength` parameter. |
| 204 | + |
| 205 | +```gdscript |
| 206 | +func fit_plane(center: Vector3, size: Vector2, strength: float): |
| 207 | + # ... |
| 208 | + var surface_point = center - _wave_gradient(center) * strength |
| 209 | +``` |
| 210 | + |
| 211 | + |
| 212 | +{{<video "soft_slide.webm" >}} |
| 213 | + |
| 214 | +Because of the smaller step, we don't keep up with the wave. Because of the |
| 215 | +wave changing slope at a given point over time, the object can eventually end |
| 216 | +up changing direction. Instead of getting stuck at some local minimum after |
| 217 | +encountering one wave, our object looks like it's swaying back and forth. |
| 218 | + |
| 219 | + |
| 220 | +## Swimming |
| 221 | + |
| 222 | +What about objects that aren't _always_ in the water? We can re-use all of what |
| 223 | +we've done so far to implement a swimming mechanic. Instead of the model being |
| 224 | +a child of the plane, we can have a plane that is the child of a body. |
| 225 | + |
| 226 | + |
| 227 | +First we detect whether or not we're in the water to turn the influence on or off: |
| 228 | + |
| 229 | +```gdscript |
| 230 | +# get swimming position of the parent body |
| 231 | +var surface_pos = water.fit_plane(self, Math.vec2(size)) |
| 232 | +surface_pos.origin -= (global_position - parent.global_position) |
| 233 | +
|
| 234 | +# activate swimming mode if we're submerged |
| 235 | +if surface_pos.origin.y > global_position.y: |
| 236 | + active = true |
| 237 | + animation.queue_action("swim") |
| 238 | +
|
| 239 | +# if the player jumps or otherwise ends up above the surface, we're no longer swimming |
| 240 | +if parent.global_position.y - surface_pos.origin.y > deactivate_margin: |
| 241 | + active = false |
| 242 | +``` |
| 243 | + |
| 244 | +And then apply the influence of the water on the body to `velocity` rather than directly |
| 245 | +changing the `position`. |
| 246 | + |
| 247 | +```gdscript |
| 248 | +# align to surface |
| 249 | +parent.global_position.y = surface_pos.origin.y |
| 250 | +
|
| 251 | +# push the player around with the waves |
| 252 | +parent.velocity += (surface_pos.origin - parent.global_position) * delta |
| 253 | +``` |
| 254 | + |
| 255 | +{{<video "bobbing.webm" >}} |
| 256 | + |
| 257 | +This bobbing effect looks neat. After some fine tuning, it could be pretty |
| 258 | +good, but there is already a lot of motion applied to the player outside their |
| 259 | +control. Another layer of unpredicatability would take away from the fun, so |
| 260 | +instead, lets make them stick to the water's surface. |
| 261 | + |
| 262 | +```gdscript |
| 263 | +parent.velocity += (surface_pos.origin - parent.global_position) * Vector3(1, 0, 1) * delta |
| 264 | +parent.global_position.y = surface_pos.origin.y |
| 265 | +``` |
| 266 | + |
| 267 | +{{<video "bad_col.webm" >}} |
| 268 | + |
| 269 | +Well, setting the position directly means `move_and_slide` doesn't get the |
| 270 | +opportunity to slide the player and leads to them clipping through walls. |
| 271 | +Instead, we can _assign_ the y component of velocity so that it puts us exactly |
| 272 | +on the surface. |
| 273 | + |
| 274 | +```gdscript |
| 275 | +var impulse = (surface_pos.origin - parent.global_position) * Vector3(delta, 1/delta, delta) |
| 276 | +parent.velocity += impulse |
| 277 | +parent.velocity.y = impulse.y |
| 278 | +``` |
| 279 | + |
| 280 | +{{<video "swim.webm" >}} |
| 281 | + |
| 282 | +## Conclusion |
| 283 | + |
| 284 | +As with any stylized art, it's best to start with a somewhat realistic "ground |
| 285 | +truth" and then simplify and improvise. Even cartoonish styles require |
| 286 | +attention to detail. The key is choosing _which_ details to draw attention to |
| 287 | +and which to minimize or omit. But of course, not having as many performance |
| 288 | +concerns as a hyperrealistic render is nice as well. |
| 289 | + |
0 commit comments