Skip to content

3D Model System

hifihedgehog edited this page Mar 3, 2026 · 18 revisions

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)


Architecture Overview

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.


ControllerModelBase

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

Data Dictionaries

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.

Scene Graph

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.

Common Geometry Groups

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

Rotation Parameters

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

ButtonFileMap

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" },
};

Constructor Flow

protected ControllerModelBase(string modelName)
  1. Sets ModelName.
  2. Loads common geometry via LoadModel(): MainBody, stick rings, motors, triggers.
  3. Registers trigger ClickMap entries: LeftShoulderTrigger -> "LeftTrigger", RightShoulderTrigger -> "RightTrigger".
  4. Iterates ButtonFileMap, loading each OBJ via TryLoadModel(). Calls RegisterButton() for found meshes. Sets LeftThumb/RightThumb references for stick click meshes.
  5. 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.

RegisterButton

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).

DrawAccentHighlights

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.

Embedded Resource Loading

protected Model3DGroup LoadModel(string filename)     // Throws FileNotFoundException
protected Model3DGroup TryLoadModel(string filename)  // Returns null on failure

Loads .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 it

Dispose Pattern

public 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.


ControllerModelXbox360

File: PadForge.App/Models3D/ControllerModelXbox360.cs

public class ControllerModelXbox360 : ControllerModelBase

Calls base("XBOX360").

Xbox 360-Specific Mesh Groups

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

Color Palette

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.

Rotation Points

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

Material Assignment Order

  1. Face button overlays (B1Button-B4Button) get transparent color materials and are registered into ButtonMap alongside the base button meshes so they highlight together.
  2. SpecialLED gets green transparent material.
  3. Base face buttons (B1.obj-B4.obj) get opaque color materials.
  4. Guide button gets silver material.
  5. White parts: MainBody, LeftMotor, RightMotor, LeftShoulderBottom, RightShoulderBottom.
  6. All remaining parts default to black.
  7. DrawAccentHighlights() called last to generate highlight materials from accent brush.

ControllerModelDS4

File: PadForge.App/Models3D/ControllerModelDS4.cs

public class ControllerModelDS4 : ControllerModelBase

Calls base("DS4").

DS4-Specific Mesh Groups

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

PlayStation Color Palette

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

Face Button Symbols

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.

Rotation Points

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

OBJ Mesh Files

Directory Structure

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" />

ControllerModelView

File: PadForge.App/Views/ControllerModelView.xaml, ControllerModelView.xaml.cs

WPF UserControl hosting a HelixViewport3D for 3D controller visualization. ~1000 lines of code-behind.

XAML Structure

<helix:HelixViewport3D
    IsRotationEnabled="False" IsPanEnabled="False"
    IsMoveEnabled="False" IsZoomEnabled="True"
    ShowViewCube="False" Background="Transparent">
    <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>

Built-in HelixToolkit camera rotation/pan/move is disabled. Only zoom (scroll wheel) is enabled. Model rotation is implemented via Preview events to intercept before HelixToolkit's built-in handlers.

Events

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").

Private State

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
_isRightDragging bool Right-mouse drag active flag
_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)

ViewModel Binding

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.

Model Lifecycle

private void EnsureModel()

Determines the needed model from OutputType and VJoyConfig.Preset:

  • DualShock4 -> "DS4"
  • VJoy with Preset == 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.

Render-Frame Update Pipeline

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

HighlightButtons()

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.

UpdateJoystick()

private void UpdateJoystick(
    short rawX, short rawY,
    Model3DGroup thumbRing, Model3D thumb,
    Vector3D rotationPoint, float maxAngleDeg)
  1. Normalizes raw values (short.MaxValue) to -1..1 range.
  2. Gradient highlight: If stick is deflected, blends between default and highlight materials based on deflection magnitude via GradientHighlight().
  3. Rotation: Creates AxisAngleRotation3D transforms for X (around Z axis) and Y (around X axis), centered at rotationPoint. Both ring and thumb meshes get the same Transform3DGroup.

UpdateTrigger()

private void UpdateTrigger(
    double triggerNorm,
    Model3DGroup triggerModel,
    Vector3D rotationPoint,
    float maxAngleDeg,
    ref float prevAngle)
  1. Gradient color: Blends between default and highlight materials based on trigger value (0-1).
  2. Rotation: Applies AxisAngleRotation3D around X axis, centered at rotationPoint. Max angle: -maxAngleDeg * value.
  3. Change detection: Skips rotation update if angle delta < 0.01 degrees.

GradientHighlight()

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).

Model Rotation

Turntable-style rotation implemented via Preview events (fires before HelixToolkit's built-in camera controls):

Event Handler Action
PreviewMouseRightButtonDown Viewport_MouseRightButtonDown Captures mouse, stores starting position
PreviewMouseMove Viewport_PreviewMouseMove Updates _modelYaw and _modelPitch via persistent AxisAngleRotation3D objects
PreviewMouseRightButtonUp Viewport_MouseRightButtonUp Releases capture
PreviewTouchDown Viewport_PreviewTouchDown Captures first touch, same 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.

Click-to-Record Hit Testing

private void Viewport_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
  1. Calls Viewport3DHelper.FindHits() on the 3D viewport at the click position.
  2. For each hit GeometryModel3D:
    • Stick ring check: IsStickRingHit() checks if the geometry belongs to LeftThumbRing or RightThumbRing. If yes, delegates to DetermineAxisFromQuadrant().
    • ClickMap check: Walks ClickMap entries to find which Model3DGroup contains the hit geometry.
  3. Fires ControllerElementRecordRequested with the PadSetting target name.

Quadrant Detection (Stick Rings)

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|): Returns xAxis or xAxis + "Neg" based on deltaX sign.
  • Dominant Z axis: Returns yAxis or yAxis + "Neg" based on deltaZ sign.
  • Y-axis inversion: Model Z-up corresponds to stick up. deltaZ >= 0 (up in model) maps to yAxis + "Neg" because Step 3's NegateAxis inverts the Y output. This way, pushing the stick up in-game maps to the positive direction.

Hover Highlighting

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_MouseLeave also clears hover state (and releases any dangling right-drag).

Flash Animation (Map All)

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 and ShowArrowForTarget() for directional guidance. FlashQuadrantRing() toggles the overlay's alpha between 200 and 0.
  • Flash stops when CurrentRecordingTarget becomes null.

Arrow Overlay

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.

Quadrant Ring Overlay

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:

  1. Defines two half-planes at +/-45 degrees to isolate one quadrant.
  2. Sutherland-Hodgman clipping: Clips each source triangle against both half-planes using ClipPolygonByHalfPlane().
  3. 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.
  4. Triangulates clipped polygons as fans.

Performance Considerations

  • 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 DiffuseMaterial per 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: ControllerModelBase loads all OBJ meshes in the constructor. Models are cached by EnsureModel() -- 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.

Clone this wiki locally