[FREE RELEASE] cr-3dnui - Interactive 3D DUI panels for FiveM —( real web apps rendered on in-game surfaces)

:sunglasses:

this is just to share my progress in using this. this is in fact the coolest script ive played with

3 Likes

That looks really good, the camera detach is excellent!

This is literally what I envisioned when I thought of this… walk up to ATM and actually press the buttons kind of thing

It’s also makes me very happy that you’re making this and using my library!

But more importantly you’re helping me shape it by giving me these little edge cases so I could put up guard rails making it much easier for devs to strap in.

Tonight when I’m done with my day job I will crack at the new mouse input interactions.

2 Likes

cr-3dnui — Update v2.4

I’ve been iterating hard based on real-world use cases + edge cases.

What’s new

Screen alignment helpers (tilted monitor props)

Many GTA/FiveM monitor models are not perfectly flat (slightly tilted back), which can cause bezel clipping even when your normal looks “correct”.

New options to make panels behave more like a real screen:

  • depthCompensation = "screen" — safer depth bias for tilted props / bezel clipping
  • frontOnly = true — only render + accept input when viewing the front (prevents backside interaction)

“Static vs Live” attachments (bones/entities)

If a panel looks “stuck” when attached to an entity/bone, it’s usually not updating transforms.

To make it behave like a real attachment (like the vehicle demo):

  • updateInterval = 0 — per-frame transform updates
  • updateMaxDistance = 120 — optional distance culling

Whiteboard demo: dual interaction modes

The whiteboard demo now supports both:

  • UV/Raycast (world-space UV mapping)
  • KEY2DUI (cursor rendered inside the DUI + forwarded input)

KEY2DUI is especially useful for constrained camera scenarios (vehicles / tight desks) because it avoids the “UV fighting the camera” feeling.

Internal refactor (no API breakage)

Core client code was split into smaller modules for maintainability. Exports remain backwards compatible.

1 Like

been loving following the quick progress on this. keep up the good work!

1 Like

→ Update v2.5

panel → prop → bone :x:
prop → bone + panel → bone :white_check_mark:

-- Keep the PROP as the bone-attached visual object (true entity parenting),
-- but keep the PANEL attached to the VEHICLE using a baked offset/normal derived from the prop.
-- This removes jitter while still looking like the panel is mounted to the prop.

-- Helper: normalize a vector3
local function vNorm(v)
  local l = #(vector3(v.x, v.y, v.z))
  if l < 0.000001 then return vector3(0.0, 0.0, 1.0) end
  return vector3(v.x / l, v.y / l, v.z / l)
end

-- 1) Spawn prop and attach it to a vehicle bone (inherits full bone animation)
local function attachPropToBone(veh, boneName, model, off, rot)
  local bone = GetEntityBoneIndexByName(veh, boneName)
  if bone == -1 then return nil end

  RequestModel(model)
  while not HasModelLoaded(model) do Wait(0) end

  local prop = CreateObject(model, 0.0, 0.0, 0.0, false, false, false)

  -- make it non-physical (no collision, no damage)
  SetEntityCollision(prop, false, false)
  SetEntityCompletelyDisableCollision(prop, true, true)
  SetEntityHasGravity(prop, false)

  AttachEntityToEntity(
    prop, veh, bone,
    off.x, off.y, off.z,
    rot.x, rot.y, rot.z,
    false, false, false, true, 2, true
  )

  SetModelAsNoLongerNeeded(model)
  return prop
end

