Skip to content

Commit 6d1f234

Browse files
committed
a bunch of fixes
1 parent 3f09eb9 commit 6d1f234

39 files changed

Lines changed: 13366 additions & 163 deletions

Basis Server/BasisNetworkCore/BasisNetworkVersion.cs

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,24 @@ public class BasisNetworkVersion
7878
// derived from the channel (worth 1.6pp on keyframes, zero on deltas, and it would make
7979
// the decoder depend on reproducing the serializer's exact byte geometry).
8080
// Wire-incompatible in both directions: a v49 peer reads the [n] byte as a length.
81-
public static ushort ServerVersion = 50;
81+
// 51: three of the four bone slots that quantized their smallest-three components into a
82+
// range narrower than InvSqrt2 — UpperChest (0.50), Shoulders (0.50), Feet (0.60) —
83+
// go to the full range. A narrowed range is a hard clamp at 2*asin(range), and clamping
84+
// a component does not shorten the rotation, it changes it: the decoder rebuilds the
85+
// dropped component from the three that survived. Measured with both ends in perfect
86+
// agreement and no loss anywhere, a 0.50 slot is 20° wrong at 80° and 30° wrong at 90°,
87+
// and a 0.60 foot is 16° wrong at 90°. Those are the only slots in the rig that were
88+
// narrowed and the only ones that could be visibly wrong, which is why the symptom read
89+
// as "feet point the wrong way" rather than as general pose noise.
90+
// Same field layout, same widths, same packet size — only the SCALE of three components
91+
// per affected slot changes, so a v50 peer decodes those bones ~18% short and this is
92+
// wire-incompatible in both directions.
93+
// Precision given up at High: 0.06° -> 0.06° on the 12-bit slots, i.e. nothing; a win or
94+
// a wash at every lower tier too. Toes are deliberately NOT widened — at 5/3/3/2 bits
95+
// their error is quantization-dominated, so stretching the same codes over 1.4x the
96+
// range costs more than the clamp does below High. They still carry a 30° cliff and want
97+
// a per-quality range table rather than this one shared one.
98+
// See the MAX_COMPONENT note in BasisBoneRotationCompression for the full table.
99+
public static ushort ServerVersion = 51;
82100
}
83101
}

Basis Server/BasisNetworkCore/Compression/BasisBoneRotationCompression.cs

