@@ -16,7 +16,7 @@ I discovered [Acerola's](https://youtube.com/@Acerola_t) amazing channel. In
16
16
using plane fitting algorithms as a method for faking buoyancy. This inspired
17
17
me to add a bit more fidelity to the rocking.
18
18
19
- ## Simple "Plane Fitting"
19
+ ## Floating
20
20
21
21
Plane fitting is a 3D form of [ line
22
22
fitting] ( https://en.wikipedia.org/wiki/Line_fitting ) ; a process of finding the
@@ -32,43 +32,157 @@ stylized game. My requirements are minimal:
32
32
* Orient the object to rest on the water's curve surface.
33
33
* Put the object (near) the top of the water.
34
34
35
- ### Orienting
36
-
37
35
Starting with the orientation, we can look at this very similarly to looking
38
36
for the normal of a heightmap. Our waves are just a vertex-displaced plane,
39
37
where the y-displacement is defined by some function. There are a few options for
40
38
computing the normals:
41
39
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
+ 
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
+ $$
43
128
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 >}}
49
133
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
+ ```
51
162
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
56
164
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.
62
172
63
173

64
174
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:
72
186
73
187
```gdscript
74
188
func fit_plane(center: Vector3, size: Vector2) -> Transform3D:
@@ -108,32 +222,45 @@ func fit_plane(center: Vector3, size: Vector2) -> Transform3D:
108
222
The left side uses only `normal_b` and the right side uses `normal_f`. The center is the
109
223
averaged normal. In my opinion, it looks much better.
110
224
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.
112
231
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.
116
235
117
236
```gdscript
118
237
func fit_plane(plane: Node3D, size: Vector2) -> Transform3D:
238
+ size *= Math.vec2(plane.scale)
239
+
240
+ # samples for "average of triangles"
119
241
var front_r = center + Vector3(size.x, 0, size.y).rotated(Vector3.UP, plane.global_rotation.y)
120
242
var front_l = center + Vector3(-size.x, 0, size.y).rotated(Vector3.UP, plane.global_rotation.y)
121
243
var back_r = center + Vector3(size.x, 0, -size.y).rotated(Vector3.UP, plane.global_rotation.y)
122
244
var back_l = center + Vector3(-size.x, 0, -size.y).rotated(Vector3.UP, plane.global_rotation.y)
123
245
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()
125
254
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);
129
255
```
130
256
257
+
131
258
{{< gallery >}}
132
259
<img src="broken_rot.png" class="grid-w50"/>
133
260
<img src="fixed_rot.png" class="grid-w50"/>
134
261
{{< /gallery >}}
135
262
136
- ### Positioning
263
+ ## Positioning and Movement
137
264
138
265
Now that our plane faces the right direction, we want to put it at the surface
139
266
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:
177
304
var w = pow(2.1231, sin(d) + sin((uv.y + 1.0))) * height
178
305
var dx = (uv.x * w * cos(d)) / d
179
306
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()
181
308
182
309
func fit_plane(center: Vector3, size: Vector2):
183
310
# ...
@@ -219,37 +346,22 @@ wave changing slope at a given point over time, the object can eventually end
219
346
up changing direction. Instead of getting stuck at some local minimum after
220
347
encountering one wave, our object looks like it's swaying back and forth.
221
348
222
- ### Central Differences
349
+ ## Central Differences (Again)
223
350
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.
230
357
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:
239
360
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
246
363
```
247
364
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
-
253
365
## Swimming
254
366
255
367
What about objects that aren't _ always_ in the water? We can re-use all of what
0 commit comments