-- 2) Bake the panel transform from the prop into VEHICLE LOCAL space
local function bakePanelFromProp(veh, prop, panelLocalOffsetOnProp, panelLocalNormalOnProp)
  -- where the panel should be in WORLD space (prop-local offset)
  local worldPos = GetOffsetFromEntityInWorldCoords(
    prop,
    panelLocalOffsetOnProp.x,
    panelLocalOffsetOnProp.y,
    panelLocalOffsetOnProp.z
  )

  -- convert that WORLD position into VEHICLE LOCAL offset (for stable attach)
  local bakedOffset = GetOffsetFromEntityGivenWorldCoords(veh, worldPos.x, worldPos.y, worldPos.z)

  -- compute WORLD normal direction from prop-local "normal"
  local propOrigin = GetEntityCoords(prop)
  local worldDirPoint = GetOffsetFromEntityInWorldCoords(
    prop,
    panelLocalNormalOnProp.x,
    panelLocalNormalOnProp.y,
    panelLocalNormalOnProp.z
  )
  local worldDir = vNorm(vector3(worldDirPoint.x - propOrigin.x, worldDirPoint.y - propOrigin.y, worldDirPoint.z - propOrigin.z))

  -- convert that WORLD direction into VEHICLE LOCAL direction (baked normal)
  local vehOrigin = GetEntityCoords(veh)
  local vehLocalDirPoint = GetOffsetFromEntityGivenWorldCoords(
    veh,
    vehOrigin.x + worldDir.x,
    vehOrigin.y + worldDir.y,
    vehOrigin.z + worldDir.z
  )
  local bakedNormal = vNorm(vehLocalDirPoint)

  return bakedOffset, bakedNormal
end

-- 3) Stable setup: prop -> bone, panel -> vehicle (baked from prop)
local function mountDashPanelStable(veh)
  local prop = attachPropToBone(veh, DASH_BONE, DASH_PROP_MODEL, DASH_PROP_OFFSET, DASH_PROP_ROT)
  if not prop then return nil, nil end

  local bakedOffset, bakedNormal = bakePanelFromProp(veh, prop, MONITOR_LOCAL_OFFSET, MONITOR_LOCAL_NORMAL)

  local panelId = exports["cr-3dnui"]:AttachPanelToEntity({
    entity      = veh,
    localOffset = bakedOffset,   --  stable position
    localNormal = bakedNormal,   --  correct facing (vehicle-local)
    width       = MONITOR_W,
    height      = MONITOR_H,
    rotateNormal = true,         --  keep it vehicle-relative
  })

  return panelId, prop
end


attaching a panel to a prop then attaching that prop to a bone creates limitations with updating the position of the panel fast enough with the position of the car

its basically extra math for no reason since your no calculating car/prop/panel
meaning more steps

but if we instead use the prop to calulate the offset then attach panel to bone from that offset we could fake it being attached to the prop but have no jitter when moving fast or driving

the only limitation this really presents is that each vehicle will have unique offsets for the position of the panel

so ive created a simple tool to move the prop XYZ and ROT

and you simply use your mouse wheel to modify values

this way (if needed) you can modify the positions and use a prop based panel in any car – *instead of 8 hours of hair pulling–

How to use the car debug (steps)

  • 1 /nuidash
    this is the command to attach the panel and the prop to your car.
    (dont worry if they arent lined up even with your offset)

  • 2 /nuidashprop
    to bind the panel to prop ( this is REQUIRED to be used BEFORE /dashprint)

  • 3 /dashtune
    this will enter the mode that allows you to modify the X/Y/Z/rX/rY/rZ
    simply use your scroll wheel to adjust the location + or -
    for small adjustments hold CTRL
    for big adjustments hold SHIFT

  • 4/dashaxis <x|y|z|rx|ry|rz>
    use commands like /dashaxis x or /dashaxis rz to modify individual parameters
    rx/ry/rz are rotational variables

  • 5/dashprint
    once your happy with the positions this command will print to the F8 console for easy copy pasta

MUST USE /nuidashprop BEFORE /dashprint to have the COMPLETE output like image below:

Basically NOW in 5 easy steps you can attach a prop with a panel to any car!


Note:
(The debug tools default settings use the Elegy2 as the car and prop_monitor_01a as the prop but you can change them but each car and prop must be tuned individually)

Panels and ROT (WORK IN PROGRESS)

3D-NUI panels are not real entities, so they do not automatically inherit full parent rotation; their orientation must be explicitly recomputed from the parent’s live transform if you want true roll/pitch/yaw behavior.

solution: “reverse gravity” lol - seriously

The solution is to stop baking the panel’s normal – and instead recompute it continuously from the vehicle’s live right/forward/up vectors, using the panel’s vehicle-space rest orientation as input.

Almost acting like gravity or magnets where it pulls / pushes where it needs to in order to match the car.

