Categories
Development High Stakes Tutorials

Converting a NFS1 track to blender

As it turned out, using already converted NFS1 tracks (e.g. NFS3, NFS4 or Assetto versions) being highly inaccurate (e.g. NFS3 “Rusty Springs” is actually a reskinned “Oasis Springs”) I got deeper into getting the original geometry exported.

Upon research I discovered the mighty tools of Andrew Gura (andrew_gura) within the discord community of RENFS. I got into contact with him and he managed to fix some problems I discovered, yet me being able to convert all NFS1(SE) tracks fluently to blender.

Prequisites

Preparations

Once you have downloaded the NFS resource toolkit by Andrew Gura from above and got your TNFS files dumped from your original copy, open your terminal (cmd) and navigate to the folder where you unzipped the nfsrc toolkit to, e.g. d:\NFSToolkit\, or in my case D:\_HighStakes\nfs-resources-converter-main.

For the most fluent experience make sure you have no other python version active and/or installed. I tend to use a specific unix WSL for it or a virtual windows machine.

Make sure that blender and ffmpeg commands work in terminal (cmd). If not, either fix your system environment variable PATH, or reboot your system if software was just installed, or set an absolute path to executables in settings.py

Then just enter

pip install -r requirements.txt

This will download a bunch of other libs and tools

just sit back and relax until everything is finished, it will take some time.

For the next step I recommend copying all neccessary data to your working nfsrc-directory.

My directories are, as “nfs-resources-converter-main” is the root:

  • /media/fast/NFSSE/ – all NFSSE files here
  • /media/out/where the track files go to

Now just run

python run.py convert media/fast/NFSSE --out media/out/

you maybe need to adjust your pathes to your local configuration. Bare in mind not to use “/” at the very beginning of your pathes.

once this process is completed (I have error printing enabled if you wonder) – you will find all converted ressources in the spcified folder:

\media\out\SIMDATA\MISC

This is where you will find the converted track files. Let’s take a look at Burnt Sienna as this is one of my favorite tracks of Need For Speed 1.

Burnt Sienna is TR6, this is the folder contents:

For me opening the .blend file does work, yet I cannot get the texture getting displayed. Instead I am importing the .obj-file into 3D Studio Max. Importing into blender does also work like a charm, yet the axis’ are somewhat flipped as you will surely notice. So make sure your import axis’ settings are as follows:

Forward Axis: X
Up Axis: Z

And then you will see the imported original track “Burnt Sienna” in all of it’s glory inside blender, 3dsmax or any other 3d software capable of handling .obj-files.

Further processing

As you now have a blender-compatible version of all the nfs1se-tracks you are free to use them for your projects. Bare in mind that the original tracks do have specific rights to the owners and may not be used in any public project.

For my use, I united all track and object parts to one each for better replacing and recreating in the unreal engine 5 for my High Stakes Racing project.

Hidden Tracks

As the nfs-fans of you might know, there are two hidden versions of “Rusty Springs” included in the PSX (Playstation) version of the game. Yet those two tracks (Oasis Springs and Lunar Springs) are indeed included but not playable in the game. Together with Andrew Gura I did manage to get them into blender.

Oasis Springs

Oasis Springs is available as “normal” bonus track and is therefor directly being exported during the process above. You will find it in the download section.

Lunar Springs

For Lunar Springs, as this is – beside the objects – just a reskinned Oasis Springs. To get access to the correctly exported textures and objects, we need to modify the following data before converting it.

Renaming TR8_R01.FAM inside NTRACKFM folder to TR5_001.FAM inside ETRACKFM folder.

After that you need to run the whole conversion process again as all dependencies are baked during conversion.

After running the conversion again just copy all content of NTRACKFM/TR8_R01.FAM over NTRACKFM/TR5_M01.FAM and re-run the conversion command.

Alternatively you can launch the gui and switch manually to NTRACKFM/TR8_R01.FAM

python run.py gui /media/fast/NFSSE/SIMDATA/MISC/TR5.TRI

Although some objects are odd as there are just some remains of this track beside the full version. But as I am recreating all tracks by myself I just needed the placement and textures as reference.


Troubleshooting

I do not have any objects (trees, signs, houses) in my track

This is related to a setting in the settings.py file and as fixed in newer versions of the nfsrc toolkit – so you should consider getting the newest build from the git repo, or manually adjust the setting to “true”.

maps__add_props_to_obj = True

Blender file is not being generated

If you face the issue that you do not find any .blend file once the conversion process is complete this is most likely related due to wrong executable file specification within the settings.py

If you did noit modify the settings.py the script will require you to have blender set as system variable, otherwise you need to fix the path, e.g:

blender_executable = 'C:\Program Files\Blender Foundation\Blender 3.6\blender.exe'

Textures are not being generated

If you see that there are no textures being generated – this will be related to a problem with correctly replacing the placeholder backslashes within the bitmap.py script.

If you have error printing enabled, you will see an error like this

