SamSuka
naelstrof
naelstrof

patreon


Penetration Tech

I'm finally kicking things into gear again, I've got some big changes in the works that make me more comfortable and excited to work on the project again. Meanwhile me and Raliv have worked on a new iteration of the penetration tech found in KoboldKare, and I'm going to dump a big writeup I did on it! If you want to see an article without massive gifs, check out the same article hosted on my website here: https://koboldkare.com/penetrationtech.php 

Problem

A very typical problem in nsfw game development is needing a solution to make penetrators go into penetrables in a convincing manner.

At low fidelity, this is a really easy task. Just point the penetrator at the hole! Though as soon as you want anything extra-- deformation response, knot forces, complex curves... Then the problem becomes immensely more difficult.

Our Technique

We wanted exactly two things:

  1. Smoothly bend the penetrator towards the entrance.
  2. Penetrable reacts to the shape of the penetrator.

To do this in an extensible way, and not get overwhelmed with math-- We structured the problem as one of spaces!

The problem comes down to creating a clean transformation to and from Bézier space. Seems easy enough, right?

Nope! I'll do my best to explain the shenanigans required.

Catmull-Rom Breakdown

First we need to define the curves that we're going to construct. If you're unfamiliar with splines, I highly recommend briefing yourself with this video by Freya Holmér.

Each penetrator will have its own spline that it follows, and each penetrable will too. We need to be able to stitch these curves together in real-time and have them smoothly align.

We can choose any kind of spline, I chose Catmull-Rom arbitrarily because I hardly can tell the differences! It doesn't matter too much anyway, provided we can do the math to get points along the curve.

Catmull-Rom Parallel Transport Frames

In order to construct a space that we can transform into along the spline, we need a way to detect which way is "forward", "up", and "right" along the curve.

The naive solution would be to use Frenet-Serret frames.

You can see that the space flips randomly along the path, this is due to it passing local minima and maxima--

Luckily Nikolai Janakiev has written a very detailed and animated blog post about this problem here: https://janakiev.com/blog/framing-parametric-curves/

Using Nikolai's code as a reference, I generate a lookup table with the following code:

protected void GenerateBinormalLUT(int resolution) {
   // https://janakiev.com/blog/framing-parametric-curves/
   binormalLUT.Clear(); // A reusable List<Vector3>, as we regenerate this very often
   Vector3 lastTangent = GetVelocityFromT(0).normalized;
   // Initial reference frame, uses Vector3.up arbitrarily
   Vector3 lastBinormal = Vector3.Cross(GetVelocityFromT(0),Vector3.up).normalized;
   if (lastBinormal.magnitude == 0f) {
       lastBinormal = Vector3.Cross(GetVelocityFromT(0),Vector3.right).normalized;
   }
   for(int i=0;i<resolution;i++) {
       float t = (((float)i)/(float)resolution);
       Vector3 point = GetPositionFromT(t);
       Vector3 tangent = GetVelocityFromT(t).normalized;
       Vector3 binormal = Vector3.Cross(lastTangent, tangent);
       if (binormal.magnitude == 0f) {
           binormal = lastBinormal;
       } else {
           float theta = Vector3.Angle(lastTangent, tangent); // equivalent to Mathf.Acos(Vector3.Dot(lastTangent,tangent))
           binormal = Quaternion.AngleAxis(theta,binormal.normalized)*lastBinormal;
       }
       lastTangent = tangent;
       lastBinormal = binormal;
       binormalLUT.Add(binormal);
   }

   // Undo any twist.
   float overallAngle = Vector3.Angle(binormalLUT[0], binormalLUT[resolution-1]);
   for(int i=0;i<resolution;i++) {
       float t = (float)i/(float)resolution;
       binormalLUT[i] = Quaternion.AngleAxis(-overallAngle*t, GetVelocityFromT(t).normalized)*binormalLUT[i];
   }
}

This lookup is generated everytime we change the curve, and as an added benefit we can untwist the curve as a post-process!

Ta-da! Now we have a stable spline-space. New problem though, notice that each frame isn't evenly spaced? The curve has a sort of velocity that causes points to scrunch up and spread out randomly, this will make awkward to predict deformations.

Catmull-Rom distance distortion

Splines don't have a closed-form solution for arc-length. They also don't have an closed-form solution to fix these distance distortions. We don't have a closed-form solution, but luckily we have an easy one. We can simply use lookup tables and brute-force again! [1]

protected void GenerateDistanceLUT(int resolution) {
   float dist = 0f;
   Vector3 lastPosition = GetPositionFromT(0f);
   distanceLUT.Clear(); // A reusable List<float>, as we regenerate this very often.
   for(int i=0;i<resolution;i++) {
       float t = (((float)i)/(float)resolution);
       Vector3 position = GetPositionFromT(t);
       dist += Vector3.Distance(lastPosition, position);
       lastPosition = position;
       distanceLUT.Add(dist);
   }
   arcLength = dist;
}