-- panel's REST orientation in VEHICLE SPACE (never changes)
-- this is what you tuned
local PANEL_REST_NORMAL = vector3(nx, ny, nz)

-- every update (frame or throttled loop)
local function updatePanelNormal(panelId, vehicle)
    -- get the vehicle’s live axes (already include roll/pitch/yaw)
    local right, forward, up, _ = GetEntityMatrix(vehicle)

    -- rebuild the panel normal from the vehicle’s current orientation
    local dynamicNormal =
        right   * PANEL_REST_NORMAL.x +
        forward * PANEL_REST_NORMAL.y +
        up      * PANEL_REST_NORMAL.z

    -- normalize (important)
    dynamicNormal = dynamicNormal / #(dynamicNormal)

    -- apply to the panel
    exports['cr-3dnui']:SetPanelNormal(panelId, dynamicNormal)
end

In theory this should work-- or something similar.


TL:DR
i did the annoying part and made a debug tool to make it easy to get the locations/offsets/normals/rots… i also solved the prop > bone + panel > bone nightmare solving the jittery panels issue on moving cars and i came up with a plan to tackle panels to follow cars rotations.
The library is updated to 2.5 (only the car demo has changed)

edit: currently debugging and fixing the panel ROT to allgin with car

1 Like

v2.5 (bug fix)

How ROT (Roll / Pitch / Yaw) Is Actually Solved

3D-NUI panels are not entities.

That means:

  • They will never inherit rotation
  • Roll will not exist unless you explicitly provide it
  • A normal alone is not enough

ROT requires two vectors:

  • localNormal → facing
  • localUp → roll reference

Library requirement (what changed in the lib)

The library must support an explicit up vector:

panelUp

Internally, the panel basis is built from:

normal
panelUp
right = cross(panelUp, normal)
up    = cross(normal, right)

Without panelUp, true roll is impossible.
This was the library-side limitation that had to be fixed.


Where the rotation actually comes from

ROT comes from rebuilding the panel orientation from a live transform.

That source is the prop attached to the bone.

You do not attach the panel to the prop.

Instead:

prop → bone           (real rotation)
panel → vehicle       (stable)

Then you bake the prop’s orientation into vehicle-local space.


Baking ROT correctly (core snippet)

From demo (simplified):

-- prop local → world
local worldNormal = pr * propNormal.x + pf * propNormal.y + pu * propNormal.z
local worldUp     = pr * propUp.x     + pf * propUp.y     + pu * propUp.z

-- world → vehicle local
local localNormal = normalize(worldNormal • vehicleAxes)
local localUp     = normalize(worldUp     • vehicleAxes)

This gives you:

  • a vehicle-local normal
  • a vehicle-local up
  • full roll / pitch / yaw preserved

That’s ROT.

The demo-side fix (this is where it broke)

In the demo, Originally was:

localNormal = vNeg(bakedNormal)
localUp     = vNeg(bakedUp)

That mirrors the basis.

Result:

  • upside-down UI
  • backwards text
  • 180° in-plane rotation

The actual fix (one line)

localNormal = vNeg(bakedNormal)
localUp     = bakedUp

That’s it.

  • Flip normal → controls facing
  • Keep up → preserves roll + UI orientation

Final Result

After this:

  • Panel follows yaw
  • Panel follows pitch
  • Panel follows roll
  • No jitter
  • No camera hacks
  • No per-frame math spam
  • UI orientation stays correct

TL:DR

ROT is solved by:

  1. Library supports panelUp
  2. ROT is baked from a real entity (prop → bone)
  3. Panel attaches to vehicle using baked normal + up
  4. Only the normal is negated — never the up

(If any one of those is missing, ROT breaks)




—NEXT UPDATE: v2.6:

WORK IN PROGESS

New panel creation method
(MUCH EASIER)

Replace texture


Instead of calculating all the fancy math to place a panel on a prop…

This replaces the texture inside the prop with our 3d-NUI
Simply open code walker and search the model and look up the textures in that model
over ride the texture!

still working on focus mode and a few things but should be pushed tonight


Laptop with 3dnui injected via texture override

Animated 3dnui injected via texture override into Yankton plate
(that means WORKING registration tags for cars and animated plates)

