-
Notifications
You must be signed in to change notification settings - Fork 6
3D Model System
The 3D controller visualization uses HelixToolkit.WPF to render interactive Xbox 360 and DualShock 4 controller models from Wavefront OBJ meshes. Adapted from Handheld Companion (CC BY-NC-SA 4.0).
Namespace: PadForge.Models3D (model classes), PadForge.Views (view)
ControllerModelBase (abstract)
|
+-- ControllerModelXbox360 (Xbox 360 meshes, colors, rotation points)
+-- ControllerModelDS4 (DualShock 4 meshes, colors, rotation points)
ControllerModelView (UserControl)
|
+-- HelixViewport3D (3D rendering viewport)
+-- ModelVisual3D (hosts the model3DGroup scene graph)
+-- CompositionTarget.Rendering (per-frame visual updates)
The model classes own the geometry and materials. The view class owns the viewport, input handling, and animation logic. They are connected via ControllerModelView.EnsureModel() which instantiates the correct model class and assigns it to ModelVisual3D.Content.
File: PadForge.App/Models3D/ControllerModelBase.cs
Abstract base class for 3D controller models. Each concrete subclass represents a controller type with its own meshes, colors, and rotation points.
public abstract class ControllerModelBase : IDisposable| Field | Type | Description |
|---|---|---|
ButtonMap |
Dictionary<string, List<Model3DGroup>> |
PadSetting property name to list of Model3DGroups for highlighting. Multiple groups per button support button + overlay meshes highlighting together. |
ClickMap |
Dictionary<Model3DGroup, string> |
Model3DGroup to PadSetting name for hit-test click-to-record. Reverse lookup of ButtonMap. |
DefaultMaterials |
Dictionary<Model3DGroup, Material> |
Original material per model group. Restored after highlight/flash. |
HighlightMaterials |
Dictionary<Model3DGroup, Material> |
Accent-colored material per model group. Applied when button is pressed or flash is active. |
| Field | Type | Description |
|---|---|---|
model3DGroup |
Model3DGroup |
Root scene group. All child meshes added here. Assigned to ModelVisual3D.Content. |
ModelName |
string |
Model identifier: "XBOX360" or "DS4". Used for embedded resource path resolution. |
| Field | Loaded From | Description |
|---|---|---|
MainBody |
MainBody.obj |
Main controller body mesh |
LeftThumb |
LeftStickClick.obj |
Left stick click mesh |
LeftThumbRing |
Joystick-Left-Ring.obj |
Left stick ring mesh (torus) |
RightThumb |
RightStickClick.obj |
Right stick click mesh |
RightThumbRing |
Joystick-Right-Ring.obj |
Right stick ring mesh (torus) |
LeftShoulderTrigger |
Shoulder-Left-Trigger.obj |
Left shoulder trigger mesh |
RightShoulderTrigger |
Shoulder-Right-Trigger.obj |
Right shoulder trigger mesh |
LeftMotor |
MotorLeft.obj |
Left rumble motor mesh |
RightMotor |
MotorRight.obj |
Right rumble motor mesh |
| Field | Type | Description |
|---|---|---|
JoystickRotationPointCenterLeftMillimeter |
Vector3D |
Pivot point for left stick tilt animation |
JoystickRotationPointCenterRightMillimeter |
Vector3D |
Pivot point for right stick tilt animation |
JoystickMaxAngleDeg |
float |
Maximum stick tilt angle in degrees |
ShoulderTriggerRotationPointCenterLeftMillimeter |
Vector3D |
Pivot point for left trigger rotation |
ShoulderTriggerRotationPointCenterRightMillimeter |
Vector3D |
Pivot point for right trigger rotation |
TriggerMaxAngleDeg |
float |
Maximum trigger depression angle in degrees |
UpwardVisibilityRotationAxisLeft/Right |
Vector3D |
Rotation axis for shoulder visibility correction |
UpwardVisibilityRotationPointLeft/Right |
Vector3D |
Rotation origin for shoulder visibility correction |
Static dictionary mapping Handheld Companion .obj filenames to PadSetting property names. HC uses ButtonFlags enum names as filenames; PadForge uses PadSetting property names for the recording system.
protected static readonly Dictionary<string, string> ButtonFileMap = new()
{
{ "B1.obj", "ButtonA" },
{ "B2.obj", "ButtonB" },
{ "B3.obj", "ButtonX" },
{ "B4.obj", "ButtonY" },
{ "L1.obj", "LeftShoulder" },
{ "R1.obj", "RightShoulder" },
{ "Back.obj", "ButtonBack" },
{ "Start.obj", "ButtonStart" },
{ "Special.obj", "ButtonGuide" },
{ "DPadUp.obj", "DPadUp" },
{ "DPadDown.obj", "DPadDown" },
{ "DPadLeft.obj", "DPadLeft" },
{ "DPadRight.obj", "DPadRight" },
{ "LeftStickClick.obj", "LeftThumbButton" },
{ "RightStickClick.obj", "RightThumbButton" },
};protected ControllerModelBase(string modelName)- Sets
ModelName. - Loads common geometry via
LoadModel(): MainBody, stick rings, motors, triggers. - Registers trigger ClickMap entries:
LeftShoulderTrigger->"LeftTrigger",RightShoulderTrigger->"RightTrigger". - Iterates
ButtonFileMap, loading each OBJ viaTryLoadModel(). CallsRegisterButton()for found meshes. SetsLeftThumb/RightThumbreferences for stick click meshes. - Adds all parts to
model3DGroup.Children.
Note: Stick rings are NOT in ClickMap. The view handles stick ring clicks via IsStickRingHit() with quadrant-based axis detection.
protected void RegisterButton(string padSettingName, Model3DGroup group)Adds group to ButtonMap[padSettingName] (creates list if needed) and adds ClickMap[group] = padSettingName. This bidirectional mapping enables both highlighting (ButtonMap: name -> groups) and click detection (ClickMap: group -> name).
protected virtual void DrawAccentHighlights()Creates accent-colored DiffuseMaterial for all children using the app's AccentButtonBackground resource from ModernWpfUI. Falls back to #2196F3 blue if the resource is unavailable. Called at the end of each subclass constructor.
protected Model3DGroup LoadModel(string filename) // Throws FileNotFoundException
protected Model3DGroup TryLoadModel(string filename) // Returns null on failureLoads .obj meshes from embedded resources via HelixToolkit's ObjReader. Searches the assembly's manifest resource names by suffix (.{ModelName}.{filename}) to handle MSBuild digit-prefix mangling.
MSBuild mangling: The 3DModels folder becomes _3DModels in resource names because MSBuild prefixes folder names starting with a digit. Suffix matching avoids needing to know the exact prefix.
string suffix = $".{ModelName}.{filename}";
foreach (var name in assembly.GetManifestResourceNames())
if (name.EndsWith(suffix, StringComparison.OrdinalIgnoreCase))
// Found itpublic void Dispose()
protected virtual void Dispose(bool disposing)
~ControllerModelBase()Clears all dictionaries and model3DGroup.Children. Implements standard dispose pattern with destructor finalizer. Called by ControllerModelView.EnsureModel() when switching between model types.
File: PadForge.App/Models3D/ControllerModelXbox360.cs
public class ControllerModelXbox360 : ControllerModelBaseCalls base("XBOX360").
| Field | Loaded From | Description |
|---|---|---|
MainBodyCharger |
MainBody-Charger.obj |
Battery pack/charger compartment |
SpecialRing |
SpecialRing.obj |
Guide button ring |
SpecialLED |
SpecialLED.obj |
Guide button LED indicator |
LeftShoulderBottom |
LeftShoulderBottom.obj |
Left bumper bottom piece |
RightShoulderBottom |
RightShoulderBottom.obj |
Right bumper bottom piece |
B1Button |
B1Button.obj |
A button colored overlay |
B2Button |
B2Button.obj |
B button colored overlay |
B3Button |
B3Button.obj |
X button colored overlay |
B4Button |
B4Button.obj |
Y button colored overlay |
| Name | Hex | Usage |
|---|---|---|
ColorPlasticBlack |
#707477 |
Default for most parts |
ColorPlasticWhite |
#D4D4D4 |
Main body, motors, shoulder bottoms |
ColorPlasticSilver |
#CEDAE1 |
Guide button |
ColorPlasticGreen |
#7cb63b |
A button |
ColorPlasticRed |
#ff5f4b |
B button |
ColorPlasticBlue |
#6ac4f6 |
X button |
ColorPlasticYellow |
#faa51f |
Y button |
Face button overlays (B1Button-B4Button) use transparent variants with Alpha = 150 to allow the base button color to show through while providing a colored tint.
| Parameter | Value |
|---|---|
JoystickRotationPointCenterLeftMillimeter |
(-42.231, -6.10, 21.436) |
JoystickRotationPointCenterRightMillimeter |
(21.013, -6.1, -3.559) |
JoystickMaxAngleDeg |
19.0 |
ShoulderTriggerRotationPointCenterLeftMillimeter |
(-44.668, 3.087, 39.705) |
ShoulderTriggerRotationPointCenterRightMillimeter |
(44.668, 3.087, 39.705) |
TriggerMaxAngleDeg |
16.0 |
- Face button overlays (
B1Button-B4Button) get transparent color materials and are registered intoButtonMapalongside the base button meshes so they highlight together. -
SpecialLEDgets green transparent material. - Base face buttons (
B1.obj-B4.obj) get opaque color materials. - Guide button gets silver material.
- White parts:
MainBody,LeftMotor,RightMotor,LeftShoulderBottom,RightShoulderBottom. - All remaining parts default to black.
-
DrawAccentHighlights()called last to generate highlight materials from accent brush.
File: PadForge.App/Models3D/ControllerModelDS4.cs
public class ControllerModelDS4 : ControllerModelBaseCalls base("DS4").
| Field | Loaded From | Description |
|---|---|---|
LeftShoulderMiddle |
Shoulder-Left-Middle.obj |
Left shoulder middle piece |
RightShoulderMiddle |
Shoulder-Right-Middle.obj |
Right shoulder middle piece |
Screen |
Screen.obj |
Touchpad/screen area |
MainBodyBack |
MainBodyBack.obj |
Back panel |
AuxPort |
Aux-Port.obj |
Auxiliary port |
Triangle |
Triangle.obj |
Decorative triangle element |
DPadDownArrow |
DPadDownArrow.obj |
D-pad down arrow indicator |
DPadUpArrow |
DPadUpArrow.obj |
D-pad up arrow indicator |
DPadLeftArrow |
DPadLeftArrow.obj |
D-pad left arrow indicator |
DPadRightArrow |
DPadRightArrow.obj |
D-pad right arrow indicator |
| Name | Hex | Usage |
|---|---|---|
ColorPlasticBlack |
#38383A |
Default body color |
ColorPlasticWhite |
#E0E0E0 |
Main body, motors, triangle |
MaterialPlasticTriangle |
#66a0a4 |
Triangle face button symbol |
MaterialPlasticCross |
#96b2d9 |
Cross (X) face button symbol |
MaterialPlasticCircle |
#d66673 |
Circle face button symbol |
MaterialPlasticSquare |
#d7bee5 |
Square face button symbol |
DS4 loads separate symbol meshes (B1-Symbol.obj, B2-Symbol.obj, B3-Symbol.obj, B4-Symbol.obj) via TryLoadModel(). Each symbol is added to the same ButtonMap entry as the base button mesh so they highlight together. Symbol meshes get their PlayStation-specific colored materials; base button meshes default to black.
| Parameter | Value |
|---|---|
JoystickRotationPointCenterLeftMillimeter |
(-25.5, -5.086, -21.582) |
JoystickRotationPointCenterRightMillimeter |
(25.5, -5.086, -21.582) |
JoystickMaxAngleDeg |
19.0 |
ShoulderTriggerRotationPointCenterLeftMillimeter |
(-38.061, 3.09, 26.842) |
ShoulderTriggerRotationPointCenterRightMillimeter |
(38.061, 3.09, 26.842) |
TriggerMaxAngleDeg |
16.0 |
PadForge.App/3DModels/
XBOX360/ (31 meshes)
MainBody.obj
MainBody-Charger.obj
Joystick-Left-Ring.obj
Joystick-Right-Ring.obj
MotorLeft.obj
MotorRight.obj
Shoulder-Left-Trigger.obj
Shoulder-Right-Trigger.obj
SpecialRing.obj
SpecialLED.obj
LeftShoulderBottom.obj
RightShoulderBottom.obj
B1.obj, B2.obj, B3.obj, B4.obj (base face buttons)
B1Button.obj, B2Button.obj, ... (colored overlays)
L1.obj, R1.obj (shoulder buttons)
Back.obj, Start.obj, Special.obj
DPadUp.obj, DPadDown.obj, DPadLeft.obj, DPadRight.obj
LeftStickClick.obj, RightStickClick.obj
DS4/ (36 meshes)
MainBody.obj
MainBodyBack.obj
Shoulder-Left-Middle.obj, Shoulder-Right-Middle.obj
Screen.obj, Aux-Port.obj, Triangle.obj
DPadDownArrow.obj, DPadUpArrow.obj, DPadLeftArrow.obj, DPadRightArrow.obj
B1-Symbol.obj, B2-Symbol.obj, B3-Symbol.obj, B4-Symbol.obj
(... plus common files from base class)
All OBJ files are embedded as EmbeddedResource in the project file:
<EmbeddedResource Include="3DModels\**\*.obj" />File: PadForge.App/Views/ControllerModelView.xaml, ControllerModelView.xaml.cs
WPF UserControl hosting a HelixViewport3D for 3D controller visualization. ~1200 lines of code-behind.
<helix:HelixViewport3D
IsRotationEnabled="False" IsPanEnabled="False"
IsMoveEnabled="False" IsZoomEnabled="False"
ShowViewCube="False" Background="Transparent"
IsManipulationEnabled="False">
<helix:SunLight />
<helix:DirectionalHeadLight Brightness="0.35" />
<ModelVisual3D x:Name="ModelVisual3D" />
<PerspectiveCamera FieldOfView="50"
LookDirection="0,0.793,-0.609"
Position="0,-159,122" UpDirection="0,0,1" />
</helix:HelixViewport3D>All built-in HelixToolkit camera controls are disabled, including zoom. All interaction (rotation, zoom, pan) is handled by custom event handlers to avoid conflicts between HelixToolkit's CameraController and PadForge's click-to-map and touch gesture handling.
public event EventHandler<string> ControllerElementRecordRequested;Fired when the user clicks a mappable 3D element. The string argument is the PadSetting target name (e.g., "ButtonA", "LeftThumbAxisXNeg").
| Field | Type | Description |
|---|---|---|
_vm |
PadViewModel |
Bound ViewModel |
_currentModel |
ControllerModelBase |
Active 3D model instance |
_dirty |
bool |
Render-frame update flag |
_triggerAngleLeft/Right |
float |
Current trigger rotation angles (for change detection) |
_flashTimer |
DispatcherTimer |
Map All flash animation timer (400ms) |
_flashTarget |
string |
PadSetting name being flashed |
_flashOn |
bool |
Current flash toggle state |
_arrowVisual |
ModelVisual3D |
Directional arrow overlay for axis recording |
_quadrantRingVisual |
ModelVisual3D |
Stick ring quadrant highlight overlay |
_quadrantRingMaterial |
DiffuseMaterial |
Material for quadrant ring (alpha toggled for flash) |
_hoverGroup |
Model3DGroup |
Currently hovered button/trigger group |
_hoverStickRing |
Model3DGroup |
Currently hovered stick ring |
_hoverQuadrant |
string |
Current hover quadrant axis string |
_hoverQuadrantVisual |
ModelVisual3D |
Quadrant wedge overlay for hover |
_isLeftDragging |
bool |
Left-mouse drag active flag (rotation) |
_leftDragStart |
Point |
Left-button down position (for drag threshold detection) |
_isRightDragging |
bool |
Right-mouse drag active flag (panning) |
_rightDragLast |
Point |
Last mouse position during drag |
_modelYaw |
double |
Accumulated yaw rotation (degrees, around Z axis) |
_modelPitch |
double |
Accumulated pitch rotation (degrees, around X axis, clamped -60..60) |
_touchDragId |
int? |
Active touch device ID for rotation |
_modelRotation |
Transform3DGroup |
Persistent rotation transform on ModelVisual3D |
_yawRotation |
AxisAngleRotation3D |
Yaw component: axis (0,0,1) |
_pitchRotation |
AxisAngleRotation3D |
Pitch component: axis (1,0,0) |
public void Bind(PadViewModel vm)
public void Unbind()Bind subscribes to PropertyChanged, hooks CompositionTarget.Rendering, and calls EnsureModel(). Property changes to OutputType trigger EnsureModel() to switch models. Changes to CurrentRecordingTarget trigger flash animation and arrow overlay updates. All other property changes set the _dirty flag.
private void EnsureModel()Determines the needed model from OutputType and VJoyConfig.Preset:
-
DualShock4->"DS4" -
VJoywithPreset == DualShock4->"DS4" - All others ->
"XBOX360"
If the current model matches, returns immediately. Otherwise disposes the old model and creates a new ControllerModelXbox360 or ControllerModelDS4, assigning to ModelVisual3D.Content.
CompositionTarget.Rendering handler (~60fps), gated by _dirty flag:
OnRendering()
|
+-> _dirty check (skip if clean)
|
+-> HighlightButtons() -- swap materials for 15 buttons
+-> UpdateJoystick() x 2 -- tilt left/right stick meshes
+-> UpdateTrigger() x 2 -- rotate left/right trigger meshes
Iterates ButtonProperties array (15 button names), reads corresponding PadViewModel bool via GetButtonState(), and swaps between DefaultMaterials and HighlightMaterials:
private static readonly string[] ButtonProperties =
{
"ButtonA", "ButtonB", "ButtonX", "ButtonY",
"LeftShoulder", "RightShoulder",
"ButtonBack", "ButtonStart", "ButtonGuide",
"DPadUp", "DPadDown", "DPadLeft", "DPadRight",
"LeftThumbButton", "RightThumbButton"
};For each button, iterates all Model3DGroup entries in ButtonMap (supports multi-mesh buttons like Xbox face button + overlay). Only modifies GeometryModel3D children with DiffuseMaterial.
private void UpdateJoystick(
short rawX, short rawY,
Model3DGroup thumbRing, Model3D thumb,
Vector3D rotationPoint, float maxAngleDeg)- Normalizes raw values (
short.MaxValue) to -1..1 range. -
Gradient highlight: If stick is deflected, blends between default and highlight materials based on deflection magnitude via
GradientHighlight(). -
Rotation: Creates
AxisAngleRotation3Dtransforms for X (around Z axis) and Y (around X axis), centered atrotationPoint. Both ring and thumb meshes get the sameTransform3DGroup.
private void UpdateTrigger(
double triggerNorm,
Model3DGroup triggerModel,
Vector3D rotationPoint,
float maxAngleDeg,
ref float prevAngle)- Gradient color: Blends between default and highlight materials based on trigger value (0-1).
-
Rotation: Applies
AxisAngleRotation3Daround X axis, centered atrotationPoint. Max angle:-maxAngleDeg * value. - Change detection: Skips rotation update if angle delta < 0.01 degrees.
private static DiffuseMaterial GradientHighlight(Material default, Material highlight, float factor)ARGB linear interpolation between default and highlight material colors. Creates a new DiffuseMaterial per call (no caching — called at render rate only when values change).
Turntable-style rotation (left-click drag) and camera panning (right-click drag) implemented via Preview events (fires before HelixToolkit's built-in camera controls):
| Event | Handler | Action |
|---|---|---|
PreviewMouseLeftButtonDown |
Viewport_PreviewMouseLeftButtonDown |
Records start position, captures mouse for rotation drag |
PreviewMouseLeftButtonUp |
Viewport_PreviewMouseLeftButtonUp |
If drag < 5px threshold → hit-test for click-to-record; otherwise end drag |
PreviewMouseRightButtonDown |
Viewport_MouseRightButtonDown |
Captures mouse, stores starting position for panning |
PreviewMouseRightButtonUp |
Viewport_MouseRightButtonUp |
Releases capture |
PreviewMouseMove |
Viewport_PreviewMouseMove |
Left-drag: rotation; right-drag: camera panning; no button: hover highlighting |
PreviewMouseWheel |
Viewport_PreviewMouseWheel |
Zooms camera along look direction |
PreviewTouchDown |
Viewport_PreviewTouchDown |
Captures first touch for rotation |
PreviewTouchMove |
Viewport_PreviewTouchMove |
Touch-based rotation |
PreviewTouchUp |
Viewport_PreviewTouchUp |
Releases touch |
Rotation is applied to a persistent Transform3DGroup on ModelVisual3D.Transform (not on the camera), containing two RotateTransform3D children:
- Yaw: axis
(0,0,1), angle =_modelYaw - Pitch: axis
(1,0,0), angle =_modelPitch(clamped to -60..+60 degrees)
Sensitivity: 0.5 degrees per pixel of mouse/touch movement. "Reset View" button sets both to 0.
Why Preview events? HelixToolkit's HelixViewport3D has built-in camera manipulation that consumes mouse events. Using Preview (tunneling) events lets PadForge intercept before HelixToolkit, marking them as e.Handled = true to prevent the toolkit from also processing them.
private void Viewport_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)- Calls
Viewport3DHelper.FindHits()on the 3D viewport at the click position. - For each hit
GeometryModel3D:-
Stick ring check:
IsStickRingHit()checks if the geometry belongs toLeftThumbRingorRightThumbRing. If yes, delegates toDetermineAxisFromQuadrant(). -
ClickMap check: Walks
ClickMapentries to find whichModel3DGroupcontains the hit geometry.
-
Stick ring check:
- Fires
ControllerElementRecordRequestedwith the PadSetting target name.
private bool IsStickRingHit(GeometryModel3D hitGeo, Point3D hitPos, out string axis)Checks if the hit geometry belongs to a stick ring, then calls:
private static string DetermineAxisFromQuadrant(
Point3D hitPos, Vector3D center, string xAxis, string yAxis)Uses the 3D hit position relative to the joystick rotation center:
-
Dominant X axis (
|deltaX| > |deltaZ|): ReturnsxAxisorxAxis + "Neg"based on deltaX sign. -
Dominant Z axis: Returns
yAxisoryAxis + "Neg"based on deltaZ sign. -
Y-axis inversion: Model Z-up corresponds to stick up.
deltaZ >= 0(up in model) maps toyAxis + "Neg"because Step 3's NegateAxis inverts the Y output. This way, pushing the stick up in-game maps to the positive direction.
Viewport_MouseMove performs hit-testing at the cursor position on every mouse move:
-
Buttons/triggers:
ApplyHoverHighlight()applies the highlight material.RestoreHoverGroup()restores the default material (skipping if the group is currently being flash-animated). -
Stick rings:
ShowHoverQuadrant()creates a semi-transparent quadrant wedge overlay from the ring's actual mesh triangles, clipped to the appropriate quadrant. -
ClearHover()removes all hover state and resets the cursor. -
Viewport_MouseLeavealso clears hover state (and releases any dangling right-drag).
private void UpdateFlashTarget(string target)Started when CurrentRecordingTarget changes. A DispatcherTimer at 400ms toggles between highlight and default materials:
-
Buttons/triggers: Swaps materials via
ResolveFlashGroups(). -
Stick axes: Shows
ShowQuadrantRingOverlay()for the target quadrant andShowArrowForTarget()for directional guidance.FlashQuadrantRing()toggles the overlay's alpha between 200 and 0. - Flash stops when
CurrentRecordingTargetbecomes null.
private void ShowArrowForTarget(string target)
private void RemoveArrow()Creates a 3D arrow (ModelVisual3D) using CreateFlatArrow():
- Arrow is a flat box (shaft) + triangular prism (head).
- Positioned at the stick center, offset forward (Y = center.Y - 25) for visibility.
- Direction determined by target:
LeftThumbAxisX= right arrow,LeftThumbAxisXNeg= left arrow, etc. - Uses the app's accent color.
private void ShowQuadrantRingOverlay(string target)
private MeshGeometry3D BuildClippedQuadrantMesh(
Model3DGroup ring, Vector3D center, bool isX, bool isNeg)Builds a highlight overlay from the stick ring's actual mesh triangles:
- Defines two half-planes at +/-45 degrees to isolate one quadrant.
-
Sutherland-Hodgman clipping: Clips each source triangle against both half-planes using
ClipPolygonByHalfPlane(). -
Torus-outward offset:
OffsetTorusOutward()pushes clipped vertices 0.4mm outward along the tube's radial direction to prevent z-fighting. Computes the nearest point on the torus center circle and offsets along the tube normal. - Triangulates clipped polygons as fans.
- Dirty flag batching: Multiple ViewModel property changes (15 button states + 4 axes + 2 triggers per frame) are coalesced into a single render-frame update. This prevents redundant material swaps.
-
Change detection on triggers:
UpdateTrigger()skips rotation update if angle delta < 0.01 degrees. -
No material caching in GradientHighlight: Creates a new
DiffuseMaterialper call. Acceptable because stick/trigger gradient updates only happen when values actually change (dirty flag), and WPF3D material objects are lightweight. -
Embedded resources loaded once:
ControllerModelBaseloads all OBJ meshes in the constructor. Models are cached byEnsureModel()— only disposed and recreated when switching between Xbox 360 and DS4. - Preview events for rotation: Using tunneling events prevents double-processing by HelixToolkit and PadForge.