X3D Character Skin
A 77-slot character editor with an orbiting camera, poses and lighting. No skinchanger, no database, any framework.
Buy / Get freeConfig builderInstallation
- Unzip and rename the folder to
x3d_skin(other scripts call the exports by this name) - Drop it in
resources/ - Add
ensure x3d_skintoserver.cfg
That is the whole setup: no database, no extra resource, and nothing on the server has to change. Works on ESX, QBCore and standalone.
Players open it with /skin; an admin opens it for someone else with /skin <id>.
Grant the admin permission:
add_ace group.admin x3d_skin.admin allow
⚠ On a server that already runs esx_skin, it registers /skin too — whichever resource is ensured last answers it. Put ensure x3d_skin after ensure esx_skin, or change Config.Command.
What can be edited
77 slots in the default mode, grouped into the six tabs on the bottom bar:
| Group | Slots |
|---|---|
| Basics | Gender · face · skin tone |
| Hair & eyes | Hair style, colour, highlight · eye colour · eyebrow style, colour, opacity |
| Clothing | Undershirt, torso, decals, arms, body armour, pants, shoes (each with its colour slot) |
| Accessories | Mask, chain, hat, glasses, watch, bracelet, earrings |
| Makeup | Makeup, lipstick, blush, beard |
| Details | Age, blemishes, complexion, sun damage, moles, chest hair, body blemishes |
Every ceiling is asked from the game at runtime (GetNumberOfPedDrawableVariations and friends) rather than hardcoded — install an add-on clothing pack and players can reach all of it without a config change.
Set Config.AdvancedFace = true to swap the 46 preset faces for the GTA Online heritage system: mother, father, blend weights and per-feature nose/cheek/chin sliders.
⚠ Flipping that after the server has been live breaks the faces players already saved — the two modes store different value sets.
Panel controls
| Input | What it does |
|---|---|
| LMB + drag | Rotate the character |
| RMB + drag | Pan the camera (bounded — it cannot be used to look around the map) |
| Scroll | Zoom in / out |
| Q / E | Rotate a degree at a time |
| Click a slot + Scroll | Change that slot's value |
| Shift + Scroll | Step by ±10 |
| Arrow keys | Fine adjustment |
The bottom-left toolbar: focus head · focus body · reset camera · character lighting · cycle pose (4) · hide every player within 50 m.
The camera moves to whatever is being edited, but the moment the player rotates or zooms by hand it stops moving on its own within that group, until a slot from another group is selected.
Opening it from a shop (barber, clothing store)
exports['x3d_skin']:OpenEditor({
title = 'Barber',
price = 500,
restrict = { 'hair_1', 'hair_2', 'hair_color_1', 'hair_color_2',
'beard_1', 'beard_2', 'beard_3', 'beard_4' },
onSubmit = function() TriggerServerEvent('myshop:charge', 500) end,
onCancel = function() print('cancelled') end,
})
restrict limits which slots can be edited; price turns the confirm button into "Purchase" with the amount on it.
⚠ Calling OpenEditor yourself never charges anyone — take the money in your own onSubmit. The shops in Config.Shops are the other path: there the server charges and saves in one step.
Working with an existing skinchanger / esx_skin
Nothing to configure — the script checks at startup:
skinchangeris installed → it dresses the ped andesx_skinkeeps storing the skin its own way. x3d_skin is just the UI, the shops and the wardrobe.- It is not → x3d_skin dresses the ped itself and stores skins in a JSON file.
Either way nothing has to be stopped and nothing has to be migrated; existing characters are untouched.
Force one side with Config.Backend ('skinchanger' / 'self') if you ever need to.
Bundled in the zip: optional/skinchanger + optional/esx_skin
Most ESX servers already run both — if yours does, skip the optional/ folder entirely; x3d_skin already works with what you have.
If it does not, move them into resources/ and ensure them before x3d_skin:
ensure skinchanger
ensure esx_skin
ensure x3d_skin
skinchangeris ESX Legacy's own, with the@includeof the es_extended locale file removed so it installs without erroringesx_skinis trimmed to the API only (theskincolumn plus the old event names) so no second UI competes with the panel, and the player table name is a config value
Both belong to ESX Legacy and are GPL v3 — details in optional/README.md.
The bundled esx_skin keeps esx_skin:save, the esx_skin:getPlayerSkin callback and the old open-menu events — those events now open the x3d_skin panel, so scripts that still call them keep working.
Shops (Config.Shops)
Fourteen clothing stores, seven beauty salons and one mask shop ship in this resource — no separate shop script.
- Map blips reveal within 120 m (
blipDistance = 0shows them always) - A ground marker plus an
[E] Clothing Store ($500)prompt - Each type sets its own price and editable slots in
Config.ShopTypes, with sub-tabs (clothing / accessories, hair / makeup) Config.Shops.enabled = falseturns the whole shop layer off and leaves/skinprice = 0makes a shop free and never reaches the money bridge
The charge and the save happen in one server-side step. On confirm the server re-checks that the player is still at that shop and can still pay, then charges and saves. If the charge fails nothing is written; if the write fails the money is refunded.
⚠ The original flow (a separate clothing-shop script + esx_skin) charged from the shop script while the editor saved separately, so a player who blocked the charge event kept the clothes for free. That hole is closed here.
Wire your own money system in addons/framework/server.lua (ESX included, QBCore commented). Until it is wired, a priced shop refuses to open and says so — it never gives clothes away.
Wardrobe (Config.Wardrobe)
Save what you are wearing under a name, wear it again in one click, rename it, overwrite it or delete it. Each player keeps up to maxPerType outfits (default 20) and names must be unique.
Wearing a saved outfit swaps the clothing only — the face is never touched.
The list is owned by the server and pushed to the panel after every change; the panel never decides what exists.
Server exports
exports['x3d_skin']:GetSkin(src) --> the saved table, or nil
exports['x3d_skin']:SetSkin(src, skin) --> boolean (validated exactly like a player save)
exports['x3d_skin']:OpenEditor(src) --> boolean
GetSkin takes either a server id or an identifier. SetSkin is not rate limited, so a uniform system can dress a whole gang at once.
Client exports
exports['x3d_skin']:GetSkin()
exports['x3d_skin']:GetMaxValues()
exports['x3d_skin']:LoadSkin(skin)
exports['x3d_skin']:LoadClothes(playerSkin, clothesSkin) -- clothing only, the face is left alone
exports['x3d_skin']:Change('torso_1', 5)
exports['x3d_skin']:OpenEditor(opts)
exports['x3d_skin']:CloseEditor()
exports['x3d_skin']:IsOpen()
Hook
-- server-internal, not a net event — a client cannot fire it
AddEventHandler('x3d_skin:onSave', function(src, identifier, skin)
print(('%s saved a new appearance'):format(identifier))
end)
Client-side there are x3d_skin:opened and x3d_skin:closed so another resource can hide its own HUD while the panel is up.
Where the data lives
Saved appearances are server-side in one JSON file (Config.Storage.File, default data/skins.json), loaded once at resource start and written back every Config.Storage.FlushSeconds, on player drop and on a graceful stop. The key is the player's permanent identifier (license/discord/steam), never a server id.
A corrupt file is preserved as data/skins.broken.json and the store starts empty, with a console warning — it is never overwritten silently.
To use MySQL instead:
- Run
install.sqlthrough oxmysql - Open
addons/storage/server.lua, scroll to the bottom and copy the oxmysql block over the fourStorage.Get/Set/All/Flushfunctions above it
Safety, backed by tests
- Nothing the panel sends is trusted — the slot name must exist in
components.lua, the value must be a number, and it is floored and clamped into range; unknown keys are dropped - The storage key is always a stable identifier, never a reusable server id
- One save per second per player, and a player's cooldown is cleared the moment they disconnect
/skin <id>always needs the ace, even whenConfig.PlayersCanEditlets everyone edit themselves- An out-of-range index is clamped for the native call only, never written back into the saved skin — an add-on pack that has not mounted yet cannot permanently zero a player's outfit
- The panel closes itself on death and on resource stop, releasing the cursor and the player's controls every time
pip install lupa
python tools/lua-test/x3d_skin_check.py
Config (config.lua)
| Key | What it does |
|---|---|
Config.Locale |
Language of the Lua-side strings (th/en) — separate from the panel's own switch |
Config.Command / Config.Key |
The open command (/skin) and the key binding (empty = none) |
Config.AdminAce |
The ace required to open the editor for another player |
Config.PlayersCanEdit |
false = only ace holders may edit at all |
Config.AdvancedFace |
Preset faces, or the mother/father heritage system |
Config.ApplyOnSpawn / Config.FirstTimeSetup |
Apply the saved skin on spawn · open the editor for a player who has never saved |
Config.Storage.File / .FlushSeconds |
Where appearances are written and how often |
Config.Camera |
Zoom range, pan limits, character lighting |
Config.HideHud |
Hide the radar/HUD while the panel is open |
Config.Shops / Config.ShopTypes / Config.ShopLocations |
The shops — on/off, prices, editable slots and locations |
Config.Wardrobe |
The wardrobe — on/off and how many outfits each player may keep |
Config.Backend |
auto / skinchanger / self — who dresses the ped and who stores the skin |
Support
Discord: discord.gg/x3d-developer · Web: x3d-developer.com