Lines changed: 51 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -172,13 +172,40 @@ static BasisBoneRotationCompression()
172172
/// Values derived from max anatomical rotation, computing sin(maxAngle/2)
173173
/// for the largest possible remaining component, plus safety margin.
174174
/// Full InvSqrt2 used for any joint that can approach or exceed 90° from T-pose.
175+
///
176+
/// <para><b>A range narrower than InvSqrt2 is a CLIFF, not a graceful loss of precision.</b>
177+
/// While the rotation angle stays under 90° the dropped component is w, so the three that
178+
/// survive are the vector part and every one of them is bounded by sin(angle/2). A range of
179+
/// r therefore encodes exactly up to 2*asin(r) and CLAMPS above it — and clamping a component
180+
/// does not shorten the rotation, it changes it, because the decoder rebuilds the dropped
181+
/// component as sqrt(1 - a² - b² - c²) from whatever survived. Measured worst case with
182+
/// sender and receiver in perfect agreement, no packet loss and no interpolation:
183+
///
184+
/// <code>
185+
/// range 0.50 (clamps at 60°): 10° error at 70°, 20° at 80°, 30° at 90°
186+
/// range 0.60 (clamps at 74°): 6° error at 80°, 16° at 90°
187+
/// </code>
188+
///
189+
/// Past ~100° the error falls away again as a vector component overtakes w and gets dropped
190+
/// instead, so the damage is a band just above the clamp — which is exactly where an ankle
191+
/// sits when the toes are pointed, and where a clavicle sits on a shrug.</para>
192+
///
193+
/// <para>Feet, shoulders, upper chest and toes were the only slots in the rig carrying a
194+
/// narrowed range, and they were the only slots that could be visibly wrong on the wire. The
195+
/// anatomical ROM figures they were derived from are sound; what they missed is that the
196+
/// encoded quantity is the delta from the AVATAR'S OWN rest pose, which does not sit at
197+
/// anatomical neutral on every rig, so the real budget needed is the ROM plus whatever the
198+
/// bind pose already spent. There is no headroom to be had at 0.50. Feet, shoulders and
199+
/// upper chest are InvSqrt2 now, which costs 0.06° -> 0.06° at High (i.e. nothing) and is a
200+
/// win or a wash at every lower tier. Toes stay narrow — see the note on that entry.</para>
175201
/// </summary>
176202
public static readonly float[] MAX_COMPONENT = new float[]
177203
{
178204
// 3-DOF body (9): Spine, Chest, UpperChest, Neck, Head, UpperArms, UpperLegs
179205
InvSqrt2, // Spine full (deep backbend/fold can exceed 90° combined)
180206
InvSqrt2, // Chest full
181-
0.50f, // UpperChest thoracic limit ~58° → 1.41x
207+
InvSqrt2, // UpperChest was 0.50 (~58° thoracic limit): clamped at 60°,
208+
// 30° wrong at 90°. Costs 0.00° at High to widen.
182209
InvSqrt2, // Neck full (extreme head tilt)
183210
InvSqrt2, // Head full
184211
InvSqrt2, InvSqrt2, // UpperArms full (shoulder has ~180° ROM)
@@ -189,12 +216,27 @@ static BasisBoneRotationCompression()
189216
InvSqrt2, InvSqrt2, // LowerLegs full (knee 150°)
190217

191218
// 2-DOF extremities (6): Shoulders, Hands, Feet
192-
0.50f, 0.50f, // Shoulders clavicle max ~58° (shrug+protract) → 1.41x
219+
InvSqrt2, InvSqrt2, // Shoulders was 0.50 (~58° clavicle): a shrug+protract lands
220+
// in the clamp band. 30° wrong at 90°.
193221
InvSqrt2, InvSqrt2, // Hands full (wrist can circle ~90°)
194-
0.60f, 0.60f, // Feet ankle max ~70° combined → 1.18x
222+
InvSqrt2, InvSqrt2, // Feet was 0.60 (~70° ankle): pointed toes, kneeling and
223+
// tiptoe all exceed it. 16° wrong at 90°.
195224

196225
// toes (2) — eyes/jaw excluded (driven by face system)
197-
0.50f, 0.50f, // Toes ~58° curl → 1.41x
226+
0.50f, 0.50f, // Toes KEPT NARROW, and it is a genuine trade rather
227+
// than an oversight. Toes get 5/3/3/2 bits, so
228+
// unlike every slot above them their error is
229+
// dominated by quantization, not by the clamp.
230+
// Widening to InvSqrt2 helps only at High
231+
// (worst 30.0° -> 7.4°) and hurts everywhere else
232+
// (Medium/Low 3 bits: 15.9° -> 32.9° in-band;
233+
// VeryLow 2 bits: 38.4° -> 97.7°), because
234+
// stretching the same 4 or 8 codes over 1.4x the
235+
// range costs more than the cliff it removes.
236+
// Fixing this properly means a per-QUALITY range
237+
// table (full range where bits are plentiful,
238+
// narrow where they are not), the same way
239+
// GetBpcTable already varies by quality.
198240

199241
// finger proximal (10): curl ~90° + spread ~25° → combined ~95°
200242
// At 95°: axis=0.74, w=0.68. After dropping axis, remaining max=0.68
@@ -338,16 +380,11 @@ public static int ConvertToSize(BasisAvatarBitPacking.BitQuality q)
338380
return BasisAvatarBitPacking.PositionBytes(q) + RotationBytes(q) + TailBytes + EndEffectorBytes(q);
339381
}
340382

341-
public static int ComputeBitOffsets(byte[] bpc, int[] outBitOffsets)
342-
{
343-
int pos = 0;
344-
for (int i = 0; i < bpc.Length; i++)
345-
{
346-
outBitOffsets[i] = pos;
347-
pos += 2 + 3 * bpc[i];
348-
}
349-
return pos;
350-
}
383+
// ComputeBitOffsets lived here: it laid out all 51 bone slots as 2 + 3*bpc apiece, which was
384+
// the wire format until v47 moved the thirty finger joints to ten curl/splay channels. It had
385+
// no callers left but its own test, and that test failed because it compared its total
386+
// against RotationBytes, which follows the real layout. BuildRotationFieldOffsets is the
387+
// version that models the wire as it is; use that.
351388