v2.6

Texture Replace v2.6

– NEW panel creation method
override the props texture with a 3d-NUI…

What this means:

  • No math
  • No offsets
  • Much lighter code

All you do is open the prop in codewalker to find the texture name needed to target override

prop_lester_screen is the texture name we target on prop model named prop_laptop_lester

-- create the DUI
local dui = CreateDui(
  "nui://cr-3dnui_laptopdemo/html/index.html",
  1024,
  512
)

-- get DUI handle
local duiHandle = GetDuiHandle(dui)

-- create runtime texture dictionary
local txd = CreateRuntimeTxd("cr_laptop_txd")

-- create runtime texture from DUI
local txn = CreateRuntimeTextureFromDuiHandle(
  txd,
  "cr_laptop_screen",
  duiHandle
)

-- replace the laptop screen texture
AddReplaceTexture(
  "prop_laptop_lester",   -- model / txd
  "prop_lester_screen",   -- texture name on the model
  "cr_laptop_txd",        -- runtime txd
  "cr_laptop_screen"      -- runtime texture
)

Limitations in texture replace:

ReplaceTexture is client-side, so each player can see their own thing.
But the actual replacement is global per texture slot (orig TXD/TXN) on that client.
That means if you have two open laptops using the same screen slot, they will mirror — changing one changes both.

Workaround (fake per-laptop independence):

only allow one “screen-capable” laptop open per client at a time

(swap others to the closed model so the replaced texture isn’t visible)

when switching laptops we reload the UI with a per-laptop id (ex: index.html?lap=3)
and the NUI saves/restores state per id (theme, logs, page, etc).

so each laptop appears independent:

  • open Laptop A → swap colors → close
  • open Laptop B → default colors → beep → close
  • reopen Laptop A → colors are still swapped (state restored)

even though under the hood it’s still one ReplaceTexture/DUI at a time.


Not interacting = no open laptop = no live screen visible.
saved state per laptop gives the illusison each laptop has its own screen

Screen Glare

random but can be annoying in cars or outside in broad daylight

Because of the txd replace the panel now lives within the model and inherits glare from the prop since the panel is inside of the prop.

2 Likes

For the Snake Game.

How would you handle the DUI so that when it gets placed, it displays for all players in the area, and is synced showing someone playing it?

1 Like

how to make the Snake demo update in real time for nearby players
by syncing authoritative game state

Only ONE player sends input.
The SERVER owns the simulation.
ALL clients render the same state.


State Shape (this is what gets synced)

-- server-owned authoritative state
local function NewSnakeState()
  return {
    tick  = 0,
    dir   = "RIGHT",
    snake = {
      { x = 5, y = 5 },
      { x = 4, y = 5 },
      { x = 3, y = 5 },
    },
    food  = { x = 8, y = 3 },
    alive = true,
  }
end

Client → Server (focused player sends INTENT only)

-- called from NUI when the focused player presses a direction key
TriggerServerEvent("snake:setDir", panelId, "LEFT")

Only the focused player ever sends input.


Server: Receive intent + own the simulation

local Games = {} -- Games[panelId] = state

RegisterNetEvent("snake:create", function(panelId)
  if Games[panelId] then return end
  Games[panelId] = NewSnakeState()
end)

RegisterNetEvent("snake:setDir", function(panelId, dir)
  local g = Games[panelId]
  if not g or not g.alive then return end
  g.dir = dir
end)

Server: Authoritative Tick Loop (ONLY place the snake moves)

CreateThread(function()
  while true do
    Wait(150)

    for panelId, g in pairs(Games) do
      if g.alive then
        stepSnake(g)        -- move head, handle food/collision
        g.tick = g.tick + 1

        -- broadcast authoritative state to everyone
        TriggerClientEvent("snake:update", -1, panelId, g)
      end
    end
  end
end)

Client: Receive state and forward to NUI (all players)

RegisterNetEvent("snake:update", function(panelId, state)
  SendNUIMessage({
    type = "snake:update",
    panelId = panelId,
    state = state
  })
end)

Focused player and observers all receive the same update.


NUI: Render from state only (no authority)

