Skip to content

Commit 10535e1

Browse files
committed
buoyancy
1 parent cb6a222 commit 10535e1

16 files changed

+291
-1
lines changed

content/tech/buoyancy/averaged.png

89.4 KB
Loading

content/tech/buoyancy/backbias.png

88.3 KB
Loading

content/tech/buoyancy/bad_col.webm

2.12 MB
Binary file not shown.
6.4 MB
Binary file not shown.

content/tech/buoyancy/bobbing.webm

728 KB
Binary file not shown.

content/tech/buoyancy/broken_rot.png

93.1 KB
Loading

content/tech/buoyancy/demo.webm

5.31 MB
Binary file not shown.

content/tech/buoyancy/featured.png

329 KB
Loading

content/tech/buoyancy/fixed_rot.png

83.7 KB
Loading

content/tech/buoyancy/frontbias.png

86.3 KB
Loading

content/tech/buoyancy/index.md

+289
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,289 @@
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+
![projection](projection.png)
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+

content/tech/buoyancy/projection.png

53.9 KB
Loading

content/tech/buoyancy/soft_slide.webm

17.1 MB
Binary file not shown.

content/tech/buoyancy/swim.webm

2.51 MB
Binary file not shown.

content/tech/wfc-01-basic-wfc/index.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -222,7 +222,7 @@ use that definite value to narrow down the possible values of neighboring
222222
cells.
223223

224224
{{< alert emoji="🤦" >}}
225-
I used the word Collapse instead of Observe/Observation. Collapse is what
225+
🤦 I used the word Collapse instead of Observe/Observation. Collapse is what
226226
happens due to observation and propagation. Oops.
227227
{{< /alert >}}
228228

resources/.gitignore

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
_gen/

0 commit comments

Comments
 (0)