352389
// ────────────────────────────────────────────────────────────
353390
// Smallest-Three Encode / Decode (pure floats, no Unity types)

Basis Server/BasisServerTests/CorePrimitiveCompressionTests.cs

Lines changed: 90 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,71 @@ public void SmallestThree_OutOfRangeComponents_ClampToMaxRange()
178178
Assert.True(Math.Abs(norm - 1.0) < 1e-3);
179179
}
180180

181+
/// <summary>
182+
/// The clamp above is correct behaviour for an explicitly narrow range, but on the RIG it is a
183+
/// defect: a bone whose MAX_COMPONENT is narrower than the motion it actually sees does not lose
184+
/// precision gracefully, it reconstructs a different rotation, because the decoder rebuilds the
185+
/// dropped component from whatever survived the clamp. UpperChest, the shoulders and the feet
186+
/// each carried a narrowed range and each was 16-30° wrong in the band just above its clamp
187+
/// (v51). This pins the whole High rig against that returning: every slot must reproduce every
188+
/// rotation angle to within its own quantization step.
189+
///
190+
/// Toes are excluded deliberately and are the one slot still narrow — see the note on the
191+
/// MAX_COMPONENT entry for why widening them costs more than it buys below High.
192+
/// </summary>
193+
[Fact]
194+
public void SmallestThree_EveryHighSlot_TracksRotationAcrossItsFullAngleRange()
195+
{
196+
byte[] bpc = BasisBoneRotationCompression.GetBpcTable(BitQuality.High);
197+
float[] maxComp = BasisBoneRotationCompression.MAX_COMPONENT;
198+
int[] toeSlots = { 19, 20 };
199+
200+
// Fixed seed: the axis set must be dense enough to find a clamp but must not be tuned to it.
201+
var rng = new Random(20260809);
202+
203+
for (int slot = 0; slot < BasisBoneRotationCompression.WireBoneSlotCount; slot++)
204+
{
205+
if (Array.IndexOf(toeSlots, slot) >= 0) continue;
206+
207+
// A component step is 2*range/(2^bpc - 1); the angular error it produces is a small
208+
// multiple of that in radians. 12x leaves ~4x headroom over the measured worst case
209+
// (0.06° at High) while still being ~100x under the 16-30° a clamp produces.
210+
double step = 2.0 * maxComp[slot] / ((1 << bpc[slot]) - 1);
211+
double tolDeg = 12.0 * step * 180.0 / Math.PI;
212+
213+
for (int deg = 0; deg <= 180; deg += 5)
214+
{
215+
for (int i = 0; i < 400; i++)
216+
{
217+
// Uniform axis on the sphere — a clamp is a property of the component that ends
218+
// up largest, so the axis has to be swept, not just the angle.
219+
double z0 = 2 * rng.NextDouble() - 1;
220+
double r = Math.Sqrt(Math.Max(0, 1 - z0 * z0));
221+
double phi = 2 * Math.PI * rng.NextDouble();
222+
float ax = (float)(r * Math.Cos(phi)), ay = (float)(r * Math.Sin(phi)), az = (float)z0;
223+
224+
double half = deg * Math.PI / 180.0 * 0.5;
225+
float s = (float)Math.Sin(half);
226+
float qx = ax * s, qy = ay * s, qz = az * s, qw = (float)Math.Cos(half);
227+
228+
ulong packed = BasisBoneRotationCompression.EncodeSmallestThree(qx, qy, qz, qw, bpc[slot], maxComp[slot]);
229+
BasisBoneRotationCompression.DecodeSmallestThree(packed, bpc[slot],
230+
out float dx, out float dy, out float dz, out float dw, maxComp[slot]);
231+
232+
double dot = Math.Min(1.0, Math.Abs((double)qx * dx + (double)qy * dy + (double)qz * dz + (double)qw * dw));
233+
double errDeg = 2.0 * Math.Acos(dot) * 180.0 / Math.PI;
234+
235+
Assert.True(errDeg <= tolDeg,
236+
$"slot {slot} (bpc {bpc[slot]}, range {maxComp[slot]}) is {errDeg:F2}° off at " +
237+
$"{deg}° about ({ax:F3}, {ay:F3}, {az:F3}); tolerance {tolDeg:F2}°. A narrowed " +
238+
$"MAX_COMPONENT clamps at " +
239+
$"{2 * Math.Asin(Math.Min(1f, maxComp[slot])) * 180 / Math.PI:F1}° and reconstructs " +
240+
$"a DIFFERENT rotation above it, not a less precise one.");
241+
}
242+
}
243+
}
244+
}
245+
181246
// ────────────────────────────────────────────────────────────
182247
// Bitstream
183248
// ────────────────────────────────────────────────────────────
@@ -247,34 +312,41 @@ public void WriteBits_IsLsbFirst_AndLeavesNeighborsUntouched()
247312
// Bone tables and packet sizing
248313
// ────────────────────────────────────────────────────────────
249314