window.addEventListener("message", (e) => {
  const msg = e.data;
  if (msg.type !== "snake:update") return;

  const s = msg.state;
  clearCanvas();
  drawFood(s.food);
  drawSnake(s.snake);

  if (!s.alive) drawGameOver();
});

Core Rules

  • Focus is local
  • Input is local
  • State is server-authoritative
  • Everyone renders the same state
  • Late joiners reconstruct instantly

One player drives. Everyone else watches. The UI just renders the truth.

This is interesting, I’d assume you would have client own it, and just have the server pass the info to everyone as the client does something.

1 Like

yep! this best way for networking stuff like this imho. doing it as well

1 Like

WORK IN PROGRESS
update 2.7: External Websites

In a nutshell using a UV grid we overlay above the panel that can forward mouse inputs into the DUI because external sites cant be embedded into an iframe.

This allows interaction with any website while in fivem

Currently i need to tune the UV grid to better match the cursor clicks but have been preoccupied with other projects… update SOON™

3 Likes

OMFG! you are THE GUY its amazing!

1 Like

Thank you for this!!

2 Likes

LOOKING GOOD!

Love to see the txd replace in action!

its sooooo easy to add panels to props using it :purple_heart:

1 Like

I can’t type in apps or use the scroll wheel. Do you have any suggestions?

1 Like


The update… I got it sync’d to other clients.

1 Like

So for keyboard it’s pretty much the same as mouse

Lua

local panelFocused = false

local function SetPanelFocus(state)
    panelFocused = state
    SetNuiFocus(state, state)              -- mouse + keyboard
    SetNuiFocusKeepInput(false)            -- usually false while typing
end

RegisterCommand("panel", function()
    SetPanelFocus(true)
    SendNUIMessage({ type = "panelFocus", focused = true })
end)

RegisterNUICallback("exit", function(_, cb)
    SetPanelFocus(false)
    cb({})
end)

CreateThread(function()
    while true do
        if panelFocused then
            DisableAllControlActions(0)

            -- allow ESC to exit (optional)
            EnableControlAction(0, 322, true) -- ESC
            if IsControlJustPressed(0, 322) then
                SetPanelFocus(false)
                SendNUIMessage({ type = "panelFocus", focused = false })
            end

            Wait(0)
        else
            Wait(250)
        end
    end
end)

NUI JS

window.addEventListener("message", (e) => {
  if (e.data?.type === "panelFocus") {
    if (e.data.focused) {
      // focus an input so typing works immediately
      document.querySelector("#chatInput")?.focus();
    }
  }
});

function exit() {
  fetch(`https://${GetParentResourceName()}/exit`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({})
  });
}

You can’t send keyboard input “to the prop / render target”. The prop is display-only. Put the player in an interaction state, call SetNuiFocus(true,true) and (optionally) disable game controls. Then typing goes to your NUI page normally; any extra keybinds you want can be captured in Lua and forwarded to NUI via SendNUIMessage.

Scroll Wheel………….

If NUI focus is on

  • SetNuiFocus(true, true) → the Chromium NUI will receive wheel events normally.

  • In your UI, make sure you’re not blocking it with CSS:

    • A container needs overflow: auto (or scroll)
    • Don’t globally disable scrolling unless you mean to (overflow: hidden)

Lua

- while panelFocused
if IsControlJustPressed(0, 241) then -- INPUT_SELECT_NEXT_WEAPON (mouse wheel up)
  SendNUIMessage({ type = "wheel", dir = 1 })
end

if IsControlJustPressed(0, 242) then -- INPUT_SELECT_PREV_WEAPON (mouse wheel down)
  SendNUIMessage({ type = "wheel", dir = -1 })
end

Js:

window.addEventListener("message", (e) => {
  if (e.data?.type === "wheel") {
    // example: scroll a list
    const el = document.querySelector("#list");
    el?.scrollBy(0, e.data.dir * 80);
  }
});
1 Like


Love it.

2 Likes

VERY nice!!

You got server sync and more importantly you solved the pain point of the txd replace a dui…

Multiple clients with one txd slot and multiple states

Having the 3 individual laptops with unique dui rendering on each simultaneously on the same client is the coolest part about your video!

That means you solved the biggest pain point with it

1 Like