Custom Suspension β Full Tutorial (Advanced Suspension Maker)
π£ New to this? Read
GETTING_STARTED.mdfirst β itβs the simple, click-by-click version. This file is the deep reference (how it all works under the hood).
End-to-end guide to building a custom, articulated, per-car suspension for CarX Drift Online: study the basegame rig, author your own in the Kino content SDK, and have the mod drive it in-game.
Note (toolset trimmed). The active SDK tools are: KSusp Bone (+ custom Inspector), Build Rig Hierarchy, Attach Parts to Bones, Bake Suspension Descriptor. The Auto-Skin Suspension, Export Suspension Bundle, and Import Suspension Dump editor tools were removed (they belonged to the single-mesh / standalone-bundle / dump-study paths, which the current rigid-parts + chassis-embedded workflow doesnβt use). Sections below that mention them are kept for reference / git history. The in-game F9 dump and the modβs dormant swap mode still exist; only the matching editor tools are gone.
0. The big picture β how the SDK half and the mod half align
There are two halves, and they connect through three shared artifacts (not through the SDKβs built-in part categories β see Β§9, there is no βSuspensionβ category):
SDK / Unity (authoring + build) MOD / in-game (runtime)
βββββββββββββββββββββββββββββββ βββββββββββββββββββββββ
mesh + bone GameObjects GameBindings.GetWheelTransforms()
+ KSusp* components (reads car's live wheel data)
β β
β bake β
βΌ βΌ
β <name>.suspension.json ββββββββββββββββββββΆ SuspensionAnimator (copy/aim/span)
β build β²
βΌ β instantiates + drives
β‘ <name>.bundle βββββββββββββββββββββββββββββΆ mod loads it + swaps stock suspension
β β
βββββββββ β’ bone NAME convention ββββββββββββββββββββ
The three contracts that keep the halves in sync:
| # | Artifact | Produced by (SDK) | Consumed by (mod) | The rule |
|---|---|---|---|---|
| β | <name>.suspension.json |
Bake Suspension Descriptor |
SuspensionAnimator.Load |
describes how to animate (copy/aim/span) |
| β‘ | <name>.bundle |
Export Suspension Bundle |
mod AssetBundle.LoadFromFile + swap |
one prefab; mod loads & instantiates it |
| β’ | bone GameObject names | your prefab | GameBindings.FindDeep |
names in β must match the bones in β‘ |
So the mod never depends on the SDK directly β it depends on those three things. Change a bone name in Unity β re-bake β so the names still match. Thatβs the whole alignment.
1. Where everything lives
Kino content SDK (Unity project):
β¦\kino_content_sdk (wherever you unpacked the Kino content SDK)
(HDRP project. Note: OneDrive may mark some files read-only; βalways keep on this deviceβ.)
Game: your Steam library, e.g.
β¦\steamapps\common\CarX Drift Racing Online
(Steam βΈ right-click CarX βΈ Manage βΈ Browse local files, if youβre not sure which drive.)
- KSL extensions:
kino\extensions\(the mod lives here asAdvancedSusp.kse) - Dumps:
kino\dumps\<timestamp>\ - Suggested custom-part drop:
kino\suspensions\(you create this)
This repo (mod source):
src/Mod/β builds the mod DLL β packed toAdvancedSusp.ksesrc/Editor/,src/Authoring/β deploy into the SDK project (see Β§2)
Deploy the SDK-side tools into the SDK project
Copy these into the SDK Unity project:
| File | Copy to | Why |
|---|---|---|
src/Authoring/SuspensionBoneComponents.cs |
Assets/AdvancedSuspensionMaker/ (NOT an Editor folder) |
runtime components you put on bones |
src/Editor/KSuspensionDumpImporter.cs |
Assets/Editor/ |
rebuild a dump as a prefab to study |
src/Editor/SuspensionDescriptorBaker.cs |
Assets/Editor/ |
bake artifact β |
src/Editor/KSuspensionBundleExporter.cs |
Assets/Editor/ |
build artifact β‘ |
After copying, Unity compiles them and you get a Tools βΈ KSL menu.
2. Prerequisites
- The SDK project opens in the same Unity + HDRP version as the game (AssetBundles are version-locked). Donβt upgrade it.
- The mod (
AdvancedSusp.kse) is installed and loads (see this repoβsREADME.md). - For the material: the basegame uses
Shader Graphs/CarGeneric, which the SDK does not ship. Options:- Author with an HDRP Lit (or an existing SDK car shader) now; have the mod re-assign
Shader.Find("Shader Graphs/CarGeneric")at runtime (the game already has it loaded). - Recreate a
CarGenericShaderGraph with the same property names (_BaseMap/_PackMap/_NormalMap/_ColormaskMap/_BaseColor/_SecondaryColor/_ThirdColor). Either way the colormask (R/G/B of_ColormaskMapβ Base/Secondary/Third colour) is what gives independently-recolourable zones.
- Author with an HDRP Lit (or an existing SDK car shader) now; have the mod re-assign
3. Part 1 β Study the basegame suspension (mod side, already working)
- Load into a car in-game.
- Press F9 (or the menu button in the KSL panel) β a dump is written to
kino\dumps\<timestamp>\targets\Mesh_Suspension\. - Study it:
- Open
geometry.glbin Blender (rig comes in posed/skinned), or - In the SDK: Tools βΈ KSL βΈ Import Suspension Dump β pick the dump folder β it
rebuilds a prefab under
Assets/KSL_DumpImports/you can inspect.
- Open
The basegame rig (front MacPherson + rear multilink, 49 bones) is your reference for what a
working rig looks like: Root β Front/Rear β FR/FL/RR/RL β {Lower_Arm, Upper_Arm,
Lower_ShA, Upper_ShA, SteeringRod, Wheel}.
4. Part 2 β Author your suspension in the SDK
- Create a folder, e.g.
Assets/Content/CarParts/Examples/MySuspension/. - Bring in / model your suspension mesh (one
SkinnedMeshRenderer, name itMesh_Suspensionto match the basegame convention). - Build a bone hierarchy of empty GameObjects as children under the meshβs root. You need, per corner, the bones your design uses: a wheel-mount, control arms/links, a shock.
- Assign the material (see Β§2). Paint the colormask so the zones you want recolourable map to R/G/B.
Per-car: because the mod loads and swaps the suspension itself (suspension isnβt a selectable category and the game hardcodes it β see Β§9), you make one bundle per car and place each corner at that carβs geometry. The mod still auto-fits width/wheelbase live (the wheel bones follow the carβs wheel transforms), so you mostly need the inboard/chassis points right for each car.
5. Part 3 β Annotate the bones (component-based, no JSON by hand)
In the Inspector, add the KSusp* components:
KSuspensionRooton the rig root β setsuspensionType(label, e.g. βMacPhersonβ).KSuspCornerGroupon each corner node (FR/FL/RR/RL) β setcorner. Everything beneath inherits that corner.KSuspWheelBoneon the wheel-mount bone β it copies the carβs wheel transform (this is what gives track width + wheelbase + travel + steer automatically).KSuspAimBoneon each control arm / link / steering rod β drag the target locator it should point at (usually a child of the wheel bone so it moves with the wheel). SetaimAxis= the boneβs local axis that should aim (default +Z).KSuspShockon each shock assembly (see below).
Hub rotation modes (front vs rear)
The Hubβs Wheel Rotation picks how much of the game wheelβs orientation the upright copies. It mirrors the wheel β it never generates motion the wheel doesnβt have:
| Mode | Copies | Use |
|---|---|---|
| SteerCamber | steer + camber, no spin | the right default for any upright (front and rear) |
| SteerOnly | flat yaw only β no camber | rare; only if camber misbehaves |
| Full | steer + camber + spin | only an actual spinning wheel mesh |
| None | nothing (locked to authored pose) | a fixed/decorative hub |
SteerCamber on the rear β it will not steer left/right, because the game doesnβt steer the rear wheels, so thereβs no steering input to copy. You still get camber + toe (static toe, plus dynamic toe / bump-steer through travel), which is exactly what a rear hub should do. SteerCamber doesnβt add fake steering β it only reflects what the real wheel does. So keep the rear on SteerCamber; the front swings because the front wheels are steered, the rear wonβt.
Shocks (body + spring + tophat)
A coilover is three parts; put one KSuspShock on the assembly and wire:
| Field | Drag in | Behaviour |
|---|---|---|
tophat |
the fixed upper-mount bone | NOT animated β stays at its authored chassis position. Do not parent it under the wheel bone. |
lowerMount |
a locator on the moving side (child of wheel bone / lower arm) | follows the wheel |
bodyBone |
the rigid damper-tube bone | sits at the lower mount, points up at the tophat (telescopes) |
springBone |
the spring bone | stretches between tophat and lower mount β visual compression |
axis |
the long axis of the body/spring meshes (default Z) | Β |
The baker expands this into a rigid span (body) + a scaling span (spring). The tophat is
βfixed based on its position in the carβ simply by being authored there and left un-driven.
Grouping reference β what to put on each bone, per suspension type
Legend: β Component means add that component; (child) means parent it under the line
above so it inherits motion; (fixed) means a chassis bone with no component.
Diagrams (in docs/visuals/): MacPherson Β·
double wishbone Β·
multilink Β·
cantilever.
Components unified. There is now ONE component β
KSuspBoneβ with a Role dropdown. The trees below still show the old per-role component names; map them like this:KSuspWheelBoneβ Role Hub Β·KSuspAimBoneβ Role Arm Β·KSuspShockβ Role Shock Β·KSuspWheelChildβ Role RidesWithHub.KSuspCornerGroupis gone (corner is a dropdown on eachKSuspBone). Marker names are free β just keep them unique.
MacPherson (strut rigid with the hub) β the strut rides and steers with the hub, so parent it under the wheel bone (no aim needed); only the spring compresses:
FR_Wheel β KSuspWheelBone (copy: travel + steer)
βββ FR_StrutBody (child, no component) rides + steers with the hub
β βββ FR_LowerPerch (child locator, no comp.) spring lower seat (moves)
βββ FR_LowerArm_End (child locator, no comp.) lower-arm aim target
βββ FR_Steer_End (child locator, no comp.) steering aim target
FR_Tophat (fixed) strut top mount
FR_StrutSpring β KSuspShock tophat=FR_Tophat, lowerMount=FR_LowerPerch,
springBone=FR_StrutSpring, bodyBone=EMPTY
FR_LowerArm β KSuspAimBone target = FR_LowerArm_End
FR_SteeringRod β KSuspAimBone target = FR_Steer_End
Double wishbone β the shock mounts to an arm (not the hub), so the shock body aims
(use bodyBone); arms aim at ball-joint locators under the upright:
XX_Upright β KSuspWheelBone (copy)
βββ XX_UpperBall (child locator) upper-arm aim target
βββ XX_LowerBall (child locator) lower-arm aim target
XX_UpperArm β KSuspAimBone target = XX_UpperBall
XX_LowerArm β KSuspAimBone target = XX_LowerBall
β βββ XX_ShockLower (child locator on lower arm) shock bottom mount
XX_Tophat (fixed) shock top mount
XX_ShockBody β KSuspShock tophat=XX_Tophat, lowerMount=XX_ShockLower,
bodyBone=XX_ShockBody, springBone=XX_Spring
Multilink β same as wishbone but N links, each its own aim:
RX_Upright β KSuspWheelBone (copy)
βββ RX_Link1_End β¦ RX_LinkN_End (child locators) one aim target per link
RX_Link1 β¦ RX_LinkN β KSuspAimBone (each) target = its *_End locator
RX_Tophat (fixed)
RX_ShockBody β KSuspShock top=RX_Tophat, lowerMount on the lower link
Cantilever / pushrod β pushrod drives a rocker; the shock spans rockerβchassis:
XX_Upright β KSuspWheelBone (copy)
β βββ XX_Pushrod_End (child locator) pushrod outboard target
XX_Rocker (pivot bone)
β βββ XX_Rocker_PushEnd (child locator) where the pushrod points
β βββ XX_Rocker_ShockEnd (child locator) shock bottom mount
XX_Pushrod β KSuspAimBone target = XX_Rocker_PushEnd
XX_Rocker β KSuspAimBone target = XX_Pushrod_End (rocker tracks the pushrod)
XX_ChassisShockMount (fixed)
XX_ShockBody β KSuspShock tophat=XX_ChassisShockMount,
lowerMount=XX_Rocker_ShockEnd, body+spring
Rule of thumb: rigid-with-hub parts get parented under the wheel bone; everything that
pivots gets a KSuspAimBone; every spring gets a KSuspShock; chassis mounts stay fixed.
Deployment note (this build): the suspension is imported as part of the CustomChassis prefab of the KNPP part β i.e. it ships inside the chassis, built via the SDKβs CarParts Chassis category, not as a standalone
.bundle. The custom chassis brings its own suspension mesh + bones, so the modβs job is reduced to find the suspension bones on the loaded car + load the descriptor + animate (no bundle load / no stock swap needed; for a fully-custom chassis the game already skips loading the stock suspension). The descriptor (artifact β ) and bone-name contract (β’) are unchanged. See Β§10.
6. Part 4 β Skin the mesh
The mesh must be a SkinnedMeshRenderer weighted to the bones so it deforms when they move.
Two ways:
- Auto-skin (recommended): Tools βΈ KSL βΈ Auto-Skin Suspension β set Mesh object (the
GameObject holding the mesh) and Bone root (your rig root) β Auto-Skin. It assigns each
vertex to its nearest bone (rigid by default β perfect for mechanical parts), computes
bindposes, saves a new mesh asset, and converts the object to a
SkinnedMeshRendererwithbones+rootBoneset. Turn on Blend seams only if you want soft top-2 blending at part boundaries. (Tip: place each bone inside the part it drives so βnearest boneβ is unambiguous.) - Manual: skin in your DCC / Unity, weighting each arm/spring/wheel section to its bone.
Then save the whole thing as a prefab.
7. Part 5 β Bake the descriptor (artifact β )
Tools βΈ KSL βΈ Bake Suspension Descriptor β assign the rig root β Bake β
saves <name>.suspension.json. The report warns about: missing corners, unresolved
aim targets, a tophat accidentally under a wheel bone, and duplicate bone names (which
would make the modβs name lookup ambiguous β keep names unique).
Example baked corner (MacPherson FL):
{ "corner":"FL", "wheelBone":"FLWheel", "copyRotation":true,
"aims": [{ "bone":"FL_Lower_Arm", "target":"FL_Lower_Arm_1", "aimAxis":[0,0,1] }],
"spans": [
{ "bone":"FL_ShockBody", "top":"FL_LowerMount", "bottom":"FL_Tophat", "scaleAxis":"z", "scale":false, "restLength":0 },
{ "bone":"FL_ShockSpring", "top":"FL_Tophat", "bottom":"FL_LowerMount", "scaleAxis":"z", "scale":true, "restLength":0 }
] }
8. Part 6 β Build the bundle (artifact β‘)
Tools βΈ KSL βΈ Export Suspension Bundle β assign the prefab β Validate (fix errors)
β Validate + Build β produces <name>.bundle.
Notes:
- It builds a raw AssetBundle (extension
.bundle) containing exactly the one prefab β the gameβs external loader instantiatesGetAllAssetNames()[0]. - A
CarSuspensionTargetcomponent is not required β the mod animates the bones.
Chosen path β embed in the chassis: instead of a standalone
.bundle, drop the suspension prefab (mesh + bones +KSusp*components) into your CustomChassis prefab and build it through the SDKβs CarParts βΈ Chassis tool β.knpp. The suspension then ships inside the chassis and installs like any chassis part. You still Bake the descriptor (artifact β ) and deploy it alongside; the mod reads it and animates the bones that came in with the chassis. (TheExport Suspension Bundletool stays as the standalone alternative.)
9. Part 7 β Deploy + per-car config
- Put both artifacts where the mod can read them, e.g.:
E:\...\CarX Drift Racing Online\kino\suspensions\ridge_gt\ridge.bundleE:\...\CarX Drift Racing Online\kino\suspensions\ridge_gt\ridge.suspension.json -
Easiest (no config): the baker writes the
carId(and aname) inside a custom-named.ksuspfile. Drop any number of them inkino\suspensions\β e.g.drift.ksusp,grip.ksusp. The mod scans them, keeps those whosecarIdmatchesRaceCar.carId, and when several share a carId it picks the one whosesuspensionRootbones resolve on the loaded chassis (so multiple setups per car donβt clash). A.ksuspmay also carry a"bundle"field (relative to the file) for swap mode. The mod logs(carId N)for the current car.Or use
kino\suspensions\config.jsonfor explicit control (wins over the scan). Match bycarId(precise) orcarname (substring, either direction βRidge_GTmatchesRidge_GT(Clone)); pointdescriptorat a.ksusp:{ "suspensions": [ { "car": "Ridge_GT", "descriptor": "kino/suspensions/ridge_gt/ridge.suspension.json" }, { "car": "SomeOtherCar", "bundle": "kino/suspensions/other/other.bundle", "descriptor": "kino/suspensions/other/other.suspension.json" } ] }The first entry (no
bundle) is find + animate β for a suspension that ships inside the CustomChassis. The second (withbundle) is swap β a standalone bundle the mod loads and swaps in. In the KSL panel: Enable swap toggles the whole system; Reload config re-reads this file after edits.
Why the mod loads it itself (not a CarParts pack, not a path override): suspension is
not customizable content in this game. The SDK CarParts categories are
Wheel/Bumper/Skirt/β¦/Chassis/BrakeRotor/Tire β there is no Suspension. In
CarVisualManager, any SuspensionId entry is removed from the visual-parts dictionary
(line ~9697) and suspension is then loaded by hardcoded name:
LoadPart(SuspensionId, flag2 ? null : "suspensionmacpherson") β "suspensionmacpherson"
for stock cars, or null for a fully-custom car (whose own model carries its suspension).
So there is no category and no path-override hook. The mod injects a custom suspension by
loading the bundle itself and swapping the stock one at runtime (Β§10).
10. Part 8 β In-game (mod runtime)
This is built (SuspensionSwap, driven from the extensionβs OnUpdate). Each ~0.5 s it
checks the player car; when it changes it:
- Finds the stock
Mesh_SuspensionSkinnedMeshRenderer and hides its renderer (bones stay, so the gameβs own suspension targets donβt error). AssetBundle.LoadFromFile(<bundle>)(reflective), loads the prefab, and instantiates it under the carβsModelat the stock suspensionβs local transform.- Loads
<name>.suspension.jsonβSuspensionAnimator.Load(...). - Every frame:
animator.Tick(wheelTransforms)β wheel bones follow the wheel transforms (width/wheelbase/travel/steer), arms aim, shocks compress.
When the car changes (or swap is disabled) it restores the stock renderer and destroys the
spawned suspension. (0Harmony is referenced if we ever prefer patching the suspension
LoadPart instead of the runtime swap β but the swap keeps us off fragile game internals.)
Chassis-embedded mode (built): if the suspension ships inside the CustomChassis (the chosen path, Β§8), thereβs nothing to load or swap β the bones are already on the car.
SuspensionSwappicks the mode per config entry:
- entry has
bundleβ swap mode (hide stock, load bundle, instantiate, animate).- entry has only
descriptor(nobundle) β find + animate: the animator resolves the descriptorβssuspensionRoot+ bones directly on the car and Ticks them. No load, no swap, no stock hide. So for your chassis-embedded suspension, just omitbundleinconfig.json.
Until that activation is wired, you can still author, bake, build, and validate everything.
11. Reference
Descriptor schema (artifact β )
{
"type": "MacPherson", // label only
"suspensionRoot": "Root", // bone the mod searches under (FindDeep)
"corners": [
{
"corner": "FL", // FL | FR | RL | RR β wheel index 0..3
"wheelBone": "FLWheel", // copies the wheel transform
"rotation": "steercamber", // steercamber (steer+camber, no spin) | steeronly (flat yaw) | full (+spin) | none
"aims": [ { "bone": "...", "target": "...", "aimAxis": [0,0,1] } ],
"spans": [ { "bone": "...", "top": "...", "bottom": "...",
"scaleAxis": "z", "scale": true, "restLength": 0 } ]
}
]
}
Primitives: copy (wheel bone β wheel transform), aim (bone LookAt target),
span (bone stretches topβbottom; scale:false = rigid). Any suspension type =
a combination of these (MacPherson = 1 aim + 2 spans; double wishbone = 2 aims; multilink =
N aims; cantilever = rocker aim + span).
Bone roles (component β primitive)
| Component | Primitive | Drives |
|ββββ|ββββ|βββ|
| KSuspWheelBone | copy | wheel mount (width/wheelbase/travel/steer) |
| KSuspAimBone | aim | control arms, links, steering rods, rockers |
| KSuspShock | span Γ2 | rigid body + compressing spring (tophat fixed) |
| KSuspSpanBone | span | anything exotic / manual |
Suspension data the mod reads (per wheel, reflective via GameBindings)
car.GetWheelTransform(WheelIndex)β positioned per car (track/wheelbase) and animated (travel inlocalPosition.y, steer in rotation). This is the single source.car.GetWheel(idx).topPos/.maxSpringLenβ if you ever want normalized travel.
12. Status
| Piece | State |
|---|---|
| Dumper + GLB + Unity importer | β working |
Authoring components (KSusp*) |
β built (deploy to SDK) |
| Descriptor baker (artifact β ) | β built (deploy to SDK) |
| Bundle exporter (artifact β‘) | β built (deploy to SDK) |
| Generic animator (copy/aim/span) | β built into the mod, compiles |
| Runtime activation (find/hide stock β load bundle β swap β per-frame Tick) | β
built (SuspensionSwap), deployed |
| Auto-skinning helper (locators β weights) | β³ optional, not built |
Things to verify in-engine (canβt be tested outside the game)
- The runtime swap works: hiding the stock
Mesh_Suspensionand instantiating our bundle underModelloads + renders correctly. BakeMesh-recovered geometry / GLB orientation look correct.- The bundleβs material renders (shader-reference / CarGeneric handling, Β§2).
13. Troubleshooting
- Mesh loads but doesnβt move β descriptor not loaded, or bone names in β donβt match β‘ (contract β’). Re-bake after any rename.
- Suspension too wide/narrow β the wheel bones arenβt following; check
KSuspWheelBoneis on the right bone and the car exposesGetWheelTransform(logged by the mod). - Spring doesnβt compress β
KSuspShock.springBoneaxis wrong, or top/bottom swapped. - Tophat moves with the wheel β itβs parented under the wheel bone; reparent to chassis.
- Mirrored / inside-out in Blender β GLB winding; tell me and Iβll flip it.
- Pink material in-game β shader reference didnβt resolve; use the runtime
Shader.Find("Shader Graphs/CarGeneric")fix-up (Β§2).