315+
/// <summary>
316+
/// Replaces ComputeBitOffsets_MatchesRotationBytes_ForAllQualities, which pinned a helper that
317+
/// laid out all 51 bone slots as 2 + 3*bpc — the wire format until v47 moved the thirty finger
318+
/// joints to ten curl/splay channels. The helper had no callers but that test and the assertion
319+
/// was simply false (Medium: 78 bytes claimed against the real 52). Both are gone; this pins the
320+
/// same invariant against BuildRotationFieldOffsets, which is what the channel map actually uses.
321+
/// </summary>
250322
[Fact]
251-
public void ComputeBitOffsets_MatchesRotationBytes_ForAllQualities()
323+
public void RotationFieldOffsets_AreContiguous_AndMatchRotationBytes_ForAllQualities()
252324
{
253325
foreach (var q in AllQualities)
254326
{
255-
byte[] bpc = BasisBoneRotationCompression.GetBpcTable(q);
256-
var offsets = new int[bpc.Length];
257-
int totalBits = BasisBoneRotationCompression.ComputeBitOffsets(bpc, offsets);
327+
int[] widths = BasisBoneRotationCompression.BuildRotationFieldWidths(q);
328+
Assert.Equal(BasisBoneRotationCompression.RotationFieldCount, widths.Length);
329+
330+
var offsets = new int[BasisBoneRotationCompression.RotationFieldCount];
331+
int totalBits = BasisBoneRotationCompression.BuildRotationFieldOffsets(q, offsets);
258332

259-
Assert.Equal(0, offsets[0]);
333+
// Offsets must tile the region exactly: no gaps, no overlap.
260334
int expected = 0;
261-
for (int i = 0; i < bpc.Length; i++)
335+
for (int i = 0; i < widths.Length; i++)
262336
{
263337
Assert.Equal(expected, offsets[i]);
264-
expected += 2 + 3 * bpc[i];
338+
expected += widths[i];
265339
}
266340
Assert.Equal(expected, totalBits);
267-
268-
// RotationBytes is NOT this total. ComputeBitOffsets walks all 51 legacy bone slots;
269-
// since v47 the wire carries 21 of them plus a 10-channel finger block, so the wire size
270-
// comes from the rotation FIELD widths instead. (Sizing anything off the 51-slot total
271-
// overruns every sub-High payload — it is larger than the payload itself.)
272-
var fieldWidths = BasisBoneRotationCompression.BuildRotationFieldWidths(q);
273-
int wireBits = 0;
274-
foreach (int w in fieldWidths) wireBits += w;
275-
Assert.Equal(wireBits, BasisBoneRotationCompression.RotationBits(q));
276-
Assert.Equal((wireBits + 7) >> 3, BasisBoneRotationCompression.RotationBytes(q));
277-
Assert.True(totalBits > wireBits, "legacy 51-slot total should exceed the v47 wire size");
341+
Assert.Equal(totalBits, BasisBoneRotationCompression.RotationBits(q));
342+
Assert.Equal((totalBits + 7) >> 3, BasisBoneRotationCompression.RotationBytes(q));
343+
344+
// The explicit bone slots come first, then one field per finger channel.
345+
for (int slot = 0; slot < BasisBoneRotationCompression.WireBoneSlotCount; slot++)
346+
Assert.Equal(2 + 3 * BasisBoneRotationCompression.GetBpcTable(q)[slot], widths[slot]);
347+
for (int f = 0; f < BasisBoneRotationCompression.FingerChannelCount; f++)
348+
Assert.Equal(BasisBoneRotationCompression.FingerFieldWidth(q),
349+
widths[BasisBoneRotationCompression.WireBoneSlotCount + f]);
278350
}
279351
}
280352

0 commit comments

Comments
 (0)