Skip to content

Commit 4d7fd9c

Browse files
committed
corrections
1 parent d738175 commit 4d7fd9c

File tree

6 files changed

+174
-62
lines changed

6 files changed

+174
-62
lines changed

content/tech/buoyancy/adjust_pos.png

32.1 KB
Loading

content/tech/buoyancy/convex_case.png

13.5 KB
Loading
21.9 KB
Loading
21.2 KB
Loading
24.4 KB
Loading

content/tech/buoyancy/index.md

+174-62
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ I discovered [Acerola's](https://youtube.com/@Acerola_t) amazing channel. In
1616
using plane fitting algorithms as a method for faking buoyancy. This inspired
1717
me to add a bit more fidelity to the rocking.
1818

19-
## Simple "Plane Fitting"
19+
## Floating
2020

2121
Plane fitting is a 3D form of [line
2222
fitting](https://en.wikipedia.org/wiki/Line_fitting); a process of finding the
@@ -32,43 +32,157 @@ stylized game. My requirements are minimal:
3232
* Orient the object to rest on the water's curve surface.
3333
* Put the object (near) the top of the water.
3434

35-
### Orienting
36-
3735
Starting with the orientation, we can look at this very similarly to looking
3836
for the normal of a heightmap. Our waves are just a vertex-displaced plane,
3937
where the y-displacement is defined by some function. There are a few options for
4038
computing the normals:
4139

42-
#### Normal Calculation
40+
* Using small finite differences to approximate the normals at a single point.
41+
* Use central differences according to the size of the object to calculate the average normals.
42+
* Leverage the cross product to find our normals according to the size of the object.
43+
44+
## Small Finite Differences
45+
46+
Finite differences is a method of estimating the slope of a function at a given
47+
point. It's the "rise over run" concept from elementary school. For our 3D
48+
curve, we'll check the height (y) at 3 points. One center point, one to the
49+
left and one forward. If we use a very small step, we can estimate a very local
50+
slope.
51+
52+
{{<katex>}}$$\Delta x ~= \frac{w(x, z) - w(x + S, z)}{S}$$
53+
{{<katex>}}$$\Delta z ~= \frac{w(x, z) - w(x, z + S)}{S}$$
54+
55+
To get a normal from these two slopes, we can take the [cross product](https://en.wikipedia.org/wiki/Cross_product),
56+
an operation that gives a 3rd vector perpendicular to both of the given vectors:
57+
58+
{{<katex>}}$$
59+
\vec n =
60+
\begin{bmatrix}
61+
S \\
62+
\Delta x \\
63+
0
64+
\end{bmatrix}
65+
\times
66+
\begin{bmatrix}
67+
0 \\
68+
\Delta z \\
69+
S
70+
\end{bmatrix}
71+
$$
72+
73+
While this gives a very accurate approximation, it might be too accurate. When an
74+
object is longer than some concave part of the curve, this increases the likelyhood
75+
that we cut through the surface of the water.
76+
77+
![finite_diffs](finite_diffs.png)
78+
79+
## Object-Sized Finite Differences
80+
81+
To mitigate this, we can take the size of our object into account
82+
when computing the differences. Supplying a width and length for
83+
our object we define a plane or rectangle parallel with the
84+
ground. The formulas just need a slight adjustment:
85+
86+
{{<katex>}}$$\Delta x ~= \frac{w(x - 0.5W, z) - w(x + 0.5W, z)}{W}$$
87+
{{<katex>}}$$\Delta z ~= \frac{w(x, z - 0.5L) - w(x, z + 0.5L)}{L}$$
88+
89+
{{<katex>}}$$
90+
\vec n =
91+
\begin{bmatrix}
92+
W \\
93+
\Delta x \\
94+
0
95+
\end{bmatrix}
96+
\times
97+
\begin{bmatrix}
98+
0 \\
99+
\Delta z \\
100+
L
101+
\end{bmatrix}
102+
$$
103+
104+
Notice that there are now four samples, and they all go in
105+
different directions. This is a version of finite differences
106+
called central differences. From these samples, if we rotate our
107+
object then have a plane that _could_ fit on the wave but it sits
108+
underneath, tangent to the wave's curve. This is fine in convex
109+
scenarios, but it will lie underneath the curve in a concave area.
110+
The adjusted position is the mean of our samples.
111+
112+
{{<katex>}} $$
113+
y' = \frac{w(x - 0.5W, z) + w(x + 0.5W, z) + w(x, z + .5L) + w(x, z - .5L)}{4}
114+
$$
115+
116+
{{< gallery >}}
117+
<img src="finite_diffs_width.png" class="grid-w50"/>
118+
<img src="adjust_pos.png" class="grid-w50"/>
119+
{{< /gallery >}}
120+
121+
In the case where our rectangle spans across a convex area of the curve, this will
122+
move us further down into the water. Using the higher of our adjusted value and the
123+
height at the center of the rectangle easily mitigates this.
124+
125+
{{<katex>}} $$
126+
y'' = \max(y', w(x, y))
127+
$$
43128
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.
129+
{{< gallery >}}
130+
<img src="convex_case.png" class="grid-w50"/>
131+
<img src="convex_case_fix.png" class="grid-w50"/>
132+
{{< /gallery >}}
49133
50-
If we were to try central differences, we end up getting issues near the peak of a step curve.
134+
All of this, in code, looks like this:
135+
136+
```gdscript
137+
func fit_plane(plane: Node3D, size: Vector2, strength: float) -> Transform3D:
138+
# take samples
139+
var center = plane.global_position
140+
var left = _wave(center + Vector3.LEFT * size.x / 2)
141+
var right = _wave(center + Vector3.RIGHT * size.x / 2)
142+
var front = _wave(center + Vector3.FORWARD * size.y / 2)
143+
var back = _wave(center + Vector3.BACK * size.y / 2)
144+
145+
# compute the normal
146+
var dx = right -left
147+
var dz = back-front
148+
var normal = -Vector3(size.x, dx, 0).cross(Vector3(0, dz, size.y)).normalized()
149+
150+
# place the object
151+
var surface_point = center
152+
var sample_mean = (left + right + front + back) / 4)
153+
var surface_point.y = max(_wave(center.y), sample_mean)
154+
155+
# create a transform
156+
var rotation_axis = Vector3.UP.cross(normal).normalized()
157+
var rotation_angle = Vector3.UP.angle_to(normal)
158+
if rotation_axis.length_squared() < .1:
159+
rotation_axis = Vector3.RIGHT
160+
return Transform3D(Basis(rotation_axis.normalized(), rotation_angle), surface_point)
161+
```
51162
52-
The last one is not only the most accurate, but also the easiest, in my
53-
opinion. The first paragraph of [the Wikipedia article for the cross
54-
product](https://en.wikipedia.org/wiki/Cross_product) mentions using them to
55-
calculate normal vectors.
163+
## Triangles in a Quad
56164
57-
We can start by defining a rectangle, centered at {{<katex>}}\\(P\\). Next, we
58-
take 4 samples of {{<katex>}}\\(f(x)\\) to get the corners
59-
[projected](https://www.desmos.com/3d/89a779a469) down onto the curve:
60-
{{<katex>}}\\(A\\) {{<katex>}}\\(B\\) {{<katex>}}\\(C\\) {{<katex>}}\\(D\\) and
61-
{{<katex>}}\\(P\\) projected onto the wave as {{<katex>}}\\(P_w\\).
165+
While the central differences approach works very well, it doesn't
166+
handle the subtle rotation across the shorter part of our
167+
rectangle. The samples all lie on the midpoints of the sides of
168+
the triangles, creating a diamond shape. Sampling at the corners
169+
will more accurately balance the object on the surface,
170+
[projecting](https://www.desmos.com/3d/89a779a469) the entire
171+
shape onto the curve.
62172
63173
![projection](projection.png)
64174
65-
Next, we have to choose 3 points to form 2 vectors from. The cross of those two
66-
vectors will be the normal. We can get a pretty accurate normal by averaging
67-
the cross products of the sides of each of the triangles formed by
68-
{{<katex>}}\\(P_w\\) and the rectangle's corners. For gameplay purposes, we
69-
care much more about the tilt either forward or backward, so we'll use the
70-
average {{<katex>}}\\(\vec{AP_w} \times \vec{BP_w}\\) and
71-
{{<katex>}}\\(\vec{CP_w} \times \vec{DP_w}\\) as our normal.
175+
We can get a pretty accurate normal by averaging the cross
176+
products of the sides of each of the triangles formed by
177+
{{<katex>}}\\(P_w\\) and the rectangle's corners. Using just the
178+
front and back yields good enough results.
179+
180+
{{<katex>}}$$
181+
\frac{\vec{AP_w} \times \vec{BP_w} + \vec{CP_w} \times \vec{DP_w}}
182+
{2}
183+
$$
184+
185+
In code, this looks like:
72186
73187
```gdscript
74188
func fit_plane(center: Vector3, size: Vector2) -> Transform3D:
@@ -108,32 +222,45 @@ func fit_plane(center: Vector3, size: Vector2) -> Transform3D:
108222
The left side uses only `normal_b` and the right side uses `normal_f`. The center is the
109223
averaged normal. In my opinion, it looks much better.
110224
111-
#### Allowing Rotated Objects
225+
## Fixing Rotation and Scale
226+
227+
Our calculation is still inaccurate, especially if the plane we're
228+
using isn't a square. We need to remember to take the parent
229+
transforms into account. This means multiplying our rectangle size
230+
by the object's scale.
112231
113-
Our calculation is still inaccurate, especially if the plane we're using
114-
isn't a square. This is easy enough to fix by taking the source object's
115-
rotation into account when finding the corner points:
232+
Anywhere we use the `size` of our rectangle, we'll also need to
233+
rotate. Also, if using the "avreage of triangles" method to compute the normal,
234+
the rotation must be undone.
116235
117236
```gdscript
118237
func fit_plane(plane: Node3D, size: Vector2) -> Transform3D:
238+
size *= Math.vec2(plane.scale)
239+
240+
# samples for "average of triangles"
119241
var front_r = center + Vector3(size.x, 0, size.y).rotated(Vector3.UP, plane.global_rotation.y)
120242
var front_l = center + Vector3(-size.x, 0, size.y).rotated(Vector3.UP, plane.global_rotation.y)
121243
var back_r = center + Vector3(size.x, 0, -size.y).rotated(Vector3.UP, plane.global_rotation.y)
122244
var back_l = center + Vector3(-size.x, 0, -size.y).rotated(Vector3.UP, plane.global_rotation.y)
123245
124-
#...
246+
# samples for "object sized finite differences"
247+
var left = _wave(center + (Vector3.LEFT * size.x / 2).rotated(Vector3.UP, plane.global_rotation.y))
248+
var right = _wave(center + (Vector3.RIGHT * size.x / 2).rotated(Vector3.UP, plane.global_rotation.y))
249+
var front = _wave(center + (Vector3.FORWARD * size.y / 2).rotated(Vector3.UP, plane.global_rotation.y))
250+
var back = _wave(center + (Vector3.BACK * size.y / 2).rotated(Vector3.UP, plane.global_rotation.y))
251+
252+
# only needed for the triangles approach of calculating normal
253+
var normal = ((normal_b + normal_f) / 2.0).rotated(Vector3.UP, -plane.global_rotation.y).normalized()
125254
126-
# rotation based on average of cross products
127-
# we now need to convert back into local space by undoing the objects rotation
128-
var normal = ((normal_b + normal_f) / 2.0).rotated(Vector3.UP, -plane.global_rotation.y);
129255
```
130256
257+
131258
{{< gallery >}}
132259
<img src="broken_rot.png" class="grid-w50"/>
133260
<img src="fixed_rot.png" class="grid-w50"/>
134261
{{< /gallery >}}
135262
136-
### Positioning
263+
## Positioning and Movement
137264
138265
Now that our plane faces the right direction, we want to put it at the surface
139266
of the water. We could choose to use the point on the curve under the center of
@@ -177,7 +304,7 @@ func _wave_gradient(p: Vector3) -> Vector3:
177304
var w = pow(2.1231, sin(d) + sin((uv.y + 1.0))) * height
178305
var dx = (uv.x * w * cos(d)) / d
179306
var dy = w * ((uv.y * cos(d)) / d + cos(uv.y + 1))
180-
return Vector3(dx, 0, dy)
307+
return Vector3(dx, 0, dy).normalized()
181308
182309
func fit_plane(center: Vector3, size: Vector2):
183310
# ...
@@ -219,37 +346,22 @@ wave changing slope at a given point over time, the object can eventually end
219346
up changing direction. Instead of getting stuck at some local minimum after
220347
encountering one wave, our object looks like it's swaying back and forth.
221348

222-
### Central Differences
349+
## Central Differences (Again)
223350

224-
While this is very precise, doing the calculus to get that gradient takes an
225-
extra step of manual work. Each time the structure of our `_wave` function
226-
changes, that new function must be differentiated. Central differences is
227-
actually viable here, making analytic differentiation unneccessary at the cost
228-
of a few extra samples. It's viable here because we take a very small step
229-
rather than reaching to the far corners of the rectangle.
351+
While this is very precise, doing the calculus to get that
352+
gradient takes an extra step of manual work. Each time the
353+
structure of our `_wave` function changes, that new function must
354+
be differentiated. We can simply re-use the finite differences
355+
method from earlier here. The analytic approach could still be
356+
useful for validation.
230357

231-
```gdscript
232-
var step = .1
233-
var r = _wave(center + Vector3.RIGHT * step)
234-
var l = _wave(center + Vector3.LEFT * step)
235-
var u = _wave(center + Vector3.BACK * step)
236-
var d = _wave(center + Vector3.FORWARD * step)
237-
var grad = Vector3(r - l, 0.0, u - d) / (2.0 * step)
238-
```
358+
The only modification is how we construct the vector to get the
359+
gradient (aka slope) instead of the normal:
239360

240-
Measuring the difference between this method and the last method for step sizes
241-
`.01`, `.1` and `1` were all oscillating between `0.01 and .1` using the
242-
following measurement:
243-
244-
```
245-
(grad.normalized() - _wave_gradient(center).normalized()).length()
361+
```gdscript
362+
var grad = Vector3(dx, 0.0, dz) / step
246363
```
247364

248-
The magnitudes were massively different, with the difference in maginitude
249-
oscillating as well. This can be partially mitigated by using a higher
250-
`strength` or an extra division by `step`.
251-
252-
253365
## Swimming
254366

255367
What about objects that aren't _always_ in the water? We can re-use all of what

0 commit comments

Comments
 (0)