This code generates a lookup table of distances. With it, we can convert any world distance into "t-space", or a t value that we should sample to get a point along the spline at a specific arc-length.

public float GetTimeFromDistance(float distance) {
   if (distance > 0f && distance < arcLength) {
       for(int i=0;i<distanceLUT.Count-1;i++) {
           if (distance>distanceLUT[i] && distance<distanceLUT[i+1]) {
               return Remap(distance,
                           distanceLUT[i], distanceLUT[i+1],
                           (float)i/(float)(distanceLUT.Count), (float)(i+1)/(float)(distanceLUT.Count));
           }
       }
   }
   return distance/arcLength;
}

Here's my version of Freya Holmér's lookup, only difference being a small bugfix. With this we can fix the distortion along our curve.

Woo! Now we finally have an undistorted, and stable, spline space! Time to transform points along it.

GPU Powered space transformations

Armed with our curve, which consists of two look up tables and a set of weights, we can construct Change of Basis matrices at any point along the curve. [2] [3]

We need to multiply each vertex of the penetrator by a unique CoB matrix, but unfortunately the CPU is not very good at generating thousands of matrices and multiplying points.

Enter the GPU!

We can pack up the spline weights and both lookup tables into a Unity Command Buffer, and use the exact same code we used for the splines on the CPU to generate the Change of Basis matrix for each vertex.

Woo! The penetrator easily maps itself to the spline, but it currently doesn't respect the original rotation of the penetrator.

That's an easy fix by introducing an extra rotation depending on the angle between the spline's up, and the penetrator's up.

float2 worldDickUpPlane = float2(dot(worldDickUp,initialRight), dot(worldDickUp,initialUp));
float angleCorrection = atan2(worldDickUpPlane.y, worldDickUpPlane.x)-1.57079632679;

Now the penetrator can be at any angle, and cleanly map itself to the spline. While retaining its size and not being distorted!

Girth Analysis

The penetrable side of things requires that we know information about the girth of the penetrator. I tried a few strategies like analyzing vertex cross-sections like so

This was a very approximate girth analysis, it was slow, and it also had an issue where if verts described a very long cylinder, it could miss the verts entirely and give a 0 for girth...

My next thought was to use the actual triangles to do some raycasting in a ring along the penetrator. This would also be incredibly slow, inaccurate, and prone to problems.

Then I realized this is just a horrible version of rasterization, something that our GPUs do constantly.

I wrote a shader that cylindrically unwraps the penetrator directly onto the screen with the color set to the distance of the vertex from the center axis, with a blend operation of Max-- [4]

It gives a pretty good girth map! Using a command buffer and a raw vert/frag comes out with this:

In order to fill in the black sections (where polygons are strewn across the middle randomly), I simply render it twice at two angle offsets, essentially rolling it across the screen without clearing it. The Max blend mode makes sure we're getting the "thickest" data!

With this girthmap, we can easily transfer the Render Texture to the CPU and sample it for information.

Armed with these curves, and the knowledge of the exact arc-length distance of the root of the penetrator... we can easily sample how much say, a blendshape should trigger!

Procedural penetrable deformation

On the GPU we have the spline, and a girth map that describes the exact volume the penetrable takes up. If we were to bake some "nearest points" to the curve on the mesh, we can quickly sample the girthmap and figure out the deformations we had to make.

It works pretty well! As a bonus, we can even sample the mipmaps of the texture to "smooth" out the shape.

The scale kind of suffers from the fact that this ends up being a "shadow" of the penetrator.

I'm not sure how to fix that though, it looks fine if it's used as a subtle effect anyway.

Knot forces

By getting a piecewise derivative of the girth curve, we can figure out the kinds of forces it would apply on the penetrator and the penetrable.

In our implementation, we added a procedural squash and stretch for the penetrator, and adjusted it based on the knot forces.

The penetrator only deforms on the outside in order to simplify calculations, and has limited volume preservation. Despite that, it feels so visceral!

Fake Physics

So our penetrators now have one of the most sophisticated skinning systems possible, we can now generate a curve to represent the exact shape of the penetrator at any moment. So while it's not penetrating things, it'll be jiggling!

As a bonus, we can reintroduce natural curvature that would exist in normal penetrators.

Animation authoring

Something important to us was the ability to see the penetrations happen while authoring animations, it took just a little bit of effort, but the penetrators work entirely within the editor!

Conclusion

I think this is a very fast, and very convenient solution to the problem. It amounts to essentially algorithmic brute-force, but at the end its very author-able and extensible. There's probably plenty of other features that I haven't even thought of yet, and I'm hoping this lays out the groundwork for some pretty incredible content.

You can find working code, installable Unity Package, and instructions on usage here: https://github.com/naelstrof/UnityPenetrationTech

Please let me know if you made use of this article, or the tech! I'd love to see what you make with it.


More Creators