-
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)The model loading flow proceeds in a specific order because some steps depend on earlier ones:
-
Set ModelName — determines the embedded resource path prefix (
"XBOX360"or"DS4"). -
Load common geometry via
LoadModel(): MainBody, stick rings (Joystick-Left-Ring.obj,Joystick-Right-Ring.obj), motors (MotorLeft.obj,MotorRight.obj), triggers (Shoulder-Left-Trigger.obj,Shoulder-Right-Trigger.obj). -
Register trigger ClickMap entries:
LeftShoulderTrigger->"LeftTrigger",RightShoulderTrigger->"RightTrigger". Triggers are in ClickMap (not ButtonMap) because they are continuous axes, not toggle buttons. -
Iterate ButtonFileMap: For each entry, calls
TryLoadModel()to load the OBJ. If found, callsRegisterButton()to add to bothButtonMapandClickMap. Special-cases:LeftStickClick.objandRightStickClick.objalso set theLeftThumb/RightThumbreferences for joystick tilt animation. -
Add all parts to
model3DGroup.Children— this root group is what gets assigned toModelVisual3D.Content. -
Subclass constructor continues — loads model-specific extra meshes (e.g., face button overlays for Xbox 360, symbol meshes for DS4), assigns colored materials, then calls
DrawAccentHighlights()last to generate highlight materials.
Note: Stick rings are NOT in ClickMap. The view handles stick ring clicks via IsStickRingHit() with quadrant-based axis detection, because ring clicks need to determine the axis direction from the click position (quadrant detection), not just identify which button was clicked.
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 (body)
MainBody-Charger.obj (battery pack)
Joystick-Left-Ring.obj (left stick torus ring)
Joystick-Right-Ring.obj (right stick torus ring)
MotorLeft.obj, MotorRight.obj (rumble motors)
Shoulder-Left-Trigger.obj (left trigger)
Shoulder-Right-Trigger.obj (right trigger)
SpecialRing.obj, SpecialLED.obj (guide button ring + LED)
LeftShoulderBottom.obj (left bumper bottom)
RightShoulderBottom.obj (right bumper bottom)
B1.obj, B2.obj, B3.obj, B4.obj (base face buttons: A, B, X, Y)
B1Button.obj, B2Button.obj, (colored face button overlays)
B3Button.obj, B4Button.obj
L1.obj, R1.obj (shoulder bumpers)
Back.obj, Start.obj, Special.obj (center buttons)
DPadUp.obj, DPadDown.obj, (D-pad directions)
DPadLeft.obj, DPadRight.obj
LeftStickClick.obj, RightStickClick.obj (stick click caps)
DS4/ (36 meshes)
MainBody.obj (body)
MainBodyBack.obj (back panel)
Joystick-Left-Ring.obj (left stick torus ring)
Joystick-Right-Ring.obj (right stick torus ring)
MotorLeft.obj, MotorRight.obj (rumble motors)
Shoulder-Left-Trigger.obj (left trigger L2)
Shoulder-Right-Trigger.obj (right trigger R2)
Shoulder-Left-Middle.obj (left shoulder middle)
Shoulder-Right-Middle.obj (right shoulder middle)
Screen.obj (touchpad area)
Aux-Port.obj (auxiliary port)
Triangle.obj (decorative triangle)
DPadDownArrow.obj, DPadUpArrow.obj, (D-pad arrow indicators)
DPadLeftArrow.obj, DPadRightArrow.obj
B1.obj, B2.obj, B3.obj, B4.obj (base face buttons: Cross, Circle, Square, Triangle)
B1-Symbol.obj, B2-Symbol.obj, (PlayStation symbol overlays)
B3-Symbol.obj, B4-Symbol.obj
L1.obj, R1.obj (shoulder bumpers)
Back.obj, Start.obj, Special.obj (Share, Options, PS buttons)
DPadUp.obj, DPadDown.obj, (D-pad directions)
DPadLeft.obj, DPadRight.obj
LeftStickClick.obj, RightStickClick.obj (stick click caps)
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. ~1438 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 (first finger) |
_touchSecondId |
int? |
Second touch device ID for pinch-to-zoom |
_touchSecondLast |
Point |
Last position of second touch point |
_pinchStartDist |
double |
Distance between two fingers at pinch start |
_pinchMidpoint |
Point |
Midpoint of two fingers for panning |
_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 |
First finger: captures for rotation. Second finger: starts pinch-to-zoom + pan. |
PreviewTouchMove |
Viewport_PreviewTouchMove |
One finger: rotation. Two fingers: pinch-to-zoom + midpoint-based panning. |
PreviewTouchUp |
Viewport_PreviewTouchUp |
Releases touch, demotes second finger to first if needed |
PreviewStylusSystemGesture |
inline lambda | Blocks WPF system gestures (press-and-hold right-click, flicks) |
ManipulationStarting |
inline lambda | Cancels any WPF manipulation that HelixToolkit may re-enable |
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.
Touch gesture details:
-
Single finger: Treated as rotation (same as left-click drag). Captured via
_touchDragId. -
Second finger: Enables pinch-to-zoom and panning simultaneously. Zoom is applied by comparing the current inter-finger distance against
_pinchStartDistand moving the camera along its look direction. Panning tracks the midpoint of both fingers and moves the camera perpendicular to its look direction. -
Stylus suppression: WPF's press-and-hold gesture (which synthesizes a right-click) and flick gestures are blocked via
PreviewStylusSystemGestureto prevent them from interfering with touch rotation.
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.
The 3D models use WPF DiffuseMaterial with SolidColorBrush for coloring. There are three categories of materials:
Each model part gets a default DiffuseMaterial assigned during construction. Colors are defined as static Color fields in each model subclass (e.g., ColorPlasticWhite, ColorPlasticBlack). For face button overlays on Xbox 360, transparent variants use Alpha = 150 to allow the base button color to show through.
Default materials are stored in DefaultMaterials[group] for restoration after highlighting.
DrawAccentHighlights() generates accent-colored materials by reading the app's AccentButtonBackground resource from ModernWpfUI. For each GeometryModel3D child of every Model3DGroup in ButtonMap, it creates a matching DiffuseMaterial using the accent color. Falls back to #2196F3 blue if the resource is unavailable.
Highlight materials are stored in HighlightMaterials[group] and applied when a button is pressed or during flash animation.
For sticks and triggers, GradientHighlight() creates intermediate materials by ARGB-interpolating between the default and highlight colors. A new DiffuseMaterial is created per call. This is acceptable because:
- The dirty flag ensures updates only run once per render frame.
- WPF3D material objects are lightweight (no GPU resources until rendered).
- Gradient updates only occur when stick/trigger values actually change.
The OBJ meshes use a coordinate system where:
- X axis: Left-right (negative = left from front view, positive = right)
- Y axis: Forward-backward (camera's view axis)
- Z axis: Up-down (positive = up)
This is the standard WPF3D right-handed coordinate system.
The default camera is a PerspectiveCamera with:
-
Position="0,-159,122"— behind and above the controller -
LookDirection="0,0.793,-0.609"— looking forward and slightly down -
UpDirection="0,0,1"— Z-up -
FieldOfView="50"— moderate perspective
Rotation is applied to the ModelVisual3D.Transform (not the camera). This keeps the lighting fixed relative to the screen while the model rotates.
The transform group contains two RotateTransform3D children applied in order:
-
Yaw (
AxisAngleRotation3Daround Z axis): Horizontal rotation from left-drag -
Pitch (
AxisAngleRotation3Daround X axis): Vertical tilt from left-drag, clamped to [-60, +60] degrees
Sticks are animated by applying a Transform3DGroup to both the ring and thumb meshes:
-
X tilt:
AxisAngleRotation3Daround Z axis, angle proportional to stick X deflection. Centered atJoystickRotationPointCenter{Left/Right}Millimeter. -
Y tilt:
AxisAngleRotation3Daround X axis, angle proportional to stick Y deflection. Same center point.
Both rotations are capped at JoystickMaxAngleDeg (19 degrees for both Xbox 360 and DS4).
Triggers rotate around the X axis at their pivot point (ShoulderTriggerRotationPointCenter{Left/Right}Millimeter). The rotation angle is -TriggerMaxAngleDeg * value (negative to rotate downward when pressed). Change detection skips the rotation update if the angle delta is less than 0.01 degrees.
The scene uses two light sources defined in the XAML:
-
SunLight: HelixToolkit's built-in sun light (ambient + directional, fixed in world space) -
DirectionalHeadLight: A camera-relative directional light at brightness 0.35, which moves with the camera to prevent dark spots when the model is rotated
- 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.