File "\serializers\bitmaps.py", line 52, in serialize
Image.frombytes('RGBA',
File "C:\Python\39\lib\site-packages\PIL\Image.py", line 2436, in save
fp = builtins.open(filename, "w+b")
FileNotFoundError: [Errno 2] No such file or directory: 'media--bs--out/--bs--SIMDATA--bs--ETRACKFM--bs--TR2_001.FAM--bs--props--bs--1--bs--0--bs--assets/side.png'
Answer

download my fixed bitmap.py script and overwrite the existing one ine /serializers/.

ERROR: Could not install packages due to an EnvironmentError: [WinError 5]

Zugriff verweigert: ‘c:\python38\lib\site-packages\pip-20.1.1.dist-info\entry_points.txt’
Consider using the –user option or check the permissions.

WARNING: You are using pip version 20.1.1; however, version 25.0.1 is available.
You should consider upgrading via the ‘c:\python38\python.exe -m pip install –upgrade pip’ command.

Answer

The classic Windows WinError 5 — it means permission denied when pip tries to write to a system-wide folder. Here’s how to fix it 👇

✅ Option 1: Use --user to install in your user directory

Just add --user to your install command:

python -m pip install --upgrade pip --user

Or for any package:

python -m pip install <package-name> --user

This installs to your user-level site-packages (no admin needed).

✅ Option 2: Run as Administrator (if you need system-wide install)
  1. Open the Start Menu
  2. Search for Command Prompt
  3. Right-click → Run as administrator
  4. Then run: bashKopierenBearbeitenpython -m pip install --upgrade pip

This gives pip the permissions it needs to update itself or install globally.

⚠️ Optional: Use Python 3.9 instead

If you’ve already installed Python 3.9 and want to upgrade pip there:

bashKopierenBearbeitenpython3.9 -m pip install --upgrade pip --user

(or replace python3.9 with the exact path, e.g. C:\Python39\python.exe if needed)

Categories
Development General High Stakes Tutorials

Importing NFS3 PSX Tracks to Blender

If you want to import some or all Need For Speed III – Hot Pursuit PSX tracks to blender this short guide might help you.

I digged into it to get to the data of the Playstation-only Tracks Autocross, Caverns, Scorpio-7, Space Race and The Room, but also to finally see the legend TR02B myself.

Personal trivia: I knew that there needed to be some sort of hidden tracks and cars beside the ones that I could ever find as I can clearly remember as if it was yesterday when I read an article about the upcoming NFS3 game showing a screenshot of both track and car that I could never find in the (PC-)release version. Due to the fact that I never had a PSX myself but playing it on PC, I honestly never looked after it. Today I found out that the screenshot was representing the Autocross-Track with the PSX El Nino which has (for some reason) differences in it’s rear end textures.

Prequisites

  • Blender (download here)
  • The PC-Version of Need For Speed III*
  • The PSX-Release of Need For Speed III**
  • The Tool/Game OpenNFS (download here, mirror, mirror)
  • The Tool trk2blend (download here)
  • Adobe Photoshop (or any other tool with the ability to batch-convert BMP-files to PNG-files)

Setting up OpenNFS

This step might be optional as there could be other ways to get your hands on the texture of those tracks, but I found it the most easy way once you set up OpenNFS.

Once you have downloaded OpenNFS you may just want to extract the zipped folder to a location of your choice.

Following the original installation instruction (mirror) you then need to copy the whole NFSIII-PC files obtained from your original NFSIII-PC CD to resources/NFS_3 and the extracted files from your NFSIII-PSX CD to resouces/NFS_3_PS1, so that both folders look like the following screenhots show:

The following step with OpenNFS might be obsolete as there are other tools to convert the textures to BMP, PNG or whatever, but I personally found it the most easy way just to click through the dropdown in OpenNFS waiting for the tracks to load and have the textures all ready in each folder instead of working on a cli.

Starting OpenNFS

Once you started OpenNFS select TR02B (or any other track you like) from the track dropdown

In the cli window in the background you will notice some stuff going on, just be patient and wait for the track to load.

TR02B from NFS3 Hot Pursuit loaded in OpenNFS

Once the track is fully loaded you now may exit OpenNFS or take a lap around the track – your choice.

Afterwards you navigate to the installation directory of OpenNFS and jump to

/bin/assets/tracks/NFS_3_PS1/

In there you will find all track-folders you already opened in OpenNFS containing each track’s textures as BMP files.

e.g. ZZZTR02B

Converting the BMP files to PNGs

As there are many ways to achieve that I will leave that up to you, I used a simple photoshop action and batch-runned it to get them into PNGs.

I suggest to store the png files in new folder. Personally, I always create new folders like

ROOT/exports/nfs3/tracks/tr02b/png

Converting the TRK files to Blender with TRK2Blender

Open the tool “TRK2Blender”, navigate to the PSX-files-folder within your OpenNFS installation directory and select the corresponding trk file, e.g. ZZZTRK02B.

Afterwars you will see the conversion process running

Prompting “Conversion complete, please exit.” when the process is completed. Within the NFS3-PSX file folder in OpenNFS’s resources you then will find a new folder named after the track file you opened containing all .lwo files.

Note: At this step I suggest to copy all .lwo-files to the export-folder above just to keep an eye on the exported files for later use maybe.

ROOT/exports/nfs3/tracks/tr02b/

Make sure you copied the bmps converted to png in the subfolder (here “PNG”; but the name is up to you, e.g. “textures” or whatever).

Start Blender

Once you started blender I prefer to empty the scene to remove unused content.

Open scripting tab, click “new” and paste the following script.

import os
import bpy

# put the location to the folder where the objs are located here in this fashion
# this line will only work on windows ie C:\objects
path_to_obj_dir = os.path.join('C:\\', 'Users', 'USERNAME', 'PATH', 'TO', 'YOUR', 'FILES')

# get list of all files in directory
file_list = sorted(os.listdir(path_to_obj_dir))

# get a list of files ending in 'obj'
obj_list = [item for item in file_list if item.endswith('.lwo')]

# loop through the strings in obj_list and add the files to the scene
for item in obj_list:
    path_to_file = os.path.join(path_to_obj_dir, item)
    bpy.ops.import_scene.lwo(filepath = path_to_file)

Make sure to edit the “path_to_obj_dir” var to match your folder structure, e.g.

 path_to_obj_dir = os.path.join('D:\\', '_HighStakes', 'OpenNFS', 'OpenNFS.Pre-Alpha.v_03', 'resources', 'NFS_3_PS1', 'ZZZTR02B')

And hit the “play”-button in the scripting Tab. An error will occur.

Select the texture folder created, hit “Select Image Search…” and see imported track TR02B in blender.

TR02B – The hidden track from Need For Speed III – Hot Pursuit – imported to blender.

*, ** – Due to copyrights I cannot give you access to any of these original game data files, of course!

Categories
Development High Stakes Tutorials

EASY Importing NFS4-Tracks to Blender

In this *new* tutorial I explain how everyone of you can easily import tracks from NFS4 into blender. Forget the old tutorial. After digging the internet I found a small set of tools that will make your life much easier.

TL-DR – Take me directly to the tutorial!

The old way as described in this tutorial was a huge workflow with alot of conversion time needed for many many manual processes such as converting textures and geometry, correcting texture offsets etc.

While I was – more or less accidently – searching for a method to convert NFS5 Porsche Unleashed tracks into blender I found the toolset “speedtools” by Rafał Kuźnia.

That guy is a time saver.

Once you worked through all the setup process for the tools you can just one-click import NFS4-tracks into blender, that’s it – just as easy as opening a .fbx-file.

It’s true, you can just go to file > import > track resources and open any* NFS4-track

And after waiting a while you are ready to go

The Setup

First, what you need:

  • Blender, Version >= 3.5 (get it here)
  • speedtools (get it here, mirror)
  • the nfs4-import-script (get it here, mirror)
  • Kaitai Struct compiler (get it here, mirror)
  • NFS4-track files

Download all of them and have the NFS4-track files ready.

Install Blender

obviously, Blender needs to be installed, sherlock.

Install Kaitai Struct compiler

Right after you installed Blender go ahead and download and install the Katai Struct compiler. I suggest to use the install’s default settings and quickly click through the installer.

Start Blender

Now it’s time to start Blender. When blender has opened up you want to head to the scripting section from the very top main menu (at the very right side):

The viewport will change a lot. The window you are looking for is the bottom left console window where the input has the leading three “>” characters:

In this window just copy-paste the following command end hit enter. Once complete there will be a prompt with “0”. This command will install the python-tools needed.

import sys, subprocess
subprocess.call([sys.executable, "-m", "pip", "install", "speedtools"])

Once this step is complete you need to save the blank project. Bear in mind that the project’s save location will be used from the importer to storage the imported images and assets in subfolder(s) created within the save location, so I suggest to choose carefully your save location, like the following:

../projects/hs/tracks/hometown/hometown.blend

Time to import

Yet, as all the steps above are done, all is set up for the first NFSHS-track import to blender.

To enable the easy import mode, you need to copy the content of the following script to your blender scripting tab, or save the file somewhere in your file system and open it up in blender:

#
# Copyright (c) 2023 Rafał Kuźnia <rafal.kuznia@protonmail.com>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#

from __future__ import annotations

import logging
from abc import ABCMeta, abstractmethod
from collections.abc import Callable, Iterable
from dataclasses import dataclass
from functools import total_ordering
from itertools import chain, groupby
from math import pi
from pathlib import Path
from typing import Any

import bpy
import mathutils
from bpy.props import BoolProperty, EnumProperty, StringProperty
from more_itertools import collapse, duplicates_everseen, one, unique_everseen

from speedtools import TrackData, VivData
from speedtools.types import (
    Action,
    AnimationAction,
    BaseMesh,
    BlendMode,
    Camera,
    DirectionalLight,
    DrawableMesh,
    Light,
    Matrix3x3,
    Part,
    Polygon,
    Resource,
    ShapeKey,
    Vector3d,
    Vertex,
)
from speedtools.utils import export_resource

logger = logging.getLogger()
logger.setLevel(logging.INFO)
logger.addHandler(logging.StreamHandler())


bl_info = {
    "name": "Import NFS4 Track",
    "author": "Rafał Kuźnia",
    "version": (1, 0, 0),
    "blender": (3, 4, 1),
    "location": "File > Import > Track resource",
    "description": "Imports a NFS4 track files (meshes, textures and objects)."
    "Scripts/Import-Export/Track_Resource",
    "category": "Import-Export",
}


@total_ordering
@dataclass(frozen=True)
class ExtendedResource:
    resource: Resource
    backface_culling: bool

    def __lt__(self, other: ExtendedResource) -> bool:
        return hash(self) < hash(other)


class BaseImporter(metaclass=ABCMeta):
    def __init__(self, material_map: Callable[[Polygon], Resource]) -> None:
        self.materials: dict[ExtendedResource, bpy.types.Material] = {}
        self.material_map = material_map

    @classmethod
    def duplicate_common_vertices(cls, mesh: DrawableMesh) -> DrawableMesh:
        unique_vert_polys = list(unique_everseen(mesh.polygons, key=lambda x: frozenset(x.face)))
        duplicate_vert_polys = list(
            duplicates_everseen(mesh.polygons, key=lambda x: frozenset(x.face))
        )
        faces = frozenset(chain.from_iterable(poly.face for poly in duplicate_vert_polys))
        verts_to_duplicate = [mesh.vertices[x] for x in faces]
        mapping = {f: i for i, f in enumerate(faces, start=len(mesh.vertices))}

        def _make_polygon(polygon: Polygon) -> Polygon:
            face = tuple(mapping[f] for f in polygon.face)
            return Polygon(
                face=face,
                uv=polygon.uv,
                material=polygon.material,
                backface_culling=polygon.backface_culling,
            )

        polygons = unique_vert_polys + [_make_polygon(polygon) for polygon in duplicate_vert_polys]
        vertices = list(mesh.vertices) + verts_to_duplicate
        return DrawableMesh(vertices=vertices, polygons=polygons)

    def _extender_resource_map(self, polygon: Polygon) -> ExtendedResource:
        resource = self.material_map(polygon)
        return ExtendedResource(resource=resource, backface_culling=polygon.backface_culling)

    def _link_texture_to_shader(
        self, node_tree: bpy.types.NodeTree, texture: bpy.types.Node, shader: bpy.types.Node
    ) -> None:
        node_tree.links.new(texture.outputs["Color"], shader.inputs["Base Color"])
        node_tree.links.new(texture.outputs["Alpha"], shader.inputs["Alpha"])

    def _set_blend_mode(
        self,
        node_tree: bpy.types.NodeTree,
        shader_output: bpy.types.NodeSocket,
        bpy_material: bpy.types.Material,
        resource: Resource,
    ) -> bpy.types.NodeSocket:
        if resource.blend_mode is BlendMode.ALPHA:
            bpy_material.blend_method = "BLEND"
        elif resource.blend_mode is BlendMode.ADDITIVE:
            bpy_material["SPT_additive"] = True
        else:
            bpy_material.alpha_threshold = 0.001
            bpy_material.blend_method = "CLIP"
        return shader_output

    def _make_material(self, ext_resource: ExtendedResource) -> bpy.types.Material:
        resource = ext_resource.resource
        images_dir = Path(bpy.path.abspath("//images"))
        export_resource(resource, directory=images_dir)
        bpy_material = bpy.data.materials.new(resource.name)
        bpy_material.use_nodes = True
        image_path = Path(images_dir, f"{resource.name}.png")
        image = bpy.data.images.load(str(image_path), check_existing=True)
        node_tree = bpy_material.node_tree
        material_output = node_tree.nodes.get("Material Output")
        image_texture = node_tree.nodes.new("ShaderNodeTexImage")
        image_texture.image = image  # type: ignore[attr-defined]
        image_texture.extension = "EXTEND"  # type: ignore[attr-defined]
        bsdf = node_tree.nodes["Principled BSDF"]
        bsdf.inputs["Specular"].default_value = 0  # type: ignore[attr-defined]
        bsdf.inputs["Roughness"].default_value = 1  # type: ignore[attr-defined]
        bsdf.inputs["Sheen Tint"].default_value = 0  # type: ignore[attr-defined]
        self._link_texture_to_shader(node_tree=node_tree, texture=image_texture, shader=bsdf)
        output_socket = self._set_blend_mode(
            node_tree=node_tree,
            shader_output=bsdf.outputs["BSDF"],
            bpy_material=bpy_material,
            resource=resource,
        )
        node_tree.links.new(output_socket, material_output.inputs["Surface"])
        bpy_material.use_backface_culling = ext_resource.backface_culling
        return bpy_material

    def _map_material(self, ext_resource: ExtendedResource) -> bpy.types.Material:
        try:
            return self.materials[ext_resource]
        except KeyError:
            bpy_material = self._make_material(ext_resource=ext_resource)
            self.materials[ext_resource] = bpy_material
        return self.materials[ext_resource]

    def make_base_mesh(self, name: str, mesh: BaseMesh) -> bpy.types.Mesh:
        bpy_mesh = bpy.data.meshes.new(name)
        bpy_mesh.from_pydata(
            vertices=list(mesh.vertex_locations),
            edges=[],
            faces=[polygon.face for polygon in mesh.polygons],
        )
        return bpy_mesh

    def set_object_location(self, obj: bpy.types.Object, location: Vector3d) -> None:
        mu_location = mathutils.Vector(location)
        obj.location = mu_location

    def set_object_action(self, obj: bpy.types.Object, action: AnimationAction) -> None:
        animation = action.animation
        obj.rotation_mode = "QUATERNION"
        if obj.animation_data is None:
            anim_data = obj.animation_data_create()
        else:
            anim_data = obj.animation_data
        bpy_action = bpy.data.actions.new(name=str(action.action))
        anim_data.action = bpy_action
        for index, (location, quaternion) in enumerate(
            zip(animation.locations, animation.quaternions)
        ):
            mu_location = mathutils.Vector(location)
            mu_quaternion = mathutils.Quaternion(quaternion)
            mu_quaternion = mu_quaternion.normalized()
            mu_quaternion = mu_quaternion.inverted()
            obj.delta_location = mu_location
            obj.delta_rotation_quaternion = mu_quaternion  # type: ignore[assignment]
            interval = index * animation.delay
            obj.keyframe_insert(
                data_path="delta_location", frame=interval, options={"INSERTKEY_CYCLE_AWARE"}
            )
            obj.keyframe_insert(
                data_path="delta_rotation_quaternion",
                frame=interval,
                options={"INSERTKEY_CYCLE_AWARE"},
            )
        points = chain.from_iterable(fcurve.keyframe_points for fcurve in bpy_action.fcurves)
        for point in points:
            point.interpolation = "LINEAR"
        bpy_action.name = f"{obj.name}-action-{action.action}"
        track = anim_data.nla_tracks.new()
        track.strips.new(name=bpy_action.name, start=0, action=bpy_action)

    def set_object_rotation(
        self,
        obj: bpy.types.Object,
        transform: Matrix3x3,
        offset: mathutils.Euler | None = None,
    ) -> None:
        mu_matrix = mathutils.Matrix(transform)
        if offset:
            mu_euler = offset
            mu_euler.rotate(mu_matrix.to_euler("XYZ"))  # type: ignore # pylint: disable=all
        else:
            mu_euler = mu_matrix.to_euler("XYZ")  # type: ignore # pylint: disable=all
        obj.rotation_mode = "XYZ"
        obj.rotation_euler = mu_euler  # type: ignore[assignment]

    def make_drawable_object(
        self, name: str, mesh: DrawableMesh, import_shading: bool = False
    ) -> bpy.types.Object:
        bpy_mesh = self.make_base_mesh(name=name, mesh=mesh)
        uv_layer = bpy_mesh.uv_layers.new()
        uvs = collapse(polygon.uv for polygon in mesh.polygons)
        uv_layer.data.foreach_set("uv", list(uvs))
        if mesh.vertex_normals:
            normals = tuple(mesh.vertex_normals)
            # I have no idea if setting the normals even works
            bpy_mesh.normals_split_custom_set_from_vertices(normals)  # type: ignore[arg-type]
        if mesh.vertex_colors and import_shading:
            colors = collapse(color.rgba_float for color in mesh.vertex_colors)
            bpy_colors = bpy_mesh.color_attributes.new(
                name="Shading", type="FLOAT_COLOR", domain="POINT"
            )
            bpy_colors.data.foreach_set("color", tuple(colors))  # type: ignore[attr-defined]
        polygon_pairs = zip(mesh.polygons, bpy_mesh.polygons)
        sorted_by_material = sorted(polygon_pairs, key=lambda x: self._extender_resource_map(x[0]))
        grouped_by_material = groupby(
            sorted_by_material, key=lambda x: self._extender_resource_map(x[0])
        )
        for index, (key, group) in enumerate(grouped_by_material):
            material = self._map_material(key)
            bpy_mesh.materials.append(material)
            for _, bpy_polygon in group:
                bpy_polygon.use_smooth = True
                bpy_polygon.material_index = index
        bpy_mesh.validate()
        bpy_obj = bpy.data.objects.new(name, bpy_mesh)
        if mesh.shape_keys:
            bpy_obj.shape_key_add(name="Basis")
        return bpy_obj

    def make_light_object(self, name: str, light: Light) -> bpy.types.Object:
        bpy_light = bpy.data.lights.new(name=name, type="POINT")
        bpy_light.color = light.attributes.color.rgb_float
        bpy_light.use_custom_distance = True
        bpy_light.cutoff_distance = 15.0
        bpy_light.specular_factor = 0.2
        bpy_light.energy = 500  # type: ignore[attr-defined]
        bpy_light.use_shadow = False  # type: ignore[attr-defined]
        bpy_obj = bpy.data.objects.new(name=name, object_data=bpy_light)
        self.set_object_location(obj=bpy_obj, location=light.location)
        return bpy_obj

    def make_directional_light_object(
        self, name: str, light: DirectionalLight
    ) -> bpy.types.Object:
        bpy_sun = bpy.data.lights.new(name=name, type="SUN")
        bpy_obj = bpy.data.objects.new(name=name, object_data=bpy_sun)
        mu_euler = mathutils.Euler(light.euler_xyz)
        bpy_obj.rotation_mode = "XYZ"
        bpy_obj.rotation_euler = mu_euler  # type: ignore[assignment]
        return bpy_obj

    def make_camera_object(self, name: str, camera: Camera) -> bpy.types.Object:
        bpy_camera = bpy.data.cameras.new(name=name)
        bpy_obj = bpy.data.objects.new(name=name, object_data=bpy_camera)
        offset = mathutils.Euler((pi / 2, 0, 0))
        self.set_object_location(obj=bpy_obj, location=camera.location)
        self.set_object_rotation(obj=bpy_obj, transform=camera.transform, offset=offset)
        return bpy_obj

    def make_shape_key(self, obj: bpy.types.Object, shape_key: ShapeKey) -> None:
        bpy_shape_key = obj.shape_key_add(name=shape_key.type.name)
        bpy_shape_key.interpolation = "KEY_LINEAR"
        for data, vertex in zip(bpy_shape_key.data, shape_key.vertices, strict=True):
            data.co = vertex.location  # type: ignore[attr-defined]


class TrackImportStrategy(metaclass=ABCMeta):
    @abstractmethod
    def import_track(
        self,
        track: TrackData,
        import_collision: bool = False,
        import_shading: bool = False,
        import_actions: bool = False,
        import_cameras: bool = False,
    ) -> None:
        pass


class TrackImportGLTF(TrackImportStrategy, BaseImporter):
    def import_track(
        self,
        track: TrackData,
        import_collision: bool = False,
        import_shading: bool = False,
        import_actions: bool = False,
        import_cameras: bool = False,
    ) -> None:
        bpy.context.scene.render.fps = track.ANIMATION_FPS
        track_collection = bpy.data.collections.new("Track segments")
        bpy.context.scene.collection.children.link(track_collection)
        for index, segment in enumerate(track.track_segments):
            name = f"Segment {index}"
            segment_collection = bpy.data.collections.new(name=name)
            track_collection.children.link(segment_collection)
            bpy_obj = self.make_drawable_object(
                name=name, mesh=segment.mesh, import_shading=import_shading
            )
            segment_collection.objects.link(bpy_obj)
            if import_collision:
                for collision_index, collision_mesh in enumerate(segment.collision_meshes):
                    effect = collision_mesh.collision_effect
                    name = f"Collision {collision_index}.{effect}-colonly"
                    bpy_mesh = self.make_base_mesh(name=name, mesh=collision_mesh)
                    bpy_obj = bpy.data.objects.new(name, bpy_mesh)
                    segment_collection.objects.link(bpy_obj)
                    bpy_obj.hide_set(True)
        object_collection = bpy.data.collections.new("Objects")
        bpy.context.scene.collection.children.link(object_collection)
        for index, obj in enumerate(track.objects):
            name = f"Object {index}"
            mesh = self.duplicate_common_vertices(mesh=obj.mesh)
            bpy_obj = self.make_drawable_object(
                name=name, mesh=mesh, import_shading=import_shading
            )
            actions = (
                obj.actions
                if import_actions
                else filter(lambda x: x.action is Action.DEFAULT_LOOP, obj.actions)
            )
            for action in actions:
                self.set_object_action(obj=bpy_obj, action=action)
            if obj.location:
                self.set_object_location(obj=bpy_obj, location=obj.location)
            if obj.transform:
                self.set_object_rotation(obj=bpy_obj, transform=obj.transform)
            object_collection.objects.link(bpy_obj)
        light_collection = bpy.data.collections.new("Lights")
        bpy.context.scene.collection.children.link(light_collection)
        for index, light in enumerate(track.lights):
            name = f"Light {index}"
            bpy_obj = self.make_light_object(name=name, light=light)
            light_collection.objects.link(bpy_obj)
        directional_light = track.directional_light
        if directional_light:
            bpy_obj = self.make_directional_light_object(name="sun", light=directional_light)
            light_collection.objects.link(bpy_obj)
        if import_cameras:
            camera_collection = bpy.data.collections.new("Cameras")
            bpy.context.scene.collection.children.link(camera_collection)
            for index, camera in enumerate(track.cameras):
                bpy_obj = self.make_camera_object(name=f"Camera {index}", camera=camera)
                camera_collection.objects.link(bpy_obj)


class TrackImportBlender(TrackImportGLTF):
    def _link_texture_to_shader(
        self, node_tree: bpy.types.NodeTree, texture: bpy.types.Node, shader: bpy.types.Node
    ) -> None:
        color_attributes = node_tree.nodes.new("ShaderNodeAttribute")
        color_attributes.attribute_name = "Shading"  # type: ignore[attr-defined]
        mixer = node_tree.nodes.new("ShaderNodeMixRGB")
        mixer.blend_type = "MULTIPLY"  # type: ignore[attr-defined]
        mixer.inputs["Fac"].default_value = 1.0  # type: ignore[attr-defined]
        node_tree.links.new(texture.outputs["Color"], mixer.inputs["Color1"])
        node_tree.links.new(color_attributes.outputs["Color"], mixer.inputs["Color2"])
        node_tree.links.new(mixer.outputs["Color"], shader.inputs["Base Color"])
        node_tree.links.new(texture.outputs["Alpha"], shader.inputs["Alpha"])

    def _set_blend_mode(
        self,
        node_tree: bpy.types.NodeTree,
        shader_output: bpy.types.NodeSocket,
        bpy_material: bpy.types.Material,
        resource: Resource,
    ) -> bpy.types.NodeSocket:
        shader_output = super()._set_blend_mode(
            node_tree=node_tree,
            shader_output=shader_output,
            bpy_material=bpy_material,
            resource=resource,
        )
        output_socket = shader_output
        if resource.blend_mode is BlendMode.ADDITIVE:
            bpy_material.blend_method = "BLEND"
            transparent_bsdf = node_tree.nodes.new("ShaderNodeBsdfTransparent")
            add_shader = node_tree.nodes.new("ShaderNodeAddShader")
            node_tree.links.new(shader_output, add_shader.inputs[0])
            node_tree.links.new(transparent_bsdf.outputs["BSDF"], add_shader.inputs[1])
            output_socket = add_shader.outputs["Shader"]
        return output_socket


class CarImporterSimple(BaseImporter):
    def import_car(self, parts: Iterable[Part]) -> None:
        car_collection = bpy.data.collections.new("Car parts")
        bpy.context.scene.collection.children.link(car_collection)
        for part in parts:
            bpy_obj = self.make_drawable_object(name=part.name, mesh=part.mesh)
            self.set_object_location(obj=bpy_obj, location=part.location)
            car_collection.objects.link(bpy_obj)
            for shape_key in part.mesh.shape_keys:
                self.make_shape_key(obj=bpy_obj, shape_key=shape_key)


class TrackImporter(bpy.types.Operator):
    """Import NFS4 Track Operator"""

    bl_idname = "import_scene.nfs4trk"
    bl_label = "Import NFS4 Track"
    bl_description = "Import NFS4 track files"
    bl_options = {"REGISTER", "UNDO"}

    bpy.types.Scene.nfs4trk = None  # type: ignore[attr-defined]

    directory: StringProperty(  # type: ignore[valid-type]
        name="Directory Path",
        description="Directory containing the track files",
        maxlen=1024,
        default="",
    )
    mode: EnumProperty(  # type: ignore[valid-type]
        name="Mode",
        items=(
            (
                "GLTF",
                "GLTF target",
                "Parametrized import of visible track geometry, lights, animations, "
                "collision geometry and more. Stores data that can't be represented in "
                "GLTF 'extras' fields.",
            ),
            (
                "BLENDER",
                "Blender target",
                "This option should be used when accurate look in Blender is desired. "
                "Some data, such as vertex shading, can't be viewed in Blender without specific "
                "shader node connections. Such connections are on the other hand poorly understood "
                "by exporters, such as the GLTF exporter. Therefore this mode must never be "
                "used if you intent to export the track to GLTF. Vertex shading is always enabled "
                "in this mode.",
            ),
        ),
        description="Select importer mode",
    )
    night: BoolProperty(  # type: ignore[valid-type]
        name="Night on", description="Import night track variant", default=False
    )
    weather: BoolProperty(  # type: ignore[valid-type]
        name="Weather on", description="Import rainy track variant", default=False
    )
    mirrored: BoolProperty(  # type: ignore[valid-type]
        name="Mirrored on", description="Import mirrored track variant", default=False
    )
    import_shading: BoolProperty(  # type: ignore[valid-type]
        name="Import vertex shading",
        description="Import original vertex shading to obtain the 'original' track look",
        default=False,
    )
    import_collision: BoolProperty(  # type: ignore[valid-type]
        name="Import collision (experimental)",
        description="Import collision meshes (ending with -colonly)",
        default=False,
    )
    import_actions: BoolProperty(  # type: ignore[valid-type]
        name="Import animation actions (experimental)",
        description="Import track animation actions from CAN files, such as object destruction animation",
        default=False,
    )
    import_cameras: BoolProperty(  # type: ignore[valid-type]
        name="Import cameras (experimental)",
        description="Import track-specific replay cameras",
        default=False,
    )

    def invoke(self, context: bpy.types.Context, event: bpy.types.Event) -> set[int] | set[str]:
        wm = context.window_manager
        wm.fileselect_add(self)
        return {"RUNNING_MODAL"}

    def execute(self, context: bpy.types.Context) -> set[int] | set[str]:
        directory = Path(self.directory)
        # This should get us from track directory to game root directory
        game_root = directory.parent.parent.parent
        track = TrackData(
            directory=Path(self.directory),
            game_root=game_root,
            mirrored=self.mirrored,
            night=self.night,
            weather=self.weather,
        )
        import_shading = self.import_shading
        import_strategy: TrackImportStrategy
        if self.mode == "GLTF":
            import_strategy = TrackImportGLTF(material_map=track.get_polygon_material)
        elif self.mode == "BLENDER":
            import_strategy = TrackImportBlender(material_map=track.get_polygon_material)
            import_shading = True
        else:
            return {"CANCELLED"}
        import_strategy.import_track(
            track=track,
            import_collision=self.import_collision,
            import_shading=import_shading,
            import_actions=self.import_actions,
            import_cameras=self.import_cameras,
        )
        return {"FINISHED"}


class CarImporter(bpy.types.Operator):
    """Import NFS4 Car Operator"""

    bl_idname = "import_scene.nfs4car"
    bl_label = "Import NFS4 Car"
    bl_description = "Import NFS4 Car files"
    bl_options = {"REGISTER", "UNDO"}

    bpy.types.Scene.nfs4car = None  # type: ignore

    directory: StringProperty(  # type: ignore
        name="Directory Path",
        description="Directory containing the car files",
        maxlen=1024,
        default="",
    )
    import_interior: BoolProperty(  # type: ignore[valid-type]
        name="Import interior", description="Import car interior geometry", default=False
    )

    def invoke(self, context: bpy.types.Context, event: bpy.types.Event) -> set[int] | set[str]:
        wm = context.window_manager
        wm.fileselect_add(self)
        return {"RUNNING_MODAL"}

    def execute(self, context: bpy.types.Context) -> set[int] | set[str]:
        car = VivData.from_file(Path(self.directory, "CAR.VIV"))
        logger.debug(car)

        if self.import_interior:
            resource = one(car.interior_materials)
            parts = car.interior
        else:
            resource = one(car.body_materials)
            parts = car.parts
        importer = CarImporterSimple(material_map=lambda _: resource)
        importer.import_car(parts)

        return {"FINISHED"}


def menu_func(self: Any, context: bpy.types.Context) -> None:
    self.layout.operator(TrackImporter.bl_idname, text="Track resources")
    self.layout.operator(CarImporter.bl_idname, text="Car resources")


def register() -> None:
    bpy.utils.register_class(TrackImporter)
    bpy.utils.register_class(CarImporter)
    bpy.types.TOPBAR_MT_file_import.append(menu_func)


def unregister() -> None:
    bpy.utils.unregister_class(TrackImporter)
    bpy.utils.unregister_class(CarImporter)
    bpy.types.TOPBAR_MT_file_import.remove(menu_func)


if __name__ == "__main__":
    register()

Once this file / script is loaded you can hit the play button in the scripting window. Bear in mind that the project’s save location will be used from the importer to storage the imported images and assets in subfolder(s) created within the save location.

Now, when you executed the script, you will find the option “Track Resources” under the file > import option.

In the import window you now can browse to your NFS4/NFSHS track-folder within your NFS installation directory.

Warning! Opening Tracks outside the NFS-installation directory will not work as the importer requires global NFS-assets such as sounds, textures and other information.

▶️▶️In case you want to import an Addon-Track, read here.

When you arrived at the track folder, open up the folder containing the track you want to import, e.g. EMPIRE (= Empire City) and click on import.

Now just wait a couple of seconds and keep an eye in the scene overview on the top right corner. Soon you will notice that there will be a couple of folders and objects appearing – that is your imported track!

How to import an NFS-Addon track to blender?

So, it seems that you are interested in opening/importing an community made track in blender. The import itself is working the exact way it does as for official tracks but requires some work right before you can import them.

Setting up the track folder

You – basically – need to to the same kind of work that you would need to do to get the track working in the NFS-game, the major difference here is that you do not need to overwrite the track the addon is based on.

Let’s say you downloaded this track here:

Mololithic Studios for NFS4 High Stakes

Author of Track Conversion: Ryan Trevisol
Author of Track Enhancement: KillRide and UnBtable
and changes by JimDiabolo & Benyy

It is a replacement for the official NFS-Track “Empire City”. So head over to your track-folder in the NFS4-installation directory, locate the original track folder (in this case “EMPIRE”) and duplicate and rename it as you wish, e.g “MONOLITHIC“.

Now copy all addon-content over the original content within the new folder “MONOLITHIC” – and select overwrite-yes of course.

Once this step is done you may proceed with the normal import process as mentioned above.

*any: you can also convert NFS:HS addon-tracks, such as "Lake Diablo" (get it here), but you need to copy the addon-track files over the original NFS:HS track it is replacing, just as you would do if you want to play the addon track ingame as the tools are requiring all animation, sounds etc. - read the tutorial for addon-tracks here.